Compare commits

..
104 changed files with 9599 additions and 7877 deletions
-17
View File
@@ -1,17 +0,0 @@
## Design Context
### Users
Developer-founders and indie hackers building SaaS products. They use Plunk to handle transactional and marketing email without the complexity of tools like Mailchimp or Customer.io. They notice tiny details — inconsistent spacing, placeholder text that adds no value, a button that doesn't communicate state. Context: professional environment, desktop-first.
### Brand Personality
Sharp, minimal, confident. The product earns trust by being simple and correct, not by being flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design" — the brand is *care without noise*.
### Aesthetic Direction
Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors. No color for decoration — only for semantics (red = error, green = success). Backgrounds are near-white with subtle texture. Cards use white with a neutral border and light shadow. Typography should feel precise and legible, not editorial. Spacing should feel considered, not generous.
### Design Principles
1. **Every pixel earns its place.** If something doesn't communicate information or provide affordance, remove it.
2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning states, not decoration.
3. **Interaction should feel fast.** Loading states communicate exactly what's happening. No silent actions.
4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they add value. Labels are unambiguous.
5. **Consistency is trust.** The same pattern everywhere. One way to show errors. One way to show success. No creative variation in functional UI.
-2
View File
@@ -36,7 +36,6 @@
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.2",
"mailchecker": "^6.0.19",
"mailparser": "^3.9.8",
"morgan": "^1.10.0",
"multer": "^2.1.1",
"signale": "^1.4.0",
@@ -49,7 +48,6 @@
"@types/express": "^5.0.5",
"@types/helmet": "^4.0.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/mailparser": "^3.4.6",
"@types/morgan": "^1.9.9",
"@types/multer": "^2.0.0",
"@types/signale": "^1.4.7",
+1 -18
View File
@@ -3,12 +3,11 @@ import {DomainSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
import {redis} from '../database/redis.js';
import {NotAllowed, NotFound} from '../exceptions/index.js';
import {NotFound} from '../exceptions/index.js';
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
import {DomainService} from '../services/DomainService.js';
import {Keys} from '../services/keys.js';
import {MembershipService} from '../services/MembershipService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@Controller('domains')
@@ -48,14 +47,6 @@ export class Domains {
// Verify user has admin access to this project
await MembershipService.requireAdminAccess(auth.userId!, projectId);
// Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(projectId);
if (isDisabled) {
throw new NotAllowed(
'Cannot add domains to a disabled project. Please contact support to resolve security violations before making changes.',
);
}
// Check if domain is already linked to another project
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
@@ -134,14 +125,6 @@ export class Domains {
// Verify user has admin access to the project this domain belongs to
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
// Block domain changes on disabled projects
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
if (isDisabled) {
throw new NotAllowed(
'Cannot remove domains from a disabled project. Please contact support to resolve security violations before making changes.',
);
}
await DomainService.removeDomain(id);
await redis.del(Keys.Domain.id(id));
+3 -22
View File
@@ -2,7 +2,6 @@ import {Controller, Post} from '@overnightjs/core';
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import type {Request, Response} from 'express';
import {simpleParser} from 'mailparser';
import signale from 'signale';
import type Stripe from 'stripe';
@@ -147,21 +146,6 @@ export class Webhooks {
const senderEmail = body.mail?.source;
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
// Parse email content if available
let htmlBody: string | undefined;
if (body.content) {
try {
const parsed = await simpleParser(body.content);
// Prefer HTML body, fallback to text if no HTML available
htmlBody = parsed.html ? String(parsed.html) : parsed.text || undefined;
signale.info('[WEBHOOK] Email content parsed successfully');
} catch (parseError) {
signale.error('[WEBHOOK] Failed to parse email content:', parseError);
// Continue processing without content
}
}
// Process inbound email for each project that has this domain verified
for (const domainRecord of domainRecords) {
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
@@ -187,13 +171,13 @@ export class Webhooks {
);
}
// Create an Email record for tracking with parsed content
// Create an Email record for tracking (no actual email content since it's inbound)
const inboundEmail = await prisma.email.create({
data: {
projectId: domainRecord.projectId,
contactId: contact!.id,
subject: body.mail?.commonHeaders?.subject || '(No subject)',
body: htmlBody || '', // Store HTML body in the body field
body: '', // Inbound emails don't have body content in our system
from: recipientEmail, // The recipient address that received the email
sourceType: EmailSourceType.INBOUND,
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
@@ -213,7 +197,7 @@ export class Webhooks {
);
}
// Prepare event data with all inbound email details including body content
// Prepare event data with all inbound email details
const eventData = {
messageId: body.mail?.messageId,
from: senderEmail,
@@ -223,8 +207,6 @@ export class Webhooks {
timestamp: body.mail?.timestamp,
recipients: body.receipt?.recipients,
hasContent: !!body.content,
// Email body content
body: htmlBody,
// Security verdicts
spamVerdict: body.receipt?.spamVerdict?.status,
virusVerdict: body.receipt?.virusVerdict?.status,
@@ -291,7 +273,6 @@ export class Webhooks {
from: email.from,
fromName: email.fromName,
messageId: email.messageId,
emailId: email.id,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
-1
View File
@@ -188,7 +188,6 @@ export async function createEmailWorker() {
from: email.from,
fromName: email.fromName,
messageId: result.messageId,
emailId: email.id,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
+1 -13
View File
@@ -67,14 +67,10 @@ export class ActivityService {
const fetchLimit = effectiveLimit;
// Default date range to last 30 days if not specified
// IMPORTANT: When cursor is provided (pagination), we should NOT apply the gte constraint
// to allow users to paginate back beyond the initial date range
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
// Only apply start date filter on initial load (no cursor)
// This allows pagination to go back indefinitely
...(cursor ? {} : {gte: startDate || defaultStartDate}),
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
};
@@ -368,14 +364,6 @@ export class ActivityService {
const where: Prisma.EmailWhereInput = {
projectId,
...(contactId ? {contactId} : {}),
// Apply cursor-based pagination filter on createdAt
// This is critical for pagination to work correctly
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
};
// Build OR conditions to filter by the appropriate timestamp field for each activity type
+12 -192
View File
@@ -15,7 +15,7 @@ import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants
* These limits protect AWS SES reputation and prevent account suspension
*/
const SECURITY_THRESHOLDS = {
// Minimum emails required before enforcing rate-based limits (prevents false positives)
// Minimum emails required before enforcing limits (prevents false positives)
MIN_EMAILS_FOR_ENFORCEMENT: 100,
// Bounce rate thresholds (hard bounces only)
@@ -31,37 +31,11 @@ const SECURITY_THRESHOLDS = {
COMPLAINT_ALLTIME_CRITICAL: 0.12,
// Minimum absolute counts (prevents small sample size false positives)
// Both percentage AND absolute count must be exceeded to trigger rate-based checks
// Both percentage AND absolute count must be exceeded to trigger
MIN_BOUNCES_FOR_CRITICAL: 10,
MIN_BOUNCES_FOR_WARNING: 5,
MIN_COMPLAINTS_FOR_CRITICAL: 5,
MIN_COMPLAINTS_FOR_WARNING: 3,
// === Absolute count ceilings ===
// These trigger regardless of rate — catches high-volume spammers who dilute their bounce rate
// 24-hour absolute ceilings
BOUNCE_24H_CEILING_WARNING: 50,
BOUNCE_24H_CEILING_CRITICAL: 100,
COMPLAINT_24H_CEILING_WARNING: 10,
COMPLAINT_24H_CEILING_CRITICAL: 25,
// 7-day absolute ceilings
BOUNCE_7DAY_CEILING_WARNING: 200,
BOUNCE_7DAY_CEILING_CRITICAL: 500,
COMPLAINT_7DAY_CEILING_WARNING: 30,
COMPLAINT_7DAY_CEILING_CRITICAL: 75,
// === New project thresholds (projects < 30 days old) ===
// Legitimate senders ramp up gradually; spammers blast immediately
NEW_PROJECT_AGE_DAYS: 30,
NEW_PROJECT_BOUNCE_24H_CEILING_WARNING: 10,
NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL: 25,
NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING: 25,
NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL: 50,
NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING: 3,
NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL: 7,
NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING: 10,
NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL: 20,
} as const;
interface RateData {
@@ -76,10 +50,8 @@ interface SecurityStatus {
projectId: string;
isHealthy: boolean;
shouldDisable: boolean;
twentyFourHour: RateData;
sevenDay: RateData;
allTime: RateData;
isNewProject: boolean;
violations: string[];
warnings: string[];
}
@@ -114,13 +86,6 @@ export class SecurityService {
projectId,
isHealthy: true,
shouldDisable: false,
twentyFourHour: {
total: 0,
bounces: 0,
complaints: 0,
bounceRate: 0,
complaintRate: 0,
},
sevenDay: {
total: 0,
bounces: 0,
@@ -135,7 +100,6 @@ export class SecurityService {
bounceRate: 0,
complaintRate: 0,
},
isNewProject: false,
violations: [],
warnings: [],
};
@@ -169,20 +133,12 @@ export class SecurityService {
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
status.violations,
);
signale.info(
`[SECURITY] 24-hour stats: ${status.twentyFourHour.bounces} bounces, ${status.twentyFourHour.complaints} complaints out of ${status.twentyFourHour.total} emails`,
);
signale.info(
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
);
signale.info(
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
);
if (status.isNewProject) {
signale.info(
`[SECURITY] Project is under ${SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS} days old — stricter ceilings apply`,
);
}
// Send notification about critical security violations
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
@@ -245,17 +201,11 @@ export class SecurityService {
}
/**
* Get a project's security metrics (for dashboard display)
* Does NOT expose internal thresholds — only computed health levels
* Get a project's security metrics (for admin/dashboard display)
*/
public static async getProjectSecurityMetrics(projectId: string): Promise<{
status: SecurityStatus;
levels: {
bounce7Day: 'healthy' | 'warning' | 'critical';
bounceAllTime: 'healthy' | 'warning' | 'critical';
complaint7Day: 'healthy' | 'warning' | 'critical';
complaintAllTime: 'healthy' | 'warning' | 'critical';
};
thresholds: typeof SECURITY_THRESHOLDS;
isDisabled: boolean;
}> {
const [status, project] = await Promise.all([
@@ -266,55 +216,13 @@ export class SecurityService {
}),
]);
// Strip internal details from the client-facing response:
// - Replace detailed violation/warning messages (they contain exact thresholds)
// - Remove 24-hour data and new project flag (reveals enforcement windows)
const sanitizedStatus: SecurityStatus = {
...status,
twentyFourHour: {total: 0, bounces: 0, complaints: 0, bounceRate: 0, complaintRate: 0},
isNewProject: false,
violations: status.violations.map(() => 'Security threshold exceeded'),
warnings: status.warnings.map(() => 'Approaching security threshold'),
};
return {
status: sanitizedStatus,
levels: {
bounce7Day: this.computeLevel(
status.sevenDay.bounceRate,
SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING,
SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL,
),
bounceAllTime: this.computeLevel(
status.allTime.bounceRate,
SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING,
SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL,
),
complaint7Day: this.computeLevel(
status.sevenDay.complaintRate,
SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING,
SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL,
),
complaintAllTime: this.computeLevel(
status.allTime.complaintRate,
SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING,
SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL,
),
},
status,
thresholds: SECURITY_THRESHOLDS,
isDisabled: project?.disabled ?? false,
};
}
private static computeLevel(
value: number,
warningThreshold: number,
criticalThreshold: number,
): 'healthy' | 'warning' | 'critical' {
if (value >= criticalThreshold) return 'critical';
if (value >= warningThreshold) return 'warning';
return 'healthy';
}
/**
* Calculate bounce and complaint rates for a project
*/
@@ -362,21 +270,10 @@ export class SecurityService {
*/
private static async calculateSecurityStatus(projectId: string): Promise<SecurityStatus> {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Get project age to determine if stricter new-project thresholds apply
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {createdAt: true},
});
const projectAgeDays = project ? (now.getTime() - project.createdAt.getTime()) / (1000 * 60 * 60 * 24) : Infinity;
const isNewProject = projectAgeDays < SECURITY_THRESHOLDS.NEW_PROJECT_AGE_DAYS;
// Get 24-hour, 7-day and all-time rates in parallel
const [twentyFourHour, sevenDay, allTime] = await Promise.all([
this.calculateRates(projectId, oneDayAgo),
// Get 7-day and all-time rates in parallel
const [sevenDay, allTime] = await Promise.all([
this.calculateRates(projectId, sevenDaysAgo),
this.calculateRates(projectId),
]);
@@ -384,91 +281,13 @@ export class SecurityService {
const violations: string[] = [];
const warnings: string[] = [];
// Pick absolute count ceilings based on project age
const bounceCeilings = isNewProject
? {
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_BOUNCE_7DAY_CEILING_CRITICAL,
}
: {
ceiling24hWarning: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.BOUNCE_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.BOUNCE_7DAY_CEILING_CRITICAL,
};
const complaintCeilings = isNewProject
? {
ceiling24hWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL,
}
: {
ceiling24hWarning: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_WARNING,
ceiling24hCritical: SECURITY_THRESHOLDS.COMPLAINT_24H_CEILING_CRITICAL,
ceiling7dWarning: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_WARNING,
ceiling7dCritical: SECURITY_THRESHOLDS.COMPLAINT_7DAY_CEILING_CRITICAL,
};
const projectLabel = isNewProject ? ' (new project)' : '';
// === Absolute count ceiling checks (rate-independent) ===
// These catch high-volume spammers who dilute their bounce rate by blasting emails
// 24-hour bounce ceilings
if (twentyFourHour.bounces >= bounceCeilings.ceiling24hCritical) {
violations.push(
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling24hCritical})`,
);
} else if (twentyFourHour.bounces >= bounceCeilings.ceiling24hWarning) {
warnings.push(
`24-hour bounce count${projectLabel} (${twentyFourHour.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling24hWarning})`,
);
}
// 7-day bounce ceilings
if (sevenDay.bounces >= bounceCeilings.ceiling7dCritical) {
violations.push(
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds critical ceiling (${bounceCeilings.ceiling7dCritical})`,
);
} else if (sevenDay.bounces >= bounceCeilings.ceiling7dWarning) {
warnings.push(
`7-day bounce count${projectLabel} (${sevenDay.bounces} bounces) exceeds warning ceiling (${bounceCeilings.ceiling7dWarning})`,
);
}
// 24-hour complaint ceilings
if (twentyFourHour.complaints >= complaintCeilings.ceiling24hCritical) {
violations.push(
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling24hCritical})`,
);
} else if (twentyFourHour.complaints >= complaintCeilings.ceiling24hWarning) {
warnings.push(
`24-hour complaint count${projectLabel} (${twentyFourHour.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling24hWarning})`,
);
}
// 7-day complaint ceilings
if (sevenDay.complaints >= complaintCeilings.ceiling7dCritical) {
violations.push(
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds critical ceiling (${complaintCeilings.ceiling7dCritical})`,
);
} else if (sevenDay.complaints >= complaintCeilings.ceiling7dWarning) {
warnings.push(
`7-day complaint count${projectLabel} (${sevenDay.complaints} complaints) exceeds warning ceiling (${complaintCeilings.ceiling7dWarning})`,
);
}
// === Rate-based checks (existing logic) ===
// Only enforce if minimum emails threshold is met
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
// Check 7-day bounce rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
// Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
@@ -488,6 +307,7 @@ export class SecurityService {
// Check 7-day complaint rate (only if 7-day volume is sufficient)
if (hasMinimumVolume7Day) {
// Critical: requires BOTH rate AND absolute count thresholds
if (
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
@@ -507,6 +327,7 @@ export class SecurityService {
// Check all-time rates (only if all-time volume is sufficient)
if (hasMinimumVolumeAllTime) {
// Check all-time bounce rate - requires BOTH rate AND absolute count
if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
@@ -523,6 +344,7 @@ export class SecurityService {
);
}
// Check all-time complaint rate - requires BOTH rate AND absolute count
if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
@@ -544,10 +366,8 @@ export class SecurityService {
projectId,
isHealthy: violations.length === 0,
shouldDisable: violations.length > 0,
twentyFourHour,
sevenDay,
allTime,
isNewProject,
violations,
warnings,
};
@@ -1,274 +0,0 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailStatus, EmailSourceType} from '@plunk/db';
import {SecurityService} from '../SecurityService';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {redis} from '../../database/redis';
vi.mock('../../app/constants.js', async () => {
const actual = await vi.importActual('../../app/constants.js');
return {
...actual,
AUTO_PROJECT_DISABLE: true,
};
});
// Mock NtfyService to prevent actual notifications
vi.mock('../NtfyService.js', () => ({
NtfyService: {
notifySecurityWarning: vi.fn(),
notifyProjectDisabledForSecurity: vi.fn(),
},
}));
// Mock email sending for project disabled notifications
vi.mock('@plunk/email', () => ({
ProjectDisabledEmail: vi.fn(),
sendPlatformEmail: vi.fn(),
}));
describe('SecurityService', () => {
let projectId: string;
let contactId: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
const contact = await factories.createContact({projectId});
contactId = contact.id;
// Clear redis cache
await redis.flushdb();
});
/**
* Helper to create N emails, some of which are bounced
*/
async function createEmails(count: number, opts?: {bouncedCount?: number; complainedCount?: number; createdAt?: Date}) {
const bouncedCount = opts?.bouncedCount ?? 0;
const complainedCount = opts?.complainedCount ?? 0;
const createdAt = opts?.createdAt ?? new Date();
const emails = [];
for (let i = 0; i < count; i++) {
emails.push(
prisma.email.create({
data: {
projectId,
contactId,
subject: `Test ${i}`,
body: '<p>test</p>',
from: '[email protected]',
status: EmailStatus.SENT,
sourceType: EmailSourceType.TRANSACTIONAL,
sentAt: createdAt,
createdAt,
bouncedAt: i < bouncedCount ? createdAt : null,
complainedAt: i >= bouncedCount && i < bouncedCount + complainedCount ? createdAt : null,
},
}),
);
}
await Promise.all(emails);
}
describe('Rate-based checks (existing behavior)', () => {
it('should report healthy when bounce rate is below warning', async () => {
// 200 emails, 5 bounces = 2.5% (below 5% warning)
await createEmails(200, {bouncedCount: 5});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true);
expect(status.shouldDisable).toBe(false);
expect(status.violations).toHaveLength(0);
expect(status.warnings).toHaveLength(0);
});
it('should trigger warning when 7-day bounce rate exceeds warning threshold', async () => {
// 100 emails, 6 bounces = 6% (above 5% warning, below 10% critical)
await createEmails(100, {bouncedCount: 6});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true);
expect(status.warnings.length).toBeGreaterThan(0);
});
it('should trigger violation when 7-day bounce rate exceeds critical threshold', async () => {
// 100 emails, 11 bounces = 11% (above 10% critical)
await createEmails(100, {bouncedCount: 11});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(false);
expect(status.shouldDisable).toBe(true);
expect(status.violations.length).toBeGreaterThan(0);
});
it('should not enforce rate checks when below minimum volume', async () => {
// 50 emails (below 100 minimum), 10 bounces = 20% (would exceed critical)
await createEmails(50, {bouncedCount: 10});
const status = await SecurityService.getSecurityStatus(projectId);
// Rate-based check doesn't trigger, but absolute count ceiling might
// With 10 bounces in 24h, this is below the 50-bounce ceiling for established projects
expect(status.violations).toHaveLength(0);
});
});
describe('Absolute count ceilings (established projects)', () => {
// Age the project past the new-project window so standard ceilings apply
beforeEach(async () => {
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
where: {id: projectId},
data: {createdAt: oldDate},
});
});
it('should trigger critical when 24-hour bounce count exceeds ceiling', async () => {
// 20,000 emails, 101 bounces = 0.5% rate (well below rate threshold)
// But 101 bounces > 100 (24h critical ceiling for established projects)
await createEmails(20000, {bouncedCount: 101});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.shouldDisable).toBe(true);
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true);
});
it('should trigger warning when 24-hour bounce count exceeds warning ceiling', async () => {
// 10,000 emails, 51 bounces = 0.51% (below rate threshold)
// But 51 > 50 (24h warning ceiling), below 100 critical
await createEmails(10000, {bouncedCount: 51});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true); // warnings don't make it unhealthy
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(true);
});
it('should trigger critical when 24-hour complaint count exceeds ceiling', async () => {
// 20,000 emails, 26 complaints = 0.13% (below complaint rate critical of 0.15%)
// But 26 > 25 (24h complaint critical ceiling)
await createEmails(20000, {complainedCount: 26});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.shouldDisable).toBe(true);
expect(status.violations.some(v => v.includes('24-hour complaint count'))).toBe(true);
});
it('should NOT trigger ceiling when bounce count is below ceiling', async () => {
// 20,000 emails, 40 bounces = below 50 warning ceiling for established projects
await createEmails(20000, {bouncedCount: 40});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isHealthy).toBe(true);
expect(status.violations).toHaveLength(0);
expect(status.warnings).toHaveLength(0);
});
});
describe('New project stricter thresholds', () => {
it('should apply stricter ceilings for projects under 30 days old', async () => {
// Default project is created "now", so it's a new project
// 10,000 emails, 26 bounces (above 25 new project 24h critical ceiling)
await createEmails(10000, {bouncedCount: 26});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isNewProject).toBe(true);
expect(status.shouldDisable).toBe(true);
expect(status.violations.some(v => v.includes('new project'))).toBe(true);
});
it('should apply standard ceilings for projects over 30 days old', async () => {
// Age the project to 31 days
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
where: {id: projectId},
data: {createdAt: oldDate},
});
// 10,000 emails, 26 bounces (above 25 new project ceiling, below 50 standard warning ceiling)
await createEmails(10000, {bouncedCount: 26});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isNewProject).toBe(false);
// 26 is below the 50-bounce 24h warning ceiling for established projects
expect(status.warnings.some(w => w.includes('24-hour bounce count'))).toBe(false);
// And below the 100-bounce 24h critical ceiling
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(false);
});
it('should catch new project blasting emails with delayed bounces', async () => {
// Simulate the spammer scenario: new project sends 20K emails,
// only 30 bounces have come back so far (rate is tiny: 0.15%)
await createEmails(20000, {bouncedCount: 30});
const status = await SecurityService.getSecurityStatus(projectId);
expect(status.isNewProject).toBe(true);
expect(status.shouldDisable).toBe(true);
// 30 > 25 new project 24h critical ceiling
expect(status.violations.some(v => v.includes('24-hour bounce count'))).toBe(true);
});
});
describe('checkAndEnforceSecurityLimits', () => {
it('should disable project when critical thresholds are exceeded', async () => {
// Create enough bounces to trigger critical
await createEmails(20000, {bouncedCount: 101});
await SecurityService.checkAndEnforceSecurityLimits(projectId);
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
});
expect(project?.disabled).toBe(true);
});
it('should NOT disable project when only warnings exist', async () => {
// 10,000 emails, 51 bounces (above warning but below critical for established project)
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000);
await prisma.project.update({
where: {id: projectId},
data: {createdAt: oldDate},
});
await createEmails(10000, {bouncedCount: 51});
await SecurityService.checkAndEnforceSecurityLimits(projectId);
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {disabled: true},
});
expect(project?.disabled).toBe(false);
});
});
describe('getProjectSecurityMetrics (client-facing)', () => {
it('should NOT expose internal thresholds or detailed violation messages', async () => {
// Create a violation scenario
await createEmails(100, {bouncedCount: 15});
const metrics = await SecurityService.getProjectSecurityMetrics(projectId);
// Should have levels, not thresholds
expect(metrics.levels).toBeDefined();
expect((metrics as Record<string, unknown>).thresholds).toBeUndefined();
// Violation messages should be generic
if (metrics.status.violations.length > 0) {
for (const v of metrics.status.violations) {
expect(v).toBe('Security threshold exceeded');
expect(v).not.toMatch(/\d+%/); // No percentages
expect(v).not.toMatch(/\d+ minimum/); // No absolute numbers
}
}
// 24-hour data should be zeroed out
expect(metrics.status.twentyFourHour.total).toBe(0);
expect(metrics.status.twentyFourHour.bounces).toBe(0);
// New project flag should be hidden
expect(metrics.status.isNewProject).toBe(false);
});
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+6 -2
View File
@@ -1,3 +1,7 @@
<svg width="1080" height="1080" viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z" fill="black"/>
<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1080" height="1080" fill="white"/>
<path d="M976 284.628C976 348.237 959.54 406.608 926.62 459.74C893.701 512.873 845.817 556.276 782.97 589.952C720.124 623.627 645.306 643.458 558.517 649.445L510.26 919.971C491.556 1023.99 440.68 1076 357.632 1076C311.993 1076 269.721 1062.53 230.816 1035.59C192.659 1008.65 161.984 967.49 138.79 912.113C115.597 856.736 104 788.637 104 707.816C104 555.902 128.316 427.187 176.947 321.671C226.327 215.407 292.166 136.082 374.466 83.6984C457.514 30.5661 548.791 4 648.299 4C718.627 4 778.107 16.3476 826.739 41.0429C876.118 65.7382 913.153 99.4136 937.843 142.069C963.281 183.976 976 231.496 976 284.628ZM578.718 533.826C732.094 514.369 808.783 434.671 808.783 294.731C808.783 245.34 792.323 205.304 759.403 174.622C727.231 143.192 677.103 127.476 609.019 127.476C531.957 127.476 464.621 151.798 407.012 200.44C350.15 249.082 306.008 316.807 274.584 403.615C243.909 489.674 228.571 588.081 228.571 698.836C228.571 745.233 233.06 786.392 242.039 822.312C251.765 858.232 263.736 886.295 277.951 906.501C292.915 925.957 307.13 935.686 320.597 935.686C339.302 935.686 353.517 909.868 363.243 858.232L400.278 646.078C371.099 641.587 358.38 639.717 362.121 640.465C339.676 636.723 325.086 629.988 318.353 620.26C311.619 609.783 308.252 596.687 308.252 580.972C308.252 564.508 312.741 551.412 321.719 541.684C331.446 531.955 344.539 527.091 360.999 527.091C368.481 527.091 374.092 527.465 377.833 528.214C395.789 531.207 409.63 533.078 419.357 533.826C429.083 475.455 442.924 397.254 460.88 299.221C465.369 273.777 475.47 255.817 491.182 245.34C507.641 234.115 526.72 228.503 548.417 228.503C573.107 228.503 590.689 233.367 601.163 243.095C612.386 252.075 617.997 266.668 617.997 286.873C617.997 298.847 617.249 308.575 615.753 316.059L578.718 533.826Z"
fill="black"/>
<path d="M304.835 467.541L426.099 489.952L411.851 580.937L391.091 699.816L266.088 676.643L304.835 467.541Z"
fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+19 -14
View File
@@ -13,21 +13,26 @@ interface ComparisonTableProps {
rows: ComparisonRow[];
}
/**
* Reusable comparison table component for competitor pages
*/
export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
return (
<div className={'overflow-hidden rounded-[24px] border border-neutral-200'}>
<div className={'overflow-hidden rounded-xl border border-neutral-200'}>
{/* Header */}
<div className={'grid grid-cols-3 gap-px bg-neutral-200'}>
<div className={'bg-neutral-50 p-6'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Feature</span>
<div className={'bg-white p-6'}>
<span className={'text-sm font-semibold text-neutral-900'}>Feature</span>
</div>
<div className={'bg-neutral-900 p-6 text-center'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-white'}>Plunk</span>
<div className={'bg-white p-6 text-center'}>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk</span>
</div>
<div className={'bg-neutral-50 p-6 text-center'}>
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>{competitorName}</span>
<div className={'bg-white p-6 text-center'}>
<span className={'text-sm font-semibold text-neutral-900'}>{competitorName}</span>
</div>
</div>
{/* Rows */}
<div className={'grid gap-px bg-neutral-200'}>
{rows.map((row, index) => (
<motion.div
@@ -35,22 +40,22 @@ export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
initial={{opacity: 0, y: 10}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.4, delay: index * 0.04, ease: [0.22, 1, 0.36, 1]}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-3 gap-px bg-neutral-200'}
>
<div className={'bg-white p-6'}>
<span className={'text-sm text-neutral-700'}>{row.feature}</span>
<span className={'text-sm text-neutral-600'}>{row.feature}</span>
</div>
<div className={'bg-neutral-50/70 p-6'}>
<div className={'bg-white p-6'}>
<div className={'flex justify-center'}>
{typeof row.plunk === 'boolean' ? (
row.plunk ? (
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
) : (
<X className="h-5 w-5 text-neutral-300" strokeWidth={2} />
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
)
) : (
<span className={'text-sm font-medium text-neutral-900'}>{row.plunk}</span>
<span className={'text-sm text-neutral-900'}>{row.plunk}</span>
)}
</div>
</div>
@@ -60,10 +65,10 @@ export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
row.competitor ? (
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
) : (
<X className="h-5 w-5 text-neutral-300" strokeWidth={2} />
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
)
) : (
<span className={'text-sm text-neutral-600'}>{row.competitor}</span>
<span className={'text-sm text-neutral-900'}>{row.competitor}</span>
)}
</div>
</div>
+21 -28
View File
@@ -12,6 +12,9 @@ interface FAQSectionProps {
schemaId?: string;
}
/**
* Reusable FAQ section component with structured data support
*/
export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
return (
<>
@@ -34,44 +37,34 @@ export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
}}
/>
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-12'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Frequently asked questions
</h2>
</motion.div>
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl'}
>
<h2 className={'mb-16 text-center text-5xl font-bold tracking-tight text-neutral-900'}>
Frequently asked questions
</h2>
<div className={'mx-auto max-w-3xl divide-y divide-neutral-200'}>
<div className={'space-y-8'}>
{faqs.map((faq, index) => (
<motion.div
key={index}
initial={{opacity: 0, y: 16}}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={'py-8'}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'border-b border-neutral-200 pb-8 last:border-b-0'}
>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-lg font-bold tracking-[-0.02em] text-neutral-900'}
>
{faq.question}
</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>{faq.answer}</p>
<h3 className={'text-xl font-semibold text-neutral-900'}>{faq.question}</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>{faq.answer}</p>
</motion.div>
))}
</div>
</div>
</motion.div>
</section>
</>
);
@@ -10,7 +10,7 @@ export default function Footer() {
return (
<>
<footer className={'border-t border-neutral-200 bg-white'}>
<div className="mx-auto max-w-[88rem] px-6 py-20 sm:px-10">
<div className="mx-auto max-w-7xl px-8 py-20 xl:px-0">
<div className="grid gap-12 lg:grid-cols-12">
{/* Logo and description */}
<div className="space-y-6 lg:col-span-3">
@@ -59,7 +59,7 @@ export default function Footer() {
{/* Links */}
<div className="grid grid-cols-2 gap-8 lg:col-span-9 lg:grid-cols-5">
<div>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Product</h3>
<h3 className="text-sm font-semibold text-neutral-900">Product</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/pricing'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -90,7 +90,7 @@ export default function Footer() {
</div>
<div>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Features</h3>
<h3 className="text-sm font-semibold text-neutral-900">Features</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/features/email-editor'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -121,7 +121,7 @@ export default function Footer() {
</div>
<div>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Compare</h3>
<h3 className="text-sm font-semibold text-neutral-900">Compare</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/vs'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -162,7 +162,7 @@ export default function Footer() {
</div>
<div>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Community</h3>
<h3 className="text-sm font-semibold text-neutral-900">Community</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/discord'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -181,7 +181,7 @@ export default function Footer() {
</li>
</ul>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Legal</h3>
<h3 className="mt-8 text-sm font-semibold text-neutral-900">Legal</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/privacy'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -202,7 +202,7 @@ export default function Footer() {
</div>
<div>
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Guides</h3>
<h3 className="text-sm font-semibold text-neutral-900">Guides</h3>
<ul role="list" className="mt-6 space-y-4">
<li>
<Link href={'/guides/email-deliverability'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
@@ -47,8 +47,8 @@ export default function Navbar() {
const [featuresOpen, setFeaturesOpen] = useState(false);
return (
<header className={'sticky top-0 z-40 w-full border-b border-neutral-200 bg-white/95 backdrop-blur-sm'}>
<div className={'relative mx-auto max-w-[88rem] px-6 sm:px-10'}>
<header className={'sticky top-0 z-40 w-full border-b border-neutral-100 bg-white/95 backdrop-blur-sm'}>
<div className={'relative mx-auto max-w-7xl px-8 xl:px-0'}>
<div className={'py-5'}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-12">
@@ -83,14 +83,14 @@ export default function Navbar() {
onMouseEnter={() => setFeaturesOpen(true)}
onMouseLeave={() => setFeaturesOpen(false)}
className={
'absolute left-0 top-full z-50 mt-2 w-80 rounded-[16px] border border-neutral-200 bg-white p-2 shadow-lg'
'absolute left-0 top-full z-50 mt-2 w-80 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg'
}
>
{featuresMenu.map(feature => (
<Link
key={feature.href}
href={feature.href}
className={'flex items-start gap-3 rounded-[10px] p-3 transition hover:bg-neutral-50'}
className={'flex items-start gap-3 rounded-lg p-3 transition hover:bg-neutral-50'}
>
<div
className={
@@ -166,7 +166,7 @@ export default function Navbar() {
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-full bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-neutral-800'
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
}
>
Get started
@@ -226,7 +226,7 @@ export default function Navbar() {
className="space-y-1 p-4"
>
<div className="mb-2">
<div style={{fontFamily: 'var(--font-mono)'}} className="px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">
<div className="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">
Features
</div>
{featuresMenu.map(feature => (
@@ -293,7 +293,7 @@ export default function Navbar() {
</a>
<a
href={`${DASHBOARD_URI}/auth/signup`}
className="mt-2 block rounded-full bg-neutral-900 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800"
className="mt-2 block rounded-lg bg-neutral-900 px-4 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800"
>
Get started
</a>
@@ -1,48 +0,0 @@
import {motion} from 'framer-motion';
import React from 'react';
export function SectionHeader({
number,
label,
title,
titleAccent,
subtitle,
}: {
number: string;
label: string;
title: string;
titleAccent?: string;
subtitle?: string;
}) {
return (
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true, margin: '-10%'}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'grid gap-8 lg:grid-cols-12 lg:gap-16'}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={
'flex items-center gap-4 border-t border-neutral-900 pt-4 text-[11px] uppercase tracking-[0.2em] text-neutral-700 lg:col-span-3 lg:self-start'
}
>
<span className={'font-medium text-neutral-900'}>§ {number}</span>
<span className={'text-neutral-500'}>{label}</span>
</div>
<div className={'lg:col-span-9'}>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.25rem,5.5vw,4.5rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'
}
>
{title}
{titleAccent && <> {titleAccent}</>}
</h2>
{subtitle && <p className={'mt-6 max-w-2xl text-lg leading-relaxed text-neutral-600'}>{subtitle}</p>}
</div>
</motion.div>
);
}
@@ -114,7 +114,7 @@ export function GuideLayout({
<Navbar />
<main className={'mx-auto max-w-[88rem] px-4 sm:px-8 w-full overflow-x-hidden'}>
<main className={'mx-auto max-w-7xl px-4 sm:px-8 w-full overflow-x-hidden'}>
<div className={'flex flex-col lg:flex-row gap-8 lg:gap-12 py-8 sm:py-16 w-full'}>
{/* Main Content */}
<article className={'flex-1 max-w-full lg:max-w-4xl w-full'}>
@@ -148,10 +148,7 @@ export function GuideLayout({
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-8 sm:mb-12 w-full'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-3xl sm:text-4xl font-bold tracking-[-0.02em] text-neutral-900 break-words max-w-full'}
>
<h1 className={'text-2xl sm:text-4xl font-bold tracking-tight text-neutral-900 break-words max-w-full'}>
{title}
</h1>
<p
@@ -202,16 +199,16 @@ export function GuideLayout({
<div className={'rounded-xl border border-neutral-200 bg-white p-6 shadow-sm'}>
<h2 className={'text-sm font-semibold text-neutral-900 mb-4 uppercase tracking-wide'}>On this page</h2>
<nav>
<ul className={'space-y-0.5'}>
<ul className={'space-y-1'}>
{headings.map(heading => (
<li key={heading.id} className={heading.level === 3 ? 'ml-3' : ''}>
<li key={heading.id} className={heading.level === 3 ? 'ml-4 mt-0.5' : 'mt-2 first:mt-0'}>
<a
href={`#${heading.id}`}
onClick={e => {
e.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
const offset = 100;
const offset = 100; // Account for fixed header
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: elementPosition - offset,
@@ -219,14 +216,14 @@ export function GuideLayout({
});
}
}}
className={`block rounded px-2 py-1.5 transition-all duration-200 ${
className={`block py-1 border-l-2 -ml-px pl-3 transition-all duration-200 ${
heading.level === 2
? activeId === heading.id
? 'bg-neutral-100 text-sm font-semibold text-neutral-900'
: 'text-sm font-medium text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
? 'border-neutral-900 text-neutral-900 font-semibold text-sm'
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 font-medium text-sm'
: activeId === heading.id
? 'bg-neutral-50 text-xs font-medium text-neutral-800'
: 'text-xs text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700'
? 'border-neutral-700 text-neutral-800 font-medium text-xs'
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-200 text-xs'
}`}
>
{heading.text}
-1
View File
@@ -3,4 +3,3 @@ export * from './Footer';
export * from './ComparisonTable';
export * from './FAQSection';
export * from './CodeBlock';
export * from './SectionHeader';
+2 -24
View File
@@ -7,28 +7,6 @@ import {SWRConfig} from 'swr';
import {network} from '../lib/network';
import {DefaultSeo} from 'next-seo';
import Script from 'next/script';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
/**
* Main app component
@@ -47,7 +25,7 @@ function App({Component, pageProps}: AppProps) {
}, []);
return (
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<>
<Head>
<title>Plunk | The Open-Source Email Platform</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
@@ -55,7 +33,7 @@ function App({Component, pageProps}: AppProps) {
<Toaster position={'top-right'} />
<Component {...pageProps} />
</div>
</>
);
}
+8
View File
@@ -6,6 +6,14 @@ export default class MyDocument extends Document {
return (
<Html lang="en">
<Head>
{/* Start fonts */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@500&display=swap"
rel="stylesheet"
/>
{/* End fonts */}
{/* Start favicon */}
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
+265 -288
View File
@@ -8,34 +8,36 @@ import Head from 'next/head';
const features = [
{
icon: <Type className="h-6 w-6" strokeWidth={1.5} />,
icon: <Type className="h-5 w-5" />,
title: 'Visual WYSIWYG Editor',
description:
'Rich text editing with formatting toolbar. Bold, italic, headings, lists, links, images, and tables. No code required.',
featured: true,
},
{
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
icon: <Code2 className="h-5 w-5" />,
title: 'Full HTML Editor',
description: 'Syntax highlighting, auto-completion, and bracket matching. Write custom HTML when you need complete control.',
description:
'Syntax highlighting, auto-completion, and bracket matching. Write custom HTML when you need complete control.',
},
{
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
icon: <Zap className="h-5 w-5" />,
title: 'Smart Mode Switching',
description: 'Automatically detects complex HTML and switches to code mode. Warns you before changes that would lose custom formatting.',
description:
'Automatically detects complex HTML and switches to code mode. Warns you before changes that would lose custom formatting.',
},
{
icon: <Sparkles className="h-6 w-6" strokeWidth={1.5} />,
icon: <Sparkles className="h-5 w-5" />,
title: 'Powerful Variables',
description: 'Autocomplete with {{variable}} syntax. Supports fallbacks, nested properties, and custom contact fields.',
description:
'Autocomplete with {{variable}} syntax. Supports fallbacks, nested properties, and custom contact fields.',
},
{
icon: <Eye className="h-6 w-6" strokeWidth={1.5} />,
icon: <Eye className="h-5 w-5" />,
title: 'Live Preview',
description: 'Preview with real contact data. Test on desktop, tablet, and mobile views before sending.',
},
{
icon: <Palette className="h-6 w-6" strokeWidth={1.5} />,
icon: <Palette className="h-5 w-5" />,
title: 'Email-Safe HTML',
description: 'Automatic CSS inlining and email-client-friendly code generation. Yes, even in Outlook.',
},
@@ -43,21 +45,21 @@ const features = [
const useCases = [
{
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
icon: <Code2 className="h-6 w-6" />,
title: 'For Developers',
description:
'Full HTML control when you need it. Powerful variable system with autocomplete and fallbacks. Use templates in API calls, workflows, and campaigns.',
example: 'Password resets → API-triggered alerts → Webhook notifications',
example: 'Password resets → API-triggered alerts → Webhook notifications → Those cat meme attachments',
},
{
icon: <Palette className="h-6 w-6" strokeWidth={1.5} />,
icon: <Palette className="h-6 w-6" />,
title: 'For Marketers',
description:
'Visual editor for quick changes. Live preview with real customer data. Create professional emails without waiting for developers.',
example: 'Product announcements → Newsletter campaigns → Promotional emails → Customer onboarding',
},
{
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
icon: <Zap className="h-6 w-6" />,
title: 'For Teams',
description:
'One tool for everyone. Developers can code, marketers can design, everyone can preview. Reusable templates across campaigns and workflows.',
@@ -65,6 +67,9 @@ const useCases = [
},
];
/**
*
*/
export default function EmailEditorFeature() {
return (
<>
@@ -74,7 +79,10 @@ export default function EmailEditorFeature() {
name="description"
content="The email editor that speaks both languages. Switch seamlessly between visual and code editing, preview with real data, and create templates that work everywhere."
/>
<meta property="og:title" content="Email Editor - Create Beautiful Emails Without Fighting Your Tools | Plunk" />
<meta
property="og:title"
content="Email Editor - Create Beautiful Emails Without Fighting Your Tools | Plunk"
/>
<meta
property="og:description"
content="The email editor that speaks both languages. Switch seamlessly between visual and code editing, preview with real data, and create templates that work everywhere."
@@ -83,296 +91,265 @@ export default function EmailEditorFeature() {
<Navbar />
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>Email Editor & Templates</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Mail className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>Email Editor & Templates</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
The Email Editor
<br />
That Speaks Both Languages
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Switch seamlessly between visual and code editing. Preview with real customer data. Create templates that
work everywhere.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
The editor that
<br />
speaks both languages.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Switch seamlessly between visual and code editing. Preview with real customer data. Create templates that
work everywhere.
</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try the editor free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Two editors, one experience
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Visual editing for speed, code editing for control</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
View documentation
</Link>
</div>
</motion.div>
</div>
</section>
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Two editors, one experience
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Visual editing for speed, code editing for control</p>
</motion.div>
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* How it works */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
From first draft to send
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Create, preview, and deploy templates in minutes</p>
</motion.div>
<div className={'mx-auto max-w-4xl'}>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
{[
{
step: '01',
title: 'Create your template',
body: 'Use the visual editor for quick formatting or write custom HTML. Add variables with autocomplete.',
},
{
step: '02',
title: 'Preview with real data',
body: 'Select any contact and see exactly what they\'ll receive. Test on desktop, tablet, and mobile.',
},
{
step: '03',
title: 'Use everywhere',
body: 'Use your template in campaigns, workflows, and API calls. One template, unlimited uses.',
},
].map((item, i) => (
<motion.div
key={item.step}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'bg-white p-10'}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Step {item.step}
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>{item.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Built for every team
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Whether you&apos;re a developer, marketer, or founder</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<div className={'col-span-12 sm:col-span-8'}>
<p className={'leading-relaxed text-neutral-600'}>{useCase.description}</p>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'mt-5 text-[11px] uppercase tracking-[0.16em] text-neutral-400'}
>
{useCase.example}
</p>
</div>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Build your first template today.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Free plan available. $0.001 per email on paid. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
</div>
</section>
{/* How It Works */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
From first draft to send
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>Create, preview, and deploy templates in minutes</p>
</motion.div>
<div className={'mx-auto mt-16 max-w-5xl'}>
<div className={'grid gap-12 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
1
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Create your template</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Use the visual editor for quick formatting or write custom HTML. Add variables with autocomplete.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
2
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Preview with real data</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Select any contact and see exactly what they'll receive. Test on desktop, tablet, and mobile. No
surprises.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
3
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Use everywhere</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Use your template in campaigns, workflows, and API calls. One template, unlimited uses.
</p>
</motion.div>
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Built for every team</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Whether you're a developer, marketer, or founder</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex items-start gap-6'}>
<div
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
<div className={'mt-4 rounded-lg bg-neutral-50 p-4'}>
<p className={'text-sm text-neutral-700'}>{useCase.example}</p>
</div>
</div>
</div>
</motion.div>
))}
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Build your first template today
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails free every month. Then $0.001 per email. No contact limits, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
+326 -281
View File
@@ -8,33 +8,33 @@ import Head from 'next/head';
const features = [
{
icon: <Database className="h-6 w-6" strokeWidth={1.5} />,
icon: <Database className="h-5 w-5" />,
title: 'Automatic Contact Capture',
description: 'Every sender is automatically added to your contact database with no manual data entry required.',
featured: true,
},
{
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
icon: <Zap className="h-5 w-5" />,
title: 'Workflow Automation',
description: 'Trigger automated workflows when emails are received to create sophisticated two-way communication.',
},
{
icon: <Shield className="h-6 w-6" strokeWidth={1.5} />,
icon: <Shield className="h-5 w-5" />,
title: 'Built-in Security',
description: 'Spam, virus, SPF, DKIM, and DMARC filtering keeps your inbox clean. The spam stays out, the good stuff gets in.',
description:
'Spam, virus, SPF, DKIM, and DMARC filtering keeps your inbox clean. The spam stays out, the good stuff gets in.',
},
{
icon: <Bell className="h-6 w-6" strokeWidth={1.5} />,
icon: <Bell className="h-5 w-5" />,
title: 'Webhook Notifications',
description: 'Get instant notifications with rich metadata whenever an email arrives at your domain.',
},
{
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
icon: <Mail className="h-5 w-5" />,
title: 'Simple DNS Setup',
description: 'Add one MX record to your domain and start receiving emails immediately. No PhD required.',
},
{
icon: <Inbox className="h-6 w-6" strokeWidth={1.5} />,
icon: <Inbox className="h-5 w-5" />,
title: 'Real-Time Processing',
description: 'Emails are processed instantly and can trigger workflows or webhooks in real-time.',
},
@@ -42,21 +42,21 @@ const features = [
const useCases = [
{
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
icon: <Mail className="h-6 w-6" />,
title: 'Support Ticket Creation',
description:
'Automatically create support tickets when customers email [email protected]. Send auto-replies and route to your help desk system via webhooks. Your support team will thank you.',
benefits: ['Instant acknowledgment', 'Automatic ticket creation', 'No emails missed'],
},
{
icon: <Database className="h-6 w-6" strokeWidth={1.5} />,
icon: <Database className="h-6 w-6" />,
title: 'Lead Capture from Email',
description:
'Receive emails at [email protected] and automatically add senders to your CRM. Trigger nurture workflows based on when they reached out.',
benefits: ['Zero-friction lead capture', 'Auto-segmentation', 'Instant follow-up'],
},
{
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
icon: <Zap className="h-6 w-6" />,
title: 'Two-Way Conversations',
description:
'Let customers reply to your campaign emails and automatically trigger engagement workflows. Tag contacts as "engaged" when they respond.',
@@ -64,6 +64,9 @@ const useCases = [
},
];
/**
*
*/
export default function InboundEmailFeature() {
return (
<>
@@ -82,301 +85,343 @@ export default function InboundEmailFeature() {
<Navbar />
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>Inbound Email</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Inbox className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>Inbound Email</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Turn Incoming Emails
<br />
into Actions
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Receive emails at your custom domain and automatically trigger workflows, capture leads, or create support
tickets. Two-way email communication made simple.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
Emails in,
<br />
actions out.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Receive emails at your custom domain and automatically trigger workflows, capture leads, or create support
tickets. Two-way email communication made simple.
</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Start receiving emails
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Complete inbound email solution
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
Everything you need to receive and process incoming emails
</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
View documentation
</Link>
</div>
</motion.div>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
</div>
</section>
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Complete inbound email solution
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to receive and process incoming emails</p>
</motion.div>
{/* How It Works */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Set up in minutes</h2>
<p className={'mt-6 text-lg text-neutral-600'}>Get started with inbound email in three simple steps</p>
</motion.div>
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
<div className={'mx-auto mt-16 max-w-5xl'}>
<div className={'grid gap-12 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div
className={
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
}
>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* How it works */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Set up in minutes
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>One MX record is all it takes</p>
</motion.div>
<div className={'mx-auto max-w-4xl'}>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
{[
{
step: '01',
title: 'Verify your domain',
body: 'Add and verify your custom domain in Plunk by configuring DKIM and SPF records in your DNS settings.',
},
{
step: '02',
title: 'Add MX record',
body: 'Add one MX record to your DNS to route incoming emails to Plunk. Copy the record directly from your dashboard.',
},
{
step: '03',
title: 'Start receiving',
body: 'Emails sent to any address at your domain are automatically received and can trigger workflows or webhooks.',
},
].map((item, i) => (
<motion.div
key={item.step}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'bg-white p-10'}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Step {item.step}
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>{item.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Powerful use cases
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From support to sales, inbound email unlocks new automation possibilities</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<div className={'col-span-12 sm:col-span-8'}>
<p className={'leading-relaxed text-neutral-600'}>{useCase.description}</p>
<div className={'mt-5 flex flex-wrap gap-x-6 gap-y-1'}>
{useCase.benefits.map(b => (
<span
key={b}
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.16em] text-neutral-400'}
>
{b}
</span>
))}
</div>
1
</div>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Your domain can receive emails too.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Free plan available. $0.001 per email on paid. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Verify your domain</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Add and verify your custom domain in Plunk by configuring DKIM and SPF records in your DNS settings.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div
className={
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
}
>
2
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Add MX record</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Add one MX record to your DNS to route incoming emails to Plunk. Copy the record from your dashboard.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div
className={
'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'
}
>
3
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Start receiving</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Emails sent to any address at your domain are automatically received and can trigger workflows or
webhooks.
</p>
</motion.div>
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Powerful use cases</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
From support to sales, inbound email unlocks new automation possibilities
</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex items-start gap-6'}>
<div
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
<div className={'mt-4 flex flex-wrap gap-2'}>
{useCase.benefits.map(benefit => (
<span
key={benefit}
className={'rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700'}
>
{benefit}
</span>
))}
</div>
</div>
</div>
</motion.div>
))}
</div>
</section>
{/* Technical Details */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl'}
>
<div className={'rounded-2xl border border-neutral-200 bg-white p-8 sm:p-12'}>
<h2 className={'text-3xl font-bold tracking-tight text-neutral-900 text-balance'}>
What happens when an email arrives?
</h2>
<div className={'mt-10'}>
{[
{
title: 'Email arrives at your domain',
description: 'Your MX record routes the email to Plunk for processing',
},
{
title: 'Security checks pass',
description: 'Automatic validation of spam, virus, SPF, DKIM, and DMARC',
},
{
title: 'Contact is created or updated',
description: 'The sender is automatically added to your contact database',
},
{
title: 'Workflows trigger automatically',
description: 'Configured workflows start running based on the incoming email',
},
].map((step, i, arr) => (
<div key={step.title} className={'relative flex gap-6'}>
{/* Vertical connector */}
{i < arr.length - 1 && (
<div className={'absolute left-[1.125rem] top-10 bottom-0 w-px bg-neutral-200'} />
)}
<div className={'relative flex-shrink-0'}>
<div
className={
'flex h-9 w-9 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-sm font-bold text-neutral-900'
}
>
{i + 1}
</div>
</div>
<div className={i < arr.length - 1 ? 'pb-8' : ''}>
<p className={'font-semibold text-neutral-900'}>{step.title}</p>
<p className={'mt-1 text-sm text-neutral-600'}>{step.description}</p>
</div>
</div>
))}
</div>
</div>
</motion.div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Your domain can receive emails too
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Set up inbound email on any verified domain in minutes. Replies, support tickets, and webhooks, all from
one platform.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
+304 -331
View File
@@ -8,35 +8,37 @@ import Head from 'next/head';
const features = [
{
icon: <Filter className="h-6 w-6" strokeWidth={1.5} />,
icon: <Filter className="h-5 w-5" />,
title: 'Dynamic Filtering',
description: 'Create segments based on contact data, custom fields, email activity, and events with powerful AND/OR logic.',
featured: true,
description:
'Create segments based on contact data, custom fields, email activity, and events with powerful AND/OR logic.',
},
{
icon: <TrendingUp className="h-6 w-6" strokeWidth={1.5} />,
icon: <TrendingUp className="h-5 w-5" />,
title: 'Real-Time Updates',
description: 'Dynamic segments automatically update as contact data changes, always keeping your audience current.',
},
{
icon: <GitBranch className="h-6 w-6" strokeWidth={1.5} />,
icon: <GitBranch className="h-5 w-5" />,
title: 'Workflow Integration',
description: 'Trigger workflows when contacts enter or exit segments, or use segment conditions in workflow branching.',
description:
'Trigger workflows when contacts enter or exit segments, or use segment conditions in workflow branching.',
},
{
icon: <Target className="h-6 w-6" strokeWidth={1.5} />,
icon: <Target className="h-5 w-5" />,
title: 'Campaign Targeting',
description: 'Send targeted campaigns to specific segments instead of your entire contact list. Less noise, more signal.',
description:
'Send targeted campaigns to specific segments instead of your entire contact list. Less noise, more signal.',
},
{
icon: <Users className="h-6 w-6" strokeWidth={1.5} />,
icon: <Users className="h-5 w-5" />,
title: 'Static Segments',
description: 'Manually curate contact lists for special groups like beta testers or VIP customers.',
},
{
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
icon: <Mail className="h-5 w-5" />,
title: 'Behavior-Based',
description: 'Segment by email engagement who opened, clicked, bounced, or never received your emails.',
description: 'Segment by email engagement - who opened, clicked, bounced, or never received your emails.',
},
];
@@ -48,7 +50,7 @@ const filterExamples = [
},
{
title: 'Re-engagement Needed',
description: 'Find inactive users who need a nudge to come back',
description: 'Find inactive users who need a nudge to come back. Sometimes they just need a reminder.',
filters: ['Last activity older than 60 days', 'Email sent but not opened', 'Subscribed equals true'],
},
{
@@ -60,25 +62,28 @@ const filterExamples = [
const useCases = [
{
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
icon: <Mail className="h-6 w-6" />,
title: 'Targeted Campaigns',
description:
'Send newsletters and announcements to specific audience segments instead of blasting everyone. Increase open rates by sending relevant content to the right people.',
'Send newsletters and announcements to specific audience segments instead of blasting everyone. Increase open rates by sending relevant content to the right people. Your unsubscribe rate will thank you.',
},
{
icon: <GitBranch className="h-6 w-6" strokeWidth={1.5} />,
icon: <GitBranch className="h-6 w-6" />,
title: 'Behavior-Based Workflows',
description:
'Trigger workflows when contacts enter segments like "VIP Customers" or "Churning Users". Create personalized automations based on segment membership.',
},
{
icon: <Target className="h-6 w-6" strokeWidth={1.5} />,
icon: <Target className="h-6 w-6" />,
title: 'A/B Testing',
description:
'Create segments for test groups and control groups. Send different campaigns to each segment and measure results.',
},
];
/**
*
*/
export default function SegmentsFeature() {
return (
<>
@@ -91,352 +96,320 @@ export default function SegmentsFeature() {
<meta property="og:title" content="Audience Segmentation - Smart Contact Organization | Plunk" />
<meta
property="og:description"
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement."
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement. Target campaigns and trigger workflows based on segment membership."
/>
</Head>
<Navbar />
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>Audience Segmentation</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Users className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>Audience Segmentation</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Target the Right Audience,
<br />
Every Time
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Organize contacts into dynamic segments based on behavior, attributes, and engagement. Send targeted
campaigns and trigger personalized workflows.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
Target the right audience,
<br />
every time.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Organize contacts into dynamic segments based on behavior, attributes, and engagement. Send targeted
campaigns and trigger personalized workflows.
</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Start segmenting
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
The right message to the right person
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Build precise audiences and send campaigns that land</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
View documentation
</Link>
</div>
</motion.div>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
</div>
</section>
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
The right message to the right person
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Build precise audiences and send campaigns that land</p>
</motion.div>
{/* Filter Examples */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Flexible filtering options
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Build complex segments with nested AND/OR logic</p>
</motion.div>
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* Filter examples */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Flexible filtering options
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Build complex segments with nested AND/OR logic</p>
</motion.div>
<div className={'space-y-5'}>
{filterExamples.map((example, index) => (
<motion.div
key={example.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-[24px] border border-neutral-200 bg-white p-8'}
>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{example.title}
</h3>
<p className={'mt-1 text-neutral-600'}>{example.description}</p>
<div className={'mt-6 space-y-2'}>
{example.filters.map((filter, filterIndex) => (
<div key={filterIndex} className={'flex items-center gap-3 rounded-xl border border-neutral-100 bg-neutral-50 px-4 py-3'}>
<Filter className="h-4 w-4 flex-shrink-0 text-neutral-400" />
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-sm text-neutral-700'}>
{filter}
</span>
</div>
))}
</div>
</motion.div>
))}
</div>
</div>
</section>
{/* Segment types */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Two types of segments
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Choose between dynamic filtering or manual curation</p>
</motion.div>
<div className={'grid gap-5 sm:grid-cols-2'}>
{[
{
icon: <TrendingUp className="h-6 w-6" strokeWidth={1.5} />,
title: 'Dynamic Segments',
description: 'Automatically update based on filter conditions. As contact data changes, segment membership updates in real-time.',
bullets: ['Filter-based membership', 'Automatic updates', 'Optional entry/exit tracking'],
},
{
icon: <Users className="h-6 w-6" strokeWidth={1.5} />,
title: 'Static Segments',
description: 'Manually curate your segment by adding specific contacts. Membership stays fixed until you change it.',
bullets: ['Manual contact selection', 'Fixed membership', 'Perfect for VIP lists'],
},
].map((type, i) => (
<motion.div
key={type.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-[24px] border border-neutral-200 bg-white p-8'}
>
<div className={'text-neutral-900'}>{type.icon}</div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{type.title}
</h3>
<p className={'mt-2 text-neutral-600'}>{type.description}</p>
<ul className={'mt-6 space-y-2'}>
{type.bullets.map(b => (
<li key={b} className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-900'} />
{b}
</li>
))}
</ul>
</motion.div>
))}
</div>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Use segments everywhere
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From targeted campaigns to automated workflows</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<p className={'col-span-12 leading-relaxed text-neutral-600 sm:col-span-8'}>
{useCase.description}
</p>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Stop sending the same email to everyone.
</motion.h2>
<div className={'mx-auto max-w-5xl space-y-6'}>
{filterExamples.map((example, index) => (
<motion.div
initial={{opacity: 0, y: 16}}
key={example.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Build precise audience segments and watch your open rates climb. 1,000 emails free, no credit card
required.
<div className={'flex items-start justify-between gap-6'}>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{example.title}</h3>
<p className={'mt-2 text-neutral-600'}>{example.description}</p>
</div>
</div>
<div className={'mt-6 space-y-2'}>
{example.filters.map((filter, filterIndex) => (
<div key={filterIndex} className={'flex items-center gap-3 rounded-lg bg-neutral-50 px-4 py-3'}>
<Filter className="h-4 w-4 flex-shrink-0 text-neutral-400" />
<span className={'font-mono text-sm text-neutral-700'}>{filter}</span>
</div>
))}
</div>
</motion.div>
))}
</div>
</section>
{/* Types of Segments */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Two types of segments</h2>
<p className={'mt-6 text-lg text-neutral-600'}>Choose between dynamic filtering or manual curation</p>
</motion.div>
<div className={'mx-auto mt-16 max-w-5xl'}>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>
<TrendingUp className="h-6 w-6" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Dynamic Segments</h3>
<p className={'mt-2 text-neutral-600'}>
Automatically update based on filter conditions. As contact data changes, segment membership updates
in real-time.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
<div className={'mt-6 space-y-2'}>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Filter-based membership</span>
</div>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Automatic updates</span>
</div>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Optional entry/exit tracking</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>
<Users className="h-6 w-6" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Static Segments</h3>
<p className={'mt-2 text-neutral-600'}>
Manually curate your segment by adding specific contacts. Membership stays fixed until you change it.
</p>
<div className={'mt-6 space-y-2'}>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Manual contact selection</span>
</div>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Fixed membership</span>
</div>
<div className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>Perfect for VIP lists</span>
</div>
</div>
</motion.div>
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Use segments everywhere
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From targeted campaigns to automated workflows</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex items-start gap-6'}>
<div
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
</div>
</div>
</motion.div>
))}
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Stop sending the same email to everyone
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Build precise audience segments and watch your open rates climb. 1,000 emails free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
+216 -261
View File
@@ -8,33 +8,34 @@ import Head from 'next/head';
const features = [
{
icon: <Settings className="h-6 w-6" strokeWidth={1.5} />,
icon: <Settings className="h-5 w-5" />,
title: 'Simple Configuration',
description: 'Quick setup with your project credentials. Works with any email client or application.',
featured: true,
},
{
icon: <Lock className="h-6 w-6" strokeWidth={1.5} />,
icon: <Lock className="h-5 w-5" />,
title: 'Secure Connections',
description: 'TLS/SSL encryption on ports 465 and 587. Your emails are always transmitted securely.',
},
{
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
icon: <Mail className="h-5 w-5" />,
title: 'Universal Compatibility',
description: 'Works with Outlook, Thunderbird, Apple Mail, or any SMTP-compatible application.',
description:
'Works with Outlook, Thunderbird, Apple Mail, or any SMTP-compatible application. Even that ancient email client from 2005.',
},
{
icon: <Server className="h-6 w-6" strokeWidth={1.5} />,
icon: <Server className="h-5 w-5" />,
title: 'Domain Validation',
description: 'Automatic verification that your sender domain is verified before accepting emails.',
},
{
icon: <Shield className="h-6 w-6" strokeWidth={1.5} />,
icon: <Shield className="h-5 w-5" />,
title: 'Full Feature Support',
description: 'Attachments, custom headers, HTML emails, and multiple recipients.',
description:
'Attachments, custom headers, HTML emails, and multiple recipients. Send those cat memes with confidence.',
},
{
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
icon: <Code2 className="h-5 w-5" />,
title: 'Same Infrastructure',
description: 'SMTP emails use the same reliable delivery infrastructure as API emails with full tracking.',
},
@@ -53,21 +54,21 @@ const comparisonData = [
const useCases = [
{
icon: <Settings className="h-6 w-6" strokeWidth={1.5} />,
icon: <Settings className="h-6 w-6" />,
title: 'Legacy System Integration',
description:
'Already have applications using SMTP? No need to rewrite code. Just swap your SMTP credentials and keep everything else the same.',
'Already have applications using SMTP? No need to rewrite code. Just swap your SMTP credentials and keep everything else the same. Your PM will love you.',
benefit: 'Zero code changes required',
},
{
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
icon: <Mail className="h-6 w-6" />,
title: 'Email Client Sending',
description:
'Marketing teams can send emails directly from Outlook, Thunderbird, or Apple Mail using familiar tools without learning new APIs.',
benefit: 'No technical knowledge needed',
},
{
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
icon: <Code2 className="h-6 w-6" />,
title: 'Framework Compatibility',
description:
'Works with any framework or language that supports SMTP. Perfect for older systems or platforms without HTTP API support.',
@@ -75,6 +76,9 @@ const useCases = [
},
];
/**
*
*/
export default function SMTPFeature() {
return (
<>
@@ -93,285 +97,236 @@ export default function SMTPFeature() {
<Navbar />
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>SMTP</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Server className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>SMTP Email Sending</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Send Emails via SMTP or API
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Use our HTTP API for modern apps or drop in SMTP credentials for any legacy system. Same deliverability, same pricing, zero lock-in.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
Send via SMTP
<br />
or API. Your call.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Use our HTTP API for modern apps or drop in SMTP credentials for any legacy system. Same deliverability,
same pricing, zero lock-in.
</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get SMTP credentials
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>SMTP that works with everything</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Full authentication, tracking, and deliverability out of the box</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
View documentation
</Link>
</div>
</motion.div>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
</div>
</section>
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
SMTP that works with everything
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
Full authentication, tracking, and deliverability out of the box
</p>
</motion.div>
{/* Comparison Table */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-12 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>SMTP vs API</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Choose the right option for your use case</p>
</motion.div>
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* Comparison table */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-12'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
SMTP vs API
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Choose the right option for your use case</p>
</motion.div>
<div className={'overflow-hidden rounded-[24px] border border-neutral-200 bg-white'}>
<div className={'mx-auto max-w-4xl'}>
<div className={'overflow-hidden rounded-xl border border-neutral-200 bg-white'}>
<table className={'w-full'}>
<thead className={'border-b border-neutral-200 bg-neutral-50'}>
<thead className={'bg-neutral-50'}>
<tr>
<th className={'px-6 py-4 text-left text-sm font-semibold text-neutral-900'}>Feature</th>
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-500'}>Traditional SMTP</th>
<th className={'bg-neutral-900 px-6 py-4 text-center text-sm font-semibold text-white'}>Plunk SMTP</th>
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-500'}>Plunk API</th>
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>Traditional SMTP</th>
<th className={'bg-neutral-100 px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>
Plunk SMTP
</th>
<th className={'px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>Plunk API</th>
</tr>
</thead>
<tbody className={'divide-y divide-neutral-100'}>
<tbody className={'divide-y divide-neutral-200'}>
{comparisonData.map((row, index) => (
<tr key={index} className={'transition hover:bg-neutral-50'}>
<td className={'px-6 py-4 text-sm font-medium text-neutral-900'}>{row.feature}</td>
<td className={'px-6 py-4 text-center text-sm text-neutral-500'}>{row.traditional}</td>
<td className={'bg-neutral-50 px-6 py-4 text-center text-sm font-semibold text-neutral-900'}>{row.plunkSMTP}</td>
<td className={'px-6 py-4 text-center text-sm text-neutral-500'}>{row.plunkAPI}</td>
<td className={'px-6 py-4 text-sm text-neutral-900'}>{row.feature}</td>
<td className={'px-6 py-4 text-center text-sm text-neutral-600'}>{row.traditional}</td>
<td className={'bg-neutral-50 px-6 py-4 text-center text-sm font-medium text-neutral-900'}>
{row.plunkSMTP}
</td>
<td className={'px-6 py-4 text-center text-sm text-neutral-600'}>{row.plunkAPI}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className={'mt-4 text-sm text-neutral-500'}>
Recommendation: Use the API for modern applications with workflow automation. Use SMTP for email clients
and legacy systems.
</p>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
When to use SMTP
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Perfect for these scenarios</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<div className={'col-span-12 sm:col-span-8'}>
<p className={'leading-relaxed text-neutral-600'}>{useCase.description}</p>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'mt-5 inline-block text-[11px] uppercase tracking-[0.16em] text-neutral-400'}
>
{useCase.benefit}
</span>
</div>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Start sending via SMTP today.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Get your SMTP credentials and start sending from any client or application. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
</motion.div>
<div className={'mt-6 rounded-lg bg-neutral-50 p-4 text-center'}>
<p className={'text-sm text-neutral-600'}>
<strong>Recommendation:</strong> Use API for modern applications with workflow automation. Use SMTP for
email clients and legacy systems.
</p>
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>When to use SMTP</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Perfect for these scenarios</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex items-start gap-6'}>
<div
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
<div className={'mt-4 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2'}>
<div className={'h-2 w-2 rounded-full bg-green-500'} />
<span className={'text-sm font-medium text-neutral-700'}>{useCase.benefit}</span>
</div>
</div>
</div>
</motion.div>
))}
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>Start sending via SMTP</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Get your SMTP credentials and start sending emails from any client or application. No credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
+251 -280
View File
@@ -8,33 +8,32 @@ import Head from 'next/head';
const features = [
{
icon: <Zap className="h-6 w-6" strokeWidth={1.5} />,
icon: <Zap className="h-5 w-5" />,
title: 'Event-Driven Triggers',
description: 'Start workflows automatically when users sign up, make a purchase, or perform any custom action.',
featured: true,
},
{
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
icon: <Mail className="h-5 w-5" />,
title: 'Smart Email Sequences',
description: 'Send personalized emails at the right time with dynamic content based on user data.',
},
{
icon: <Clock className="h-6 w-6" strokeWidth={1.5} />,
icon: <Clock className="h-5 w-5" />,
title: 'Time-Based Delays',
description: 'Add strategic delays between steps to create perfectly timed email journeys. Patience is a virtue.',
},
{
icon: <GitBranch className="h-6 w-6" strokeWidth={1.5} />,
icon: <GitBranch className="h-5 w-5" />,
title: 'Conditional Logic',
description: 'Branch workflows based on user behavior, attributes, or engagement to personalize every journey.',
},
{
icon: <Webhook className="h-6 w-6" strokeWidth={1.5} />,
icon: <Webhook className="h-5 w-5" />,
title: 'External Integrations',
description: 'Connect to external systems with webhooks to sync data or trigger actions outside of Plunk.',
},
{
icon: <RefreshCw className="h-6 w-6" strokeWidth={1.5} />,
icon: <RefreshCw className="h-5 w-5" />,
title: 'Re-entry Control',
description: 'Decide whether contacts can enter workflows multiple times or just once. No spam, just strategy.',
},
@@ -42,21 +41,21 @@ const features = [
const useCases = [
{
icon: <UserPlus className="h-6 w-6" strokeWidth={1.5} />,
icon: <UserPlus className="h-6 w-6" />,
title: 'User Onboarding',
description:
'Welcome new users with a personalized email series that guides them through your product features and helps them get started.',
example: 'Trigger on signup → Send welcome email → Wait 2 days → Send getting started tips',
},
{
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
icon: <Mail className="h-6 w-6" />,
title: 'Abandoned Cart Recovery',
description:
'Automatically remind customers about items left in their cart with timely follow-ups and special incentives.',
'Automatically remind customers about items left in their cart with timely follow-ups and special incentives. Those forgotten items need a gentle nudge.',
example: 'Trigger on cart abandoned → Wait 1 hour → Send reminder → Wait 1 day → Send discount offer',
},
{
icon: <RefreshCw className="h-6 w-6" strokeWidth={1.5} />,
icon: <RefreshCw className="h-6 w-6" />,
title: 'Re-engagement Campaigns',
description:
'Win back inactive users with targeted campaigns based on their last activity and engagement patterns.',
@@ -64,6 +63,9 @@ const useCases = [
},
];
/**
*
*/
export default function WorkflowsFeature() {
return (
<>
@@ -82,300 +84,269 @@ export default function WorkflowsFeature() {
<Navbar />
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-20 sm:py-32'}>
{/* Subtle background grid */}
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-10 border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700'}
>
<span className={'text-neutral-400'}>Features</span>
<span className={'mx-3 text-neutral-300'}></span>
<span className={'font-medium text-neutral-900'}>Workflow Automation</span>
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div className={'mb-6 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-2 text-sm'}>
<Zap className="h-4 w-4 text-neutral-600" />
<span className={'font-medium text-neutral-600'}>Workflow Automation</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Email Automation
<br />
That Actually Works
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Turn events into personalized email journeys. Build sophisticated automation workflows with our visual
no-code builder.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
Email automation
<br />
that actually works.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Turn events into personalized email journeys. Build sophisticated automation workflows with our visual
no-code builder.
</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Start building workflows
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
View documentation
</Link>
</div>
</motion.div>
</div>
</section>
{/* Features grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
Everything you need for email automation
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Powerful features that make complex automations simple</p>
</motion.div>
<div className={'grid gap-5 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => {
const highlighted = feature.featured;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className={
highlighted
? 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-900 bg-neutral-900 p-8 text-white'
: 'flex min-h-[16rem] flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
<div className={'flex items-start justify-between'}>
<div className={highlighted ? 'text-white' : 'text-neutral-900'}>{feature.icon}</div>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={`text-[11px] uppercase tracking-[0.18em] ${highlighted ? 'text-neutral-500' : 'text-neutral-400'}`}
>
{String(index + 1).padStart(2, '0')}
</span>
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={`mt-8 text-xl font-bold tracking-[-0.02em] ${highlighted ? 'text-white' : 'text-neutral-900'}`}
>
{feature.title}
</h3>
<p className={`mt-2 text-sm leading-relaxed ${highlighted ? 'text-neutral-300' : 'text-neutral-600'}`}>
{feature.description}
</p>
</div>
</motion.div>
);
})}
View documentation
</Link>
</div>
</div>
</motion.div>
</section>
{/* How it works */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Visual workflow builder
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
Create complex email automations without writing a single line of code
</p>
</motion.div>
{/* Features Grid */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Everything you need for email automation
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Powerful features that make complex automations simple</p>
</motion.div>
<div className={'mx-auto max-w-4xl'}>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
{[
{
step: '01',
title: 'Choose a trigger',
body: 'Select an event that starts your workflow, like user signup, purchase, or any custom action you track.',
},
{
step: '02',
title: 'Build your flow',
body: 'Drag and drop steps to create your workflow. Add emails, delays, conditions, webhooks, and more.',
},
{
step: '03',
title: 'Activate and monitor',
body: 'Enable your workflow and watch it run automatically. Monitor executions in real-time with full visibility.',
},
].map((item, i) => (
<motion.div
key={item.step}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'bg-white p-10'}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Step {item.step}
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>{item.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</div>
</section>
{/* Use cases */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Built for every use case
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From onboarding to re-engagement, workflows handle it all</p>
</motion.div>
<ul className={'divide-y divide-neutral-200 border-y border-neutral-200'}>
{useCases.map((useCase, index) => (
<motion.li
key={useCase.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.08, ease: [0.22, 1, 0.36, 1]}}
className={'grid grid-cols-12 gap-6 py-10 sm:py-12'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'col-span-12 text-[11px] uppercase tracking-[0.18em] text-neutral-400 sm:col-span-1 sm:pt-1.5'}
>
{String(index + 1).padStart(2, '0')}
</span>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'col-span-12 text-xl font-bold tracking-[-0.02em] text-neutral-900 sm:col-span-3'}
>
{useCase.title}
</h3>
<div className={'col-span-12 sm:col-span-8'}>
<p className={'leading-relaxed text-neutral-600'}>{useCase.description}</p>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'mt-5 text-[11px] uppercase tracking-[0.16em] text-neutral-400'}
>
{useCase.example}
</p>
</div>
</motion.li>
))}
</ul>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Set up your first workflow in minutes.
</motion.h2>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{features.map((feature, index) => (
<motion.div
initial={{opacity: 0, y: 16}}
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Free plan available. $0.001 per email on paid. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started for free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
</div>
</section>
{/* How It Works */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Visual workflow builder
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Create complex email automations without writing a single line of code
</p>
</motion.div>
<div className={'mx-auto mt-16 max-w-5xl'}>
<div className={'grid gap-12 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
1
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Choose a trigger</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Select an event that starts your workflow, like user signup, purchase, or any custom action you track.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
2
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Build your flow</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Drag and drop steps to create your workflow. Add emails, delays, conditions, webhooks, and more.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-5 flex items-center gap-4'}>
<div className={'flex h-14 w-14 items-center justify-center rounded-full border-2 border-neutral-200 bg-white text-xl font-bold text-neutral-900'}>
3
</div>
</div>
<h3 className={'text-lg font-semibold text-neutral-900'}>Activate and monitor</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>
Enable your workflow and watch it run automatically. Monitor executions in real-time with full
visibility.
</p>
</motion.div>
</div>
</div>
</section>
{/* Use Cases */}
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Built for every use case
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>From onboarding to re-engagement, workflows handle it all</p>
</motion.div>
<div className={'mx-auto max-w-5xl space-y-8'}>
{useCases.map((useCase, index) => (
<motion.div
key={useCase.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-xl border border-neutral-200 bg-white p-8'}
>
<div className={'flex items-start gap-6'}>
<div
className={
'flex h-14 w-14 flex-shrink-0 items-center justify-center rounded-xl bg-neutral-900 text-white'
}
>
{useCase.icon}
</div>
<div className={'flex-1'}>
<h3 className={'text-xl font-semibold text-neutral-900'}>{useCase.title}</h3>
<p className={'mt-2 text-neutral-600'}>{useCase.description}</p>
<div className={'mt-4 rounded-lg bg-neutral-50 p-4'}>
<p className={'font-mono text-sm text-neutral-700'}>{useCase.example}</p>
</div>
</div>
</div>
</motion.div>
))}
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Set up your first workflow in minutes
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails free every month. Then $0.001 per email. No contact limits, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started for free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'/pricing'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
@@ -78,7 +78,7 @@ export default function EmailAPIGuide() {
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How Email APIs Work</h2>
<div className="space-y-6">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Authentication</h3>
<p className="text-neutral-700">
You authenticate requests using an API key (usually passed in headers). This identifies your account and
@@ -86,14 +86,14 @@ export default function EmailAPIGuide() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Make HTTP Request</h3>
<p className="text-neutral-700">
Send a POST request to the API endpoint with email details (recipient, subject, body, etc.) as JSON.
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. API Validates & Queues</h3>
<p className="text-neutral-700">
The API validates your request, queues the email for delivery, and returns a response with the email ID
@@ -101,14 +101,14 @@ export default function EmailAPIGuide() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Email Delivery</h3>
<p className="text-neutral-700">
The service handles SMTP connections, retry logic, and delivery to the recipient's mail server.
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Webhooks & Tracking</h3>
<p className="text-neutral-700">
You receive webhook notifications for delivery events (delivered, bounced, opened, clicked) and can query
@@ -535,7 +535,7 @@ func main() {
</p>
<div className="space-y-6">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Deliverability Reputation</h3>
<p className="text-neutral-700">
Choose providers with strong deliverability rates and sender reputation. Poor deliverability means your
@@ -543,7 +543,7 @@ func main() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Feature Set</h3>
<p className="text-neutral-700">
Ensure the API supports your needs: templates, webhooks, analytics, scheduling, attachments, etc. Some
@@ -551,7 +551,7 @@ func main() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Pricing Model</h3>
<p className="text-neutral-700">
Understand pricing: per-email charges, monthly tiers, overage fees. Calculate costs for your expected
@@ -559,7 +559,7 @@ func main() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Developer Experience</h3>
<p className="text-neutral-700">
Good documentation, SDKs in your language, clear error messages, and responsive support make
@@ -567,7 +567,7 @@ func main() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Scalability & Reliability</h3>
<p className="text-neutral-700">
Can the provider handle your peak volumes? What's their uptime guarantee (SLA)? Do they have redundancy
@@ -575,7 +575,7 @@ func main() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Compliance & Security</h3>
<p className="text-neutral-700">
Ensure the provider complies with GDPR, CAN-SPAM, and other relevant regulations. Check their security
@@ -141,7 +141,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Click-Through Rates?</h2>
<div className="space-y-6">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Call-to-Action (CTA) Design</h3>
<p className="text-neutral-700">
Your CTA's design, placement, and copy directly impact clicks. Clear, prominent, action-oriented CTAs
@@ -149,7 +149,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Email Relevance</h3>
<p className="text-neutral-700">
Targeted, personalized content generates 2-3x higher CTR than generic blasts. Segmentation and
@@ -157,7 +157,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Value Proposition</h3>
<p className="text-neutral-700">
Recipients need a clear reason to click. Compelling value propositions—exclusive content, limited offers,
@@ -165,7 +165,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Mobile Optimization</h3>
<p className="text-neutral-700">
60%+ of emails are read on mobile. Unoptimized emails with small links or poorly formatted content see 50%
@@ -173,7 +173,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Content Scannability</h3>
<p className="text-neutral-700">
Most people skim emails. Clear structure with headers, bullet points, and whitespace helps readers find
@@ -181,7 +181,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Number of CTAs</h3>
<p className="text-neutral-700">
More CTAs = divided attention. Emails with one primary CTA convert 371% better than those with multiple
@@ -402,14 +402,14 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common CTR Mistakes to Avoid</h2>
<div className="space-y-6">
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Too Many CTAs</h3>
<p className="text-neutral-700">
Multiple competing CTAs confuse readers and reduce overall clicks. Focus on one primary action per email.
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Vague CTA Copy</h3>
<p className="text-neutral-700">
"Click here" and "Learn more" don't communicate value. Be specific: "Download free guide" or "Start 14-day
@@ -417,7 +417,7 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Image-Only CTAs</h3>
<p className="text-neutral-700">
Many email clients block images by default. If your CTA is an image, users won't see it. Use HTML buttons
@@ -425,14 +425,14 @@ CTOR = (294 ÷ 2,450) × 100 = 12.0%`}
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Small, Hard-to-Tap Links</h3>
<p className="text-neutral-700">
Tiny text links are difficult to tap on mobile. Use large buttons (minimum 44x44px) for easy tapping.
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ No Clear Value Proposition</h3>
<p className="text-neutral-700">
Readers won't click if they don't know why they should. Clearly communicate the benefit of clicking before
@@ -71,7 +71,7 @@ export default function EmailDeliverability() {
</p>
<div className="space-y-6">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Sender Reputation</h3>
<p className="text-neutral-700">
Email providers track your sending behavior over time. High engagement rates, low spam complaints, and few
@@ -79,7 +79,7 @@ export default function EmailDeliverability() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Email Authentication</h3>
<p className="text-neutral-700">
SPF, DKIM, and DMARC authenticate your emails and prove they're legitimate. Without proper authentication,
@@ -87,7 +87,7 @@ export default function EmailDeliverability() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Email Content</h3>
<p className="text-neutral-700">
Spam filters analyze your subject lines, body content, links, and images. Spammy language, excessive
@@ -95,7 +95,7 @@ export default function EmailDeliverability() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. List Quality & Engagement</h3>
<p className="text-neutral-700">
Sending to engaged subscribers who want your emails is crucial. High open rates and clicks signal quality.
@@ -103,7 +103,7 @@ export default function EmailDeliverability() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Technical Infrastructure</h3>
<p className="text-neutral-700">
Your sending IP address, domain reputation, and email infrastructure affect how providers perceive your
@@ -107,7 +107,7 @@ export default function EmailMarketingBestPractices() {
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Email Content Best Practices</h2>
<div className="space-y-6 mb-8">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Craft Compelling Subject Lines</h3>
<p className="text-neutral-700 mb-3">
Your subject line determines whether emails get opened. Keep them under 50 characters, create curiosity,
@@ -125,7 +125,7 @@ export default function EmailMarketingBestPractices() {
</div>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Personalize Beyond First Names</h3>
<p className="text-neutral-700">
Use behavioral data, purchase history, browsing activity, or preferences. Personalized emails deliver 6x
@@ -133,7 +133,7 @@ export default function EmailMarketingBestPractices() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Focus on One Primary Goal</h3>
<p className="text-neutral-700">
Each email should have one clear call-to-action (CTA). Multiple CTAs confuse readers and reduce conversion
@@ -141,7 +141,7 @@ export default function EmailMarketingBestPractices() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Write Scannable Content</h3>
<p className="text-neutral-700 mb-3">Most people skim emails. Structure content for easy scanning:</p>
<ul className="list-disc list-inside space-y-1 text-neutral-700">
@@ -153,7 +153,7 @@ export default function EmailMarketingBestPractices() {
</ul>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Create Compelling CTAs</h3>
<p className="text-neutral-700 mb-3">Effective CTAs are:</p>
<ul className="list-disc list-inside space-y-1 text-neutral-700">
@@ -172,7 +172,7 @@ export default function EmailMarketingBestPractices() {
</ul>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Provide Real Value</h3>
<p className="text-neutral-700">
Every email should benefit the reader. Share insights, solve problems, offer exclusive content, or provide
@@ -361,7 +361,7 @@ export default function EmailMarketingBestPractices() {
</p>
<div className="space-y-6">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">What to A/B Test</h3>
<ul className="space-y-2 text-neutral-700">
<li>
@@ -385,7 +385,7 @@ export default function EmailMarketingBestPractices() {
</ul>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">A/B Testing Best Practices</h3>
<ul className="space-y-2 text-neutral-700">
<li> Test one variable at a time for clear results</li>
@@ -131,7 +131,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Email Open Rates?</h2>
<div className="space-y-6">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Subject Line</h3>
<p className="text-neutral-700 mb-3">
Your subject line is the #1 factor. It must be compelling, relevant, and create curiosity without being
@@ -161,7 +161,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</div>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Sender Name</h3>
<p className="text-neutral-700">
Recipients decide whether to open based on who it's from. Use a recognizable name—either your brand or a
@@ -169,7 +169,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Send Time & Day</h3>
<p className="text-neutral-700">
Timing impacts visibility. B2B emails typically perform best Tuesday-Thursday, 10am-2pm. B2C varies more
@@ -177,7 +177,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. List Quality & Segmentation</h3>
<p className="text-neutral-700">
Engaged subscribers open more. Segmenting by behavior, interests, or demographics ensures relevance.
@@ -185,7 +185,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Deliverability & Sender Reputation</h3>
<p className="text-neutral-700">
If emails land in spam, they won't be opened. Maintain good sender reputation through proper
@@ -193,7 +193,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Email Frequency</h3>
<p className="text-neutral-700">
Too frequent leads to fatigue and unsubscribes. Too infrequent and subscribers forget you. Find the sweet
@@ -201,7 +201,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">7. Mobile Optimization</h3>
<p className="text-neutral-700">
60%+ of emails are opened on mobile. Use short subject lines (40-50 characters), clear preview text, and
@@ -70,7 +70,7 @@ export default function EmailSenderReputation() {
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Sender Reputation?</h2>
<div className="space-y-6">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Spam Complaint Rate</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Critical.</strong> When recipients mark your emails as spam, it severely damages
@@ -81,7 +81,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Engagement Metrics</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Very High.</strong> Email providers track opens, clicks, replies, forwards, and deletes.
@@ -92,7 +92,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Bounce Rate</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: High.</strong> High bounce rates (especially hard bounces) indicate poor list hygiene,
@@ -103,7 +103,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Email Authentication</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: High.</strong> Proper SPF, DKIM, and DMARC authentication proves your emails are
@@ -114,7 +114,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Spam Trap Hits</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Critical.</strong> Spam traps are email addresses used to catch senders with poor
@@ -125,7 +125,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Sending Volume & Consistency</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Moderate.</strong> Sudden spikes in volume look suspicious. Inconsistent sending (long
@@ -136,7 +136,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">7. Content Quality</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Moderate.</strong> Spammy content (excessive links, misleading subject lines, all caps)
@@ -147,7 +147,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">8. Blacklist Status</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Critical.</strong> Being listed on major blacklists (Spamhaus, Barracuda, SURBL) can block
@@ -158,7 +158,7 @@ export default function EmailSenderReputation() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">9. Sending History</h3>
<p className="text-neutral-700 mb-3">
<strong>Impact: Cumulative.</strong> Reputation is built over time. New domains/IPs have no history (zero
+131 -146
View File
@@ -10,7 +10,7 @@ interface Guide {
title: string;
description: string;
href: string;
icon: React.ComponentType<{className?: string; strokeWidth?: number}>;
icon: React.ComponentType<{className?: string}>;
badge?: string;
}
@@ -94,13 +94,10 @@ const guides: Guide[] = [
},
];
const badgeOrder = ['Authentication', 'Deliverability', 'Analytics', 'Technical', 'Best Practices', 'Fundamentals'];
/**
* Email Guides hub page
*/
export default function GuidesIndex() {
const categories = badgeOrder
.map(name => ({name, guides: guides.filter(g => g.badge === name)}))
.filter(c => c.guides.length > 0);
return (
<>
<NextSeo
@@ -118,166 +115,154 @@ export default function GuidesIndex() {
<Navbar />
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
Free Guides
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
<Book className="h-4 w-4 text-neutral-600" />
<span className={'text-sm text-neutral-600'}>Free Email Guides</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
Master email
<br />
marketing & deliverability
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Free guides on email authentication, deliverability, best practices, and technical implementation. Learn
from the experts.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
Master email,
<br />
start to finish.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Free guides on email authentication, deliverability, best practices, and technical implementation.
</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
</div>
</motion.div>
</div>
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
</div>
</motion.div>
</section>
{/* Guides - Categorized */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
<div className={'space-y-16'}>
{categories.map((category, catIndex) => (
{/* Guides Grid */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Browse all guides</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to master email</p>
</motion.div>
<div className={'grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
{guides.map((guide, index) => {
const Icon = guide.icon;
return (
<motion.div
key={category.name}
initial={{opacity: 0, y: 16}}
key={guide.href}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.6, delay: catIndex * 0.06, ease: [0.22, 1, 0.36, 1]}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
>
<div className={'mb-6 flex items-center gap-4'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'shrink-0 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
{category.name}
</span>
<div className={'h-px flex-1 bg-neutral-200'} />
</div>
<div className={'divide-y divide-neutral-100'}>
{category.guides.map(guide => {
const Icon = guide.icon;
return (
<Link
key={guide.href}
href={guide.href}
className={
'group -mx-4 flex items-center gap-5 rounded-lg px-4 py-5 transition hover:bg-neutral-50 sm:gap-6'
}
>
<Icon
className={'h-5 w-5 shrink-0 text-neutral-400 transition group-hover:text-neutral-600'}
strokeWidth={1.5}
/>
<div className={'min-w-0 flex-1'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'font-bold tracking-[-0.01em] text-neutral-900'}
>
{guide.title}
</h3>
<p className={'mt-0.5 truncate text-sm text-neutral-500'}>{guide.description}</p>
</div>
<ArrowRight
className={
'h-4 w-4 shrink-0 text-neutral-300 transition-transform group-hover:translate-x-0.5 group-hover:text-neutral-600'
}
/>
</Link>
);
})}
</div>
</motion.div>
))}
</div>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Put it into practice.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Start free. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
href={guide.href}
className={
'group block h-full rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
View pricing
<div className={'flex items-start justify-between mb-4'}>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Icon className="h-5 w-5" />
</div>
{guide.badge && (
<span className={'rounded-full bg-neutral-100 px-3 py-1 text-xs font-medium text-neutral-700'}>
{guide.badge}
</span>
)}
</div>
<h3 className={'text-xl font-semibold text-neutral-900 mb-2 group-hover:text-neutral-700'}>
{guide.title}
</h3>
<p className={'text-sm text-neutral-600 leading-relaxed'}>{guide.description}</p>
</Link>
</div>
</motion.div>
</div>
</motion.div>
);
})}
</div>
</section>
{/* CTA Section */}
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to get started?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Put these guides into practice with Plunk's modern email platform. Start free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Get started free
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</motion.div>
</section>
</main>
<Footer />
+10 -10
View File
@@ -33,7 +33,7 @@ export default function WhatIsDKIM() {
</p>
<div className="space-y-6 mb-8">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. The Sending Server Signs the Email</h3>
<p className="text-neutral-700">
When you send an email, your email server adds a DKIM signature to the email header. This signature is
@@ -41,7 +41,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. The Signature is Added to Headers</h3>
<p className="text-neutral-700">
The DKIM signature includes a hash of specific email components (like the subject, body, and sender) and
@@ -49,7 +49,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. The Receiving Server Verifies</h3>
<p className="text-neutral-700">
When the email arrives, the receiving server looks up your domain's public DKIM key in DNS, then uses it
@@ -57,7 +57,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Delivery Decision</h3>
<p className="text-neutral-700">
Passing DKIM verification improves your sender reputation and deliverability. Failing or missing DKIM may
@@ -252,7 +252,7 @@ export default function WhatIsDKIM() {
<h2 className="text-3xl font-bold text-neutral-900 mb-6">DKIM Best Practices</h2>
<div className="space-y-6">
<div className="py-2">
<div className="border-l-4 border-green-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Use 2048-bit Keys</h3>
<p className="text-neutral-700">
While 1024-bit keys still work, 2048-bit keys provide better security and are recommended by Gmail and
@@ -260,7 +260,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-green-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Implement SPF and DMARC Too</h3>
<p className="text-neutral-700">
DKIM works best when combined with SPF and DMARC for comprehensive email authentication. Use all three for
@@ -268,7 +268,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-green-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Monitor DKIM Status</h3>
<p className="text-neutral-700">
Regularly check that your DKIM signatures are passing. Most email platforms provide authentication
@@ -276,7 +276,7 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-green-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Rotate Keys Periodically</h3>
<p className="text-neutral-700">
For enhanced security, rotate your DKIM keys every 6-12 months. Plan key rotation carefully to avoid
@@ -284,14 +284,14 @@ export default function WhatIsDKIM() {
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Don't Share Private Keys</h3>
<p className="text-neutral-700">
Your DKIM private key should never be shared or stored insecurely. Treat it like a password.
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Don't Use the Same Key Across Domains</h3>
<p className="text-neutral-700">
Each domain should have its own unique DKIM key pair for security and proper authentication.
@@ -32,7 +32,7 @@ export default function WhatIsDMARC() {
</p>
<div className="space-y-6 mb-8">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Email Authentication</h3>
<p className="text-neutral-700">
When an email is received, the server first checks SPF and DKIM authentication. At least one of these must
@@ -40,7 +40,7 @@ export default function WhatIsDMARC() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Alignment Check</h3>
<p className="text-neutral-700">
DMARC checks if the domain in the "From" header aligns with the domain that passed SPF or DKIM. This is
@@ -48,7 +48,7 @@ export default function WhatIsDMARC() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Policy Application</h3>
<p className="text-neutral-700">
If authentication and alignment pass, the email is delivered. If they fail, the receiving server follows
@@ -56,7 +56,7 @@ export default function WhatIsDMARC() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Reporting</h3>
<p className="text-neutral-700">
Receiving servers send daily reports to your specified email address, showing authentication results for
@@ -32,7 +32,7 @@ export default function WhatIsSPF() {
</p>
<div className="space-y-6 mb-8">
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. You Publish an SPF Record</h3>
<p className="text-neutral-700">
You add a TXT record to your domain's DNS that lists all IP addresses and services authorized to send
@@ -40,7 +40,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. An Email is Sent</h3>
<p className="text-neutral-700">
When someone sends an email claiming to be from your domain, the receiving server notes the IP address of
@@ -48,7 +48,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. The Receiving Server Checks SPF</h3>
<p className="text-neutral-700">
The receiving server looks up your domain's SPF record in DNS and checks if the sending server's IP
@@ -56,7 +56,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="">
<div className="border-l-4 border-neutral-900 pl-6">
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Pass or Fail</h3>
<p className="text-neutral-700">
If the IP matches, SPF passes. If not, SPF fails and the email may be flagged as spam or rejected,
@@ -268,7 +268,7 @@ export default function WhatIsSPF() {
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common SPF Mistakes to Avoid</h2>
<div className="space-y-6">
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Multiple SPF Records</h3>
<p className="text-neutral-700">
Never create multiple SPF TXT records. You can only have ONE SPF record per domain. Combine all authorized
@@ -276,7 +276,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Exceeding 10 DNS Lookups</h3>
<p className="text-neutral-700">
Each <code>include:</code> mechanism counts toward the 10 lookup limit. Too many includes will cause SPF
@@ -284,7 +284,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Forgetting to Update SPF</h3>
<p className="text-neutral-700">
When you add new email services, remember to update your SPF record. Outdated SPF records cause legitimate
@@ -292,7 +292,7 @@ export default function WhatIsSPF() {
</p>
</div>
<div className="py-2">
<div className="border-l-4 border-red-500 pl-6 py-2">
<h3 className="text-lg font-semibold text-neutral-900 mb-2"> Using +all</h3>
<p className="text-neutral-700">
Never use <code>+all</code> (pass all). This completely defeats the purpose of SPF by allowing anyone to
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+194 -257
View File
@@ -1,21 +1,9 @@
import {NextSeo} from 'next-seo';
import React from 'react';
import {Footer, Navbar, SectionHeader} from '../components';
import {Footer, Navbar} from '../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../lib/constants';
import {
ArrowRight,
BarChart3,
Code2,
Globe,
Mail,
PackageOpen,
Shield,
Users,
Zap,
X,
Check,
} from 'lucide-react';
import {ArrowRight, BarChart3, Code2, Globe, Mail, PackageOpen, Shield, Users, Zap, X, Check} from 'lucide-react';
import Link from 'next/link';
import {GithubIcon} from 'lucide-react';
@@ -67,6 +55,9 @@ const includedFeatures = [
},
];
/**
*
*/
export default function Pricing() {
return (
<>
@@ -78,286 +69,232 @@ export default function Pricing() {
openGraph={{
title: 'Plunk Pricing | The Open-Source Email Platform',
description:
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 1,000 emails/month. No hidden fees.',
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 1,000 emails/month across transactional, workflow, and campaign emails. No hidden fees, pay only for what you use.',
}}
additionalMetaTags={[{property: 'title', content: 'Plunk Pricing | The Open-Source Email Platform'}]}
additionalMetaTags={[
{
property: 'title',
content: 'Plunk Pricing | The Open-Source Email Platform',
},
]}
/>
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<section className={'relative py-20 sm:py-32'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
Simple, transparent pricing
</h1>
<p className={'mx-auto mt-6 max-w-2xl text-xl text-neutral-600'}>
Free plan: 1,000 emails per month. Paid plan: $0.001 per email. Unlimited contacts, no hidden fees.
</p>
</motion.div>
</div>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<h1 className="text-5xl font-bold tracking-tight text-neutral-900 sm:text-6xl lg:text-7xl text-balance">
Simple, transparent pricing
</h1>
<p className="mx-auto mt-6 max-w-2xl text-xl text-neutral-600">
1,000 emails free every month. Then $0.001 per email. Unlimited contacts, no hidden fees.
</p>
</motion.div>
</section>
{/* Pricing tiers */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
<div className={'mx-auto grid max-w-4xl gap-px bg-neutral-200 sm:grid-cols-2'}>
{/* Free */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.1, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col bg-white p-10'}
>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-400'}
>
Free forever
</p>
<div className={'mt-4 flex items-baseline gap-2'}>
<span
style={{fontFamily: 'var(--font-display)'}}
className={'text-6xl font-extrabold tracking-[-0.03em] text-neutral-900'}
>
1,000
</span>
<span className={'text-lg text-neutral-500'}>emails / mo</span>
</div>
<p className={'mt-2 text-sm text-neutral-500'}>No credit card required</p>
<section className={'pb-20'}>
<div className={'mx-auto grid max-w-4xl gap-px bg-neutral-200 sm:grid-cols-2'}>
{/* Free */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.1, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col bg-white p-10'}
>
<p className={'text-sm font-semibold uppercase tracking-widest text-neutral-400'}>Free forever</p>
<div className={'mt-4 flex items-baseline gap-2'}>
<span className={'text-6xl font-bold tracking-tight text-neutral-900'}>1,000</span>
<span className={'text-lg text-neutral-500'}>emails / mo</span>
</div>
<p className={'mt-2 text-sm text-neutral-500'}>No credit card required</p>
<ul className={'mt-8 flex-1 space-y-3'}>
{[
'Transactional emails',
'Workflow automation',
'Campaign broadcasts',
'Custom domains',
'Click & open tracking',
'Unlimited contacts',
].map(item => (
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
{item}
</li>
))}
<li className={'flex items-center gap-3 text-sm text-neutral-400'}>
<X className={'h-4 w-4 flex-shrink-0'} />
Plunk branding on emails
<ul className={'mt-8 flex-1 space-y-3'}>
{['Transactional emails', 'Workflow automation', 'Campaign broadcasts', 'Custom domains', 'Click & open tracking', 'Unlimited contacts'].map(item => (
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
{item}
</li>
</ul>
))}
<li className={'flex items-center gap-3 text-sm text-neutral-400'}>
<X className={'h-4 w-4 flex-shrink-0'} />
Plunk branding on emails
</li>
</ul>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={
'mt-10 block w-full rounded-full border border-neutral-300 px-6 py-3 text-center text-sm font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
Start for free
</motion.a>
</motion.div>
{/* Pay as you grow */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.2, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col bg-neutral-900 p-10 text-white'}
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={'mt-10 block w-full rounded-lg border border-neutral-300 px-6 py-3 text-center text-sm font-semibold text-neutral-900 transition hover:border-neutral-400'}
>
<p
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-400'}
>
Pay as you grow
</p>
<div className={'mt-4 flex items-baseline gap-2'}>
<span
style={{fontFamily: 'var(--font-display)'}}
className={'text-6xl font-extrabold tracking-[-0.03em] text-white'}
>
$0.001
</span>
<span className={'text-lg text-neutral-400'}>/ email</span>
</div>
<p className={'mt-2 text-sm'}>&nbsp;</p>
Start for free
</motion.a>
</motion.div>
<ul className={'mt-8 flex-1 space-y-3'}>
{['Everything in Free', 'No Plunk branding', 'Monthly spend cap', 'Unlimited emails'].map(item => (
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-300'}>
<Check className={'h-4 w-4 flex-shrink-0 text-white'} />
{item}
</li>
))}
</ul>
{/* Pay as you grow */}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{delay: 0.2, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col bg-white p-10'}
>
<p className={'text-sm font-semibold uppercase tracking-widest text-neutral-400'}>Pay as you grow</p>
<div className={'mt-4 flex items-baseline gap-2'}>
<span className={'text-6xl font-bold tracking-tight text-neutral-900'}>$0.001</span>
<span className={'text-lg text-neutral-500'}>/ email</span>
</div>
<p className={'mt-2 text-sm'}>&nbsp;</p>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={
'mt-10 block w-full rounded-full bg-white px-6 py-3 text-center text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Get started
</motion.a>
</motion.div>
</div>
<ul className={'mt-8 flex-1 space-y-3'}>
{['Everything in Free', 'No Plunk branding', 'Monthly spend cap', 'Unlimited emails'].map(item => (
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
{item}
</li>
))}
</ul>
<motion.a
href={`${DASHBOARD_URI}/auth/signup`}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={'mt-10 block w-full rounded-lg bg-neutral-900 px-6 py-3 text-center text-sm font-semibold text-white transition hover:bg-neutral-800'}
>
Get started
</motion.a>
</motion.div>
</div>
</section>
{/* Every feature included */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
<SectionHeader
number={'01'}
label={'Included'}
title={'Every feature, every plan.'}
subtitle={'No feature tiers, no add-ons, no surprises.'}
/>
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Every feature on every plan
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>No feature tiers, no add-ons, no surprises</p>
</motion.div>
<ul className={'mt-16 divide-y divide-neutral-200 border-y border-neutral-200'}>
{includedFeatures.map((feature, index) => (
<motion.li
key={feature.title}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.04, ease: [0.22, 1, 0.36, 1]}}
className={'grid gap-3 py-7 sm:grid-cols-[1fr_2fr] sm:items-baseline sm:gap-12 sm:py-8'}
>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-base font-semibold text-neutral-900'}
>
{feature.title}
</h3>
<p className={'text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.li>
))}
</ul>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{includedFeatures.map((feature, index) => (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-10 transition hover:bg-neutral-50'}
>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'}>
{feature.icon}
</div>
<h3 className={'mt-6 text-lg font-semibold text-neutral-900'}>{feature.title}</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</motion.div>
))}
</div>
</section>
{/* Self-host */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'overflow-hidden rounded-[24px] border border-neutral-200 bg-white'}
>
<div className={'flex flex-col items-center gap-6 p-10 sm:flex-row sm:gap-0'}>
<div className={'flex-1 text-center sm:text-left'}>
<div
style={{fontFamily: 'var(--font-mono)'}}
className={'mb-3 inline-flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
<PackageOpen className={'h-3.5 w-3.5'} />
Self-hostable
</div>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-2xl font-bold tracking-[-0.025em] text-neutral-900'}
>
Run it on your own infrastructure
</h2>
<p className={'mt-2 text-neutral-600'}>
Full data ownership, no per-email costs, and GDPR compliance by default. Deploy with Docker Compose
in minutes.
</p>
</div>
<div className={'sm:ml-auto sm:pl-8'}>
<motion.button
onClick={() => window.open('https://github.com/useplunk/plunk', '_blank')}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={
'flex w-full items-center justify-center gap-x-3 rounded-full bg-neutral-900 px-6 py-3 text-base font-semibold text-white transition hover:bg-neutral-800 sm:w-auto'
}
>
<GithubIcon size={18} />
View on GitHub
</motion.button>
<section className={'py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'overflow-hidden rounded-xl border border-neutral-200 bg-white'}
>
<div className={'flex flex-col items-center gap-6 p-10 sm:flex-row sm:gap-0'}>
<div className={'flex-1 text-center sm:text-left'}>
<div className={'mb-3 inline-flex items-center gap-2 rounded-full bg-neutral-100 px-3 py-1 text-sm font-medium text-neutral-700'}>
<PackageOpen className={'h-3.5 w-3.5'} />
Self-hostable
</div>
<h2 className={'text-2xl font-bold text-neutral-900'}>Run it on your own infrastructure</h2>
<p className={'mt-2 text-neutral-600'}>
Full data ownership, no per-email costs, and GDPR compliance by default. Deploy with Docker Compose in minutes.
</p>
</div>
</motion.div>
</div>
<div className={'sm:ml-auto sm:pl-8'}>
<motion.button
onClick={() => {
window.open('https://github.com/useplunk/plunk', '_blank');
}}
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
className={'flex w-full items-center justify-center gap-x-3 rounded-lg bg-neutral-900 px-6 py-3 text-base font-semibold text-white transition hover:bg-neutral-800 sm:w-auto'}
>
<GithubIcon size={18} />
View on GitHub
</motion.button>
</div>
</div>
</motion.div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
<section className={'relative overflow-hidden border-t border-neutral-200 py-20'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-4xl font-bold tracking-tight text-neutral-900 sm:text-5xl text-balance'}>
Start sending in 5 minutes
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails free every month. No credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'}
>
Start sending in 5 minutes.
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
<span className={'flex items-center gap-2'}>
Create free account
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={'https://github.com/useplunk/plunk'}
target={'_blank'}
className={'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Start free. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Create free account
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'https://github.com/useplunk/plunk'}
target={'_blank'}
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'
}
>
Self-host for free
</Link>
</div>
</motion.div>
Self-host for free
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
+210 -265
View File
@@ -1,50 +1,31 @@
import {Footer, Navbar, SectionHeader} from '../../components';
import {Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, ArrowUpRight, Code2, Search} from 'lucide-react';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
import {ArrowRight, Code2, Mail, Search, Sparkles, Wrench} from 'lucide-react';
const tools = [
{
name: 'Markdown to Email',
slug: 'markdown-to-email',
description: 'Convert rich text to email-safe HTML with a visual editor and instant preview.',
description: 'Convert rich text to email-safe HTML with our visual editor and instant preview.',
features: ['Visual Editor', 'Email-Safe HTML', 'Inline CSS', 'Copy to Clipboard'],
icon: Code2,
number: '01',
},
{
name: 'Email Verification',
slug: 'verify-email',
description: 'Verify email addresses instantly. Check DNS, MX records, typos, and disposable domains.',
features: ['DNS Validation', 'Typo Detection', 'MX Records', 'Disposable Check'],
icon: Search,
number: '02',
},
];
/**
* Free Email Tools index page
*/
export default function ToolsIndex() {
return (
<>
@@ -63,261 +44,225 @@ export default function ToolsIndex() {
<Navbar />
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
}
/>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<Wrench className="h-4 w-4 text-neutral-600" />
<span className={'text-sm text-neutral-600'}>Free Email Tools</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Free tools for
<br />
email developers
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Build better emails with our free tools. Convert markdown to email-safe HTML, verify email addresses, and
more. No sign-up required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'font-medium text-neutral-900'}>§ Tools &nbsp; &nbsp;Plunk</span>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/guides"
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(3rem,9vw,8rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
Free email
<br />
developer tools
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
Convert markdown to email-safe HTML, verify addresses, and more. No sign-up required.
</p>
<div className={'mt-10 flex flex-wrap justify-center gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'
}
>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href="/guides"
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'
}
>
Browse guides
</Link>
</div>
</motion.div>
Browse guides
</Link>
</div>
</section>
</motion.div>
</section>
{/* ========== TOOLS LIST ========== */}
<section className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'01'}
label={'Tools'}
title={'Available tools.'}
subtitle={'Everything you need to work with email. No sign-up, no limits.'}
/>
{/* Tools Grid */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Available Tools</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to work with emails</p>
</motion.div>
<ul className={'mt-20 divide-y divide-neutral-200 border-y border-neutral-200'}>
{tools.map((tool, i) => (
<motion.li
key={tool.slug}
initial={{opacity: 0, y: 12}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.06, ease: [0.22, 1, 0.36, 1]}}
>
<Link
href={`/tools/${tool.slug}`}
className={
'group flex items-center justify-between gap-6 py-6 transition-colors hover:bg-neutral-50 sm:py-8'
}
>
<div className={'flex items-center gap-6 sm:gap-10'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'w-12 text-xs tabular-nums tracking-[0.18em] text-neutral-400 sm:w-16'}
>
{tool.number}
</span>
<div>
<span
style={{fontFamily: 'var(--font-display)'}}
className={
'block text-4xl font-bold tracking-[-0.03em] text-neutral-900 transition-transform duration-300 group-hover:-translate-x-1 sm:text-5xl lg:text-6xl'
}
>
{tool.name}
</span>
<p className={'mt-2 text-sm text-neutral-500'}>{tool.description}</p>
</div>
</div>
<div className={'flex items-center gap-4'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={
'hidden text-xs uppercase tracking-[0.18em] text-neutral-500 transition group-hover:text-neutral-900 sm:inline'
}
>
Open tool
</span>
<span
className={
'flex h-11 w-11 items-center justify-center rounded-full border border-neutral-300 text-neutral-900 transition group-hover:border-neutral-900 group-hover:bg-neutral-900 group-hover:text-white sm:h-14 sm:w-14'
}
>
<ArrowUpRight className={'h-5 w-5'} strokeWidth={2} />
</span>
</div>
</Link>
</motion.li>
))}
</ul>
</section>
{/* ========== WHY ========== */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'02'}
label={'Why'}
title={'Built for developers,'}
titleAccent={'free forever.'}
subtitle={'No sign-up, no paywalls. Tools built by email experts for real-world workflows.'}
/>
<div className={'mt-20 grid gap-10 sm:grid-cols-3 sm:gap-16'}>
{[
{
tag: 'Access',
big: '$0',
title: 'Free forever',
body: 'No sign-up, no paywalls, no limits. Use these tools as much as you need, completely free.',
},
{
tag: 'Focus',
big: 'Dev-first',
title: 'Developer-focused',
body: 'Built by developers who work with email every day. Clean outputs, instant results, real-world workflows.',
},
{
tag: 'Output',
big: '100%',
title: 'Production ready',
body: 'Email-safe HTML that works across all email clients. Industry-standard validation. Battle-tested by thousands.',
},
].map((item, i) => (
<div className={'grid gap-8 md:grid-cols-2 lg:grid-cols-2 max-w-4xl mx-auto'}>
{tools.map((tool, index) => {
const Icon = tool.icon;
return (
<Link key={tool.slug} href={`/tools/${tool.slug}`}>
<motion.div
key={item.tag}
initial={{opacity: 0, y: 16}}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.6, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col gap-6'}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={
'group rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg cursor-pointer h-full'
}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.2em] text-neutral-500'}
>
/ {item.tag}
</span>
<div
style={{fontFamily: 'var(--font-display)'}}
className={'text-5xl font-extrabold tracking-[-0.035em] text-neutral-900 sm:text-6xl'}
>
{item.big}
<div className={'flex items-start justify-between mb-4'}>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Icon className="h-5 w-5" />
</div>
<Sparkles className="h-5 w-5 text-neutral-400" />
</div>
<h3 className={'text-2xl font-bold text-neutral-900 mb-3'}>{tool.name}</h3>
<p className={'mb-6 leading-relaxed text-neutral-600'}>{tool.description}</p>
<div className={'space-y-2'}>
{tool.features.map(feature => (
<div key={feature} className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>{feature}</span>
</div>
))}
</div>
<div className={'h-px w-full bg-neutral-300'} />
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-semibold text-neutral-900'}
>
{item.title}
</h3>
<p className={'text-base leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
</Link>
);
})}
</div>
</section>
{/* ========== CTA ========== */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'
}
>
Need production-grade email?
</motion.h2>
{/* Why Use These Tools */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why use these tools?</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Built by email experts for email developers</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Templates, scheduling, automation, analytics, and deliverability. Start free, scale as you grow.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Start with Plunk
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href="/pricing"
className={
'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'
}
>
View pricing
</Link>
</div>
</motion.div>
</div>
<div className={'grid gap-8 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Mail className="h-8 w-8 text-blue-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Free Forever</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
No sign-up, no paywalls, no limits. Use our tools as much as you need, completely free. We believe in
giving back to the email development community.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Code2 className="h-8 w-8 text-green-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Developer-Focused</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
Built by developers who work with email every day. Clean outputs, instant results, and designed for
real-world email workflows.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Sparkles className="h-8 w-8 text-yellow-500 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Production Ready</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
Generate email-safe HTML that works across all email clients. Verify emails with industry-standard
checks. Our tools are battle-tested and used by thousands of developers.
</p>
</motion.div>
</div>
</section>
{/* CTA */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Need production-grade email tools?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
These free tools are great for development, but Plunk offers so much more: templates, scheduling,
automation, analytics, and deliverability optimization. Start free, scale as you grow.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Start with Plunk
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</section>
</main>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
+113 -240
View File
@@ -1,41 +1,21 @@
import {Footer, Navbar, SectionHeader} from '../../components';
import {Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React, {useMemo, useState} from 'react';
import {NextSeo} from 'next-seo';
import {ArrowRight, Check, Code2, Copy} from 'lucide-react';
import {ArrowRight, Check, Code2, Copy, Sparkles} from 'lucide-react';
import {MarkdownEmailEditor} from '../../components/tools/MarkdownEmailEditor';
import {convertToCompleteEmailHtml} from '../../lib/emailHtmlConverter';
import {Button} from '@plunk/ui';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
import Link from 'next/link';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
export default function MarkdownToEmail() {
const [editorContent, setEditorContent] = useState('<p>Hello!</p><p>Try editing this text...</p>');
const [copied, setCopied] = useState(false);
const emailSafeHtml = useMemo(() => convertToCompleteEmailHtml(editorContent), [editorContent]);
// Convert editor content to complete, ready-to-send email HTML
const emailSafeHtml = useMemo(() => {
return convertToCompleteEmailHtml(editorContent);
}, [editorContent]);
const handleCopy = async () => {
await navigator.clipboard.writeText(emailSafeHtml);
@@ -60,230 +40,123 @@ export default function MarkdownToEmail() {
<Navbar />
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
className={
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
}
>
<span className={'font-medium text-neutral-900'}>§ T-01 &nbsp; &nbsp;Tool</span>
<Link
href="/tools"
className={'text-neutral-500 transition hover:text-neutral-900'}
>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
Markdown to
<br />
Email HTML
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
Format your content with the visual editor and get production-ready HTML with inlined styles that works across all email clients.
</p>
</motion.div>
</div>
</section>
{/* ========== EDITOR ========== */}
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'grid gap-6 lg:grid-cols-2'}
>
{/* Left: Editor */}
<div>
<div className={'mb-4 flex items-center justify-between'}>
<div className={'flex items-center gap-3'}>
<Code2 className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
Visual Editor
</span>
</div>
</div>
<MarkdownEmailEditor value={editorContent} onChange={setEditorContent} />
</div>
<Sparkles className="h-4 w-4 text-neutral-600" />
<span className={'text-sm text-neutral-600'}>Free Tool</span>
</div>
{/* Right: Email-Safe HTML Output */}
<div>
<div className={'mb-4 flex items-center justify-between'}>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
Email-Safe HTML
</span>
<Button onClick={handleCopy} size="sm" variant="outline" className={'gap-2'}>
{copied ? (
<>
<Check className={'h-4 w-4'} />
Copied!
</>
) : (
<>
<Copy className={'h-4 w-4'} />
Copy
</>
)}
</Button>
</div>
<div className={'overflow-hidden rounded-[16px] border border-neutral-200 bg-white'}>
<pre className={'max-h-[500px] min-h-[500px] overflow-x-auto overflow-y-auto p-4 text-xs'}>
<code style={{fontFamily: 'var(--font-mono)'}} className={'text-neutral-700'}>
{emailSafeHtml}
</code>
</pre>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Markdown to Email
<br />
HTML Converter
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Create beautiful, email-safe HTML instantly. Format your text with our visual editor and get
production-ready HTML with inlined styles that works across all email clients.
</p>
</motion.div>
</section>
{/* Editor Section */}
<section className={'py-16'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'grid gap-6 lg:grid-cols-2'}
>
{/* Left: Editor */}
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-neutral-900">Visual Editor</h2>
<div className="flex items-center gap-2 text-sm text-neutral-600">
<Code2 className="h-4 w-4" />
<span>Format your content</span>
</div>
</div>
</motion.div>
</section>
<MarkdownEmailEditor value={editorContent} onChange={setEditorContent} />
</div>
{/* ========== ABOUT ========== */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'01'}
label={'How it works'}
title={'Write once,'}
titleAccent={'send everywhere.'}
subtitle={
'Email clients ignore most CSS. This tool inlines every style rule so your formatting survives any inbox.'
{/* Right: Email-Safe HTML Output */}
<div>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-neutral-900">Email-Safe HTML</h2>
<Button onClick={handleCopy} size="sm" variant="outline" className="gap-2">
{copied ? (
<>
<Check className="h-4 w-4" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</Button>
</div>
<div className="border border-neutral-200 rounded-lg overflow-hidden bg-white">
<pre className="p-4 text-xs font-mono overflow-x-auto min-h-[500px] max-h-[500px] overflow-y-auto">
<code className="text-neutral-700">{emailSafeHtml}</code>
</pre>
</div>
</div>
</motion.div>
</section>
{/* CTA Section */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Ready to send great emails?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
This tool is great for creating email HTML, but Plunk handles everything: templates, sending, tracking,
and deliverability. Start free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
/>
<div className={'mt-20 grid gap-10 sm:grid-cols-3 sm:gap-16'}>
{[
{
tag: 'Step 1',
big: 'Write',
title: 'Format your content',
body: 'Use the visual editor to format text, add headings, lists, and links — just like a word processor.',
},
{
tag: 'Step 2',
big: 'Convert',
title: 'Styles get inlined',
body: 'Every CSS rule is moved inline so email clients like Outlook, Gmail, and Apple Mail render it correctly.',
},
{
tag: 'Step 3',
big: 'Copy',
title: 'Drop it in your send',
body: 'Copy the output HTML and paste it into any email service, SMTP template, or API payload.',
},
].map((item, i) => (
<motion.div
key={item.tag}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.6, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col gap-6'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.2em] text-neutral-500'}
>
/ {item.tag}
</span>
<div
style={{fontFamily: 'var(--font-display)'}}
className={'text-5xl font-extrabold tracking-[-0.035em] text-neutral-900 sm:text-6xl'}
>
{item.big}
</div>
<div className={'h-px w-full bg-neutral-300'} />
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-semibold text-neutral-900'}
>
{item.title}
</h3>
<p className={'text-base leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
>
<span className={'flex items-center gap-2'}>
Start with Plunk
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
</div>
</section>
{/* ========== CTA ========== */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Ready to send great emails?
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Plunk handles everything: templates, sending, tracking, and deliverability. Start free, no credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Start with Plunk
<ArrowRight className={'h-4 w-4'} />
</motion.a>
</div>
</motion.div>
</div>
</div>
</section>
</main>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
+161 -218
View File
@@ -1,4 +1,4 @@
import {Footer, Navbar, SectionHeader} from '../../components';
import {Footer, Navbar} from '../../components';
import {motion} from 'framer-motion';
import {DASHBOARD_URI} from '../../lib/constants';
import React, {useState} from 'react';
@@ -9,29 +9,6 @@ import {verifyEmail} from '../../lib/emailVerification';
import {EmailVerificationResult} from '../../components/tools/EmailVerificationResult';
import {Button, Input} from '@plunk/ui';
import {EMAIL_VERIFICATION_FEATURES} from '../../lib/toolsContent';
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
import Link from 'next/link';
const display = Bricolage_Grotesque({
subsets: ['latin'],
variable: '--font-display',
display: 'swap',
weight: ['400', '500', '600', '700', '800'],
});
const body = Hanken_Grotesk({
subsets: ['latin'],
variable: '--font-body',
display: 'swap',
weight: ['400', '500', '600', '700'],
});
const mono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500'],
});
export default function VerifyEmailPage() {
const [email, setEmail] = useState('');
@@ -71,218 +48,184 @@ export default function VerifyEmailPage() {
<Navbar />
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
aria-hidden
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
className={
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
}
>
<span className={'font-medium text-neutral-900'}>§ T-02 &nbsp; &nbsp;Tool</span>
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
All tools
</Link>
</motion.div>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-5xl text-center'}
>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={
'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
}
>
Email address
<br />
verification
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
Check for typos, disposable domains, DNS configuration, and MX records. Detailed results in seconds.
</p>
</motion.div>
</div>
</section>
{/* ========== VERIFICATION TOOL ========== */}
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-2xl'}
>
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
<div className={'border-b border-neutral-200 px-8 py-5'}>
<div className={'flex items-center gap-3'}>
<Search className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
>
Verify an address
</span>
</div>
<Search className="h-4 w-4 text-neutral-600" />
<span className={'text-sm text-neutral-600'}>Free Tool</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Email Verification
<br />
Tool
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Verify email addresses instantly. Check for typos, disposable domains, DNS configuration, and more. Get
detailed verification results in seconds.
</p>
</motion.div>
</section>
{/* Verification Tool Section */}
<section className={'pb-16'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-2xl'}
>
{/* Input Form */}
<div className="rounded-lg border border-neutral-200 bg-white p-8 shadow-lg">
<form onSubmit={handleVerify} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-neutral-900 mb-2">
Email Address
</label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="[email protected]"
required
disabled={loading}
className="w-full"
/>
</div>
<form onSubmit={handleVerify} className={'space-y-4 p-8'}>
<div>
<label htmlFor="email" className={'mb-2 block text-sm font-medium text-neutral-900'}>
Email address
</label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="[email protected]"
required
disabled={loading}
className={'w-full'}
/>
</div>
<Button type="submit" disabled={loading || !email} className="w-full gap-2">
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Verifying...
</>
) : (
<>
<CheckCircle className="h-4 w-4" />
Verify Email
</>
)}
</Button>
</form>
<Button type="submit" disabled={loading || !email} className={'w-full gap-2'}>
{loading ? (
<>
<Loader2 className={'h-4 w-4 animate-spin'} />
Verifying
</>
) : (
<>
<CheckCircle className={'h-4 w-4'} />
Verify email
</>
)}
</Button>
</form>
{error && (
<div className={'mx-8 mb-8 rounded-lg border border-red-200 bg-red-50 p-4'}>
<p className={'text-sm text-red-700'}>{error}</p>
</div>
)}
</div>
{result && (
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'mt-6'}
>
<EmailVerificationResult result={result} />
</motion.div>
{/* Error Display */}
{error && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
</motion.div>
</section>
{/* ========== WHY VERIFY ========== */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
<SectionHeader
number={'01'}
label={'Why verify'}
title={'Protect your'}
titleAccent={'sender reputation.'}
subtitle={'Invalid addresses hurt deliverability. Verification keeps your list clean before you send.'}
/>
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
{EMAIL_VERIFICATION_FEATURES.map((feature, index) => {
const Icon = feature.icon;
return (
<motion.div
key={feature.title}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={
'flex flex-col gap-8 rounded-[20px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
}
>
<div className={'text-neutral-900'}>
<Icon className={'h-6 w-6'} strokeWidth={1.5} />
</div>
<div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{feature.title}
</h3>
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
</div>
</motion.div>
);
})}
</div>
</div>
</section>
{/* ========== CTA ========== */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Ready for production-grade verification?
</motion.h2>
{/* Results Display */}
{result && (
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5}}
className="mt-8"
>
<EmailVerificationResult result={result} />
</motion.div>
)}
</motion.div>
</section>
{/* Educational Content */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why verify email addresses?</h2>
<p className={'mt-4 text-lg text-neutral-600'}>
Email verification helps improve deliverability and protect your sender reputation.
</p>
</motion.div>
<div className={'grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
{EMAIL_VERIFICATION_FEATURES.map((feature, index) => {
const Icon = feature.icon;
return (
<motion.div
initial={{opacity: 0, y: 16}}
key={feature.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
className={
'group block h-full rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Bulk verification, real-time validation, and seamless integration with your email workflows. Start free, no credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
}
>
Start with Plunk
<ArrowRight className={'h-4 w-4'} />
</motion.a>
<div
className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white mb-4'}
>
<Icon className="h-5 w-5" />
</div>
<h3 className={'text-xl font-semibold text-neutral-900 mb-2'}>{feature.title}</h3>
<p className={'text-sm text-neutral-600 leading-relaxed'}>{feature.description}</p>
</motion.div>
</div>
);
})}
</div>
</section>
{/* CTA Section */}
<section className={'border-t border-neutral-200 py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Ready for production-grade email verification?
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
This tool is great for testing individual emails, but Plunk offers bulk verification, real-time
validation, and seamless integration with your email workflows. Start free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Start with Plunk
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
</div>
</section>
</main>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
+344 -106
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Check, Code2, DollarSign, Globe, PackageOpen, Workflow, Zap} from 'lucide-react';
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Workflow, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -49,6 +49,9 @@ const faqs: FAQ[] = [
},
];
/**
* Plunk vs ActiveCampaign comparison page
*/
export default function ActiveCampaignComparison() {
return (
<>
@@ -67,132 +70,367 @@ export default function ActiveCampaignComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs ActiveCampaign
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ActiveCampaign</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for ActiveCampaign
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
ActiveCampaign is powerful but expensive and complex. Plunk delivers essential email automation without
CRM bloat, sales features, or enterprise pricing. Built for developers, not marketing departments.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Enterprise Features Without Enterprise Prices
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Email automation that doesn't break the bank</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for ActiveCampaign
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>ActiveCampaign is powerful but expensive and complex. Plunk delivers essential email automation without CRM bloat, sales features, or enterprise pricing. Built for developers, not marketing departments.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Simple pay-as-you-go</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails sent. All features included, no tier gating, no forced upgrades.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>All automation features included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No monthly minimums or contracts</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Typical savings: 50-80% vs ActiveCampaign</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>ActiveCampaign</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Expensive subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Complex pricing with feature gating. Automation requires higher tiers.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>From $29/month</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Advanced automation: $149+/month</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Feature limits on lower tiers</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Includes CRM/sales features you may not need</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
Enterprise Features Without Enterprise Prices
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Email automation that doesn't break the bank</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Automation Without Complexity</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Affordable Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go with all features included. No expensive tier upgrades required for automation. Typically
50-80% cheaper than ActiveCampaign.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending in minutes. No CRM to configure, no sales pipeline to set up. Just authenticate and build
your email workflows.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-First</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Clean API designed for developers, not marketing teams. Integrate email into your product without
learning complex enterprise software.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Full transparency into how your emails are processed. Contribute features, understand
the system. ActiveCampaign is proprietary.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Deploy on your own infrastructure with Docker. Full data control, compliance-ready, no vendor lock-in.
ActiveCampaign is cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Workflow className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Event-based triggers, segmentation, and automated sequences. All the email automation power of
ActiveCampaign without CRM complexity.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Simple pay-as-you-go</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Only pay for emails sent. All features included, no tier gating, no forced upgrades.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />All automation features included</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />No monthly minimums or contracts</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Typical savings: 50-80% vs ActiveCampaign</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>ActiveCampaign</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Expensive subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Complex pricing with feature gating. Automation requires higher tiers.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>From $29/month</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Advanced automation: $149+/month</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Feature limits on lower tiers</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Includes CRM/sales features you may not need</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Automation Without Complexity</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Powerful email workflows without the enterprise overhead</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{[
{icon: <Workflow className="h-5 w-5" />, title: 'Workflow Automation', body: 'Build automated email sequences with triggers, delays, and conditions. No CRM required. Pure email automation that integrates with your product.'},
{icon: <Code2 className="h-5 w-5" />, title: 'Developer-First API', body: 'Integrate email into your application with a clean REST API. No complex marketing UI to navigate just code and send.'},
{icon: <DollarSign className="h-5 w-5" />, title: '50-80% Cost Savings', body: 'ActiveCampaign charges $149+/month for automation features. Plunk charges for emails sent only. The savings add up fast.'},
{icon: <PackageOpen className="h-5 w-5" />, title: 'Open Source', body: 'AGPL-3.0 licensed. Inspect the code, understand the system, contribute features. No proprietary algorithms or black-box systems.'},
{icon: <Globe className="h-5 w-5" />, title: 'Self-Hostable', body: 'Deploy on your own infrastructure with Docker. Full data ownership and compliance control. ActiveCampaign is cloud-only.'},
{icon: <Zap className="h-5 w-5" />, title: 'Fast Setup', body: 'Start sending in 5 minutes. Simple authentication, clean API, comprehensive docs. No CRM to configure before you can send your first email.'},
].map((item, i) => (
<motion.div key={item.title} initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>{item.icon}</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>{item.title}</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
</motion.div>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="ActiveCampaign" rows={comparisonData} />
</div>
<ComparisonTable competitorName="ActiveCampaign" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Email automation, not enterprise complexity.
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Save 50-80% on email automation. Open-source, self-hostable, pay-as-you-go. Start free.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Get email automation without the bloat
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing focused email automation over enterprise complexity. Start free, no credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+342 -105
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Check, Code2, DollarSign, Globe, PackageOpen, Zap} from 'lucide-react';
import {ArrowRight, Code2, DollarSign, Globe, Mail, PackageOpen, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -49,6 +49,9 @@ const faqs: FAQ[] = [
},
];
/**
* Plunk vs Bento comparison page
*/
export default function BentoComparison() {
return (
<>
@@ -67,131 +70,365 @@ export default function BentoComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Bento
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Bento</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Bento
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Bento is an all-in-one platform. Plunk is focused on email. Get powerful email automation without CRM,
live chat, or multi-channel complexity. Developer-first, API-driven, transparent pricing.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Focused Email vs All-in-One Complexity
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not what you don't need</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Bento
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Bento is an all-in-one platform. Plunk is focused on email. Get powerful email automation without CRM, live chat, or multi-channel complexity. Developer-first, API-driven, transparent pricing.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go for email only. No CRM or chat features you don't need. Simple, transparent pricing.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails sent</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No monthly commitments</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Focus on email, nothing more</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Bento</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Trial then subscription</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
30-day unlimited trial, then subscription pricing. Includes CRM, chat, and multi-channel features.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>30-day trial</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Trial ends, then monthly subscription</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Includes features beyond email</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>All-in-one platform approach</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
Focused Email vs All-in-One Complexity
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not what you don't need</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Developer-First Email Platform</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>API-First Design</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Built for developers who integrate email into their applications. Clean REST API, comprehensive docs,
webhook support.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Full code transparency, contribute features, understand the system. Bento is
proprietary closed-source.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay-as-you-go</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails sent. No subscriptions, no trials that expire, no forced plan upgrades. Predictable
costs based on usage.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simple Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending in 5 minutes. No CRM to configure, no multi-channel setup, no live chat integration. Just
email, done right.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Deploy on your infrastructure with Docker. Full data control, compliance-ready, no vendor lock-in. Bento
is cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Mail className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Email-Focused</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Do one thing exceptionally well: email. No feature bloat, no CRM complexity, no multi-channel confusion.
Just powerful email automation.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go for email only. No CRM or chat features you don't need. Simple, transparent pricing.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Only pay for emails sent</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />No monthly commitments</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Focus on email, nothing more</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Bento</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Trial then subscription</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>30-day unlimited trial, then subscription pricing. Includes CRM, chat, and multi-channel features.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>30-day trial</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Trial ends, then monthly subscription</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Includes features beyond email</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />All-in-one platform approach</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Developer-First Email Platform</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need for email, nothing you don't</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{[
{icon: <Code2 className="h-5 w-5" />, title: 'API-First Design', body: 'Built for developers who integrate email into their applications. Clean REST API, comprehensive docs, webhook support.'},
{icon: <PackageOpen className="h-5 w-5" />, title: 'Open Source', body: 'AGPL-3.0 licensed. Full code transparency, contribute features, understand the system. Bento is proprietary closed-source.'},
{icon: <DollarSign className="h-5 w-5" />, title: 'Pay-as-you-go', body: 'Only pay for emails sent. No subscriptions, no trials that expire, no forced plan upgrades. Predictable costs based on usage.'},
{icon: <Zap className="h-5 w-5" />, title: 'Simple Setup', body: 'Start sending in 5 minutes. No CRM to configure, no multi-channel setup, no live chat integration. Just email, done right.'},
{icon: <Globe className="h-5 w-5" />, title: 'Self-Hostable', body: 'Deploy on your infrastructure with Docker. Full data ownership, compliance control, no vendor lock-in. Bento is cloud-only.'},
].map((item, i) => (
<motion.div key={item.title} initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>{item.icon}</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>{item.title}</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
</motion.div>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="Bento" rows={comparisonData} />
</div>
<ComparisonTable competitorName="Bento" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Focused email. No bloat.
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>API-first email without CRM complexity. Open-source, self-hostable, pay-as-you-go. Start free.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Get focused email automation</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing focused email solutions over all-in-one complexity. Start free, no credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+338 -127
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, Check, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -69,153 +69,364 @@ export default function BrevoComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Brevo
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Brevo</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Brevo
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Simpler pricing, cleaner API, open-source code. All the email power, none of the bloat. Built for
developers, not marketing teams.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Pricing That Scales With You</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Brevo
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Simpler pricing, cleaner API, open-source code. All the email power, none of the bloat. Built for developers, not marketing teams.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Simple pay-as-you-go pricing. Unlimited contacts, pay only for emails sent.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>$0.001/email</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>All features on all plans</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No monthly minimums</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Brevo</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Tiered by contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription tiers based on contact count with feature restrictions on lower plans.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Contact-based</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pricing increases with contacts</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Features gated by tier</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Monthly subscription required</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
Pricing That Scales With You
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Brevo</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Focus on email, not feature overload</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simpler Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay $0.001 per email with unlimited contacts. No contact-based tiers, no surprise charges as you grow.
Predictable costs.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-First</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Clean API, clear documentation, 5-minute setup. Built for developers who want to ship fast, not navigate
complex marketing suites.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, fork if needed. Full transparency, no vendor
lock-in. Brevo is proprietary.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. Brevo is
cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Store unlimited contacts at no extra cost. Brevo charges more as your contact list grows. With Plunk,
you only pay for emails sent.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Email-Focused</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Plunk does email and does it well. No SMS, chat, CRM bloat. If you need just email automation, Plunk is
simpler and more focused.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Simple pay-as-you-go pricing. Unlimited contacts, pay only for emails sent.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>$0.001/email</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Unlimited contacts included</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />All features on all plans</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />No monthly minimums</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Brevo</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Tiered by contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription tiers based on contact count with feature restrictions on lower plans.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Contact-based</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pricing increases with contacts</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Features gated by tier</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Monthly subscription required</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Why Choose Plunk Over Brevo</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Focus on email, not feature overload</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><DollarSign className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Simpler Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Pay $0.001 per email with unlimited contacts. No contact-based tiers, no surprise charges as you grow. Predictable costs.</p>
</motion.div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Zap className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Developer-First</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Clean API, clear documentation, 5-minute setup. Built for developers who want to ship fast, not navigate complex marketing suites.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, fork if needed. Full transparency, no vendor lock-in. Brevo is proprietary.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. Brevo is cloud-only.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Store unlimited contacts at no extra cost. Brevo charges more as your contact list grows. With Plunk, you only pay for emails sent.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Email-Focused</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Plunk does email and does it well. No SMS, chat, CRM bloat. If you need just email automation, Plunk is simpler and more focused.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="Brevo" rows={comparisonData} />
</div>
<ComparisonTable competitorName="Brevo" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Make the switch to simplicity
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Join developers choosing focused tools over bloated marketing suites. Start free, no credit card required.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Make the switch to simplicity</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing focused tools over bloated marketing suites. Start free, no credit card required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing details
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+339 -126
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Check, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -70,152 +70,365 @@ export default function ConvertkitComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs ConvertKit
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs ConvertKit</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for ConvertKit
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
ConvertKit is built for creators. Plunk is built for developers who need powerful email automation without
the creator-focused bloat. Same automation, better DX, pay-as-you-go.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Pay for Emails, Not Subscribers</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pricing that grows with usage, not list size</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for ConvertKit
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>ConvertKit is built for creators. Plunk is built for developers who need powerful email automation without the creator-focused bloat. Same automation, better DX, pay-as-you-go.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you send, not for subscribers you store.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited subscribers at no cost</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay when you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Grow your list without price increases</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>ConvertKit</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per subscriber count</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription based on total subscribers, whether you email them or not.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>From $25/month</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Price increases with subscriber count</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pay even if you don't send emails</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>10K subscribers = $119/month</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
Pay for Emails, Not Subscribers
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pricing that grows with usage, not list size</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Built for Developers, Not Creators</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-First API</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Modern REST API designed for developers. No visual builder complexity, just clean endpoints for sending
emails and managing workflows.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay-as-you-go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails sent, not subscribers stored. Grow your list without worrying about tier upgrades or
subscription increases.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your emails are sent.
ConvertKit is proprietary.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your own infrastructure with Docker. Full data control, compliance-ready, cost-optimized for
scale. ConvertKit is cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending emails in minutes. No form builders to configure, no landing pages to design. Just
authenticate and send.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Store unlimited contacts without penalty. No subscriber tiers, no forced upgrades. Your contact database
grows freely.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you send, not for subscribers you store.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Unlimited subscribers at no cost</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Only pay when you actually send</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Grow your list without price increases</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>ConvertKit</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Pay per subscriber count</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription based on total subscribers, whether you email them or not.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>From $25/month</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Price increases with subscriber count</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pay even if you don't send emails</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />10K subscribers = $119/month</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Built for Developers, Not Creators</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Code2 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Developer-First API</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Modern REST API designed for developers. No visual builder complexity, just clean endpoints for sending emails and managing workflows.</p>
</motion.div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><DollarSign className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Pay-as-you-go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Only pay for emails sent, not subscribers stored. Grow your list without worrying about tier upgrades or subscription increases.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your emails are sent. ConvertKit is proprietary.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your own infrastructure with Docker. Full data control, compliance-ready, cost-optimized for scale. ConvertKit is cloud-only.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Zap className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Start sending emails in minutes. No form builders to configure, no landing pages to design. Just authenticate and send.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Store unlimited contacts without penalty. No subscriber tiers, no forced upgrades. Your contact database grows freely.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="ConvertKit" rows={comparisonData} />
</div>
<ComparisonTable competitorName="ConvertKit" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Ready for a developer-first email platform?
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Join developers who've switched from ConvertKit to Plunk for better API design and transparent pay-as-you-go pricing.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Ready for a developer-first email platform?
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers who've switched from ConvertKit to Plunk for better API design and transparent
pay-as-you-go pricing.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+332 -122
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, Check, Code2, DollarSign, Globe, PackageOpen, Zap} from 'lucide-react';
import {ArrowRight, BarChart3, Code2, DollarSign, Globe, PackageOpen, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -65,124 +65,311 @@ export default function CustomerioComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Customer.io
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Customer.io</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Customer.io
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Customer.io starts at $100/month and takes weeks to set up. Plunk delivers the same event-driven automation in an open-source platform you can be sending from in under 5 minutes.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Simple Pricing That Scales</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Transparent pay-as-you-go vs complex enterprise tiers</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Customer.io
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Customer.io starts at $100/month and takes weeks to set up. Plunk delivers the same event-driven automation in an open-source platform you can be sending from in under 5 minutes.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No contact-based pricing complexity</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>All features included, no upsells</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Customer.io</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Complex usage-based pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pricing based on profiles, messages, and feature tiers.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Tiered complexity</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pay for profiles and messages sent</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Enterprise pricing can be expensive</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Advanced features locked behind tiers</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
Simple Pricing That Scales
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Transparent pay-as-you-go vs complex enterprise tiers</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Only pay for emails you actually send</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />No contact-based pricing complexity</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />All features included, no upsells</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Customer.io</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Complex usage-based pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Pricing based on profiles, messages, and feature tiers.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Tiered complexity</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pay for profiles and messages sent</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Enterprise pricing can be expensive</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Advanced features locked behind tiers</li>
</ul>
</motion.div>
</div>
</div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
</motion.div>
<ComparisonTable competitorName="Customer.io" rows={comparisonData} />
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Key Advantages</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Why developers choose Plunk</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Key Advantages</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Why developers choose Plunk</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-First Experience</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Clean, simple APIs designed for developers. No complex visual builders or marketing jargon. Integrate in
minutes, not weeks.
</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Code2 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Developer-First Experience</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Clean, simple APIs designed for developers. No complex visual builders or marketing jargon. Integrate in minutes, not weeks.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><DollarSign className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Transparent Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Pay-as-you-go pricing with no hidden costs or complex tiers. No need to pay for contact lists or profile counts - just emails sent.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Transparent Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing with no hidden costs or complex tiers. No need to pay for contact lists or profile
counts - just emails sent.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, fork if needed. No vendor lock-in with proprietary platforms.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, fork if needed. No vendor lock-in with
proprietary platforms.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Zap className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Quick Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Minutes to integrate and start sending. No lengthy onboarding, no sales calls, no enterprise setup processes. Start free immediately.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Quick Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Minutes to integrate and start sending. No lengthy onboarding, no sales calls, no enterprise setup
processes. Start free immediately.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hosting Option</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only AWS SES fees when self-hosting.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hosting Option</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only AWS SES fees when
self-hosting.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Simplicity at Scale</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Simple workflows that scale without over-engineering. Get enterprise capabilities without enterprise complexity or dedicated ops teams.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simplicity at Scale</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Simple workflows that scale without over-engineering. Get enterprise capabilities without enterprise
complexity or dedicated ops teams.
</p>
</motion.div>
<ComparisonTable competitorName="Customer.io" rows={comparisonData} />
</div>
</section>
@@ -190,28 +377,51 @@ export default function CustomerioComparison() {
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Try Plunk free
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Developer-friendly from day one.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails/month free. No credit card required. Developer-friendly from day one.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
Read documentation
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+194 -198
View File
@@ -87,6 +87,9 @@ const competitors = [
},
];
/**
* Plunk vs Competitors index page
*/
export default function CompetitorsIndex() {
return (
<>
@@ -105,227 +108,220 @@ export default function CompetitorsIndex() {
<Navbar />
<main className={'text-neutral-800'}>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
aria-hidden
className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'}
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div
initial={{opacity: 0, y: 16}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Comparisons
</div>
<h1
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}
>
Plunk vs the
<br />
competition.
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
Most email platforms charge by the contact, lock you in, and split transactional from marketing. Plunk does all three in one open-source platform at $0.001 per email.
</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}
>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}
>
View documentation
</Link>
</div>
</motion.div>
</div>
</section>
{/* Competitors grid */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-24'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-10'}
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
All comparisons
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See how Plunk stacks up against popular email platforms</p>
</motion.div>
<div className={'grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'}>
{competitors.map((competitor, index) => (
<motion.div
key={competitor.slug}
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: index * 0.04, ease: [0.22, 1, 0.36, 1]}}
>
<Link
href={`/vs/${competitor.slug}`}
className={'group flex flex-col justify-between rounded-[28px] border border-neutral-200 bg-white p-6 sm:p-8 transition hover:border-neutral-900'}
>
<div className={'flex items-start justify-between'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{competitor.name}
</h3>
<ArrowRight className="h-4 w-4 text-neutral-400 transition-transform group-hover:translate-x-0.5 group-hover:text-neutral-900" />
</div>
<div>
<p className={'text-sm text-neutral-600'}>{competitor.description}</p>
<div className={'mt-4 flex flex-wrap items-center gap-x-2 gap-y-1'}>
{competitor.features.map((feature, i) => (
<React.Fragment key={feature}>
{i > 0 && <span className={'text-neutral-300'} aria-hidden>·</span>}
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[10px] uppercase tracking-[0.12em] text-neutral-400'}
>
{feature}
</span>
</React.Fragment>
))}
</div>
</div>
</Link>
</motion.div>
))}
<span className={'text-sm text-neutral-600'}>Email Platform Comparisons</span>
</div>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Plunk vs the
<br />
competition
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Most email platforms charge by the contact, lock you in, and split transactional from marketing. Plunk does all three in one open-source platform at $0.001 per email.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Why Plunk */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-14 sm:px-10 sm:py-20'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-10'}
>
<h2
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}
>
Why Plunk wins
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>One platform for all your email needs</p>
</motion.div>
{/* Competitors Grid */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Compare Plunk with Industry Leaders
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See how Plunk stacks up against popular email platforms</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
{[
{
icon: <Mail className="h-5 w-5" />,
title: 'Transactional + Marketing',
body: 'Send transactional emails with the same reliability as dedicated providers, plus marketing campaigns, automation, and segmentation. All in one platform.',
},
{
icon: <Code className="h-5 w-5" />,
title: 'Open Source & Self-Hostable',
body: 'AGPL-3.0 licensed code you can inspect, modify, and self-host. Full control over your data and infrastructure. No vendor lock-in.',
},
{
icon: <DollarSign className="h-5 w-5" />,
title: 'Simple Pricing',
body: 'Pay-as-you-go with all features included. No separate charges for transactional vs marketing emails. No tiers, no commitments.',
},
].map((item, i) => (
<div className={'grid gap-8 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'}>
{competitors.map((competitor, index) => (
<Link key={competitor.slug} href={`/vs/${competitor.slug}`}>
<motion.div
key={item.title}
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'bg-white p-10'}
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
className={
'group rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg cursor-pointer'
}
>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>
{item.icon}
<div className={'mb-4 flex items-center justify-between'}>
<h3 className={'text-xl font-bold text-neutral-900'}>{competitor.name}</h3>
</div>
<p className={'mb-6 leading-relaxed text-neutral-600'}>{competitor.description}</p>
<div className={'mb-6 space-y-2'}>
{competitor.features.map(feature => (
<div key={feature} className={'flex items-center gap-2 text-sm text-neutral-600'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
<span>{feature}</span>
</div>
))}
</div>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{item.title}
</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</Link>
))}
</div>
</section>
{/* Why Choose Plunk */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk</h2>
<p className={'mt-4 text-lg text-neutral-600'}>One platform for all your email needs</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Mail className="h-8 w-8 text-neutral-900 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Transactional + Marketing</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
Send transactional emails with the same reliability as dedicated providers, plus marketing campaigns, automation, and segmentation. All in one platform.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<Code className="h-8 w-8 text-neutral-900 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Open Source & Self-Hostable</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed code you can inspect, modify, and self-host. Full control over your data and
infrastructure. No vendor lock-in.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
}
>
<DollarSign className="h-8 w-8 text-neutral-900 mb-4" />
<h3 className={'text-2xl font-bold text-neutral-900'}>Simple Pricing</h3>
<p className={'mt-4 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing with all features included. No separate charges for transactional vs marketing
emails. No tiers, no commitments.
</p>
</motion.div>
</div>
</section>
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-display)'}}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Ready to try Plunk?</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join thousands of developers using Plunk for reliable email delivery. Start free, scale as you grow.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
Ready to try Plunk?
</motion.h2>
<motion.div
initial={{opacity: 0, y: 16}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
className={'flex max-w-md flex-col gap-6'}
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
<p className={'text-base text-neutral-300 sm:text-lg'}>
Start free. No credit card required.
</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a
whileHover={{scale: 1.015}}
whileTap={{scale: 0.985}}
href={`${DASHBOARD_URI}/auth/signup`}
className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}
>
Get started free
<ArrowRight className="h-4 w-4" />
</motion.a>
<Link
href={'/pricing'}
className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}
>
View pricing
</Link>
</div>
</motion.div>
View pricing
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
+345 -106
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Check, Code2, DollarSign, Globe, Package, PackageOpen, Zap} from 'lucide-react';
import {ArrowRight, Code2, DollarSign, Globe, Package, PackageOpen, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -49,6 +49,9 @@ const faqs: FAQ[] = [
},
];
/**
* Plunk vs Klaviyo comparison page
*/
export default function KlaviyoComparison() {
return (
<>
@@ -67,132 +70,368 @@ export default function KlaviyoComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Klaviyo
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Klaviyo</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Klaviyo
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Klaviyo is powerful for e-commerce but extremely expensive. Plunk delivers the same email automation, including full e-commerce support, at a fraction of the cost. No hidden fees, no contact-based pricing.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
E-commerce Email Without the Premium Price
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Klaviyo
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Klaviyo is powerful for e-commerce but extremely expensive. Plunk delivers the same email automation, including full e-commerce support, at a fraction of the cost. No hidden fees, no contact-based pricing.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails sent, not contacts stored. Predictable costs as you scale.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts at no extra cost</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No surprise charges as you grow</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Typical savings: 60-90% vs Klaviyo</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Klaviyo</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Contact-based tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Expensive pricing based on contact count. Costs escalate quickly as your list grows.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>From $20/month</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>10K contacts = $150-300+/month</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Price increases with contact list growth</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Hidden costs for additional features</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
E-commerce Email Without the Premium Price
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
E-commerce Email, Developer-Friendly
</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Affordable Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go instead of expensive contact-based tiers. Save 60-90% compared to Klaviyo while getting
the same email automation power.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Package className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>E-commerce Ready</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Handle order confirmations, shipping notifications, abandoned carts, and product campaigns. All
e-commerce email use cases via flexible API.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Developer-Friendly</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Modern API designed for developers. Integrate with Shopify, WooCommerce, or any e-commerce platform via
webhooks and API calls.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Full transparency into how your emails work. No black-box algorithms or proprietary
systems. Klaviyo is closed-source.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data ownership, compliance control, cost optimization at
scale. Klaviyo is cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simple Integration</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Connect your store via webhooks or API. No complex Shopify app to configure. Build exactly the
integration you need with full control.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails sent, not contacts stored. Predictable costs as you scale.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Unlimited contacts at no extra cost</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />No surprise charges as you grow</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Typical savings: 60-90% vs Klaviyo</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Klaviyo</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Contact-based tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Expensive pricing based on contact count. Costs escalate quickly as your list grows.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>From $20/month</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />10K contacts = $150-300+/month</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Price increases with contact list growth</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Hidden costs for additional features</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>E-commerce Email, Developer-Friendly</h2>
<p className={'mt-4 text-lg text-neutral-600'}>All the automation power, at a fraction of the cost</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{[
{icon: <DollarSign className="h-5 w-5" />, title: 'Affordable Pricing', body: 'Pay-as-you-go instead of expensive contact-based tiers. Save 60-90% compared to Klaviyo while getting the same email automation power.'},
{icon: <Package className="h-5 w-5" />, title: 'E-commerce Ready', body: 'Handle order confirmations, shipping notifications, abandoned carts, and product campaigns. All e-commerce email use cases via flexible API.'},
{icon: <Code2 className="h-5 w-5" />, title: 'Developer-Friendly', body: 'Modern API designed for developers. Integrate with Shopify, WooCommerce, or any e-commerce platform via webhooks and API calls.'},
{icon: <PackageOpen className="h-5 w-5" />, title: 'Open Source', body: 'AGPL-3.0 licensed. Full transparency into how your emails work. No black-box algorithms or proprietary systems. Klaviyo is closed-source.'},
{icon: <Globe className="h-5 w-5" />, title: 'Self-Hostable', body: 'Run on your infrastructure with Docker. Full data ownership, compliance control, cost optimization at scale. Klaviyo is cloud-only.'},
{icon: <Zap className="h-5 w-5" />, title: 'Simple Integration', body: 'Connect your store via webhooks or API. No complex Shopify app to configure. Build exactly the integration you need with full control.'},
].map((item, i) => (
<motion.div key={item.title} initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>{item.icon}</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>{item.title}</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
</motion.div>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="Klaviyo" rows={comparisonData} />
</div>
<ComparisonTable competitorName="Klaviyo" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
E-commerce email at a fraction of the cost.
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Save 60-90% vs Klaviyo. Open-source, self-hostable, pay-as-you-go. No contact-based tiers. Start free.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
E-commerce email at a fraction of the cost
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join e-commerce developers who've switched from Klaviyo to Plunk for dramatic cost savings and better API
design.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+340 -127
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, Check, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -69,153 +69,366 @@ export default function LoopsComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Loops
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Loops</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Loops
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Same modern features, but open-source and self-hostable. No contact limits, no proprietary lock-in, no
hidden costs. Built for transparency.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Transparent Pricing vs Vendor Lock-In
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay per email, not per contact</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Loops
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Same modern features, but open-source and self-hostable. No contact limits, no proprietary lock-in, no hidden costs. Built for transparency.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing with unlimited contacts. No monthly commitments or contact-based limits.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>$0.001/email</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts at no extra cost</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>All features included on all plans</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Open-source and self-hostable</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Loops</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Subscription tiers by contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription based on contact count with tier-based limits and feature restrictions.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Tiered pricing</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Contact-based pricing limits</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Feature limits on lower tiers</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Proprietary, cloud-only platform</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
Transparent Pricing vs Vendor Lock-In
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay per email, not per contact</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Loops</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Open-source transparency meets modern SaaS features</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your emails are sent.
No black boxes.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized for scale.
Loops is cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
No contact-based limits. Grow your audience without worrying about tier upgrades or surprise charges.
Pay for emails, not contacts.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Predictable Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go per email. No surprise costs as you grow. No forced tier upgrades. No sales calls. Just
simple, transparent pricing.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Full API Access</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Complete API access on all plans. No feature restrictions, no "contact sales" for API access. Everything
documented and ready to use.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All Features Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Transactional emails, campaigns, workflows, segmentation. All included. No artificial feature gating based on your plan.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing with unlimited contacts. No monthly commitments or contact-based limits.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>$0.001/email</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Unlimited contacts at no extra cost</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />All features included on all plans</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Open-source and self-hostable</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Loops</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Subscription tiers by contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription based on contact count with tier-based limits and feature restrictions.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Tiered pricing</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Contact-based pricing limits</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Feature limits on lower tiers</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Proprietary, cloud-only platform</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Why Choose Plunk Over Loops</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Open-source transparency meets modern SaaS features</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your emails are sent. No black boxes.</p>
</motion.div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized for scale. Loops is cloud-only.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>No contact-based limits. Grow your audience without worrying about tier upgrades or surprise charges. Pay for emails, not contacts.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><DollarSign className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Predictable Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Pay-as-you-go per email. No surprise costs as you grow. No forced tier upgrades. No sales calls. Just simple, transparent pricing.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Zap className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Full API Access</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Complete API access on all plans. No feature restrictions, no "contact sales" for API access. Everything documented and ready to use.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All Features Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Transactional emails, campaigns, workflows, segmentation. All included. No artificial feature gating based on your plan.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="Loops" rows={comparisonData} />
</div>
<ComparisonTable competitorName="Loops" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Switch to open source
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Join developers choosing transparency and control over proprietary platforms. Start free, no credit card required.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Switch to open source</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing transparency and control over proprietary platforms. Start free, no credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing details
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+334 -128
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Check, Code2, DollarSign, Mail, PackageOpen, Zap} from 'lucide-react';
import {ArrowRight, Code2, DollarSign, Mail, PackageOpen, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -70,154 +70,360 @@ export default function MailchimpComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Mailchimp
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailchimp</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Mailchimp
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Mailchimp is for marketers. Plunk is for developers who need full control, modern API, and transparent
pay-as-you-go pricing. Same features, better DX.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you store</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Mailchimp
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Mailchimp is for marketers. Plunk is for developers who need full control, modern API, and transparent pay-as-you-go pricing. Same features, better DX.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send, not for contacts stored.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts in your database</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No penalties for growing your list</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Mailchimp</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per contact stored</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Charged for every contact in your list, whether you email them or not.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Fixed subscription</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pay for contacts even if you don't email them</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Growing your list = automatic price increase</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Duplicate contacts count multiple times</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
The Pricing Model That Makes Sense
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you store</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Developers Choose Plunk</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay-as-you-go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails you send, not for contacts you store. No monthly minimums or fixed subscription
costs.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>API-First</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Modern REST API designed for developers. 10x easier to integrate than Mailchimp's marketing-focused API.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending in minutes. No audiences to configure, no lists to manage. Just send emails.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, self-host, no vendor lock-in. Mailchimp is proprietary.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Mail className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All-in-One</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Transactional and marketing emails in one platform. No need for separate Mandrill account.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<ArrowRight className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Event-Driven</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Trigger workflows based on user actions. Advanced automation that Mailchimp can't match.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
{/* Plunk card - DARK */}
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send, not for contacts stored.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Only pay for emails you actually send</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Unlimited contacts in your database</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />No penalties for growing your list</li>
</ul>
</motion.div>
{/* Competitor card - LIGHT */}
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Mailchimp</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Pay per contact stored</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Charged for every contact in your list, whether you email them or not.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed subscription</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pay for contacts even if you don't email them</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Growing your list = automatic price increase</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Duplicate contacts count multiple times</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Why Developers Choose Plunk</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><DollarSign className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Pay-as-you-go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Only pay for emails you send, not for contacts you store. No monthly minimums or fixed subscription costs.</p>
</motion.div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Code2 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>API-First</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Modern REST API designed for developers. 10x easier to integrate than Mailchimp's marketing-focused API.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Zap className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Start sending in minutes. No audiences to configure, no lists to manage. Just send emails.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, self-host, no vendor lock-in. Mailchimp is proprietary.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Mail className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All-in-One</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Transactional and marketing emails in one platform. No need for separate Mandrill account.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><ArrowRight className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Event-Driven</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Trigger workflows based on user actions. Advanced automation that Mailchimp can't match.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="Mailchimp" rows={comparisonData} />
</div>
<ComparisonTable competitorName="Mailchimp" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Ready for a better developer experience?
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Join developers who've switched from Mailchimp to Plunk for better DX and transparent pay-as-you-go pricing.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Ready for a better developer experience?
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers who've switched from Mailchimp to Plunk for better DX and transparent pay-as-you-go
pricing.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+346 -106
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, Check, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -49,6 +49,9 @@ const faqs: FAQ[] = [
},
];
/**
* Plunk vs MailerLite comparison page
*/
export default function MailerliteComparison() {
return (
<>
@@ -67,132 +70,369 @@ export default function MailerliteComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs MailerLite
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs MailerLite</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for MailerLite
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
MailerLite is developer-friendly. Plunk is developer-FIRST. Open-source, self-hostable, with API-first
design and pay-as-you-go pricing instead of subscriber tiers.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Open Source Meets Developer Experience
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not subscribers stored</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for MailerLite
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>MailerLite is developer-friendly. Plunk is developer-FIRST. Open-source, self-hostable, with API-first design and pay-as-you-go pricing instead of subscriber tiers.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Open-source and self-hostable for full control.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Unlimited contacts, no subscriber fees</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Self-host or use our cloud</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Full code transparency (AGPL-3.0)</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>MailerLite</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per subscriber count</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription based on total subscribers. Proprietary cloud-only platform.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>From $10/month</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Pricing based on subscriber count</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Cloud-only, no self-hosting</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Proprietary closed-source code</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
Open Source Meets Developer Experience
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not subscribers stored</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
The Truly Developer-First Alternative
</h2>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your platform works.
MailerLite is proprietary.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Code2 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>API-First Design</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Built for developers from day one. Modern REST API with comprehensive documentation. Integrate email
into your product seamlessly.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay per Email</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Only pay for emails sent, not subscribers stored. Grow your contact list without worrying about tier
upgrades or price increases.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Deploy on your infrastructure with Docker. Full data ownership, compliance control, no vendor lock-in.
MailerLite is cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Fast Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending in 5 minutes. Simple authentication, clean API endpoints, comprehensive docs. No visual
builder learning curve.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Unlimited Contacts</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Store unlimited contacts without additional cost. Your database grows freely, pricing stays predictable
based on emails sent.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Open-source and self-hostable for full control.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Unlimited contacts, no subscriber fees</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Self-host or use our cloud</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Full code transparency (AGPL-3.0)</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>MailerLite</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Pay per subscriber count</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription based on total subscribers. Proprietary cloud-only platform.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>From $10/month</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Pricing based on subscriber count</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Cloud-only, no self-hosting</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Proprietary closed-source code</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>The Truly Developer-First Alternative</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Full control, transparent code, pay only for what you send</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
{[
{icon: <PackageOpen className="h-5 w-5" />, title: 'Open Source', body: 'AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how your platform works. MailerLite is proprietary.'},
{icon: <Code2 className="h-5 w-5" />, title: 'API-First Design', body: 'Built for developers from day one. Modern REST API with comprehensive documentation. Integrate email into your product seamlessly.'},
{icon: <DollarSign className="h-5 w-5" />, title: 'Pay per Email', body: 'Only pay for emails sent, not subscribers stored. Grow your contact list without worrying about tier upgrades or price increases.'},
{icon: <Globe className="h-5 w-5" />, title: 'Self-Hostable', body: 'Deploy on your infrastructure with Docker. Full data ownership, compliance control, no vendor lock-in. MailerLite is cloud-only.'},
{icon: <Zap className="h-5 w-5" />, title: 'Fast Setup', body: 'Start sending in 5 minutes. Simple authentication, clean API endpoints, comprehensive docs. No visual builder learning curve.'},
{icon: <Users className="h-5 w-5" />, title: 'Unlimited Contacts', body: 'Store unlimited contacts without additional cost. Your database grows freely, pricing stays predictable based on emails sent.'},
].map((item, i) => (
<motion.div key={item.title} initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>{item.icon}</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>{item.title}</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>{item.body}</p>
</motion.div>
))}
</div>
</div>
</section>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature Comparison</h2>
</motion.div>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="MailerLite" rows={comparisonData} />
</div>
<ComparisonTable competitorName="MailerLite" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Experience true developer-first email.
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Open-source transparency and API-first design. Unlimited contacts. Pay only for emails sent. Start free.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>
Experience true developer-first email
</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join developers choosing open-source transparency and API-first design. Start free, no credit card
required.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+330 -122
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, Check, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -65,124 +65,309 @@ export default function MailgunComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Mailgun
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Mailgun</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Mailgun
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Mailgun only handles transactional email. Plunk adds marketing campaigns, workflow automation, and segmentation on top, all open-source and self-hostable at $0.001 per email.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Mailgun
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Mailgun only handles transactional email. Plunk adds marketing campaigns, workflow automation, and segmentation on top, all open-source and self-hostable at $0.001 per email.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Scale up or down without commitment</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Marketing and automation included</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Mailgun</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Tiered monthly plans</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription with tiered email volume limits.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Fixed tiers</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Locked into monthly subscription tiers</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Need to upgrade plan as you grow</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Transactional only, no marketing</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
The Pricing Model That Makes Sense
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Only pay for emails you actually send</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Scale up or down without commitment</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Marketing and automation included</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Mailgun</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Tiered monthly plans</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription with tiered email volume limits.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed tiers</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Locked into monthly subscription tiers</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Need to upgrade plan as you grow</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Transactional only, no marketing</li>
</ul>
</motion.div>
</div>
</div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
</motion.div>
<ComparisonTable competitorName="Mailgun" rows={comparisonData} />
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>What Plunk Adds</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
{/* What Plunk Adds */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>What Plunk Adds</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Marketing Campaigns</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Send one-time broadcasts to all contacts or specific segments. Schedule sends, track performance.
Mailgun doesn't offer this.
</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Campaigns</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Send one-time broadcasts to all contacts or specific segments. Schedule sends, track performance. Mailgun doesn't offer this.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Workflow className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Build multi-step email sequences with triggers, delays, and conditions. Perfect for onboarding, drip campaigns, cart abandonment.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Workflow className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Build multi-step email sequences with triggers, delays, and conditions. Perfect for onboarding, drip
campaigns, cart abandonment.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Create audience segments that update automatically based on contact data and behavior. Target campaigns precisely.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Dynamic Segmentation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Create audience segments that update automatically based on contact data and behavior. Target campaigns
precisely.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, no vendor lock-in. Mailgun is proprietary.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, no vendor lock-in. Mailgun is proprietary.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only AWS SES fees when self-hosting.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only AWS SES fees when
self-hosting.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Layers className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All-in-One Platform</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>One platform for transactional, marketing, and automation. No need for multiple tools or integrations.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Layers className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All-in-One Platform</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
One platform for transactional, marketing, and automation. No need for multiple tools or integrations.
</p>
</motion.div>
<ComparisonTable competitorName="Mailgun" rows={comparisonData} />
</div>
</section>
@@ -190,28 +375,51 @@ export default function MailgunComparison() {
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Try Plunk free
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Add marketing and automation when you need it.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
Read documentation
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+339 -127
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, Check, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -66,153 +66,365 @@ export default function PostmarkComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Postmark
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Postmark</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Postmark
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Everything Postmark offers for transactional emails, plus marketing campaigns, workflow automation, and
segmentation. One platform, no extra cost.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>One Platform for All Your Emails</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Transactional reliability meets marketing power</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Postmark
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Everything Postmark offers for transactional emails, plus marketing campaigns, workflow automation, and segmentation. One platform, no extra cost.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Transactional + Marketing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Reliable transactional emails plus marketing campaigns, workflows, and segmentation in one platform.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>All-in-one</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Transactional emails included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Marketing campaigns included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Workflow automation included</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Postmark</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Transactional only</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Excellent for transactional emails, but no marketing features. Need a second service for campaigns.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Limited scope</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Transactional emails only</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>No marketing campaigns</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>No workflow automation</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
One Platform for All Your Emails
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Transactional reliability meets marketing power</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over Postmark</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Do more with one platform instead of two</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Marketing Campaigns Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Send newsletters, announcements, and promotional emails without needing a separate marketing platform.
One platform, one bill.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Build automated email sequences with triggers, delays, and conditions. Onboard users, nurture leads,
re-engage customers. All automated.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Dynamic Segmentation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Segment your audience based on behavior, properties, and engagement. Send targeted emails to the right
people at the right time.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how it works. No
proprietary black boxes.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. Postmark is
cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Pay-As-You-Go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Simple per-email pricing with all features included. No separate charges for transactional vs marketing
emails. No tiers, no commitments.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Transactional + Marketing</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Reliable transactional emails plus marketing campaigns, workflows, and segmentation in one platform.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>All-in-one</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Transactional emails included</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Marketing campaigns included</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Workflow automation included</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Postmark</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Transactional only</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Excellent for transactional emails, but no marketing features. Need a second service for campaigns.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Limited scope</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Transactional emails only</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />No marketing campaigns</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />No workflow automation</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Why Choose Plunk Over Postmark</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Do more with one platform instead of two</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Campaigns Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Send newsletters, announcements, and promotional emails without needing a separate marketing platform. One platform, one bill.</p>
</motion.div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Zap className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Build automated email sequences with triggers, delays, and conditions. Onboard users, nurture leads, re-engage customers. All automated.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Segment your audience based on behavior, properties, and engagement. Send targeted emails to the right people at the right time.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, understand exactly how it works. No proprietary black boxes.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. Postmark is cloud-only.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><DollarSign className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Pay-As-You-Go Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Simple per-email pricing with all features included. No separate charges for transactional vs marketing emails. No tiers, no commitments.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="Postmark" rows={comparisonData} />
</div>
<ComparisonTable competitorName="Postmark" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Get more from your email platform
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Stop juggling multiple tools. Get transactional reliability plus marketing power in one platform. Start free.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Get more from your email platform</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Stop juggling multiple tools. Get transactional reliability plus marketing power in one platform. Start
free.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing details
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+330 -122
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, Check, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -65,124 +65,309 @@ export default function ResendComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs Resend
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs Resend</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for Resend
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Resend is transactional-only. Plunk gives you transactional emails, marketing campaigns, and workflow automation in a single open-source platform. No second tool, no second bill.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Try Plunk free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for Resend
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Resend is transactional-only. Plunk gives you transactional emails, marketing campaigns, and workflow automation in a single open-source platform. No second tool, no second bill.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Only pay for emails you actually send</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Scale up or down without commitment</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Marketing and automation included</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>Resend</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Fixed subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Monthly subscription with fixed email limits per tier.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Fixed subscription</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Locked into monthly subscription tiers</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Need to upgrade plan as you grow</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Transactional only, no marketing</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
The Pricing Model That Makes Sense
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Only pay for emails you actually send</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Scale up or down without commitment</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Marketing and automation included</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Resend</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Fixed subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription with fixed email limits per tier.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed subscription</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Locked into monthly subscription tiers</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Need to upgrade plan as you grow</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Transactional only, no marketing</li>
</ul>
</motion.div>
</div>
</div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature</h2>
</motion.div>
<ComparisonTable competitorName="Resend" rows={comparisonData} />
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>What Plunk Adds</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
{/* What Plunk Adds */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>What Plunk Adds</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Marketing Campaigns</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Send one-time broadcasts to all contacts or specific segments. Schedule sends, track performance. Resend
doesn't offer this.
</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Campaigns</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Send one-time broadcasts to all contacts or specific segments. Schedule sends, track performance. Resend doesn't offer this.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Workflow className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Build multi-step email sequences with triggers, delays, and conditions. Perfect for onboarding, drip campaigns, cart abandonment.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Workflow className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Workflow Automation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Build multi-step email sequences with triggers, delays, and conditions. Perfect for onboarding, drip
campaigns, cart abandonment.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Create audience segments that update automatically based on contact data and behavior. Target campaigns precisely.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Dynamic Segmentation</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Create audience segments that update automatically based on contact data and behavior. Target campaigns
precisely.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, no vendor lock-in. Resend is proprietary.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, no vendor lock-in. Resend is proprietary.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only AWS SES fees when self-hosting.</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready. Pay only AWS SES fees when
self-hosting.
</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Layers className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All-in-One Platform</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>One platform for transactional, marketing, and automation. No need for multiple tools or integrations.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Layers className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>All-in-One Platform</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
One platform for transactional, marketing, and automation. No need for multiple tools or integrations.
</p>
</motion.div>
<ComparisonTable competitorName="Resend" rows={comparisonData} />
</div>
</section>
@@ -190,28 +375,51 @@ export default function ResendComparison() {
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Try Plunk free
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>1,000 emails/month free. No credit card required. Add marketing and automation when you need it.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Try Plunk free</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
Read documentation
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
+338 -127
View File
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
import React from 'react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {ArrowRight, BarChart3, Check, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
import type {ComparisonRow} from '../../components/ComparisonTable';
import type {FAQ} from '../../components/FAQSection';
@@ -69,153 +69,364 @@ export default function SendGridComparison() {
<Navbar />
<main className={'text-neutral-800'}>
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
{/* Hero Section */}
<section className={'relative py-32 sm:py-48'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_0%,#000_70%,transparent_110%)]'
}
/>
{/* Hero */}
<section className={'relative overflow-hidden'}>
<div aria-hidden className={'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'} />
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
<motion.div initial={{opacity: 0, y: 16}} animate={{opacity: 1, y: 0}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
Plunk vs SendGrid
<motion.div
initial={{opacity: 0, y: 20}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-4xl text-center'}
>
<div
className={
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
}
>
<span className={'text-sm text-neutral-600'}>Comparing</span>
<span className={'text-sm font-semibold text-neutral-900'}>Plunk vs SendGrid</span>
</div>
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl text-balance'}>
Open-source alternative
<br />
for SendGrid
</h1>
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
Pay-as-you-go instead of fixed subscriptions. No complex setup, no feature gating, no enterprise sales
pitches. Built for developers, not procurement teams.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-lg shadow-neutral-900/10 transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href={WIKI_URI}
target={'_blank'}
className={
'rounded-lg border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View documentation
</Link>
</div>
</motion.div>
</section>
{/* Pricing Model Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>The Pricing Model That Makes Sense</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you might use</p>
</motion.div>
<div className={'grid gap-8 lg:grid-cols-2'}>
<motion.div
initial={{opacity: 0, x: -20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-white'}>Plunk</span>
</div>
<h1 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'}>
Open-source alternative
<br />
for SendGrid
</h1>
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>Pay-as-you-go instead of fixed subscriptions. No complex setup, no feature gating, no enterprise sales pitches. Built for developers, not procurement teams.</p>
<div className={'mt-10 flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'group inline-flex items-center gap-2 rounded-full bg-neutral-900 px-8 py-4 text-base font-semibold text-white shadow-[0_10px_30px_-10px_rgba(23,23,23,0.35)] transition hover:bg-neutral-800'}>
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</motion.a>
<Link href={WIKI_URI} target={'_blank'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-300 bg-white px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-900'}>
View documentation
</Link>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go pricing. Only pay for emails you actually send, no monthly minimums.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Pay-as-you-go</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>No monthly minimum or commitment</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Marketing campaigns included</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-900 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-white'} />
</div>
<span>Workflow automation included</span>
</div>
</div>
</motion.div>
<motion.div
initial={{opacity: 0, x: 20}}
whileInView={{opacity: 1, x: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
>
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
<span className={'text-sm font-semibold text-neutral-900'}>SendGrid</span>
</div>
<h3 className={'mt-6 text-2xl font-bold text-neutral-900'}>Monthly subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Fixed monthly fees based on email volume tiers with feature restrictions.
</p>
<div className={'mt-6 text-4xl font-bold text-neutral-900'}>Fixed subscription</div>
<div className={'mt-8 space-y-3'}>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Locked into monthly subscription plan</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Marketing is a separate paid product</span>
</div>
<div className={'flex items-center gap-3 text-sm text-neutral-600'}>
<div className={'h-5 w-5 rounded-full bg-neutral-300 flex items-center justify-center'}>
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-600'} />
</div>
<span>Automation locked to enterprise tier</span>
</div>
</div>
</motion.div>
</div>
</section>
{/* Pricing comparison */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
The Pricing Model That Makes Sense
</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you might use</p>
{/* Key Advantages */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-20 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Why Choose Plunk Over SendGrid</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Built for modern developers, not enterprise sales teams</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<DollarSign className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Simple, Transparent Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Pay-as-you-go per email. No hidden fees, no complex tiers, no enterprise sales calls. Start free, scale
as you grow.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Zap className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Start sending emails in minutes, not hours. Modern API, clear documentation, no complex configuration.
Copy-paste and go.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<BarChart3 className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Marketing Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Campaigns, workflows, and segmentation at no extra cost. SendGrid requires their separate Marketing
Campaigns product with additional fees.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<PackageOpen className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
AGPL-3.0 licensed. Inspect the code, contribute features, self-host if needed. No black boxes, no vendor
lock-in.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Globe className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. SendGrid is
cloud-only.
</p>
</motion.div>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}}
className={'group bg-white p-12 transition hover:bg-neutral-50'}
>
<div
className={
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
}
>
<Users className="h-5 w-5" />
</div>
<h3 className={'mt-6 text-xl font-semibold text-neutral-900'}>No Feature Gating</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>
All features available on all plans. No artificial limits, no forced upgrades to "enterprise" for basic
automation.
</p>
</motion.div>
<div className={'grid gap-4 lg:grid-cols-2'}>
<motion.div initial={{opacity: 0, x: -20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send, no monthly minimums.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />No monthly minimum or commitment</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Marketing campaigns included</li>
<li className={'flex items-center gap-3 text-sm text-neutral-300'}><Check className="h-4 w-4 flex-shrink-0 text-neutral-400" />Workflow automation included</li>
</ul>
</motion.div>
<motion.div initial={{opacity: 0, x: 20}} whileInView={{opacity: 1, x: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'rounded-[24px] border border-neutral-200 bg-white p-10'}>
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>SendGrid</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Monthly subscription tiers</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Fixed monthly fees based on email volume tiers with feature restrictions.</p>
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed subscription</div>
<ul className={'mt-8 space-y-3'}>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Locked into monthly subscription plan</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Marketing is a separate paid product</li>
<li className={'flex items-center gap-3 text-sm text-neutral-600'}><div className={'h-1.5 w-1.5 flex-shrink-0 rounded-full bg-neutral-400'} />Automation locked to enterprise tier</li>
</ul>
</motion.div>
</div>
</div>
</section>
{/* Key advantages */}
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Why Choose Plunk Over SendGrid</h2>
<p className={'mt-4 text-lg text-neutral-600'}>Built for modern developers, not enterprise sales teams</p>
</motion.div>
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><DollarSign className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Simple, Transparent Pricing</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Pay-as-you-go per email. No hidden fees, no complex tiers, no enterprise sales calls. Start free, scale as you grow.</p>
</motion.div>
{/* Feature Comparison */}
<section className={'py-32'}>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mb-16 text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Feature-by-Feature Comparison</h2>
<p className={'mt-4 text-lg text-neutral-600'}>See exactly what you get with each platform</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Zap className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>5-Minute Setup</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Start sending emails in minutes, not hours. Modern API, clear documentation, no complex configuration. Copy-paste and go.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.3, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><BarChart3 className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Included</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Campaigns, workflows, and segmentation at no extra cost. SendGrid requires their separate Marketing Campaigns product with additional fees.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.4, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><PackageOpen className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source & Transparent</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>AGPL-3.0 licensed. Inspect the code, contribute features, self-host if needed. No black boxes, no vendor lock-in.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.5, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Globe className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>Run on your infrastructure with Docker. Full data control, compliance-ready, cost-optimized. SendGrid is cloud-only.</p>
</motion.div>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.5, delay: 0.6, ease: [0.22, 1, 0.36, 1]}} className={'bg-white p-10'}>
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}><Users className="h-5 w-5" /></div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>No Feature Gating</h3>
<p className={'mt-3 leading-relaxed text-neutral-600'}>All features available on all plans. No artificial limits, no forced upgrades to "enterprise" for basic automation.</p>
</motion.div>
</div>
</div>
</section>
{/* Feature comparison table */}
<section className={'border-t border-neutral-200'}>
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
<motion.div initial={{opacity: 0, y: 20}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}} className={'mb-10'}>
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>Feature comparison</h2>
</motion.div>
<ComparisonTable competitorName="SendGrid" rows={comparisonData} />
</div>
<ComparisonTable competitorName="SendGrid" rows={comparisonData} />
</section>
{/* FAQ */}
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
{/* CTA */}
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
<motion.h2 initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}} style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}>
Make the switch today
</motion.h2>
<motion.div initial={{opacity: 0, y: 16}} whileInView={{opacity: 1, y: 0}} viewport={{once: true}} transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}} className={'flex max-w-md flex-col gap-6'}>
<p className={'text-base text-neutral-300 sm:text-lg'}>Join hundreds of developers who've ditched SendGrid's complexity for Plunk's simplicity.</p>
<div className={'flex flex-wrap gap-3'}>
<motion.a whileHover={{scale: 1.015}} whileTap={{scale: 0.985}} href={`${DASHBOARD_URI}/auth/signup`} className={'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'}>
Get started free <ArrowRight className="h-4 w-4" />
</motion.a>
<Link href={'/pricing'} className={'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'}>
View pricing
</Link>
</div>
</motion.div>
<section className={'relative overflow-hidden border-t border-neutral-200 py-32'}>
<div
className={
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#e5e7eb_1px,transparent_1px),linear-gradient(to_bottom,#e5e7eb_1px,transparent_1px)] bg-[size:4rem_4rem] [mask-image:radial-gradient(ellipse_80%_50%_at_50%_100%,#000_70%,transparent_110%)]'
}
/>
<motion.div
initial={{opacity: 0, y: 20}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'mx-auto max-w-3xl text-center'}
>
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900 text-balance'}>Make the switch today</h2>
<p className={'mt-6 text-lg text-neutral-600'}>
Join hundreds of developers who've ditched SendGrid's complexity for Plunk's simplicity.
</p>
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
<motion.a
whileHover={{scale: 1.02}}
whileTap={{scale: 0.98}}
href={`${DASHBOARD_URI}/auth/signup`}
className={
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
}
>
<span className={'flex items-center gap-2'}>
Get started free
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
</span>
</motion.a>
<Link
href="/pricing"
className={
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
}
>
View pricing details
</Link>
</div>
</div>
</motion.div>
</section>
</main>
<Footer />
</>
);
-20
View File
@@ -161,26 +161,6 @@
body {
@apply bg-background text-neutral-800;
font-family: var(--font-body, ui-sans-serif, system-ui, sans-serif);
font-feature-settings: "rlig" 1, "calt" 1;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display, ui-sans-serif, system-ui, sans-serif);
}
}
@keyframes marquee-x {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.marquee-track {
animation: marquee-x 40s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.marquee-track {
animation: none;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+6 -2
View File
@@ -1,3 +1,7 @@
<svg width="1080" height="1080" viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z" fill="black"/>
<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1080" height="1080" fill="white"/>
<path d="M976 284.628C976 348.237 959.54 406.608 926.62 459.74C893.701 512.873 845.817 556.276 782.97 589.952C720.124 623.627 645.306 643.458 558.517 649.445L510.26 919.971C491.556 1023.99 440.68 1076 357.632 1076C311.993 1076 269.721 1062.53 230.816 1035.59C192.659 1008.65 161.984 967.49 138.79 912.113C115.597 856.736 104 788.637 104 707.816C104 555.902 128.316 427.187 176.947 321.671C226.327 215.407 292.166 136.082 374.466 83.6984C457.514 30.5661 548.791 4 648.299 4C718.627 4 778.107 16.3476 826.739 41.0429C876.118 65.7382 913.153 99.4136 937.843 142.069C963.281 183.976 976 231.496 976 284.628ZM578.718 533.826C732.094 514.369 808.783 434.671 808.783 294.731C808.783 245.34 792.323 205.304 759.403 174.622C727.231 143.192 677.103 127.476 609.019 127.476C531.957 127.476 464.621 151.798 407.012 200.44C350.15 249.082 306.008 316.807 274.584 403.615C243.909 489.674 228.571 588.081 228.571 698.836C228.571 745.233 233.06 786.392 242.039 822.312C251.765 858.232 263.736 886.295 277.951 906.501C292.915 925.957 307.13 935.686 320.597 935.686C339.302 935.686 353.517 909.868 363.243 858.232L400.278 646.078C371.099 641.587 358.38 639.717 362.121 640.465C339.676 636.723 325.086 629.988 318.353 620.26C311.619 609.783 308.252 596.687 308.252 580.972C308.252 564.508 312.741 551.412 321.719 541.684C331.446 531.955 344.539 527.091 360.999 527.091C368.481 527.091 374.092 527.465 377.833 528.214C395.789 531.207 409.63 533.078 419.357 533.826C429.083 475.455 442.924 397.254 460.88 299.221C465.369 273.777 475.47 255.817 491.182 245.34C507.641 234.115 526.72 228.503 548.417 228.503C573.107 228.503 590.689 233.367 601.163 243.095C612.386 252.075 617.997 266.668 617.997 286.873C617.997 298.847 617.249 308.575 615.753 316.059L578.718 533.826Z"
fill="black"/>
<path d="M304.835 467.541L426.099 489.952L411.851 580.937L391.091 699.816L266.088 676.643L304.835 467.541Z"
fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+14 -16
View File
@@ -2,8 +2,7 @@ import {Button} from '@plunk/ui';
import type {Activity, CursorPaginatedResponse} from '@plunk/types';
import {network} from '../lib/network';
import {ActivityItem} from './ActivityItem';
import {EmptyState} from './EmptyState';
import {Activity as ActivityIcon, Loader2} from 'lucide-react';
import {Loader2} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react';
export interface ActivityFeedProps {
@@ -28,7 +27,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
return date.toISOString();
}, [dateRangeDays]);
const fetchActivities = useCallback(
const fetchActivities = useCallback(
async (cursor?: string) => {
try {
if (cursor) {
@@ -44,14 +43,9 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
const params = new URLSearchParams({
limit: '20', // Conservative limit to avoid overloading
startDate: startDate,
});
// Only apply startDate filter on initial load, not during pagination
// When cursor is present, we're paginating backwards and should not limit by startDate
if (!cursor) {
params.set('startDate', startDate);
}
if (cursor) {
params.set('cursor', cursor);
}
@@ -80,7 +74,9 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
// For each new activity, reuse existing object if ID matches (preserves React component instances)
// Otherwise use new object. This maintains correct ordering while preserving component state.
return result.data.map(newActivity => existingMap.get(newActivity.id) ?? newActivity);
return result.data.map(newActivity =>
existingMap.get(newActivity.id) ?? newActivity
);
});
}
@@ -125,7 +121,9 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
// For each new activity, reuse existing object if ID matches
return result.activities.map(newActivity => existingMap.get(newActivity.id) ?? newActivity);
return result.activities.map(newActivity =>
existingMap.get(newActivity.id) ?? newActivity
);
});
} catch (err) {
console.error('Error fetching upcoming activities:', err);
@@ -187,11 +185,11 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
if (activities.length === 0 && upcomingActivities.length === 0) {
return (
<EmptyState
icon={ActivityIcon}
title="No activity yet"
description="Events will appear here as contacts interact with your emails."
/>
<div className="text-center py-12">
<p className="text-neutral-500 text-sm">
No activity found for the selected filters. Activities will appear here as they happen.
</p>
</div>
);
}
+7 -30
View File
@@ -1,7 +1,6 @@
import {AnimatePresence, motion} from 'framer-motion';
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
import {useState} from 'react';
import {Button} from '@plunk/ui';
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
interface ApiKeyDisplayProps {
label: string;
@@ -29,8 +28,8 @@ export function ApiKeyDisplay({
await navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard API unavailable — silent
} catch (error) {
console.error('Failed to copy:', error);
}
};
@@ -40,8 +39,8 @@ export function ApiKeyDisplay({
try {
setIsRegenerating(true);
await onRegenerate();
} catch {
// error surfaced via isRegenerating state reset
} catch (error) {
console.error('Failed to regenerate:', error);
} finally {
setIsRegenerating(false);
}
@@ -75,31 +74,9 @@ export function ApiKeyDisplay({
size="icon"
onClick={handleCopy}
title="Copy to clipboard"
className="h-9 w-9 overflow-hidden"
className="h-9 w-9"
>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -6}}
transition={{duration: 0.15}}
>
<Check className="h-4 w-4 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -6}}
transition={{duration: 0.15}}
>
<Copy className="h-4 w-4" />
</motion.span>
)}
</AnimatePresence>
{copied ? <Check className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />}
</Button>
{showRegenerate && onRegenerate && (
<Button
@@ -198,7 +198,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
{data?.data.map(campaign => (
<Card
key={campaign.id}
className="cursor-pointer hover:border-neutral-400 transition-colors"
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
onClick={() => handleCampaignClick(campaign)}
>
<CardHeader className="pb-3">
@@ -266,24 +266,30 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto pr-2">
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
{/* Campaign Preview */}
{selectedCampaign && (
<div className="pb-4 mb-1 border-b border-neutral-100">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900">{selectedCampaign.name}</span>
{getStatusBadge(selectedCampaign.status)}
</div>
{selectedCampaign.description && (
<p className="text-xs text-neutral-500 mt-1">{selectedCampaign.description}</p>
)}
</div>
<Card className="bg-neutral-50">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base">{selectedCampaign.name}</CardTitle>
{getStatusBadge(selectedCampaign.status)}
</div>
{selectedCampaign.description && (
<CardDescription className="text-xs">{selectedCampaign.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
</Card>
)}
{/* Field Selection */}
<div className="divide-y divide-neutral-100">
<div className="space-y-3">
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('subject')}
>
<Checkbox
@@ -296,13 +302,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Email Subject
</Label>
{selectedCampaign?.subject && (
<p className="text-xs text-neutral-400 mt-0.5 truncate">{selectedCampaign.subject}</p>
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.subject}</p>
)}
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('body')}
>
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
@@ -310,12 +316,12 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
Email Body
</Label>
<p className="text-xs text-neutral-400 mt-0.5">Full email content and design</p>
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('from')}
>
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
@@ -324,13 +330,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
From Email
</Label>
{selectedCampaign?.from && (
<p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.from}</p>
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.from}</p>
)}
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('fromName')}
>
<Checkbox
@@ -343,13 +349,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
From Name
</Label>
{selectedCampaign?.fromName && (
<p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.fromName}</p>
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.fromName}</p>
)}
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('replyTo')}
>
<Checkbox
@@ -362,13 +368,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Reply-To Email
</Label>
{selectedCampaign?.replyTo && (
<p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.replyTo}</p>
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.replyTo}</p>
)}
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('audience')}
>
<Checkbox
@@ -381,7 +387,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Audience Settings
</Label>
{selectedCampaign && (
<p className="text-xs text-neutral-400 mt-0.5">{getAudienceLabel(selectedCampaign)}</p>
<p className="text-xs text-neutral-500 mt-0.5">{getAudienceLabel(selectedCampaign)}</p>
)}
</div>
</div>
+17 -26
View File
@@ -15,6 +15,7 @@ import {
Menu,
Plus,
Settings,
User,
Users,
Workflow,
} from 'lucide-react';
@@ -56,6 +57,7 @@ const navigation: NavSection[] = [
],
},
{
title: 'Campaigns',
items: [{name: 'Campaigns', href: '/campaigns', icon: Megaphone}],
},
];
@@ -125,7 +127,8 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
// Redirect to login
await router.push('/auth/login');
} catch {
} catch (error) {
console.error('Logout failed:', error);
// Even if the API call fails, try to redirect to login
localStorage.removeItem('token');
localStorage.removeItem('activeProjectId');
@@ -176,14 +179,12 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
</div>
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
</div>
<ChevronDown
className={`h-4 w-4 text-neutral-500 flex-shrink-0 transition-transform duration-200 ${showProjectMenu ? 'rotate-180' : ''}`}
/>
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
</button>
{/* Project Dropdown */}
{showProjectMenu && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-md z-50 py-1 max-h-[400px] overflow-y-auto min-w-full w-max">
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1 max-h-[400px] overflow-y-auto min-w-full w-max">
{sortedProjects.map(project => (
<button
key={project.id}
@@ -195,7 +196,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors whitespace-nowrap"
>
<div className="h-6 w-6 rounded-md bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
<div className="h-6 w-6 rounded bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
{project.name.charAt(0).toUpperCase()}
</div>
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
@@ -229,16 +230,15 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
)}
<div className="space-y-1">
{section.items.map(item => {
const isActive =
item.href === '/' ? router.pathname === item.href : router.pathname.startsWith(item.href);
const isActive = router.pathname === item.href;
const Icon = item.icon;
return (
<Link
key={item.name}
href={item.href}
onClick={() => setShowMobileMenu(false)}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
isActive ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
}`}
>
<Icon className="h-5 w-5" />
@@ -257,7 +257,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
href={WIKI_URI}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900"
>
<BookOpen className="h-5 w-5" />
Documentation
@@ -266,10 +266,8 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<Link
href="/settings"
onClick={() => setShowMobileMenu(false)}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
router.pathname.startsWith('/settings')
? 'bg-neutral-100 text-neutral-900'
: 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
router.pathname.startsWith('/settings') ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
}`}
>
<Settings className="h-5 w-5" />
@@ -279,23 +277,16 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<div className="relative" ref={userMenuRef}>
<button
onClick={handleToggleUserMenu}
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
>
<div className="h-5 w-5 rounded-full bg-neutral-900 text-white flex items-center justify-center text-[10px] font-semibold flex-shrink-0">
{user?.email?.charAt(0).toUpperCase() ?? '?'}
</div>
<User className="h-5 w-5" />
<span className="flex-1 text-left truncate">{user?.email}</span>
<ChevronDown
className={`h-4 w-4 text-neutral-500 transition-transform duration-200 ${showUserMenu ? 'rotate-180' : ''}`}
/>
<ChevronDown className="h-4 w-4 text-neutral-500" />
</button>
{/* User Dropdown */}
{showUserMenu && (
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-md z-50 py-1">
<div className="px-3 py-2 border-b border-neutral-100">
<p className="text-xs text-neutral-500 truncate">{user?.email}</p>
</div>
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white border border-neutral-200 rounded-lg shadow-lg z-50 py-1">
<button
onClick={handleLogoutClick}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
@@ -151,7 +151,7 @@ export function DataManagementSettings() {
</CardHeader>
<CardContent>
{customFields.length === 0 ? (
<p className="text-sm text-neutral-500">No custom fields found</p>
<p className="text-sm text-muted-foreground">No custom fields found</p>
) : (
<Table>
<TableHeader>
@@ -170,7 +170,7 @@ export function DataManagementSettings() {
<Badge variant="secondary">{field.type}</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-neutral-500">{field.coverage}%</span>
<span className="text-sm text-muted-foreground">{field.coverage}%</span>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => openFieldDeleteDialog(field.field)}>
@@ -196,7 +196,7 @@ export function DataManagementSettings() {
</CardHeader>
<CardContent>
{customEvents.length === 0 ? (
<p className="text-sm text-neutral-500">No custom events found</p>
<p className="text-sm text-muted-foreground">No custom events found</p>
) : (
<Table>
<TableHeader>
+54 -69
View File
@@ -24,34 +24,6 @@ import {Check, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw, Trash2, XCir
import {useConfig} from '../lib/hooks/useConfig';
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
function AnimatedCopyIcon({isCopied}: {isCopied: boolean}) {
return (
<AnimatePresence mode="wait" initial={false}>
{isCopied ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
);
}
interface DomainsSettingsProps {
projectId: string;
}
@@ -447,13 +419,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
onClick={() =>
handleCopyToken(`${token}._domainkey.${domain.domain}`, index + 2000)
}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
className="shrink-0 h-6 w-6 p-0"
>
<AnimatedCopyIcon
isCopied={
copiedToken === `${token}._domainkey.${domain.domain}-${index + 2000}`
}
/>
{copiedToken ===
`${token}._domainkey.${domain.domain}-${index + 2000}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
@@ -466,11 +439,13 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`${token}.dkim.amazonses.com`, index)}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
className="shrink-0 h-6 w-6 p-0"
>
<AnimatedCopyIcon
isCopied={copiedToken === `${token}.dkim.amazonses.com-${index}`}
/>
{copiedToken === `${token}.dkim.amazonses.com-${index}` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
@@ -525,11 +500,13 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
className="shrink-0 h-6 w-6 p-0"
>
<AnimatedCopyIcon
isCopied={copiedToken === `plunk.${domain.domain}-3000`}
/>
{copiedToken === `plunk.${domain.domain}-3000` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
@@ -547,14 +524,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
1000,
)
}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
className="shrink-0 h-6 w-6 p-0"
>
<AnimatedCopyIcon
isCopied={
copiedToken ===
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000`
}
/>
{copiedToken ===
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
@@ -574,11 +551,13 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost"
size="sm"
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
className="shrink-0 h-6 w-6 p-0"
>
<AnimatedCopyIcon
isCopied={copiedToken === `plunk.${domain.domain}-3001`}
/>
{copiedToken === `plunk.${domain.domain}-3001` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
@@ -593,11 +572,13 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
onClick={() =>
handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)
}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
className="shrink-0 h-6 w-6 p-0"
>
<AnimatedCopyIcon
isCopied={copiedToken === '"v=spf1 include:amazonses.com ~all"-1001'}
/>
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
@@ -651,9 +632,13 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
variant="ghost"
size="sm"
onClick={() => handleCopyToken(domain.domain, 3002)}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
className="shrink-0 h-6 w-6 p-0"
>
<AnimatedCopyIcon isCopied={copiedToken === `${domain.domain}-3002`} />
{copiedToken === `${domain.domain}-3002` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
@@ -671,14 +656,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
1002,
)
}
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
className="shrink-0 h-6 w-6 p-0"
>
<AnimatedCopyIcon
isCopied={
copiedToken ===
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002`
}
/>
{copiedToken ===
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002` ? (
<Check className="h-3 w-3 text-green-600" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</td>
@@ -689,8 +674,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</div>
)}
<div className="flex items-start gap-2 p-3 bg-neutral-50 rounded-lg border border-neutral-200 mt-3">
<div className="text-neutral-500 mt-0.5">
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mt-3">
<div className="text-blue-600 mt-0.5">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
fillRule="evenodd"
@@ -699,7 +684,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
/>
</svg>
</div>
<p className="text-xs text-neutral-600">
<p className="text-xs text-blue-900">
Click the copy icon to copy record values. After adding all records to your DNS
provider, use the refresh button above to verify your domain.
</p>
-22
View File
@@ -1,22 +0,0 @@
import type {LucideIcon} from 'lucide-react';
import type {ReactNode} from 'react';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({icon: Icon, title, description, action}: EmptyStateProps) {
return (
<div className="text-center py-14">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 bg-neutral-50 mb-4">
<Icon className="h-5 w-5 text-neutral-400" />
</div>
<h3 className="text-sm font-semibold text-neutral-900 mb-1">{title}</h3>
<p className="text-sm text-neutral-500 max-w-xs mx-auto leading-relaxed mb-5">{description}</p>
{action}
</div>
);
}
+3 -2
View File
@@ -51,7 +51,7 @@ function HelpResources() {
<motion.button
onClick={copyEmail}
whileTap={{scale: 0.97}}
className="flex-1 relative flex items-center justify-center gap-1.5 h-9 rounded-md border border-neutral-200 bg-white px-3 text-sm font-medium text-neutral-700 overflow-hidden transition-colors hover:bg-neutral-50 hover:text-neutral-900 hover:border-neutral-300"
className="flex-1 relative flex items-center justify-center gap-1.5 rounded-md border border-neutral-200 bg-white px-3 py-1.5 text-xs font-medium text-neutral-700 overflow-hidden transition-colors hover:bg-neutral-50 hover:text-neutral-900 hover:border-neutral-300"
>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
@@ -230,7 +230,8 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
<div className="flex-1 pt-0.5">
<p className="text-sm font-semibold text-green-900 mb-1">All set!</p>
<p className="text-xs text-green-700 leading-relaxed">
Domain verified, contacts imported, campaigns running. Everything is set up correctly.
Your project is fully configured and you&apos;re actively engaging your audience. Keep up the great
work!
</p>
</div>
</div>
+164 -66
View File
@@ -1,24 +1,22 @@
import {Alert, AlertDescription, AlertTitle, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {
Alert,
AlertDescription,
AlertTitle,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Progress,
} from '@plunk/ui';
import {AlertCircle, AlertTriangle, CheckCircle, Shield} from 'lucide-react';
import type {ProjectSecurityMetrics, SecurityLevel} from '@plunk/types';
import type {ProjectSecurityMetrics} from '@plunk/types';
interface SecuritySettingsProps {
metrics: ProjectSecurityMetrics;
isLoading: boolean;
}
const STATUS_CONFIG: Record<SecurityLevel, {color: string; icon: typeof CheckCircle; bg: string; label: string}> = {
healthy: {color: 'text-green-600', icon: CheckCircle, bg: 'bg-green-100', label: 'Healthy'},
warning: {color: 'text-orange-600', icon: AlertTriangle, bg: 'bg-orange-100', label: 'Warning'},
critical: {color: 'text-red-600', icon: AlertCircle, bg: 'bg-red-100', label: 'Critical'},
};
function getOverallLevel(status: ProjectSecurityMetrics['status']): SecurityLevel {
if (status.violations.length > 0) return 'critical';
if (status.warnings.length > 0) return 'warning';
return 'healthy';
}
export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
if (isLoading) {
return (
@@ -34,9 +32,42 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
);
}
const {status, levels, isDisabled} = metrics;
const overallLevel = getOverallLevel(status);
const overallConfig = STATUS_CONFIG[overallLevel];
const {status, thresholds, isDisabled} = metrics;
// Helper to get status color and icon
const getStatusIndicator = (rate: number, warningThreshold: number, criticalThreshold: number) => {
if (rate >= criticalThreshold) {
return {color: 'text-red-600', icon: AlertCircle, bg: 'bg-red-600', label: 'Critical'};
}
if (rate >= warningThreshold) {
return {color: 'text-orange-600', icon: AlertTriangle, bg: 'bg-orange-500', label: 'Warning'};
}
return {color: 'text-green-600', icon: CheckCircle, bg: 'bg-green-600', label: 'Healthy'};
};
const sevenDayBounceStatus = getStatusIndicator(
status.sevenDay.bounceRate,
thresholds.BOUNCE_7DAY_WARNING,
thresholds.BOUNCE_7DAY_CRITICAL,
);
const allTimeBounceStatus = getStatusIndicator(
status.allTime.bounceRate,
thresholds.BOUNCE_ALLTIME_WARNING,
thresholds.BOUNCE_ALLTIME_CRITICAL,
);
const sevenDayComplaintStatus = getStatusIndicator(
status.sevenDay.complaintRate,
thresholds.COMPLAINT_7DAY_WARNING,
thresholds.COMPLAINT_7DAY_CRITICAL,
);
const allTimeComplaintStatus = getStatusIndicator(
status.allTime.complaintRate,
thresholds.COMPLAINT_ALLTIME_WARNING,
thresholds.COMPLAINT_ALLTIME_CRITICAL,
);
return (
<div className="space-y-6">
@@ -44,99 +75,131 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${overallConfig.bg}`}>
<Shield className={`h-5 w-5 ${overallConfig.color}`} />
<div className={`p-2 rounded-lg ${status.isHealthy ? 'bg-green-100' : 'bg-red-100'}`}>
<Shield className={`h-5 w-5 ${status.isHealthy ? 'text-green-600' : 'text-red-600'}`} />
</div>
<div>
<CardTitle>Security Overview</CardTitle>
<CardDescription>
{overallLevel === 'healthy' && 'Your project is in good standing'}
{overallLevel === 'warning' && 'Your email health needs attention'}
{overallLevel === 'critical' && 'Action required to maintain project health'}
{status.isHealthy ? 'Your project is in good standing' : 'Action required to maintain project health'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
{/* Project Disabled Alert */}
{isDisabled && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Project Disabled</AlertTitle>
<AlertDescription>
This project has been disabled due to security violations. Contact support to resolve.
This project has been disabled due to critical security violations. Contact support to resolve.
</AlertDescription>
</Alert>
)}
{overallLevel === 'critical' && !isDisabled && (
{/* Violations */}
{status.violations.length > 0 && !isDisabled && (
<Alert variant="destructive" className="mb-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Critical</AlertTitle>
<AlertTitle>Critical Violations ({status.violations.length})</AlertTitle>
<AlertDescription>
Your bounce or complaint rates have exceeded acceptable levels. Review your contact lists and sending
practices to avoid project suspension.
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
{status.violations.map((violation, idx) => (
<li key={idx}>{violation}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{overallLevel === 'warning' && (
{/* Warnings */}
{status.warnings.length > 0 && (
<Alert variant="warning" className="mb-4">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Warning</AlertTitle>
<AlertTitle>Security Warnings ({status.warnings.length})</AlertTitle>
<AlertDescription>
Your bounce or complaint rates are approaching limits. Review your contact lists and remove invalid
addresses to maintain good standing.
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
{status.warnings.map((warning, idx) => (
<li key={idx}>{warning}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
{overallLevel === 'healthy' && !isDisabled && (
{/* Healthy Status */}
{status.isHealthy && !isDisabled && (
<Alert>
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription>
All security metrics are within acceptable levels. Keep up the good work!
All security metrics are within acceptable thresholds. Keep up the good work!
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
{/* Bounce Metrics */}
{/* Bounce Rate Metrics */}
<Card>
<CardHeader>
<CardTitle>Bounce Rate</CardTitle>
<CardTitle>Bounce Rate Metrics</CardTitle>
<CardDescription>Hard bounces indicate invalid or non-existent email addresses</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<HealthMetric
label="Last 7 Days"
level={levels.bounce7Day}
detail={`${status.sevenDay.bounceRate.toFixed(2)}% bounce rate (${status.sevenDay.bounces.toLocaleString()} of ${status.sevenDay.total.toLocaleString()} emails)`}
{/* 7-Day Bounce Rate */}
<MetricDisplay
label="7-Day Bounce Rate"
rate={status.sevenDay.bounceRate}
count={status.sevenDay.bounces}
total={status.sevenDay.total}
warningThreshold={thresholds.BOUNCE_7DAY_WARNING}
criticalThreshold={thresholds.BOUNCE_7DAY_CRITICAL}
status={sevenDayBounceStatus}
/>
<HealthMetric
label="All Time"
level={levels.bounceAllTime}
detail={`${status.allTime.bounceRate.toFixed(2)}% bounce rate (${status.allTime.bounces.toLocaleString()} of ${status.allTime.total.toLocaleString()} emails)`}
{/* All-Time Bounce Rate */}
<MetricDisplay
label="All-Time Bounce Rate"
rate={status.allTime.bounceRate}
count={status.allTime.bounces}
total={status.allTime.total}
warningThreshold={thresholds.BOUNCE_ALLTIME_WARNING}
criticalThreshold={thresholds.BOUNCE_ALLTIME_CRITICAL}
status={allTimeBounceStatus}
/>
</CardContent>
</Card>
{/* Complaint Metrics */}
{/* Complaint Rate Metrics */}
<Card>
<CardHeader>
<CardTitle>Complaint Rate</CardTitle>
<CardTitle>Complaint Rate Metrics</CardTitle>
<CardDescription>Complaints occur when recipients mark emails as spam</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<HealthMetric
label="Last 7 Days"
level={levels.complaint7Day}
detail={`${status.sevenDay.complaintRate.toFixed(3)}% complaint rate (${status.sevenDay.complaints.toLocaleString()} of ${status.sevenDay.total.toLocaleString()} emails)`}
{/* 7-Day Complaint Rate */}
<MetricDisplay
label="7-Day Complaint Rate"
rate={status.sevenDay.complaintRate}
count={status.sevenDay.complaints}
total={status.sevenDay.total}
warningThreshold={thresholds.COMPLAINT_7DAY_WARNING}
criticalThreshold={thresholds.COMPLAINT_7DAY_CRITICAL}
status={sevenDayComplaintStatus}
isComplaintRate
/>
<HealthMetric
label="All Time"
level={levels.complaintAllTime}
detail={`${status.allTime.complaintRate.toFixed(3)}% complaint rate (${status.allTime.complaints.toLocaleString()} of ${status.allTime.total.toLocaleString()} emails)`}
{/* All-Time Complaint Rate */}
<MetricDisplay
label="All-Time Complaint Rate"
rate={status.allTime.complaintRate}
count={status.allTime.complaints}
total={status.allTime.total}
warningThreshold={thresholds.COMPLAINT_ALLTIME_WARNING}
criticalThreshold={thresholds.COMPLAINT_ALLTIME_CRITICAL}
status={allTimeComplaintStatus}
isComplaintRate
/>
</CardContent>
</Card>
@@ -144,25 +207,60 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
);
}
interface HealthMetricProps {
interface MetricDisplayProps {
label: string;
level: SecurityLevel;
detail: string;
rate: number;
count: number;
total: number;
warningThreshold: number;
criticalThreshold: number;
status: {
color: string;
icon: React.ComponentType<{className?: string}>;
bg: string;
label: string;
};
isComplaintRate?: boolean;
}
function HealthMetric({label, level, detail}: HealthMetricProps) {
const config = STATUS_CONFIG[level];
const Icon = config.icon;
function MetricDisplay({
label,
rate,
count,
total,
warningThreshold,
criticalThreshold,
status,
isComplaintRate = false,
}: MetricDisplayProps) {
const Icon = status.icon;
const progressValue = Math.min((rate / criticalThreshold) * 100, 100);
const decimals = isComplaintRate ? 3 : 2;
return (
<div className="flex items-center justify-between border border-neutral-200 rounded-lg p-4">
<div>
<h3 className="font-medium text-neutral-900">{label}</h3>
<p className="text-sm text-neutral-600 mt-1">{detail}</p>
<div className="border border-neutral-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-medium text-neutral-900">{label}</h3>
<p className="text-sm text-neutral-600 mt-1">
{count.toLocaleString()} / {total.toLocaleString()} emails
<span className="text-neutral-400 mx-2"></span>
<strong>{rate.toFixed(decimals)}%</strong>
</p>
</div>
<div className={`flex items-center gap-2 ${status.color}`}>
<Icon className="h-4 w-4" />
<span className="text-sm font-medium">{status.label}</span>
</div>
</div>
<div className={`flex items-center gap-2 ${config.color}`}>
<Icon className="h-4 w-4" />
<span className="text-sm font-medium">{config.label}</span>
<div className="space-y-2">
<Progress value={progressValue} className="h-2" indicatorClassName={status.bg} />
<div className="flex justify-between text-xs text-neutral-500">
<span>0%</span>
<span>Warning: {warningThreshold}%</span>
<span>Critical: {criticalThreshold}%</span>
</div>
</div>
</div>
);
@@ -8,6 +8,7 @@ interface SecurityWarningBannerProps {
}
export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
// Don't show if no warnings or violations
const hasCriticalViolations = status.violations.length > 0;
const hasWarnings = status.warnings.length > 0;
@@ -15,10 +16,12 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
return null;
}
// Determine severity - critical violations take precedence
const variant = hasCriticalViolations ? 'destructive' : 'warning';
const title = hasCriticalViolations
? 'Critical - Immediate Action Required'
: 'Warning - Action Recommended';
? 'Critical Security Violations - Immediate Action Required'
: 'Security Warning - Action Required';
const issues = hasCriticalViolations ? status.violations : status.warnings;
const messageColor = hasCriticalViolations ? 'text-red-800' : 'text-amber-800';
return (
@@ -27,10 +30,16 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
<AlertTitle>{title}</AlertTitle>
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="space-y-2 flex-1">
<p className={`text-sm ${messageColor}`}>
<p className="text-sm font-medium">Your project has exceeded the following security thresholds:</p>
<ul className={`list-disc list-inside space-y-1 text-sm ${messageColor}`}>
{issues.map((issue, idx) => (
<li key={idx}>{issue}</li>
))}
</ul>
<p className={`text-xs ${messageColor} mt-2`}>
{hasCriticalViolations
? 'Your bounce or complaint rates have exceeded acceptable levels. Review your contact lists and sending practices to avoid project suspension.'
: 'Your bounce or complaint rates are approaching limits. Review your contact lists and remove invalid addresses to maintain good standing.'}
? 'Your project may be suspended soon. Please review the detailed metrics immediately and take action to improve your email quality.'
: 'High bounce or complaint rates can lead to project suspension. Review the detailed metrics and take action to improve your email quality.'}
</p>
</div>
<Link href="/settings?tab=security">
@@ -775,9 +775,19 @@ interface SegmentFilterBuilderProps {
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) {
const {fields, loading} = useAvailableOptions();
if (loading) {
return <div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>;
}
return <FilterConditionComponent condition={condition} onChange={onChange} availableFields={fields} />;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-neutral-900">Filter Conditions</h3>
<p className="text-sm text-neutral-500 mt-1">Build complex audience filters with AND/OR logic</p>
</div>
</div>
{loading ? (
<div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>
) : (
<FilterConditionComponent condition={condition} onChange={onChange} availableFields={fields} />
)}
</div>
);
}
+27 -29
View File
@@ -1,6 +1,7 @@
import {useState} from 'react';
import useSWR from 'swr';
import {
Alert,
Badge,
Button,
Card,
@@ -39,8 +40,7 @@ import {
TableRow,
} from '@plunk/ui';
import {MembershipSchemas} from '@plunk/shared';
import {MoreVertical, Trash2, UserPlus} from 'lucide-react';
import {AnimatePresence, motion} from 'framer-motion';
import {AlertTriangle, Mail, MoreVertical, Trash2, UserPlus} from 'lucide-react';
import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import type {z} from 'zod';
@@ -173,30 +173,23 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
return (
<div className="space-y-6">
<AnimatePresence mode="wait">
{success && (
<motion.div
key="success"
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
>
{success}
</motion.div>
)}
{error && (
<motion.div
key="error"
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
>
{error}
</motion.div>
)}
</AnimatePresence>
{success && (
<Alert>
<Mail className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{success}</p>
</div>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{error}</p>
</div>
</Alert>
)}
<Card>
<CardHeader>
@@ -221,10 +214,10 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-900" />
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600" />
</div>
) : members.length === 0 ? (
<div className="py-8 text-center text-sm text-neutral-500">No members found</div>
<div className="py-8 text-center text-sm text-gray-500">No members found</div>
) : (
<Table>
<TableHeader>
@@ -321,7 +314,12 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
<Form {...form}>
<form onSubmit={form.handleSubmit(handleAddMember)} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">{error}</div>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">{error}</p>
</div>
</Alert>
)}
<FormField
control={form.control}
@@ -0,0 +1,106 @@
import {Input} from '@plunk/ui';
import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {Command, CommandGroup, CommandItem, CommandList} from '@plunk/ui';
import {useCallback, useRef, useState} from 'react';
import useSWR from 'swr';
interface TemplateSearchPickerProps {
/** Currently selected template ID */
value: string;
/** Display name for the pre-selected template (avoids a fetch just to show the name) */
initialName?: string;
onChange: (id: string) => void;
}
/**
* Inline combobox for picking a template.
* Fires a debounced server-side search (/templates?search=…&pageSize=20)
* so it works correctly regardless of how many templates exist.
*/
export function TemplateSearchPicker({value, initialName, onChange}: TemplateSearchPickerProps) {
const [query, setQuery] = useState(initialName ?? '');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [open, setOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevInitialName = useRef(initialName);
// Sync display name when initialName changes (e.g. dialog re-opens with a different selection)
if (initialName !== prevInitialName.current) {
prevInitialName.current = initialName;
setQuery(initialName ?? '');
}
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
setOpen(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setDebouncedQuery(val), 300);
}, []);
const {data, isLoading} = useSWR<PaginatedResponse<Template>>(
open || debouncedQuery
? `/templates?pageSize=20${debouncedQuery ? `&search=${encodeURIComponent(debouncedQuery)}` : ''}`
: null,
{revalidateOnFocus: false},
);
// When closed, show the selected template's name rather than the raw query
const displayValue = open
? query
: (value ? (data?.data.find(t => t.id === value)?.name ?? initialName ?? value) : '');
return (
<div className="relative">
<Input
type="text"
value={displayValue}
onChange={handleInput}
onFocus={() => {
setOpen(true);
setDebouncedQuery(query);
}}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search templates…"
autoComplete="off"
/>
{open && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md max-h-60 overflow-y-auto">
{isLoading ? (
<div className="px-3 py-2 text-sm text-neutral-500">Searching</div>
) : !data?.data.length ? (
<div className="px-3 py-2 text-sm text-neutral-500">No templates found</div>
) : (
<Command>
<CommandList>
<CommandGroup>
{data.data.map(t => (
<CommandItem
key={t.id}
value={t.id}
onSelect={() => {
onChange(t.id);
setQuery(t.name);
setOpen(false);
}}
>
<span className="flex-1 truncate">{t.name}</span>
<span className="ml-2 text-xs text-neutral-400 shrink-0">{t.type}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)}
{(data?.total ?? 0) > 20 && (
<div className="px-3 py-1.5 text-xs text-neutral-400 border-t border-neutral-100">
Showing 20 of {data!.total} type to narrow results
</div>
)}
</div>
)}
</div>
);
}
@@ -236,7 +236,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
{data?.data.map(template => (
<Card
key={template.id}
className="cursor-pointer hover:border-neutral-400 transition-colors"
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
onClick={() => handleTemplateClick(template)}
>
<CardHeader className="pb-3">
@@ -295,29 +295,35 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto pr-2">
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
{/* Template Preview */}
{selectedTemplate && (
<div className="pb-4 mb-1 border-b border-neutral-100">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900">{selectedTemplate.name}</span>
<Badge
className="capitalize"
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
>
{selectedTemplate.type.toLowerCase()}
</Badge>
</div>
{selectedTemplate.description && (
<p className="text-xs text-neutral-500 mt-1">{selectedTemplate.description}</p>
)}
</div>
<Card className="bg-neutral-50">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base">{selectedTemplate.name}</CardTitle>
<Badge
className="capitalize"
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
>
{selectedTemplate.type.toLowerCase()}
</Badge>
</div>
{selectedTemplate.description && (
<CardDescription className="text-xs">{selectedTemplate.description}</CardDescription>
)}
</div>
</div>
</CardHeader>
</Card>
)}
{/* Field Selection */}
<div className="divide-y divide-neutral-100">
<div className="space-y-3">
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('subject')}
>
<Checkbox
@@ -330,13 +336,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
Email Subject
</Label>
{selectedTemplate?.subject && (
<p className="text-xs text-neutral-400 mt-0.5 truncate">{selectedTemplate.subject}</p>
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.subject}</p>
)}
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('body')}
>
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
@@ -344,12 +350,12 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
Email Body
</Label>
<p className="text-xs text-neutral-400 mt-0.5">Full email content and design</p>
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('from')}
>
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
@@ -358,13 +364,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
From Email
</Label>
{selectedTemplate?.from && (
<p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.from}</p>
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.from}</p>
)}
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('fromName')}
>
<Checkbox
@@ -377,13 +383,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
From Name
</Label>
{selectedTemplate?.fromName && (
<p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.fromName}</p>
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.fromName}</p>
)}
</div>
</div>
<div
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
onClick={() => toggleField('replyTo')}
>
<Checkbox
@@ -396,7 +402,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
Reply-To Email
</Label>
{selectedTemplate?.replyTo && (
<p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.replyTo}</p>
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.replyTo}</p>
)}
</div>
</div>
+23 -14
View File
@@ -209,8 +209,8 @@ function AddStepNode({data}: {data: {label: string; onClick?: () => void}}) {
/>
<div className="cursor-pointer hover:scale-105 transition-transform" onClick={data.onClick}>
<div className="w-16 h-16 rounded-full bg-neutral-100 border-2 border-dashed border-neutral-400 hover:border-neutral-600 hover:bg-white flex items-center justify-center transition-all">
<Plus className="h-8 w-8 text-neutral-500 hover:text-neutral-700 transition-colors" />
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-neutral-100 to-neutral-200 border-2 border-dashed border-neutral-400 hover:border-neutral-600 hover:from-blue-50 hover:to-blue-100 hover:border-blue-400 flex items-center justify-center shadow-md transition-all">
<Plus className="h-8 w-8 text-neutral-500 transition-colors" />
</div>
{data.label && <div className="text-xs text-neutral-500 text-center mt-2 font-medium">{data.label}</div>}
</div>
@@ -255,7 +255,7 @@ function CustomNode({
/>
<div
className="px-5 py-4 rounded-xl border-2 bg-white shadow-sm hover:shadow-md transition-all relative group"
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all relative group"
style={{
borderColor: color,
minWidth: '280px',
@@ -274,7 +274,7 @@ function CustomNode({
}}
variant="outline"
size="icon"
className="h-7 w-7"
className="h-7 w-7 shadow-md"
title="Edit trigger settings"
>
<Settings className="h-3.5 w-3.5" />
@@ -290,7 +290,7 @@ function CustomNode({
}}
variant="outline"
size="icon"
className="h-7 w-7"
className="h-7 w-7 shadow-md"
title="Edit step"
>
<Settings className="h-3.5 w-3.5" />
@@ -302,7 +302,7 @@ function CustomNode({
}}
variant="outline"
size="icon"
className="h-7 w-7 hover:bg-red-50 hover:border-red-400"
className="h-7 w-7 shadow-md hover:bg-red-50 hover:border-red-400"
title="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -905,19 +905,19 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
/>
<MiniMap
nodeColor={node => {
const step = steps.find(s => s.id === node.id);
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
}}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
maskColor="rgba(0, 0, 0, 0.05)"
/>
<Panel
position="top-left"
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
>
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" />
@@ -933,7 +933,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
<Panel position="top-right" className="flex gap-2">
<button
onClick={handleAutoLayout}
className="bg-white px-4 py-2 rounded-lg shadow-md border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
className="bg-white/95 backdrop-blur-sm px-4 py-2 rounded-lg shadow-lg border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-white hover:text-neutral-900 transition-all"
>
Auto Layout
</button>
@@ -941,11 +941,11 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
className="bg-blue-50 border border-blue-200 px-4 py-2.5 rounded-lg shadow-lg"
>
<div className="flex items-center gap-2 text-sm text-neutral-600">
<div className="flex items-center gap-2 text-sm text-blue-900">
<Lightbulb className="h-4 w-4" />
<span>Click the + buttons to add and connect steps.</span>
<span>Click the + buttons to add and connect steps!</span>
</div>
</Panel>
)}
@@ -965,7 +965,16 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
<button
key={option.value}
onClick={() => handleCreateStep(option.value)}
className="flex flex-col items-center gap-2 p-4 rounded-lg border border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50 transition-all group"
className="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-neutral-200 hover:border-neutral-400 hover:bg-neutral-50 transition-all group"
style={{
borderColor: 'transparent',
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = option.color;
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'transparent';
}}
>
<div
className="w-12 h-12 rounded-lg flex items-center justify-center transition-transform group-hover:scale-110"
@@ -143,7 +143,7 @@ function CustomNode({
/>
<div
className="px-5 py-4 rounded-xl border-2 bg-white shadow-sm hover:shadow-md transition-all cursor-grab active:cursor-grabbing"
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all cursor-grab active:cursor-grabbing"
style={{
borderColor: color,
minWidth: '250px',
@@ -496,11 +496,11 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
/>
<Panel
position="top-left"
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
>
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" />
@@ -516,9 +516,9 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
className="bg-amber-50 border border-amber-200 px-4 py-2.5 rounded-lg shadow-lg"
>
<div className="flex items-center gap-2 text-sm text-neutral-600">
<div className="flex items-center gap-2 text-sm text-amber-900">
<AlertTriangle className="h-4 w-4" />
<span>No transitions found. Connect your steps to see the flow.</span>
</div>
+7 -7
View File
@@ -271,9 +271,9 @@ export default function AnalyticsPage() {
{!hasData ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="text-center">
<Mail className="mx-auto h-8 w-8 text-neutral-300" />
<h3 className="mt-3 text-sm font-semibold text-neutral-900">No email data yet</h3>
<p className="mt-1 text-sm text-neutral-500">Send your first email to see analytics here.</p>
<Mail className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No email data yet</h3>
<p className="mt-2 text-sm text-muted-foreground">Send your first email to see analytics here</p>
</div>
</div>
) : (
@@ -380,10 +380,10 @@ export default function AnalyticsPage() {
{!hasData ? (
<div className="flex h-[300px] w-full items-center justify-center">
<div className="text-center">
<Eye className="mx-auto h-8 w-8 text-neutral-300" />
<h3 className="mt-3 text-sm font-semibold text-neutral-900">No engagement data</h3>
<p className="mt-1 text-sm text-neutral-500">
Engagement metrics will appear once emails are opened.
<Eye className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-sm font-semibold text-neutral-900">No engagement data</h3>
<p className="mt-2 text-sm text-muted-foreground">
Engagement metrics will appear once emails are opened
</p>
</div>
</div>
+79 -94
View File
@@ -20,7 +20,6 @@ import {
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import Image from 'next/image';
import Link from 'next/link';
import {useRouter} from 'next/router';
import React, {useState} from 'react';
@@ -28,22 +27,11 @@ import {useForm} from 'react-hook-form';
import type {z} from 'zod';
import {API_URI} from '../../lib/constants';
import {useConfig} from '../../lib/hooks/useConfig';
import {useProjects} from '../../lib/hooks/useProject';
import {useUser} from '../../lib/hooks/useUser';
import {useConfig} from '../../lib/hooks/useConfig';
import {network} from '../../lib/network';
const Spinner = () => (
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
export default function Login() {
const {mutate: userMutate} = useUser();
const {mutate: projectsMutate} = useProjects();
@@ -79,7 +67,7 @@ export default function Login() {
>('POST', '/auth/login', values);
if (!response.success) {
setErrorMessage('Email or password is incorrect');
setErrorMessage('Email or password is not correct');
} else {
setErrorMessage(null);
@@ -120,23 +108,9 @@ export default function Login() {
return (
<>
<NextSeo title="Log in" />
<div
className="min-h-screen flex items-center justify-center py-12"
style={{
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
backgroundSize: '20px 20px',
}}
>
<div className="flex flex-col gap-6 max-w-md w-full px-4">
<div className="flex items-center justify-center gap-2.5">
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
</div>
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
</div>
<NextSeo title="Login" />
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card>
<CardContent className="p-0">
<Form {...form}>
@@ -148,9 +122,9 @@ export default function Login() {
className="p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1.5">
<h1 className="text-2xl font-bold tracking-tight">Welcome back</h1>
<p className="text-sm text-neutral-500">Sign in to your account</p>
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Welcome back</h1>
<p className="text-neutral-600">Enter your credentials to access your account</p>
</div>
{(oauthConfig.github || oauthConfig.google) && (
@@ -165,7 +139,7 @@ export default function Login() {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="h-4 w-4" viewBox="0 0 24 24">
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
@@ -195,7 +169,7 @@ export default function Login() {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
@@ -204,16 +178,16 @@ export default function Login() {
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-neutral-200" />
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-400 tracking-wider">or</span>
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
</div>
</div>
</>
)}
<div className="grid gap-4">
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
@@ -221,68 +195,87 @@ export default function Login() {
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Password</FormLabel>
<button
type="button"
className="text-xs text-neutral-500 hover:text-neutral-900 transition-colors"
onClick={() => setShowReset(true)}
>
Forgot password?
</button>
</div>
<FormControl>
<Input type="password" {...field} />
<Input placeholder="hello@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="password" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<button
type="button"
className="text-xs underline mt-1 text-left text-neutral-500"
onClick={() => setShowReset(true)}
>
Forgot password?
</button>
</div>
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -8}}
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -8}}
transition={{duration: 0.15}}
className="text-sm text-red-500"
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)}
</AnimatePresence>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<Spinner />
Signing in...
</>
) : (
'Log in'
)}
</Button>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Login'
)}
</Button>
</motion.div>
<p className="text-center text-sm text-neutral-500">
<div className="text-center text-sm text-neutral-500">
Don&apos;t have an account?{' '}
<Link href="/auth/signup" className="text-neutral-900 underline underline-offset-4 hover:text-neutral-600 transition-colors">
<Link href="/auth/signup" className="underline underline-offset-4 hover:text-neutral-900">
Sign up
</Link>
</p>
</div>
</div>
</form>
</Form>
@@ -314,30 +307,22 @@ export default function Login() {
>
<Input
type="email"
placeholder="[email protected]"
placeholder="Enter your email"
value={resetEmail}
onChange={e => setResetEmail(e.target.value)}
required
autoFocus
/>
<DialogFooter>
<div className="w-full space-y-2">
<Button className="w-full" type="submit" disabled={resetStatus === 'loading'}>
{resetStatus === 'loading' ? (
<>
<Spinner />
Sending...
</>
) : (
'Send reset link'
)}
<div className={'w-full space-y-2'}>
<Button className={'w-full block'} type="submit" disabled={resetStatus === 'loading'}>
{resetStatus === 'loading' ? 'Sending...' : 'Send reset link'}
</Button>
{resetStatus === 'success' && (
<p className="text-sm text-neutral-600">
<p className="text-green-600 text-sm">
If an account exists, a reset link has been sent to your email.
</p>
)}
{resetStatus === 'error' && <p className="text-sm text-red-500">{resetError}</p>}
{resetStatus === 'error' && <p className="text-red-500 text-sm">{resetError}</p>}
</div>
</DialogFooter>
</form>
+62 -77
View File
@@ -14,7 +14,6 @@ import {
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import Image from 'next/image';
import Link from 'next/link';
import {useRouter} from 'next/router';
import React, {useEffect, useState} from 'react';
@@ -23,32 +22,6 @@ import type {z} from 'zod';
import {network} from '../../lib/network';
const dotGrid = {
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
backgroundSize: '20px 20px',
};
const Spinner = () => (
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
const Wordmark = () => (
<div className="flex items-center justify-center gap-2.5">
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
</div>
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
</div>
);
export default function ResetPassword() {
const router = useRouter();
const {token} = router.query;
@@ -64,6 +37,7 @@ export default function ResetPassword() {
},
});
// Update form token when router is ready
useEffect(() => {
if (token && typeof token === 'string') {
form.setValue('token', token);
@@ -97,25 +71,22 @@ export default function ResetPassword() {
return (
<>
<NextSeo title="Reset Password" />
<div className="min-h-screen flex items-center justify-center py-12" style={dotGrid}>
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
<div className="flex flex-col gap-6 max-w-md w-full px-4">
<Wordmark />
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
<svg className="h-6 w-6 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div className="flex flex-col gap-1.5">
<h1 className="text-xl font-bold tracking-tight">Invalid reset link</h1>
<p className="text-sm text-neutral-500">
This link is invalid or has expired. Request a new one from the login page.
</p>
</div>
<Link href="/auth/login" className="mt-2">
<Button>Back to login</Button>
<h1 className="text-2xl font-bold tracking-tight text-red-600">Invalid reset link</h1>
<p className="text-neutral-600">
This password reset link is invalid. Please request a new one from the login page.
</p>
<Link href="/auth/login">
<Button className="w-full mt-4">Back to login</Button>
</Link>
</div>
</CardContent>
@@ -129,31 +100,29 @@ export default function ResetPassword() {
return (
<>
<NextSeo title="Reset Password" />
<div className="min-h-screen flex items-center justify-center py-12" style={dotGrid}>
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
<div className="flex flex-col gap-6 max-w-md w-full px-4">
<Wordmark />
<Card>
<CardContent className="p-0">
<AnimatePresence mode="wait">
{status === 'success' ? (
<motion.div
key="success"
initial={{opacity: 0, scale: 0.97}}
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0}}
transition={{duration: 0.2}}
className="p-8"
>
<div className="flex flex-col items-center gap-4 text-center">
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
<svg className="h-6 w-6 text-neutral-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex flex-col gap-1.5">
<h1 className="text-xl font-bold tracking-tight">Password updated</h1>
<p className="text-sm text-neutral-500">Redirecting you to login...</p>
</div>
<h1 className="text-2xl font-bold tracking-tight text-green-600">Password reset!</h1>
<p className="text-neutral-600">
Your password has been successfully reset. Redirecting to login...
</p>
</div>
</motion.div>
) : (
@@ -166,33 +135,34 @@ export default function ResetPassword() {
}}
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1.5">
<h1 className="text-2xl font-bold tracking-tight">Reset your password</h1>
<p className="text-sm text-neutral-500">Enter your new password below</p>
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Reset your password</h1>
<p className="text-neutral-600">Enter your new password below</p>
</div>
<FormField
control={form.control}
name="newPassword"
render={({field}) => (
<FormItem>
<FormLabel>New password</FormLabel>
<FormControl>
<Input placeholder="At least 6 characters" type="password" autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid gap-2">
<FormField
control={form.control}
name="newPassword"
render={({field}) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input placeholder="Enter new password" type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<AnimatePresence>
{status === 'error' && (
<motion.p
initial={{opacity: 0, y: -8}}
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -8}}
transition={{duration: 0.15}}
className="text-sm text-red-500"
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
@@ -202,23 +172,38 @@ export default function ResetPassword() {
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<Spinner />
Resetting...
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Reset password'
)}
</Button>
<p className="text-center text-sm text-neutral-500">
<div className="text-center text-sm text-neutral-500">
Remember your password?{' '}
<Link
href="/auth/login"
className="text-neutral-900 underline underline-offset-4 hover:text-neutral-600 transition-colors"
>
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
Back to login
</Link>
</p>
</div>
</div>
</form>
</Form>
+53 -58
View File
@@ -14,7 +14,6 @@ import {
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import Image from 'next/image';
import Link from 'next/link';
import {useRouter} from 'next/router';
import React, {useState} from 'react';
@@ -22,22 +21,11 @@ import {useForm} from 'react-hook-form';
import type {z} from 'zod';
import {API_URI} from '../../lib/constants';
import {useConfig} from '../../lib/hooks/useConfig';
import {useProjects} from '../../lib/hooks/useProject';
import {useUser} from '../../lib/hooks/useUser';
import {useConfig} from '../../lib/hooks/useConfig';
import {network} from '../../lib/network';
const Spinner = () => (
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
export default function Signup() {
const {mutate: userMutate} = useUser();
const {mutate: projectsMutate} = useProjects();
@@ -69,6 +57,7 @@ export default function Signup() {
>('POST', '/auth/signup', values);
if (!response.success) {
// Handle error message from API
const errorData = typeof response.data === 'string' ? response.data : 'Something went wrong';
setErrorMessage(errorData);
} else {
@@ -87,22 +76,8 @@ export default function Signup() {
return (
<>
<NextSeo title="Sign Up" />
<div
className="min-h-screen flex items-center justify-center py-12"
style={{
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
backgroundSize: '20px 20px',
}}
>
<div className="flex flex-col gap-6 max-w-md w-full px-4">
<div className="flex items-center justify-center gap-2.5">
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
</div>
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
</div>
<div className={'min-h-screen flex items-center justify-center bg-neutral-50 py-12'}>
<div className={'flex flex-col gap-6 max-w-md w-full px-4'}>
<Card>
<CardContent className="p-0">
<Form {...form}>
@@ -114,9 +89,9 @@ export default function Signup() {
className="p-8"
>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1.5">
<h1 className="text-2xl font-bold tracking-tight">Create an account</h1>
<p className="text-sm text-neutral-500">Start sending emails in minutes</p>
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight">Create an account</h1>
<p className="text-neutral-600">Get started with Plunk today</p>
</div>
{(oauthConfig.github || oauthConfig.google) && (
@@ -131,7 +106,7 @@ export default function Signup() {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="h-4 w-4" viewBox="0 0 24 24">
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
@@ -161,7 +136,7 @@ export default function Signup() {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<svg className="mr-2 h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
@@ -170,16 +145,16 @@ export default function Signup() {
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-neutral-200" />
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-neutral-400 tracking-wider">or</span>
<span className="bg-white px-2 text-neutral-500">Or continue with email</span>
</div>
</div>
</>
)}
<div className="grid gap-4">
<div className="grid gap-2">
<FormField
control={form.control}
name="email"
@@ -187,13 +162,14 @@ export default function Signup() {
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" autoFocus {...field} />
<Input placeholder="hello@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid gap-2">
<FormField
control={form.control}
name="password"
@@ -201,7 +177,7 @@ export default function Signup() {
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input placeholder="At least 6 characters" type="password" {...field} />
<Input placeholder="password (min. 6 characters)" type={'password'} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -212,34 +188,53 @@ export default function Signup() {
<AnimatePresence>
{errorMessage && (
<motion.p
initial={{opacity: 0, y: -8}}
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -8}}
transition={{duration: 0.15}}
className="text-sm text-red-500"
exit={{opacity: 0, y: -10}}
className="text-sm font-medium text-red-500"
>
{errorMessage}
</motion.p>
)}
</AnimatePresence>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<Spinner />
Creating account...
</>
) : (
'Create account'
)}
</Button>
<motion.div layout>
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</>
) : (
'Sign up'
)}
</Button>
</motion.div>
<p className="text-center text-sm text-neutral-500">
<div className="text-center text-sm text-neutral-500">
Already have an account?{' '}
<Link href="/auth/login" className="text-neutral-900 underline underline-offset-4 hover:text-neutral-600 transition-colors">
Log in
<Link href="/auth/login" className="underline underline-offset-4 hover:text-neutral-900">
Login
</Link>
</p>
</div>
</div>
</form>
</Form>
+57 -73
View File
@@ -2,30 +2,12 @@ import {AuthenticationSchemas} from '@plunk/shared';
import {Button, Card, CardContent} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import Image from 'next/image';
import Link from 'next/link';
import {useRouter} from 'next/router';
import React, {useEffect, useRef, useState} from 'react';
import {network} from '../../lib/network';
const dotGrid = {
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
backgroundSize: '20px 20px',
};
const Spinner = () => (
<svg className="h-6 w-6 animate-spin text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
export default function VerifyEmail() {
const router = useRouter();
const {token} = router.query;
@@ -39,6 +21,7 @@ export default function VerifyEmail() {
const processedToken = useRef<string | undefined>(undefined);
useEffect(() => {
// Wait for router to be ready before processing
if (!router.isReady) {
return;
}
@@ -51,6 +34,7 @@ export default function VerifyEmail() {
processedToken.current = normalizedToken;
// If no token, show the pending verification state
if (!token || typeof token !== 'string') {
setStatus('pending');
return;
@@ -85,24 +69,29 @@ export default function VerifyEmail() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, token]);
// Initialize cooldown from localStorage on mount
useEffect(() => {
const storedExpiry = localStorage.getItem('plunk:email-verification-cooldown');
if (storedExpiry) {
const expiryTime = parseInt(storedExpiry, 10);
// Validate: not NaN, in the future, and within reasonable range (< 1 hour from now)
if (!isNaN(expiryTime) && expiryTime > Date.now() && expiryTime < Date.now() + 3600000) {
setCooldownExpiry(expiryTime);
} else {
// Clean up invalid/expired cooldown
localStorage.removeItem('plunk:email-verification-cooldown');
}
}
}, []);
// Countdown timer effect
useEffect(() => {
if (!cooldownExpiry) {
setRemainingSeconds(0);
return;
}
// Update immediately
const updateRemaining = () => {
const remaining = Math.max(0, Math.ceil((cooldownExpiry - Date.now()) / 1000));
setRemainingSeconds(remaining);
@@ -127,6 +116,7 @@ export default function VerifyEmail() {
if (response.success) {
setResendMessage('Verification email sent! Please check your inbox.');
// Set 60-second cooldown
const expiryTime = Date.now() + 60000;
setCooldownExpiry(expiryTime);
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
@@ -134,7 +124,9 @@ export default function VerifyEmail() {
setResendMessage('Failed to send verification email. Please try again.');
}
} catch (error) {
// Show error message but still apply cooldown to prevent spam
setResendMessage(error instanceof Error ? error.message : 'Failed to send verification email. Please try again.');
// Apply cooldown even on error to prevent retry spam
const expiryTime = Date.now() + 60000;
setCooldownExpiry(expiryTime);
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
@@ -146,15 +138,8 @@ export default function VerifyEmail() {
return (
<>
<NextSeo title="Verify Email" />
<div className="min-h-screen flex items-center justify-center py-12" style={dotGrid}>
<div className="min-h-screen flex items-center justify-center bg-neutral-50 py-12">
<div className="flex flex-col gap-6 max-w-md w-full px-4">
<div className="flex items-center justify-center gap-2.5">
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
</div>
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
</div>
<Card>
<CardContent className="p-8">
<div className="flex flex-col gap-6 text-center">
@@ -162,14 +147,13 @@ export default function VerifyEmail() {
{status === 'pending' && (
<motion.div
key="pending"
initial={{opacity: 0, scale: 0.97}}
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0}}
transition={{duration: 0.2}}
className="flex flex-col items-center gap-4"
>
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
<svg className="h-6 w-6 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="h-16 w-16 rounded-full bg-blue-100 flex items-center justify-center">
<svg className="h-8 w-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -178,24 +162,21 @@ export default function VerifyEmail() {
/>
</svg>
</div>
<div className="flex flex-col gap-1.5">
<h1 className="text-xl font-bold tracking-tight">Check your email</h1>
<p className="text-sm text-neutral-500">
We sent a verification link to your inbox. Click it to verify your account.
</p>
</div>
<h1 className="text-2xl font-bold tracking-tight">Verify your email</h1>
<p className="text-neutral-600">
Please check your inbox for a verification link. Click the link in the email to verify your
account.
</p>
<div className="flex flex-col gap-2 w-full mt-2">
<div className="flex flex-col gap-3 w-full mt-4">
<Button onClick={handleResend} disabled={isResending || cooldownExpiry !== null} className="w-full">
{isResending
? 'Sending...'
: cooldownExpiry !== null
? `Resend in ${remainingSeconds}s`
: 'Resend verification email'}
{isResending ? 'Sending...' : cooldownExpiry !== null ? `Resend in ${remainingSeconds}s` : 'Resend verification email'}
</Button>
{resendMessage && (
<p className={`text-sm ${resendMessage.includes('sent') ? 'text-neutral-600' : 'text-red-500'}`}>
<p
className={`text-sm ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}
>
{resendMessage}
</p>
)}
@@ -215,70 +196,73 @@ export default function VerifyEmail() {
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
transition={{duration: 0.2}}
className="flex flex-col items-center gap-4"
>
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
<Spinner />
</div>
<div className="flex flex-col gap-1.5">
<h1 className="text-xl font-bold tracking-tight">Verifying...</h1>
<p className="text-sm text-neutral-500">Please wait a moment.</p>
<div className="h-16 w-16 rounded-full bg-neutral-100 flex items-center justify-center">
<svg
className="h-8 w-8 animate-spin text-neutral-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight">Verifying your email...</h1>
<p className="text-neutral-600">Please wait while we verify your email address.</p>
</motion.div>
)}
{status === 'success' && (
<motion.div
key="success"
initial={{opacity: 0, scale: 0.97}}
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0}}
transition={{duration: 0.2}}
className="flex flex-col items-center gap-4"
>
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
<svg className="h-6 w-6 text-neutral-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<svg className="h-8 w-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="flex flex-col gap-1.5">
<h1 className="text-xl font-bold tracking-tight">Email verified</h1>
<p className="text-sm text-neutral-500">Redirecting to your dashboard...</p>
</div>
<h1 className="text-2xl font-bold tracking-tight text-green-600">Email verified!</h1>
<p className="text-neutral-600">
Your email has been successfully verified. Redirecting to dashboard...
</p>
</motion.div>
)}
{status === 'error' && (
<motion.div
key="error"
initial={{opacity: 0, scale: 0.97}}
initial={{opacity: 0, scale: 0.95}}
animate={{opacity: 1, scale: 1}}
exit={{opacity: 0}}
transition={{duration: 0.2}}
className="flex flex-col items-center gap-4"
>
<div className="h-12 w-12 rounded-full bg-red-50 flex items-center justify-center">
<svg className="h-6 w-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
<svg className="h-8 w-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div className="flex flex-col gap-1.5">
<h1 className="text-xl font-bold tracking-tight">Verification failed</h1>
<p className="text-sm text-neutral-500">{errorMessage}</p>
</div>
<h1 className="text-2xl font-bold tracking-tight text-red-600">Verification failed</h1>
<p className="text-neutral-600">{errorMessage}</p>
<div className="flex flex-col gap-2 w-full mt-2">
<div className="flex flex-col gap-3 w-full mt-4">
<Button onClick={handleResend} disabled={isResending || cooldownExpiry !== null} className="w-full">
{isResending
? 'Sending...'
: cooldownExpiry !== null
? `Resend in ${remainingSeconds}s`
: 'Resend verification email'}
{isResending ? 'Sending...' : cooldownExpiry !== null ? `Resend in ${remainingSeconds}s` : 'Resend verification email'}
</Button>
{resendMessage && (
<p className={`text-sm ${resendMessage.includes('sent') ? 'text-neutral-600' : 'text-red-500'}`}>
<p
className={`text-sm ${resendMessage.includes('sent') ? 'text-green-600' : 'text-red-500'}`}
>
{resendMessage}
</p>
)}
+51 -33
View File
@@ -41,6 +41,7 @@ import {
ArrowLeft,
Calendar,
ChevronDown,
Info,
Mail,
MousePointer,
Save,
@@ -621,20 +622,24 @@ export default function CampaignDetailsPage() {
{/* 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="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-2">
<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">
<Users className="h-4 w-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900">
{draftRecipientCount.toLocaleString()} recipients
</span>
</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 className="flex items-start gap-2">
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800">
This count will be recalculated right before sending to ensure accuracy. The final number may
differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.'
}
</p>
</div>
</div>
)}
</CardContent>
@@ -787,10 +792,12 @@ export default function CampaignDetailsPage() {
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">
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-xs font-medium text-blue-900 mb-1">Scheduled for:</p>
<p className="text-sm text-blue-800">
<span className="font-medium">{formatFullDateTime(new Date(scheduledDateTime))}</span>
</p>
<p className="text-xs text-blue-700 mt-1">
UTC: {formatUTCDateTime(new Date(scheduledDateTime))}
</p>
</div>
@@ -877,30 +884,30 @@ export default function CampaignDetailsPage() {
{/* Sending Progress Banner */}
{c.status === CampaignStatus.SENDING && s && (
<Card>
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200">
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold text-neutral-900 text-lg">Sending in progress</h3>
<p className="text-sm text-neutral-500 mt-1">
<p className="text-sm text-neutral-600 mt-1">
{s.sentCount.toLocaleString()} of {s.totalRecipients.toLocaleString()} emails sent
</p>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-neutral-900">
<div className="text-3xl font-bold text-blue-600">
{((s.sentCount / s.totalRecipients) * 100).toFixed(0)}%
</div>
<p className="text-xs text-neutral-500 mt-1">Complete</p>
</div>
</div>
<div className="w-full bg-neutral-100 rounded-full h-2">
<div className="w-full bg-neutral-200 rounded-full h-3">
<div
className="bg-neutral-900 h-2 rounded-full transition-all duration-500"
className="bg-blue-500 h-3 rounded-full transition-all duration-500"
style={{width: `${(s.sentCount / s.totalRecipients) * 100}%`}}
/>
</div>
<p className="text-xs text-neutral-400">This page updates automatically every 5 seconds</p>
<p className="text-xs text-neutral-500">This page updates automatically every 5 seconds</p>
</div>
</CardContent>
</Card>
@@ -909,10 +916,12 @@ export default function CampaignDetailsPage() {
{/* Stats Cards */}
{s && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<Card className="border-l-4 border-l-blue-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-500">Total Recipients</CardTitle>
<Users className="h-4 w-4 text-neutral-400" />
<CardTitle className="text-sm font-medium text-neutral-600">Total Recipients</CardTitle>
<div className="p-2 bg-blue-100 rounded-lg">
<Users className="h-4 w-4 text-blue-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.totalRecipients.toLocaleString()}</div>
@@ -922,10 +931,12 @@ export default function CampaignDetailsPage() {
</CardContent>
</Card>
<Card>
<Card className="border-l-4 border-l-green-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-500">Delivery Rate</CardTitle>
<Mail className="h-4 w-4 text-neutral-400" />
<CardTitle className="text-sm font-medium text-neutral-600">Delivery Rate</CardTitle>
<div className="p-2 bg-green-100 rounded-lg">
<Mail className="h-4 w-4 text-green-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.deliveryRate.toFixed(1)}%</div>
@@ -936,10 +947,12 @@ export default function CampaignDetailsPage() {
</CardContent>
</Card>
<Card>
<Card className="border-l-4 border-l-purple-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-500">Open Rate</CardTitle>
<TrendingUp className="h-4 w-4 text-neutral-400" />
<CardTitle className="text-sm font-medium text-neutral-600">Open Rate</CardTitle>
<div className="p-2 bg-purple-100 rounded-lg">
<TrendingUp className="h-4 w-4 text-purple-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.openRate.toFixed(1)}%</div>
@@ -947,10 +960,12 @@ export default function CampaignDetailsPage() {
</CardContent>
</Card>
<Card>
<Card className="border-l-4 border-l-orange-500">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-neutral-500">Click Rate</CardTitle>
<MousePointer className="h-4 w-4 text-neutral-400" />
<CardTitle className="text-sm font-medium text-neutral-600">Click Rate</CardTitle>
<div className="p-2 bg-orange-100 rounded-lg">
<MousePointer className="h-4 w-4 text-orange-600" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-neutral-900">{s.clickRate.toFixed(1)}%</div>
@@ -1048,7 +1063,10 @@ export default function CampaignDetailsPage() {
</p>
</div>
{c.status === CampaignStatus.SCHEDULED && (
<p className="text-xs text-neutral-500">Recipient count will be recalculated at send time</p>
<div className="flex items-start gap-1.5 p-2 bg-blue-50 border border-blue-200 rounded">
<Info className="h-3 w-3 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800">Recipient count will be recalculated at send time</p>
</div>
)}
</div>
</div>
+311 -145
View File
@@ -3,7 +3,6 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
@@ -13,6 +12,7 @@ import {
SelectItemWithDescription,
SelectTrigger,
SelectValue,
Textarea,
} from '@plunk/ui';
import type {Segment, Template} from '@plunk/db';
import {CampaignAudienceType, TemplateType} from '@plunk/db';
@@ -20,9 +20,10 @@ import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {StepHeader} from '../../components/StepHeader';
import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, TriangleAlert} from 'lucide-react';
import {ArrowLeft, Save, TriangleAlert, Users} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
@@ -49,6 +50,7 @@ export default function CreateCampaignPage() {
const {data: segments} = useSWR<Segment[]>('/segments', {revalidateOnFocus: false});
// Load template or campaign data if provided in query params
useEffect(() => {
const loadData = async () => {
const {
@@ -63,26 +65,36 @@ export default function CreateCampaignPage() {
segmentId: querySegmentId,
} = router.query;
// Handle template loading
if (templateId && typeof templateId === 'string') {
setLoadingTemplate(true);
try {
// Fetch the full template to get the body content
const template = await network.fetch<Template>('GET', `/templates/${templateId}`);
// Pre-fill form with template data
if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
if (queryFromName && typeof queryFromName === 'string') setFromName(queryFromName);
if (queryReplyTo && typeof queryReplyTo === 'string') setReplyTo(queryReplyTo);
setBody(template.body);
toast.success('Template loaded successfully');
} catch {
toast.error('Failed to load template');
} finally {
setLoadingTemplate(false);
}
} else if (campaignId && typeof campaignId === 'string') {
}
// Handle campaign loading
else if (campaignId && typeof campaignId === 'string') {
setLoadingTemplate(true);
try {
// Fetch the full campaign to get the body content
const campaign = await network.fetch<{data: {body: string}}>('GET', `/campaigns/${campaignId}`);
// Pre-fill form with campaign data
if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
@@ -93,13 +105,16 @@ export default function CreateCampaignPage() {
}
if (querySegmentId && typeof querySegmentId === 'string') setSegmentId(querySegmentId);
setBody(campaign.data.body);
toast.success('Campaign loaded successfully');
} catch {
toast.error('Failed to load campaign');
} finally {
setLoadingTemplate(false);
}
} else {
}
// Handle query params without template/campaign ID (direct field values)
else {
if (queryName && typeof queryName === 'string') setName(queryName);
if (querySubject && typeof querySubject === 'string') setSubject(querySubject);
if (queryFrom && typeof queryFrom === 'string') setFrom(queryFrom);
@@ -151,12 +166,13 @@ export default function CreateCampaignPage() {
}
};
// Calculate estimated recipients
const getEstimatedRecipients = () => {
if (audienceType === CampaignAudienceType.SEGMENT && segmentId && segments) {
const segment = segments.find(s => s.id === segmentId);
return segment?.memberCount || 0;
}
return 0;
return 0; // We don't have total contact count here, but in a real scenario you'd fetch it
};
const estimatedRecipients = getEstimatedRecipients();
@@ -201,15 +217,19 @@ export default function CreateCampaignPage() {
</div>
</div>
{/* Form */}
<form onSubmit={handleSubmit}>
<div className="space-y-6">
{/* Row 1: Basic Info + Campaign Type */}
<div className="grid gap-6 md:grid-cols-2">
<div className="grid gap-6 lg:grid-cols-3">
{/* Left Column - Settings (2/3 width) */}
<div className="lg:col-span-2 space-y-6">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>Name and describe your campaign</CardDescription>
<StepHeader
stepNumber={1}
title="Basic Information"
description="Name and describe your campaign"
/>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -226,12 +246,14 @@ export default function CreateCampaignPage() {
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
<Label htmlFor="description">Description (Optional)</Label>
<Textarea
id="description"
placeholder="Internal notes about this campaign"
value={description}
onChange={e => setDescription(e.target.value)}
rows={2}
className="resize-none"
/>
</div>
</CardContent>
@@ -240,30 +262,56 @@ export default function CreateCampaignPage() {
{/* Campaign Type */}
<Card>
<CardHeader>
<CardTitle>Campaign Type</CardTitle>
<CardDescription>Choose how this campaign should be treated</CardDescription>
<StepHeader
stepNumber={2}
title="Campaign Type"
description="Choose how this campaign should be treated"
/>
</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={() => setCampaignType(value)}
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
campaignType === 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 className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setCampaignType(TemplateType.MARKETING)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === TemplateType.MARKETING
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Marketing</p>
<p className="text-xs text-neutral-500 mt-1">
Sent to subscribed contacts only. Includes unsubscribe link.
</p>
</button>
<button
type="button"
onClick={() => setCampaignType(TemplateType.TRANSACTIONAL)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === TemplateType.TRANSACTIONAL
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Transactional</p>
<p className="text-xs text-neutral-500 mt-1">
Sent to all contacts regardless of subscription status. No unsubscribe footer.
</p>
</button>
<button
type="button"
onClick={() => setCampaignType(TemplateType.HEADLESS)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === TemplateType.HEADLESS
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Headless</p>
<p className="text-xs text-neutral-500 mt-1">
Sent to subscribed contacts only. No Plunk footer you provide the unsubscribe link.
</p>
</button>
</div>
{campaignType === TemplateType.HEADLESS && !detectUnsubscribeSignal(body) && (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 overflow-hidden">
@@ -288,131 +336,249 @@ export default function CreateCampaignPage() {
)}
</CardContent>
</Card>
</div>
{/* Email Settings */}
<Card>
<CardHeader>
<CardTitle>Email Settings</CardTitle>
<CardDescription>Configure sender information and subject</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<EmailSettings
from={from}
fromName={fromName}
replyTo={replyTo}
onFromChange={setFrom}
onFromNameChange={setFromName}
onReplyToChange={setReplyTo}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
/>
<div className="space-y-2">
<Label htmlFor="subject">
Email Subject <span className="text-red-500">*</span>
</Label>
<Input
id="subject"
placeholder="e.g., Introducing our Spring Sale!"
value={subject}
onChange={e => setSubject(e.target.value)}
required
{/* Email Settings */}
<Card>
<CardHeader>
<StepHeader
stepNumber={3}
title="Email Settings"
description="Configure sender information and subject"
/>
</CardHeader>
<CardContent className="space-y-4">
<EmailSettings
from={from}
fromName={fromName}
replyTo={replyTo}
onFromChange={setFrom}
onFromNameChange={setFromName}
onReplyToChange={setReplyTo}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
/>
</div>
</CardContent>
</Card>
{/* Email Content */}
<Card className="overflow-visible">
<CardHeader>
<CardTitle>Email Content</CardTitle>
<CardDescription>Design your email message</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor value={body} onChange={setBody} />
</CardContent>
</Card>
{/* Audience */}
<Card>
<CardHeader>
<CardTitle>Audience</CardTitle>
<CardDescription>Choose who will receive this campaign</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="audienceType">
Audience Type <span className="text-red-500">*</span>
</Label>
<Select
value={audienceType}
onValueChange={value => setAudienceType(value as CampaignAudienceType)}
required
>
<SelectTrigger>
<SelectValue placeholder="Select audience type" />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription
value={CampaignAudienceType.ALL}
title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description={campaignType === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
title="Specific Segment"
description="Target a defined group of contacts"
/>
</SelectContent>
</Select>
</div>
{audienceType === CampaignAudienceType.SEGMENT && (
<div className="space-y-2">
<Label htmlFor="segment">
Select Segment <span className="text-red-500">*</span>
<Label htmlFor="subject">
Email Subject <span className="text-red-500">*</span>
</Label>
<Select value={segmentId} onValueChange={setSegmentId} required>
<Input
id="subject"
placeholder="e.g., Introducing our Spring Sale!"
value={subject}
onChange={e => setSubject(e.target.value)}
required
/>
</div>
</CardContent>
</Card>
{/* Email Content */}
<Card className="overflow-visible">
<CardHeader>
<StepHeader stepNumber={4} title="Email Content" description="Design your email message" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="body">
Email Body <span className="text-red-500">*</span>
</Label>
<EmailEditor value={body} onChange={setBody} />
</div>
</CardContent>
</Card>
{/* Audience Selection */}
<Card>
<CardHeader>
<StepHeader stepNumber={5} title="Audience" description="Choose who will receive this campaign" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="audienceType">
Audience Type <span className="text-red-500">*</span>
</Label>
<Select
value={audienceType}
onValueChange={value => setAudienceType(value as CampaignAudienceType)}
required
>
<SelectTrigger>
<SelectValue placeholder="Choose a segment" />
<SelectValue placeholder="Select audience type" />
</SelectTrigger>
<SelectContent>
{segments?.map(segment => (
<SelectItemWithDescription
key={segment.id}
value={segment.id}
title={segment.name}
description={`${segment.memberCount.toLocaleString()} contacts`}
/>
))}
<SelectItemWithDescription
value={CampaignAudienceType.ALL}
title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description={campaignType === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
title="Specific Segment"
description="Target a defined group of contacts"
/>
</SelectContent>
</Select>
{segments?.length === 0 && (
<p className="text-sm text-neutral-500">
No segments found.{' '}
<Link href="/segments/new" className="underline">
Create one first
</Link>
</p>
</div>
{audienceType === CampaignAudienceType.SEGMENT && (
<div className="space-y-2">
<Label htmlFor="segment">
Select Segment <span className="text-red-500">*</span>
</Label>
<Select value={segmentId} onValueChange={setSegmentId} required>
<SelectTrigger>
<SelectValue placeholder="Choose a segment" />
</SelectTrigger>
<SelectContent>
{segments?.map(segment => (
<SelectItemWithDescription
key={segment.id}
value={segment.id}
title={segment.name}
description={`${segment.memberCount.toLocaleString()} contacts`}
/>
))}
</SelectContent>
</Select>
{segments?.length === 0 && (
<p className="text-sm text-neutral-500 mt-2">
No segments found.{' '}
<Link href="/segments/new" className="text-primary hover:underline">
Create one first
</Link>
</p>
)}
</div>
)}
{audienceType === CampaignAudienceType.SEGMENT && estimatedRecipients > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">
{estimatedRecipients.toLocaleString()} recipients
</p>
<p className="text-xs text-blue-700 mt-1">
This campaign will be sent to all contacts in the selected segment
</p>
</div>
</div>
)}
{audienceType === CampaignAudienceType.ALL && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">
{campaignType === TemplateType.TRANSACTIONAL ? 'All contacts' : 'All subscribed contacts'}
</p>
<p className="text-xs text-blue-700 mt-1">
{campaignType === TemplateType.TRANSACTIONAL
? 'This campaign will be sent to all contacts regardless of subscription status'
: "This campaign will be sent to all contacts who haven't unsubscribed"}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* Right Column - Summary & Actions (1/3 width) */}
<div className="space-y-6">
{/* Campaign Summary */}
<Card className="sticky top-6">
<CardHeader>
<CardTitle className="text-lg">Campaign Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 text-sm">
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Status</span>
<span className="font-medium">Draft</span>
</div>
{name && (
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Name</span>
<span className="font-medium text-right truncate ml-2" title={name}>
{name}
</span>
</div>
)}
{estimatedRecipients > 0 && (
<p className="text-sm text-neutral-500">
<span className="font-medium text-neutral-900">{estimatedRecipients.toLocaleString()} recipients</span> in this segment
</p>
{subject && (
<div className="py-2 border-b border-neutral-100">
<span className="text-neutral-500 block mb-1">Subject</span>
<span className="font-medium text-sm">{subject}</span>
</div>
)}
{from && (
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">From</span>
<span className="font-medium text-right truncate ml-2" title={from}>
{from}
</span>
</div>
)}
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Type</span>
<span className="font-medium">
{campaignType === TemplateType.MARKETING ? 'Marketing' : campaignType === TemplateType.HEADLESS ? 'Headless' : 'Transactional'}
</span>
</div>
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Audience</span>
<span className="font-medium">
{audienceType === CampaignAudienceType.ALL ? 'All Contacts' : 'Segment'}
</span>
</div>
{audienceType === CampaignAudienceType.SEGMENT && estimatedRecipients > 0 && (
<div className="flex justify-between py-2">
<span className="text-neutral-500">Recipients</span>
<span className="font-medium">{estimatedRecipients.toLocaleString()}</span>
</div>
)}
{audienceType === CampaignAudienceType.ALL && (
<div className="flex justify-between py-2">
<span className="text-neutral-500">Recipients</span>
<span className="font-medium">All subscribed</span>
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Link href="/campaigns">
<Button type="button" variant="outline">Cancel</Button>
</Link>
<Button type="submit" disabled={saving}>
{saving ? 'Creating...' : 'Create Campaign'}
</Button>
{/* Info Note */}
<div className="bg-neutral-50 border border-neutral-200 rounded-lg p-3 mt-4">
<p className="text-xs text-neutral-600 leading-relaxed">
After creating this campaign, you&apos;ll be able to review it and choose when to send it.
</p>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 pt-4">
<Button type="submit" disabled={saving} className="w-full">
{saving ? (
<>Creating...</>
) : (
<>
<Save className="h-4 w-4" />
Create Campaign
</>
)}
</Button>
<Link href="/campaigns" className="w-full">
<Button type="button" variant="outline" className="w-full">
Cancel
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
</div>
</form>
+67 -65
View File
@@ -21,7 +21,6 @@ import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network';
@@ -302,71 +301,74 @@ export default function CampaignsPage() {
{!isLoading && data?.data.length === 0 && (
<Card>
<CardContent>
<EmptyState
icon={Mail}
title={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.'
}
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>
) : (
<Link href="/campaigns/create">
<Button>
<CardContent className="py-16 text-center">
<div className="max-w-md mx-auto">
<div className="bg-primary/10 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Mail className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-semibold text-neutral-900 mb-2">
{statusFilter !== 'ALL' ? `No ${statusFilter.toLowerCase()} campaigns` : 'No campaigns yet'}
</h3>
<p className="text-neutral-500 mb-6">
{statusFilter !== 'ALL'
? 'Try adjusting your filters or create a new campaign.'
: 'Create your first campaign to send emails to your contacts.'}
</p>
{statusFilter === 'ALL' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="lg">
<Plus className="h-4 w-4" />
Create Campaign
Create Your First Campaign
<ChevronDown className="h-4 w-4 ml-1" />
</Button>
</Link>
)
}
/>
</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>
)}
{statusFilter !== 'ALL' && (
<Link href="/campaigns/create">
<Button size="lg">
<Plus className="h-4 w-4" />
Create Campaign
</Button>
</Link>
)}
</div>
</CardContent>
</Card>
)}
@@ -378,7 +380,7 @@ export default function CampaignsPage() {
campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
return (
<Card key={campaign.id} className="transition-colors hover:border-neutral-300">
<Card key={campaign.id} className="hover:shadow-lg transition-all hover:border-primary/20">
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
+291 -345
View File
@@ -2,24 +2,22 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
Input,
Label,
Switch,
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {AnimatePresence, motion} from 'framer-motion';
import {ArrowLeft, Check, Copy, Database, ExternalLink, Loader2, Save, Settings, Trash2} from 'lucide-react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {DashboardLayout} from '../../components/DashboardLayout';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {ActivityFeed} from '../../components/ActivityFeed';
import {network} from '../../lib/network';
import {ArrowLeft, Copy, Database, ExternalLink, Mail, Save, Settings, Trash2} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
@@ -35,8 +33,8 @@ export default function ContactDetailPage() {
const [customData, setCustomData] = useState<Record<string, string | number | boolean> | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
// Initialize form when contact loads
useEffect(() => {
if (contact) {
setEmail(contact.email);
@@ -75,14 +73,12 @@ export default function ContactDetailPage() {
}
};
const copyToClipboard = async (text: string, label: string, copyId: string) => {
const copyToClipboard = async (url: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedId(copyId);
setTimeout(() => setCopiedId(null), 2000);
toast.success(`${label} copied to clipboard`);
await navigator.clipboard.writeText(url);
toast.success(`${label} link copied to clipboard`);
} catch {
toast.error('Failed to copy');
toast.error('Failed to copy link');
}
};
@@ -90,7 +86,22 @@ export default function ContactDetailPage() {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-neutral-400" />
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading contact...</p>
</div>
</div>
</DashboardLayout>
);
@@ -116,342 +127,277 @@ export default function ContactDetailPage() {
}
return (
<>
<NextSeo title={contact.email} />
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
<Link href="/contacts">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 truncate">{contact.email}</h1>
<p className="mt-1">
<span
className={`inline-flex items-center px-2 sm:px-2.5 py-0.5 rounded-full text-xs font-medium ${
contact.subscribed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
</span>
</p>
</div>
<DashboardLayout>
<div className="space-y-6">
{/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/contacts">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 truncate">{contact.email}</h1>
<p className="text-neutral-500 mt-1">
<span
className={`inline-flex items-center px-2 sm:px-2.5 py-0.5 rounded-full text-xs font-medium ${
contact.subscribed ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}
>
{contact.subscribed ? 'Subscribed' : 'Unsubscribed'}
</span>
</p>
</div>
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)} className="flex-shrink-0">
</div>
<div className="flex justify-end">
<Button variant="destructive" onClick={() => setShowDeleteDialog(true)} className="w-full sm:w-auto">
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">Delete Contact</span>
<span className="sm:hidden">Delete</span>
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Edit Form */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="[email protected]"
/>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<Label htmlFor="subscribed" className="font-medium cursor-pointer">
Subscribed to emails
</Label>
<p className="text-xs text-neutral-500 mt-0.5">
{subscribed ? 'Receives emails from campaigns and workflows' : 'Will not receive emails'}
</p>
</div>
<Switch id="subscribed" checked={subscribed} onCheckedChange={setSubscribed} />
</div>
<div>
{contact && (
<KeyValueEditor
key={`${contact.id}-${JSON.stringify(contact.data)}`}
initialData={contact.data as Record<string, string | number | boolean> | null}
onChange={setCustomData}
/>
)}
</div>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</form>
</CardContent>
</Card>
{/* Activity Feed */}
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
</CardHeader>
<CardContent>
<ActivityFeed contactId={id as string} />
</CardContent>
</Card>
</div>
{/* Metadata Sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Details</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-900">Contact ID</p>
<div className="flex items-center gap-1.5 mt-0.5">
<p className="text-xs text-neutral-500 font-mono break-all flex-1">{contact.id}</p>
<button
type="button"
onClick={() => copyToClipboard(contact.id, 'Contact ID', 'contact-id')}
className="flex-shrink-0 text-neutral-400 hover:text-neutral-700 transition-colors"
aria-label="Copy contact ID"
>
<AnimatePresence mode="wait" initial={false}>
{copiedId === 'contact-id' ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
</button>
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Created</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.createdAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.updatedAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Public Links Card */}
<Card>
<CardHeader>
<CardTitle>Public Links</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Subscribe Page</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/subscribe/${contact.id}`, '_blank')}
>
<ExternalLink className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
className="overflow-hidden"
onClick={() =>
copyToClipboard(
`${window.location.origin}/subscribe/${contact.id}`,
'Subscribe link',
'subscribe',
)
}
>
<AnimatePresence mode="wait" initial={false}>
{copiedId === 'subscribe' ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Unsubscribe Page</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/unsubscribe/${contact.id}`, '_blank')}
>
<ExternalLink className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
className="overflow-hidden"
onClick={() =>
copyToClipboard(
`${window.location.origin}/unsubscribe/${contact.id}`,
'Unsubscribe link',
'unsubscribe',
)
}
>
<AnimatePresence mode="wait" initial={false}>
{copiedId === 'unsubscribe' ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Manage Preferences</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/manage/${contact.id}`, '_blank')}
>
<Settings className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
className="overflow-hidden"
onClick={() =>
copyToClipboard(
`${window.location.origin}/manage/${contact.id}`,
'Manage preferences link',
'manage',
)
}
>
<AnimatePresence mode="wait" initial={false}>
{copiedId === 'manage' ? (
<motion.span
key="copied"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Check className="h-3 w-3 text-green-600" />
</motion.span>
) : (
<motion.span
key="idle"
initial={{opacity: 0, y: 4}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -4}}
transition={{duration: 0.15}}
>
<Copy className="h-3 w-3" />
</motion.span>
)}
</AnimatePresence>
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Contact"
description="Are you sure you want to delete this contact? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
</>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Edit Form */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
<CardDescription>Update contact details and subscription status</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<Label htmlFor="email">Email Address *</Label>
<Input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="[email protected]"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex-1">
<Label htmlFor="subscribed" className="text-sm font-medium text-neutral-900 cursor-pointer">
Subscribed to emails
</Label>
<p className="text-xs text-neutral-500 mt-0.5">
{subscribed ? 'Contact will receive emails' : 'Contact will not receive emails'}
</p>
</div>
<button
type="button"
id="subscribed"
onClick={() => setSubscribed(!subscribed)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2 ${
subscribed ? 'bg-neutral-900' : 'bg-neutral-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
subscribed ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div>
{contact && (
<KeyValueEditor
key={`${contact.id}-${JSON.stringify(contact.data)}`}
initialData={contact.data as Record<string, string | number | boolean> | null}
onChange={setCustomData}
/>
)}
</div>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4" />
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</form>
</CardContent>
</Card>
{/* Activity Feed */}
<Card>
<CardHeader>
<CardTitle>Activity Feed</CardTitle>
<CardDescription>Recent activity for this contact</CardDescription>
</CardHeader>
<CardContent>
<ActivityFeed contactId={id as string} />
</CardContent>
</Card>
</div>
{/* Metadata Sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Metadata</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Mail className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-900">Email</p>
<p className="text-sm text-neutral-500 break-all">{contact.email}</p>
</div>
</div>
<div className="flex items-start gap-3">
<Database className="h-5 w-5 text-neutral-500 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-neutral-900">Contact ID</p>
<p className="text-xs text-neutral-500 font-mono break-all">{contact.id}</p>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Created</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.createdAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.updatedAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Stats Card */}
<Card>
<CardHeader>
<CardTitle>Activity</CardTitle>
<CardDescription>Email engagement statistics</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Emails Sent</span>
<span className="text-sm font-medium text-neutral-900">0</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Emails Opened</span>
<span className="text-sm font-medium text-neutral-900">0</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-neutral-600">Links Clicked</span>
<span className="text-sm font-medium text-neutral-900">0</span>
</div>
</CardContent>
</Card>
{/* Public Links Card */}
<Card>
<CardHeader>
<CardTitle>Public Links</CardTitle>
<CardDescription>Share these links with the contact</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Subscribe Page</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/subscribe/${contact.id}`, '_blank')}
>
<ExternalLink className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(`${window.location.origin}/subscribe/${contact.id}`, 'Subscribe')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Unsubscribe Page</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/unsubscribe/${contact.id}`, '_blank')}
>
<ExternalLink className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
copyToClipboard(`${window.location.origin}/unsubscribe/${contact.id}`, 'Unsubscribe')
}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium text-neutral-700">Manage Preferences</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1 justify-start text-xs"
onClick={() => window.open(`${window.location.origin}/manage/${contact.id}`, '_blank')}
>
<Settings className="h-3 w-3" />
Open
</Button>
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(`${window.location.origin}/manage/${contact.id}`, 'Manage')}
>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
<div className="pt-2 border-t">
<p className="text-xs text-neutral-500">
These public links allow the contact to manage their subscription without logging in.
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="Delete Contact"
description="Are you sure you want to delete this contact? This action cannot be undone."
confirmText="Delete"
variant="destructive"
/>
</DashboardLayout>
);
}
+117 -70
View File
@@ -19,7 +19,6 @@ import {
import type {Contact} from '@plunk/db';
import type {CursorPaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
@@ -283,19 +282,19 @@ export default function ContactsPage() {
</div>
</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="text-center py-12">
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No contacts found</h3>
<p className="text-neutral-500 mb-6">
{search ? 'Try adjusting your search terms' : 'Get started by creating your first contact'}
</p>
{!search && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Add Contact
</Button>
)}
</div>
) : (
<>
{/* Desktop Table View - Hidden on mobile */}
@@ -548,7 +547,7 @@ function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialo
<DialogTitle>Create New Contact</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<div>
<Label htmlFor="email">Email Address *</Label>
<Input
id="email"
@@ -560,19 +559,21 @@ function CreateContactDialog({open, onOpenChange, onSuccess}: CreateContactDialo
/>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<Switch id="subscribed" checked={subscribed} onCheckedChange={setSubscribed} />
<div className="flex-1">
<Label htmlFor="subscribed" className="font-medium cursor-pointer">
Subscribed
</Label>
<p className="text-xs text-neutral-500 mt-0.5">
Receive emails from campaigns and workflows.
<p className="text-xs text-neutral-500 mt-1">
When enabled, this contact will receive emails from your campaigns and workflows.
</p>
</div>
<Switch id="subscribed" checked={subscribed} onCheckedChange={setSubscribed} />
</div>
<KeyValueEditor key={open ? 'create' : 'closed'} initialData={customData} onChange={setCustomData} />
<div>
<KeyValueEditor key={open ? 'create' : 'closed'} initialData={customData} onChange={setCustomData} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
@@ -777,8 +778,19 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
<div className="space-y-4">
{/* Instructions */}
<div className="text-sm text-neutral-500 space-y-1">
<p>Required column: <code className="text-neutral-700 bg-neutral-100 px-1 py-0.5 rounded text-xs">email</code>. Optional: <code className="text-neutral-700 bg-neutral-100 px-1 py-0.5 rounded text-xs">subscribed</code> (true/false) and any custom fields. Max 5MB.</p>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-2">CSV Format Requirements</h4>
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
<li>First row must contain column headers</li>
<li>
Required column: <code className="bg-blue-100 px-1 rounded">email</code>
</li>
<li>
Optional: <code className="bg-blue-100 px-1 rounded">subscribed</code> (true/false, 1/0, yes/no)
</li>
<li>Optional: Add any custom fields (e.g., firstName, lastName, plan)</li>
<li>Maximum file size: 5MB</li>
</ul>
</div>
{/* File Upload */}
@@ -816,9 +828,9 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
</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="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{width: `${progress}%`}}
/>
</div>
@@ -828,31 +840,50 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
{/* Results */}
{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.totalRows}</span> processed {' '}
<span className="text-neutral-900">{result.createdCount}</span> created,{' '}
<span className="text-neutral-900">{result.updatedCount}</span> updated
{result.failureCount > 0 && (
<>, <span className="text-red-600">{result.failureCount}</span> failed</>
)}
</span>
<div className="grid grid-cols-4 gap-3">
<div className="bg-neutral-50 rounded-lg p-4">
<div className="text-2xl font-bold text-neutral-900">{result.totalRows}</div>
<div className="text-sm text-neutral-600">Total</div>
</div>
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<div className="text-2xl font-bold text-green-900">{result.createdCount}</div>
</div>
<div className="text-sm text-green-700">Created</div>
</div>
<div className="bg-blue-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-blue-600" />
<div className="text-2xl font-bold text-blue-900">{result.updatedCount}</div>
</div>
<div className="text-sm text-blue-700">Updated</div>
</div>
<div className="bg-red-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-600" />
<div className="text-2xl font-bold text-red-900">{result.failureCount}</div>
</div>
<div className="text-sm text-red-700">Failed</div>
</div>
</div>
{/* Error Details */}
{result.errors && result.errors.length > 0 && (
<div className="max-h-40 overflow-y-auto border border-neutral-200 rounded-md">
<div className="space-y-0 text-xs text-neutral-600">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 max-h-48 overflow-y-auto">
<h4 className="font-medium text-red-900 mb-2">Import Errors</h4>
<div className="space-y-1 text-sm text-red-800">
{result.errors.slice(0, 10).map((error, idx) => (
<div key={idx} className="flex gap-3 px-3 py-2 border-b border-neutral-100 last:border-0">
<span className="font-mono text-neutral-400 flex-shrink-0">Row {error.row}</span>
<span className="text-red-600">{error.email || 'N/A'} {error.error}</span>
<div key={idx} className="flex gap-2">
<span className="font-mono text-xs">Row {error.row}:</span>
<span>
{error.email || 'N/A'} - {error.error}
</span>
</div>
))}
{result.errors.length > 10 && (
<div className="px-3 py-2 text-neutral-500">
+{result.errors.length - 10} more errors
<div className="text-red-700 font-medium mt-2">
...and {result.errors.length - 10} more errors
</div>
)}
</div>
@@ -862,9 +893,14 @@ function ImportContactsDialog({open, onOpenChange, onSuccess}: ImportContactsDia
)}
{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 check your CSV file and try again.'}</p>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-900">
<XCircle className="h-5 w-5" />
<span className="font-medium">Import failed</span>
</div>
<p className="text-sm text-red-800 mt-1">
{errorMessage || 'Please check your CSV file and try again.'}
</p>
</div>
)}
</div>
@@ -1075,13 +1111,13 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
<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="bg-neutral-50 border border-neutral-200 rounded-lg p-4">
<p className="text-sm text-neutral-900">
Are you sure you want to {operation} {contactIds.length} contact
{contactIds.length !== 1 ? 's' : ''}?
</p>
{operation === 'delete' && (
<p className="text-xs text-red-500">This action cannot be undone.</p>
<p className="text-sm text-red-600 mt-2 font-medium">This action cannot be undone.</p>
)}
</div>
)}
@@ -1092,9 +1128,9 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
<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="w-full bg-neutral-200 rounded-full h-2">
<div
className="bg-neutral-900 h-1.5 rounded-full transition-all duration-300"
className={`bg-${getOperationColor()}-600 h-2 rounded-full transition-all duration-300`}
style={{width: `${progress}%`}}
/>
</div>
@@ -1103,27 +1139,35 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
{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</>
)}
</span>
<div className="grid grid-cols-2 gap-3">
<div className="bg-green-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600" />
<div className="text-2xl font-bold text-green-900">{result.successCount}</div>
</div>
<div className="text-sm text-green-700">Succeeded</div>
</div>
{result.failureCount > 0 && (
<div className="bg-red-50 rounded-lg p-4">
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-600" />
<div className="text-2xl font-bold text-red-900">{result.failureCount}</div>
</div>
<div className="text-sm text-red-700">Failed</div>
</div>
)}
</div>
{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">
<div className="bg-red-50 border border-red-200 rounded-lg p-4 max-h-48 overflow-y-auto">
<h4 className="font-medium text-red-900 mb-2">Errors</h4>
<div className="space-y-1 text-sm text-red-800">
{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>
<div key={idx}>{error.error}</div>
))}
{result.errors.length > 10 && (
<div className="px-3 py-2 text-neutral-500">
+{result.errors.length - 10} more errors
<div className="text-red-700 font-medium mt-2">
...and {result.errors.length - 10} more errors
</div>
)}
</div>
@@ -1133,9 +1177,12 @@ function BulkActionsDialog({open, onOpenChange, operation, contactIds, onSuccess
)}
{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="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-2 text-red-900">
<XCircle className="h-5 w-5" />
<span className="font-medium">Operation failed</span>
</div>
<p className="text-sm text-red-800 mt-1">{errorMessage || 'Please try again.'}</p>
</div>
)}
</div>
+8 -11
View File
@@ -38,22 +38,22 @@ export default function Index() {
const stats = [
{
name: 'Total Contacts',
value: totalContacts.toLocaleString(),
value: isLoading ? '-' : totalContacts.toLocaleString(),
icon: Users,
},
{
name: 'Emails Sent',
value: totalEmailsSent.toLocaleString(),
value: isLoading ? '-' : totalEmailsSent.toLocaleString(),
icon: Mail,
},
{
name: 'Campaigns',
value: totalCampaigns.toLocaleString(),
value: isLoading ? '-' : totalCampaigns.toLocaleString(),
icon: Send,
},
{
name: 'Open Rate',
value: `${openRate.toFixed(1)}%`,
value: isLoading ? '-' : `${openRate.toFixed(1)}%`,
icon: TrendingUp,
},
];
@@ -173,6 +173,9 @@ export default function Index() {
{/* Header */}
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Dashboard</h1>
<p className="text-neutral-500 mt-2 text-sm sm:text-base">
Welcome back to {activeProject?.name || 'Plunk'}. Here&apos;s what&apos;s happening with your emails.
</p>
</div>
{/* Stats Grid */}
@@ -186,13 +189,7 @@ export default function Index() {
<CardDescription>{stat.name}</CardDescription>
<Icon className="h-4 w-4 text-neutral-500" />
</div>
<CardTitle className="text-2xl tabular-nums">
{isLoading ? (
<div className="h-7 w-16 bg-neutral-100 rounded animate-pulse" />
) : (
stat.value
)}
</CardTitle>
<CardTitle className="text-2xl">{stat.value}</CardTitle>
</CardHeader>
</Card>
);
+4 -10
View File
@@ -13,7 +13,7 @@ import type {Contact, Segment} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {network} from '../../lib/network';
import {ArrowLeft, Database, Filter, Layers, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, Users} from 'lucide-react';
import {ArrowLeft, Database, Filter, MailCheck, MailX, RefreshCw, Save, Trash2, UserMinus, Users} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
@@ -310,11 +310,7 @@ export default function SegmentDetailPage() {
{/* Filter Builder (DYNAMIC only) */}
{!isStatic && (
<Card>
<CardHeader>
<CardTitle>Filter Conditions</CardTitle>
<CardDescription>Build complex audience filters with AND/OR logic</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="pt-6">
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
</CardContent>
</Card>
@@ -350,9 +346,7 @@ export default function SegmentDetailPage() {
>
{isAddingMembers
? 'Adding...'
: pickedEmails.length > 0
? `Add ${pickedEmails.length} Contact${pickedEmails.length !== 1 ? 's' : ''}`
: 'Add Contacts'}
: `Add ${pickedEmails.length > 0 ? pickedEmails.length : ''} Contact${pickedEmails.length !== 1 ? 's' : ''}`}
</Button>
</CardContent>
</Card>
@@ -483,7 +477,7 @@ export default function SegmentDetailPage() {
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-neutral-500" />
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Groups</span>
</div>
<span className="text-lg font-semibold text-neutral-900">
+24 -25
View File
@@ -12,7 +12,6 @@ import {
import type {Segment} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
@@ -124,20 +123,20 @@ export default function SegmentsPage() {
</div>
) : segments?.length === 0 ? (
<Card>
<CardContent>
<EmptyState
icon={Filter}
title="No segments yet"
description="Group contacts by attributes to target specific audiences."
action={
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
</Button>
</Link>
}
/>
<CardContent className="py-12">
<div className="text-center">
<Filter className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No segments yet</h3>
<p className="text-neutral-500 mb-6">
Create your first segment to group contacts based on attributes and behaviors
</p>
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
</Button>
</Link>
</div>
</CardContent>
</Card>
) : (
@@ -176,17 +175,17 @@ export default function SegmentsPage() {
<span className="text-lg font-semibold text-neutral-900">{segment.memberCount}</span>
</div>
{(segment as unknown as {type: string}).type !== 'STATIC' && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-sm font-medium text-neutral-900">
{countFiltersInCondition(segment.condition)}
</span>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span>
</div>
)}
<span className="text-sm font-medium text-neutral-900">
{(segment as unknown as {type: string}).type === 'STATIC'
? '—'
: countFiltersInCondition(segment.condition)}
</span>
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-neutral-200">
+1 -5
View File
@@ -176,11 +176,7 @@ export default function NewSegmentPage() {
{/* Filter Builder or Contact Picker */}
{segmentType === 'DYNAMIC' ? (
<Card>
<CardHeader>
<CardTitle>Filter Conditions</CardTitle>
<CardDescription>Build complex audience filters with AND/OR logic</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="pt-6">
<SegmentFilterBuilder condition={condition} onChange={setCondition} />
</CardContent>
</Card>
+6 -7
View File
@@ -5,7 +5,6 @@ import {ProjectSchemas, SUPPORTED_LANGUAGES} from '@plunk/shared';
import {TrackingMode} from '@plunk/db';
import {
Alert,
AlertDescription,
Button,
Card,
CardContent,
@@ -826,14 +825,14 @@ export default function Settings() {
<AlertTriangle className="h-5 w-5 text-orange-500" />
Regenerate API Keys
</DialogTitle>
<DialogDescription className="space-y-3">
<DialogDescription className="space-y-2">
<p>Are you sure you want to regenerate your API keys?</p>
<Alert variant="warning">
<Alert className="bg-orange-50 border-orange-200 text-orange-900 text-xs">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Current keys will be <strong>immediately invalidated</strong>. Any integrations using the old keys
will stop working until updated.
</AlertDescription>
<div className="ml-2">
<strong>Warning:</strong> This action will immediately invalidate your current API keys. Any
applications using the old keys will stop working until you update them with the new keys.
</div>
</Alert>
</DialogDescription>
</DialogHeader>
+6 -2
View File
@@ -185,7 +185,8 @@ export default function TemplateEditorPage() {
{/* Template Editor */}
<div className="space-y-6">
{/* Template Settings */}
<Card>
<div>
<Card>
<CardHeader>
<CardTitle>Template Settings</CardTitle>
<CardDescription>Configure the basic settings for your template</CardDescription>
@@ -286,9 +287,11 @@ export default function TemplateEditorPage() {
/>
</CardContent>
</Card>
</div>
{/* Email Body */}
<Card className="overflow-visible">
<div>
<Card className="overflow-visible">
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
@@ -300,6 +303,7 @@ export default function TemplateEditorPage() {
/>
</CardContent>
</Card>
</div>
</div>
</form>
+96 -113
View File
@@ -14,7 +14,7 @@ import {EmailSettings} from '../../components/EmailSettings';
import {EmailEditor} from '../../components/EmailEditor';
import {network} from '../../lib/network';
import {EmailFormValidator} from '../../lib/validation';
import {ArrowLeft, TriangleAlert} from 'lucide-react';
import {ArrowLeft, Save, TriangleAlert} from 'lucide-react';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useState} from 'react';
@@ -44,6 +44,7 @@ export default function CreateTemplatePage() {
return;
}
setSaving(true);
try {
@@ -70,117 +71,110 @@ export default function CreateTemplatePage() {
<>
<NextSeo title="Create Template" />
<DashboardLayout>
<div className="space-y-6">
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/templates">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/templates">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Template</h1>
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
Create a reusable email template for campaigns and workflows
</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSubmit} disabled={saving} className="w-full sm:w-auto">
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{saving ? 'Creating...' : 'Create Template'}</span>
<span className="sm:hidden">{saving ? 'Creating...' : 'Create'}</span>
</Button>
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Template</h1>
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
Create a reusable email template for campaigns and workflows
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Row 1: Basic Info + Template Type */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>Name and describe your template</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Template Name <span className="text-red-500">*</span></Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Welcome Email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Sent to new subscribers"
/>
</div>
</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={() => setType(value)}
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
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>
{type === 'HEADLESS' && !detectUnsubscribeSignal(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 */}
{/* Template Settings */}
<Card>
<CardHeader>
<CardTitle>Email Settings</CardTitle>
<CardDescription>Configure sender information and subject</CardDescription>
<CardTitle>Template Settings</CardTitle>
<CardDescription>Configure your template details and email settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="subject">Subject Line <span className="text-red-500">*</span></Label>
<div>
<Label htmlFor="name">Template Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Welcome Email"
/>
</div>
<div>
<Label>Template 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={() => setType(value)}
className={`flex items-center justify-between w-full min-h-[44px] px-4 py-3 rounded-lg border-2 text-left transition-colors ${
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>
{type === 'HEADLESS' && !detectUnsubscribeSignal(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="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Sent to new subscribers"
/>
</div>
<div>
<Label htmlFor="subject">Subject Line *</Label>
<Input
id="subject"
type="text"
@@ -189,7 +183,6 @@ export default function CreateTemplatePage() {
required
placeholder="Welcome to our platform!"
/>
<p className="text-xs text-neutral-500">Use {'{{variableName}}'} for dynamic content</p>
</div>
<EmailSettings
@@ -214,16 +207,6 @@ export default function CreateTemplatePage() {
<EmailEditor value={body} onChange={setBody} />
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<Link href="/templates">
<Button type="button" variant="outline">Cancel</Button>
</Link>
<Button type="submit" disabled={saving}>
{saving ? 'Creating...' : 'Create Template'}
</Button>
</div>
</form>
</div>
</DashboardLayout>
+16 -17
View File
@@ -12,7 +12,6 @@ import {
import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
@@ -186,22 +185,22 @@ export default function TemplatesPage() {
</Card>
) : data?.data.length === 0 ? (
<Card>
<CardContent>
<EmptyState
icon={FileText}
title={search ? 'No templates match' : 'No templates yet'}
description={search ? 'Try a different search term.' : 'Create reusable email designs for campaigns.'}
action={
!search ? (
<Link href="/templates/create">
<Button>
<Plus className="h-4 w-4" />
Create Template
</Button>
</Link>
) : undefined
}
/>
<CardContent className="pt-6">
<div className="text-center py-12">
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3>
<p className="text-neutral-500 mb-6">
{search ? 'Try adjusting your search terms' : 'Get started by creating your first template'}
</p>
{!search && (
<Link href="/templates/create">
<Button>
<Plus className="h-4 w-4" />
Create Template
</Button>
</Link>
)}
</div>
</CardContent>
</Card>
) : (
+68 -78
View File
@@ -35,7 +35,6 @@ import {
import type {Template, Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
import {
AlertTriangle,
@@ -61,6 +60,7 @@ import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {WorkflowBuilder} from '../../components/WorkflowBuilder';
import {TemplateSearchPicker} from '../../components/TemplateSearchPicker';
import {ReactFlowProvider} from '@xyflow/react';
import {WorkflowSchemas} from '@plunk/shared';
import dayjs from 'dayjs';
@@ -598,11 +598,13 @@ export default function WorkflowEditorPage() {
</CardHeader>
<CardContent>
{!executionsData?.executions.length ? (
<EmptyState
icon={Users}
title="No executions yet"
description="This workflow hasn't been executed yet. Enable it to start processing contacts."
/>
<div className="text-center py-12">
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No executions yet</h3>
<p className="text-neutral-500 mb-6">
This workflow hasn&apos;t been executed yet. Enable it to start processing contacts.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
@@ -1003,7 +1005,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
const [isSubmitting, setIsSubmitting] = useState(false);
const {data: templatesData} = useSWR<PaginatedResponse<Template>>('/templates?pageSize=100');
// templates fetched on-demand by TemplateSearchPicker
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null);
// Fetch available event names when dialog opens
@@ -1253,7 +1255,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information Section */}
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Basic Information</h3>
</div>
@@ -1328,30 +1331,17 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* SEND_EMAIL Configuration */}
{type === 'SEND_EMAIL' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Email Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
<div>
<Label htmlFor="template" className="text-sm font-medium">
Email Template *
</Label>
<Select value={templateId} onValueChange={setTemplateId} required>
<SelectTrigger id="template" className="mt-1.5">
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
{templatesData?.data.map(template => (
<SelectItemWithDescription
key={template.id}
value={template.id}
title={template.name}
description={`${template.type === 'TRANSACTIONAL' ? 'Transactional' : 'Marketing'} • Subject: ${template.subject}`}
/>
))}
</SelectContent>
</Select>
<TemplateSearchPicker value={templateId} onChange={setTemplateId} />
<p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p>
</div>
@@ -1383,7 +1373,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
</div>
{recipientType === 'CUSTOM' && (
<div className="p-3 bg-neutral-50 rounded-lg border border-neutral-200">
<div className="pl-3 border-l-2 border-blue-200 bg-blue-50/50 -ml-3 py-3 pr-3">
<Label htmlFor="customEmail" className="text-sm font-medium">
Email Address *
</Label>
@@ -1408,11 +1398,12 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* DELAY Configuration */}
{type === 'DELAY' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Delay Configuration</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 pl-3">
<div>
<Label htmlFor="delayAmount" className="text-sm font-medium">
Amount *
@@ -1455,21 +1446,22 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
</Select>
</div>
</div>
<p className="text-xs text-neutral-500">Maximum delay: 365 days</p>
<p className="text-xs text-neutral-500 pl-3">Maximum delay: 365 days</p>
</div>
)}
{/* CONDITION Configuration */}
{type === 'CONDITION' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Condition Configuration</h3>
</div>
<p className="text-sm text-neutral-600">
<p className="text-sm text-neutral-600 pl-3">
Define the condition that determines which path contacts will follow
</p>
<div className="space-y-4">
<div className="space-y-4 pl-3">
<div>
<Label htmlFor="conditionField" className="text-sm font-medium">
Field to Check *
@@ -1623,11 +1615,12 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* WAIT_FOR_EVENT Configuration */}
{type === 'WAIT_FOR_EVENT' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Wait for Event Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
<div>
<Label htmlFor="eventName" className="text-sm font-medium">
Event Name *
@@ -1730,11 +1723,12 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* WEBHOOK Configuration */}
{type === 'WEBHOOK' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Webhook Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
<div>
<Label htmlFor="webhookUrl" className="text-sm font-medium">
Webhook URL *
@@ -1792,11 +1786,12 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* UPDATE_CONTACT Configuration */}
{type === 'UPDATE_CONTACT' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Contact Update Configuration</h3>
</div>
<div>
<div className="pl-3">
<Label htmlFor="contactUpdates" className="text-sm font-medium">
Contact Data Updates (JSON) *
</Label>
@@ -1819,11 +1814,12 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
{/* EXIT Configuration */}
{type === 'EXIT' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Exit Configuration</h3>
</div>
<div>
<div className="pl-3">
<Label htmlFor="exitReason" className="text-sm font-medium">
Exit Reason
</Label>
@@ -2064,7 +2060,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
// EXIT fields
const [exitReason, setExitReason] = useState(String(config?.reason || 'completed'));
const {data: templatesData} = useSWR<PaginatedResponse<Template>>('/templates?pageSize=100');
// templates fetched on-demand by TemplateSearchPicker
const {data: workflow} = useSWR<WorkflowWithDetails>(workflowId ? `/workflows/${workflowId}` : null);
// Fetch available event names when dialog opens
@@ -2313,11 +2309,12 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Basic Information</h3>
</div>
<div>
<div className="pl-3">
<Label htmlFor="editStepName" className="text-sm font-medium">
Step Name *
</Label>
@@ -2339,30 +2336,17 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* SEND_EMAIL Configuration */}
{step.type === 'SEND_EMAIL' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Email Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
<div>
<Label htmlFor="editTemplate" className="text-sm font-medium">
Email Template *
</Label>
<Select value={templateId} onValueChange={setTemplateId} required>
<SelectTrigger id="editTemplate" className="mt-1.5">
<SelectValue placeholder="Select a template..." />
</SelectTrigger>
<SelectContent>
{templatesData?.data.map(template => (
<SelectItemWithDescription
key={template.id}
value={template.id}
title={template.name}
description={`${template.type === 'TRANSACTIONAL' ? 'Transactional' : 'Marketing'} • Subject: ${template.subject}`}
/>
))}
</SelectContent>
</Select>
<TemplateSearchPicker value={templateId} initialName={step.template?.name} onChange={setTemplateId} />
<p className="text-xs text-neutral-500 mt-1.5">The email template to use for this step</p>
</div>
@@ -2394,7 +2378,7 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
</div>
{recipientType === 'CUSTOM' && (
<div className="p-3 bg-neutral-50 rounded-lg border border-neutral-200">
<div className="pl-3 border-l-2 border-blue-200 bg-blue-50/50 -ml-3 py-3 pr-3">
<Label htmlFor="editCustomEmail" className="text-sm font-medium">
Email Address *
</Label>
@@ -2419,11 +2403,12 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* DELAY Configuration */}
{step.type === 'DELAY' && (
<div className="space-y-4">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Delay Configuration</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-2 gap-4 pl-3">
<div>
<Label htmlFor="editDelayAmount" className="text-sm font-medium">
Amount *
@@ -2466,18 +2451,19 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
</Select>
</div>
</div>
<p className="text-xs text-neutral-500">Maximum delay: 365 days</p>
<p className="text-xs text-neutral-500 pl-3">Maximum delay: 365 days</p>
</div>
)}
{/* CONDITION Configuration */}
{step.type === 'CONDITION' && (
<div className="space-y-6">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Condition Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
{/* Mode toggle */}
<div>
<Label className="text-sm font-medium mb-2 block">Condition Mode</Label>
@@ -2488,10 +2474,10 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
disabled={conditionMode === 'multi' && hasMultiBranchConnections()}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
conditionMode === 'binary'
? 'border-neutral-900 bg-neutral-900 text-white'
? 'border-purple-500 bg-purple-50 text-purple-700'
: conditionMode === 'multi' && hasMultiBranchConnections()
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50'
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
}`}
>
Simple (If/Else)
@@ -2502,10 +2488,10 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
disabled={conditionMode === 'binary' && hasBinaryConnections()}
className={`flex-1 px-3 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
conditionMode === 'multi'
? 'border-neutral-900 bg-neutral-900 text-white'
? 'border-purple-500 bg-purple-50 text-purple-700'
: conditionMode === 'binary' && hasBinaryConnections()
? 'border-neutral-200 text-neutral-400 bg-neutral-50 cursor-not-allowed opacity-50'
: 'border-neutral-200 text-neutral-700 hover:border-neutral-400 hover:bg-neutral-50'
: 'border-neutral-200 text-neutral-600 hover:border-neutral-300'
}`}
>
Multi-branch (Switch)
@@ -2819,11 +2805,12 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* WAIT_FOR_EVENT Configuration */}
{step.type === 'WAIT_FOR_EVENT' && (
<div className="space-y-6">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Event Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
<div>
<Label htmlFor="editEventName">Event Name *</Label>
<div className="relative">
@@ -2923,11 +2910,12 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* WEBHOOK Configuration */}
{step.type === 'WEBHOOK' && (
<div className="space-y-6">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Webhook Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
{/* Info Alert about webhook body */}
<Alert>
<Info className="h-4 w-4" />
@@ -3082,11 +3070,12 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* UPDATE_CONTACT Configuration */}
{step.type === 'UPDATE_CONTACT' && (
<div className="space-y-6">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Contact Updates</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
<div>
<Label htmlFor="editContactUpdates">Contact Data Updates (JSON) *</Label>
<textarea
@@ -3120,11 +3109,12 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
{/* EXIT Configuration */}
{step.type === 'EXIT' && (
<div className="space-y-6">
<div className="pb-2 border-b border-neutral-200">
<div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
<div className="w-1 h-4 bg-blue-500 rounded-full" />
<h3 className="text-sm font-semibold text-neutral-900">Exit Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-4 pl-3">
<div>
<Label htmlFor="editExitReason">Exit Reason (optional)</Label>
<Select value={exitReason} onValueChange={setExitReason}>
+23 -23
View File
@@ -22,7 +22,6 @@ import {
import type {Workflow} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon} from 'lucide-react';
@@ -158,20 +157,20 @@ export default function WorkflowsPage() {
</Card>
) : data?.data.length === 0 ? (
<Card>
<CardContent>
<EmptyState
icon={WorkflowIcon}
title={search ? 'No workflows match' : 'No workflows yet'}
description={search ? 'Try a different search term.' : 'Automate emails triggered by contact events.'}
action={
!search ? (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Workflow
</Button>
) : undefined
}
/>
<CardContent className="pt-6">
<div className="text-center py-12">
<WorkflowIcon className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No workflows found</h3>
<p className="text-neutral-500 mb-6">
{search ? 'Try adjusting your search terms' : 'Get started by creating your first workflow'}
</p>
{!search && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4" />
Create Workflow
</Button>
)}
</div>
</CardContent>
</Card>
) : (
@@ -367,7 +366,7 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
<DialogTitle>Create New Workflow</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<div>
<Label htmlFor="name">Name *</Label>
<Input
id="name"
@@ -379,19 +378,19 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
/>
</div>
<div className="space-y-1.5">
<div>
<Label htmlFor="description">Description</Label>
<textarea
id="description"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Send a series of welcome emails to new subscribers"
className="w-full px-3 py-2 border border-neutral-200 rounded-md text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="w-full px-3 py-2 border border-neutral-200 rounded-lg text-sm"
rows={3}
/>
</div>
<div className="space-y-1.5">
<div>
<Label htmlFor="createEventName">Trigger Event *</Label>
{/* Combobox: 可自由輸入 event name,同時提供已追蹤 event 的下拉建議 */}
<div className="relative">
@@ -454,20 +453,21 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
</p>
</div>
<div className="flex items-start gap-3">
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<input
id="allowReentry"
type="checkbox"
checked={allowReentry}
onChange={e => setAllowReentry(e.target.checked)}
className="mt-0.5 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="allowReentry" className="font-medium cursor-pointer">
Allow Re-entry
</Label>
<p className="text-xs text-neutral-500 mt-0.5">
When enabled, contacts can enter this workflow multiple times.
<p className="text-xs text-neutral-500 mt-1">
When enabled, contacts can enter this workflow multiple times. When disabled, contacts can only enter
once, ever.
</p>
</div>
</div>
@@ -65,58 +65,24 @@ Create a new workflow and use `email.received` as the trigger event. This workfl
The `email.received` event includes the following data that you can use in your workflow steps:
| Field | Type | Description |
| ---------------------- | -------- | ------------------------------------------------- |
| `messageId` | string | Unique identifier for the received message |
| `from` | string | Email address of the sender |
| `fromHeader` | string | Full "From" header including display name |
| `to` | string | Primary recipient email address |
| `subject` | string | Email subject line |
| `timestamp` | string | ISO 8601 timestamp when the email was received |
| `recipients` | string[] | All recipient email addresses |
| `hasContent` | boolean | Whether the email body was captured |
| `body` | string | HTML body of the email (or plain text if no HTML) |
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
| `spfVerdict` | string | SPF authentication result |
| `dkimVerdict` | string | DKIM authentication result |
| `dmarcVerdict` | string | DMARC authentication result |
| `processingTimeMillis` | number | Time taken to process the email |
| Field | Type | Description |
| ---------------------- | -------- | ---------------------------------------------- |
| `messageId` | string | Unique identifier for the received message |
| `from` | string | Email address of the sender |
| `fromHeader` | string | Full "From" header including display name |
| `to` | string | Primary recipient email address |
| `subject` | string | Email subject line |
| `timestamp` | string | ISO 8601 timestamp when the email was received |
| `recipients` | string[] | All recipient email addresses |
| `hasContent` | boolean | Whether the email body was captured |
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
| `spfVerdict` | string | SPF authentication result |
| `dkimVerdict` | string | DKIM authentication result |
| `dmarcVerdict` | string | DMARC authentication result |
| `processingTimeMillis` | number | Time taken to process the email |
You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}`, `{{event.from}}`, or `{{event.body}}`.
### Example: Auto-reply workflow
Here's a simple workflow that sends an automatic reply when an email is received at `[email protected]`:
1. **Trigger**: `email.received`
2. **Condition**: Check if `{{event.to}}` equals `[email protected]`
3. **Send Email**:
- **To**: `{{event.from}}`
- **Subject**: `Re: {{event.subject}}`
- **Body**: `Thank you for your message. We received: "{{event.body}}". We'll get back to you soon!`
This workflow reads the incoming email body and includes it in the auto-reply response.
### Example: Forward to webhook
For more advanced processing (like ticket creation or AI analysis), you can forward the email content to your own API:
1. **Trigger**: `email.received`
2. **Webhook**:
- **URL**: `https://api.example.com/support/tickets`
- **Method**: `POST`
- **Body**:
```json
{
"from": "{{event.from}}",
"subject": "{{event.subject}}",
"body": "{{event.body}}",
"timestamp": "{{event.timestamp}}"
}
```
Your backend receives the full email content and can process it (create a ticket, run AI analysis, etc.).
You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}` or `{{event.from}}`.
## Multi-project domains
@@ -130,6 +96,12 @@ This allows you to segment inbound email handling across different projects if n
## Limitations
<Callout title="Email body content" variant="warning">
Currently, the email body content is not stored or made available in the event data. Only metadata (sender, subject,
recipients, timestamps, and security verdicts) is captured. The `hasContent` field indicates whether body content was
present, but the content itself is not accessible in workflows.
</Callout>
- **Catch-all addresses**: Plunk receives emails sent to any address at your verified domain (e.g., `[email protected]`). You can use workflow conditions to route emails based on the `to` field.
- **Attachments**: Email attachments are not currently captured or stored.
- **Email size**: AWS SES has a maximum message size limit of 40 MB for inbound emails.
+53 -111
View File
@@ -108,7 +108,6 @@ When using the default payload (no custom body configured), Plunk sends a JSON r
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": "camp_abc123",
"sourceType": "CAMPAIGN",
@@ -126,16 +125,15 @@ The `event` field contains the data associated with the event that triggered the
Most email events share a common set of base fields:
| Field | Description |
| ------------ | ------------------------------------------------------------------------------------------------------ |
| `subject` | The email subject line |
| `from` | The sender email address |
| `fromName` | The sender display name |
| `messageId` | The AWS SES message ID (for correlating with SES events) |
| `emailId` | The Plunk email record ID (returned from `POST /v1/send`, for correlating webhooks with API responses) |
| `templateId` | The template ID, if the email was sent using a template (otherwise `null`) |
| `campaignId` | The campaign ID, if the email was part of a campaign (otherwise `null`) |
| `sourceType` | How the email was triggered: `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND` |
| Field | Description |
| ------------ | ------------------------------------------------------------------------------------------- |
| `subject` | The email subject line |
| `from` | The sender email address |
| `fromName` | The sender display name |
| `messageId` | The AWS SES message ID |
| `templateId` | The template ID, if the email was sent using a template (otherwise `null`) |
| `campaignId` | The campaign ID, if the email was part of a campaign (otherwise `null`) |
| `sourceType` | How the email was triggered: `TRANSACTIONAL`, `CAMPAIGN`, `WORKFLOW`, or `INBOUND` |
In addition to these base fields, each event includes the following event-specific fields:
@@ -149,7 +147,6 @@ In addition to these base fields, each event includes the following event-specif
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
@@ -157,9 +154,9 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| -------- | ----------------------- |
| `sentAt` | When the email was sent |
| Field | Description |
| -------- | ------------------------ |
| `sentAt` | When the email was sent |
</Tab>
@@ -171,7 +168,6 @@ In addition to these base fields, each event includes the following event-specif
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": "camp_abc123",
"sourceType": "CAMPAIGN",
@@ -179,9 +175,9 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| ------------- | ---------------------------- |
| `deliveredAt` | When the email was delivered |
| Field | Description |
| ------------- | ------------------------------- |
| `deliveredAt` | When the email was delivered |
</Tab>
@@ -193,7 +189,6 @@ In addition to these base fields, each event includes the following event-specif
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
@@ -203,10 +198,10 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| ------------- | ------------------------------------------------------------- |
| `openedAt` | When the email was first opened |
| `opens` | Total number of times this email has been opened |
| Field | Description |
| ------------- | ------------------------------------------------------------ |
| `openedAt` | When the email was first opened |
| `opens` | Total number of times this email has been opened |
| `isFirstOpen` | `true` if this is the first time the contact opened the email |
</Tab>
@@ -219,7 +214,6 @@ In addition to these base fields, each event includes the following event-specif
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
@@ -230,11 +224,11 @@ In addition to these base fields, each event includes the following event-specif
}
```
| Field | Description |
| -------------- | ----------------------------------------------------------------- |
| `link` | The URL that was clicked |
| `clickedAt` | When the first click occurred |
| `clicks` | Total number of times links in this email have been clicked |
| Field | Description |
| -------------- | -------------------------------------------------------------- |
| `link` | The URL that was clicked |
| `clickedAt` | When the first click occurred |
| `clicks` | Total number of times links in this email have been clicked |
| `isFirstClick` | `true` if this is the first click from this contact on this email |
</Tab>
@@ -249,7 +243,6 @@ Permanent bounce:
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
@@ -266,7 +259,6 @@ Transient (soft) bounce:
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
@@ -275,15 +267,14 @@ Transient (soft) bounce:
}
```
| Field | Description |
| ----------------- | ------------------------------------------------------------------------------------------------ |
| `bounceType` | `Permanent` (hard bounce) or `Transient` (soft bounce, e.g. mailbox full or out-of-office) |
| `bouncedAt` | When the bounce occurred (permanent bounces only) |
| `transientBounce` | `true` for soft bounces — these do not count toward bounce rate and the contact stays subscribed |
| Field | Description |
| ---------------- | ----------------------------------------------------------------------------------------------- |
| `bounceType` | `Permanent` (hard bounce) or `Transient` (soft bounce, e.g. mailbox full or out-of-office) |
| `bouncedAt` | When the bounce occurred (permanent bounces only) |
| `transientBounce`| `true` for soft bounces — these do not count toward bounce rate and the contact stays subscribed |
<Callout title="Bounce rate impact" variant="warn">
Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription.
`Transient` bounces are tracked for visibility only.
Only `Permanent` bounces count toward your project's bounce rate and trigger automatic contact unsubscription. `Transient` bounces are tracked for visibility only.
</Callout>
</Tab>
@@ -296,7 +287,6 @@ Transient (soft) bounce:
"from": "[email protected]",
"fromName": "Plunk Team",
"messageId": "ses-message-id",
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"templateId": null,
"campaignId": null,
"sourceType": "TRANSACTIONAL",
@@ -304,9 +294,9 @@ Transient (soft) bounce:
}
```
| Field | Description |
| -------------- | ------------------------------------ |
| `complainedAt` | When the spam complaint was received |
| Field | Description |
| -------------- | ------------------------------------------------ |
| `complainedAt` | When the spam complaint was received |
</Tab>
@@ -324,7 +314,6 @@ This event fires when an email is received at your verified domain. See [Receivi
"timestamp": "2025-01-15T10:30:00.000Z",
"recipients": ["[email protected]"],
"hasContent": true,
"body": "<html><body>This is the email body content...</body></html>",
"spamVerdict": "PASS",
"virusVerdict": "PASS",
"spfVerdict": "PASS",
@@ -334,23 +323,22 @@ This event fires when an email is received at your verified domain. See [Receivi
}
```
| Field | Description |
| ---------------------- | --------------------------------------------------------------------- |
| `messageId` | The AWS SES message ID |
| `from` | The sender's email address |
| `fromHeader` | The full `From` header, including display name if present |
| `to` | The recipient address at your verified domain |
| `subject` | The email subject line |
| `timestamp` | When SES received the email |
| `recipients` | All recipient addresses in the envelope |
| `hasContent` | Whether the email body content is available |
| `body` | HTML body of the email (or plain text if no HTML available) |
| Field | Description |
| ---------------------- | ------------------------------------------------------------------- |
| `messageId` | The AWS SES message ID |
| `from` | The sender's email address |
| `fromHeader` | The full `From` header, including display name if present |
| `to` | The recipient address at your verified domain |
| `subject` | The email subject line |
| `timestamp` | When SES received the email |
| `recipients` | All recipient addresses in the envelope |
| `hasContent` | Whether the email body content is available |
| `spamVerdict` | SES spam check result: `PASS`, `FAIL`, `GRAY`, or `PROCESSING_FAILED` |
| `virusVerdict` | SES virus check result |
| `spfVerdict` | SPF authentication result |
| `dkimVerdict` | DKIM authentication result |
| `dmarcVerdict` | DMARC authentication result |
| `processingTimeMillis` | Time SES took to process the inbound email |
| `virusVerdict` | SES virus check result |
| `spfVerdict` | SPF authentication result |
| `dkimVerdict` | DKIM authentication result |
| `dmarcVerdict` | DMARC authentication result |
| `processingTimeMillis` | Time SES took to process the inbound email |
</Tab>
@@ -368,8 +356,8 @@ The exception is when an unsubscription is triggered automatically by an email b
}
```
| Field | Value |
| -------- | --------------------------------------------------- |
| Field | Value |
| -------- | -------------------------------------------------- |
| `reason` | `"bounce"` or `"complaint"` (when system-triggered) |
#### Segment events
@@ -383,9 +371,9 @@ Both `segment.<name>.entry` and `segment.<name>.exit` include:
}
```
| Field | Description |
| ------------- | ------------------------------- |
| `segmentId` | The ID of the segment |
| Field | Description |
| ------------- | ----------------------------- |
| `segmentId` | The ID of the segment |
| `segmentName` | The display name of the segment |
#### Custom events
@@ -396,52 +384,6 @@ Custom events tracked via the API include whatever data you passed in the `data`
For events that carry no data, the `event` field will be an empty object `{}`.
## Correlating webhooks with send requests
All email events include an `emailId` field that matches the Plunk email record ID returned when you send an email via `POST /v1/send`. This allows you to directly correlate webhook events with your API requests.
**Example workflow:**
1. Send email via API:
```json
POST /v1/send
{
"to": "[email protected]",
"subject": "Welcome",
"body": "Hello!"
}
Response:
{
"success": true,
"data": {
"emails": [
{
"contact": {"id": "cnt_abc", "email": "[email protected]"},
"email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
}
]
}
}
```
2. Store the `email` ID (`ac32f08e-c6b9-45d3-9824-a73dff1e3bbf`) in your database
3. When webhook events fire (e.g., `email.open`, `email.bounce`), match them using `event.emailId`:
```json
{
"event": {
"emailId": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf",
"messageId": "ses-message-id",
"openedAt": "2025-01-15T11:00:00.000Z"
}
}
```
This eliminates the need to match by contact email + timestamp or to listen for `email.sent` webhooks just to get the SES `messageId`.
## Common use cases
### Bounce and complaint monitoring
+1 -16
View File
@@ -471,7 +471,7 @@
},
"email": {
"type": "string",
"description": "Plunk email record ID. Use this to correlate webhook events (which include this ID as 'emailId' in the event data) with your send requests."
"description": "Email record ID"
}
}
}
@@ -483,21 +483,6 @@
}
}
}
},
"example": {
"success": true,
"data": {
"emails": [
{
"contact": {
"id": "cnt_abc123",
"email": "[email protected]"
},
"email": "ac32f08e-c6b9-45d3-9824-a73dff1e3bbf"
}
],
"timestamp": "2025-01-15T10:30:00.000Z"
}
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

-3
View File
@@ -1,3 +0,0 @@
<svg width="1080" height="1080" viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

+11 -10
View File
@@ -14,25 +14,26 @@ export interface SecurityStatus {
projectId: string;
isHealthy: boolean;
shouldDisable: boolean;
twentyFourHour: SecurityRateData;
sevenDay: SecurityRateData;
allTime: SecurityRateData;
isNewProject: boolean;
violations: string[];
warnings: string[];
}
export type SecurityLevel = 'healthy' | 'warning' | 'critical';
export interface SecurityMetricLevels {
bounce7Day: SecurityLevel;
bounceAllTime: SecurityLevel;
complaint7Day: SecurityLevel;
complaintAllTime: SecurityLevel;
export interface SecurityThresholds {
MIN_EMAILS_FOR_ENFORCEMENT: number;
BOUNCE_7DAY_WARNING: number;
BOUNCE_7DAY_CRITICAL: number;
BOUNCE_ALLTIME_WARNING: number;
BOUNCE_ALLTIME_CRITICAL: number;
COMPLAINT_7DAY_WARNING: number;
COMPLAINT_7DAY_CRITICAL: number;
COMPLAINT_ALLTIME_WARNING: number;
COMPLAINT_ALLTIME_CRITICAL: number;
}
export interface ProjectSecurityMetrics {
status: SecurityStatus;
levels: SecurityMetricLevels;
thresholds: SecurityThresholds;
isDisabled: boolean;
}
+4 -4
View File
@@ -9,10 +9,10 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: 'bg-neutral-900 text-neutral-50 hover:bg-neutral-900/90',
destructive: 'bg-red-500 text-neutral-50 hover:bg-red-500/90',
outline: 'border border-neutral-200 bg-white hover:bg-neutral-100 hover:text-neutral-900',
secondary: 'bg-neutral-100 text-neutral-900 hover:bg-neutral-100/80',
default: 'bg-neutral-900 text-neutral-50 shadow hover:bg-neutral-900/90 hover:shadow-md',
destructive: 'bg-red-500 text-neutral-50 shadow-sm hover:bg-red-500/90 hover:shadow-md',
outline: 'border border-neutral-200 bg-white shadow-sm hover:bg-neutral-100 hover:text-neutral-900 hover:shadow-md',
secondary: 'bg-neutral-100 text-neutral-900 shadow-sm hover:bg-neutral-100/80 hover:shadow-md',
ghost: 'hover:bg-neutral-100 hover:text-neutral-900',
link: 'text-neutral-900 underline-offset-4 hover:underline',
},

Some files were not shown because too many files have changed in this diff Show More