Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e450ae62a3 | ||
|
|
d56e876428 | ||
|
|
b79d416667 | ||
|
|
bf12392b88 | ||
|
|
ae9d5a6f3d | ||
|
|
1362e4672f | ||
|
|
35a830fa7b | ||
|
|
742821eba9 | ||
|
|
91f80de981 | ||
|
|
c584e42c54 | ||
|
|
545d733794 | ||
|
|
37ed1e49b7 | ||
|
|
df76be1b87 | ||
|
|
bd5a085802 | ||
|
|
bc4c79235e | ||
|
|
f2ebce0ef2 | ||
|
|
e10a94b2bc | ||
|
|
52fc7f73ce | ||
|
|
428079e632 | ||
|
|
19ea8b0122 | ||
|
|
6fdc29fc0f | ||
|
|
c91d8c2e29 | ||
|
|
3bb3411994 | ||
|
|
28fe7093f4 | ||
|
|
0803fd7bc4 | ||
|
|
03a058ad48 | ||
|
|
5e62f517fc | ||
|
|
44c86572c2 | ||
|
|
effdfdb6d6 | ||
|
|
c0b2abaeca | ||
|
|
e3395083db | ||
|
|
ae64c2dbb5 | ||
|
|
52cb2b6c77 | ||
|
|
a6d5740b77 | ||
|
|
6114c21a3f | ||
|
|
4ce4c8c2a4 | ||
|
|
7834b9e7ef | ||
|
|
c3967a98b0 | ||
|
|
9e2400c6de | ||
|
|
fb5aa8796a | ||
|
|
284838279d | ||
|
|
2c5a71518d | ||
|
|
a8014cf7cf | ||
|
|
3214f6c42d | ||
|
|
d24259e8d2 | ||
|
|
3343e891bd | ||
|
|
6f99d4190c | ||
|
|
5f990573c1 | ||
|
|
b50b5af763 | ||
|
|
35c9e4c34e | ||
|
|
7b6540a748 | ||
|
|
4b290fd0b7 | ||
|
|
f9d7cf2453 | ||
|
|
ac8aeebfbe | ||
|
|
1675b07694 | ||
|
|
d4f41892cd | ||
|
|
5046c21045 | ||
|
|
9c50e1a7b4 | ||
|
|
33df5632a3 | ||
|
|
3e3117e4a7 | ||
|
|
3b39d847cd | ||
|
|
dff25c62d9 | ||
|
|
200dfb1dbf | ||
|
|
a99cde8839 | ||
|
|
474c822640 | ||
|
|
0e8b6f813d | ||
|
|
7b6872c4b7 | ||
|
|
c17f7065d9 | ||
|
|
f44d987bc9 | ||
|
|
c83541c3eb | ||
|
|
dc2ce02d1a | ||
|
|
b9d692cf5b | ||
|
|
c19cf531b8 | ||
|
|
f0ef34646e | ||
|
|
6a340e9554 | ||
|
|
38565bbce2 | ||
|
|
c32b54f588 | ||
|
|
44987d2b5e | ||
|
|
2c3e8970cb | ||
|
|
395af0080c | ||
|
|
e3ea5fb6dc | ||
|
|
4451e921ca | ||
|
|
92949b7596 | ||
|
|
57bfbf874b | ||
|
|
1dc423a1c4 | ||
|
|
de8aaa5c45 | ||
|
|
1a1e3204ee | ||
|
|
6c4cc295b8 | ||
|
|
9f212ddcb8 | ||
|
|
5f82278282 | ||
|
|
f6a32ce610 | ||
|
|
0839975083 | ||
|
|
c90f3d0f01 | ||
|
|
5f3daf689b | ||
|
|
cc7fcdb4ef |
@@ -29,7 +29,7 @@ WIKI_URI=http://localhost:1000
|
|||||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||||
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||||
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||||
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
|
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
||||||
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
||||||
|
|
||||||
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## 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.
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.7.0"
|
".": "0.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,81 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.9.0](https://github.com/useplunk/plunk/compare/v0.8.0...v0.9.0) (2026-04-20)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add accept/markdown to apps/landing ([df76be1](https://github.com/useplunk/plunk/commit/df76be1b87de14769a219ae0e6c99711edf993a1))
|
||||||
|
* Add emailId field to webhook events for better correlation with send requests ([ae64c2d](https://github.com/useplunk/plunk/commit/ae64c2dbb5b76f16b562641953f262d76ea0d8e8)), closes [#344](https://github.com/useplunk/plunk/issues/344)
|
||||||
|
* Add headless template type ([fb5aa87](https://github.com/useplunk/plunk/commit/fb5aa8796a8550be7a70fdd91616c9a41ed3047d))
|
||||||
|
* Add onboarding flow ([bd5a085](https://github.com/useplunk/plunk/commit/bd5a0858021b9d74fae07cca5d2a0b8c10deabd7))
|
||||||
|
* Add type to campaign ([d24259e](https://github.com/useplunk/plunk/commit/d24259e8d202edd1b8c7771a63cc212c982b52e1))
|
||||||
|
* Block domain changes for disabled projects with appropriate error handling ([44c8657](https://github.com/useplunk/plunk/commit/44c86572c275221b5068605c64958f43a1f96ff4))
|
||||||
|
* Disable projects on failed payment ([3343e89](https://github.com/useplunk/plunk/commit/3343e891bdbcfee7d17d73a0d7fb181652e7c262))
|
||||||
|
* Enhance email processing to include parsed HTML body content in inbound email records ([e339508](https://github.com/useplunk/plunk/commit/e3395083db11fdb4b6cfdbace46518792ec132a3)), closes [#342](https://github.com/useplunk/plunk/issues/342)
|
||||||
|
* Enhance login forms with last used authentication method ([f2ebce0](https://github.com/useplunk/plunk/commit/f2ebce0ef2ac9c9c4e994cb754f67f6fa8a3f465))
|
||||||
|
* Enhance security metrics handling with new thresholds and improved messaging ([c0b2aba](https://github.com/useplunk/plunk/commit/c0b2abaeca5656743e189f975ab82ff9b25b577b))
|
||||||
|
* implement AWS SNS signature verification in SecurityService ([b79d416](https://github.com/useplunk/plunk/commit/b79d4166671cc04cc1458d2c24af262af0e16c9e))
|
||||||
|
* integrate DOMPurify for sanitizing HTML content ([bf12392](https://github.com/useplunk/plunk/commit/bf12392b88fc92b0760333e1b568e3d6d1b6ed74))
|
||||||
|
* Update new project bounce thresholds for stricter email handling ([5e62f51](https://github.com/useplunk/plunk/commit/5e62f517fc896849c270e795cd31a110b83707cc))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Enhance email processing to support campaign types and improve unsubscribe logic ([9e2400c](https://github.com/useplunk/plunk/commit/9e2400c6de6f13c498f02c489f956ebf89de36b0))
|
||||||
|
* Hint custom event names in combobox when no matches are found ([3214f6c](https://github.com/useplunk/plunk/commit/3214f6c42d4ca6ab628e905ac69ed82648ccf47c))
|
||||||
|
* Implement content negotiation for markdown and html in middleware ([37ed1e4](https://github.com/useplunk/plunk/commit/37ed1e49b78eb9339e7b62fd2fd5b60d451707d6))
|
||||||
|
* Implement SSRF protection in webhook handling with safeFetch method ([2c5a715](https://github.com/useplunk/plunk/commit/2c5a71518da358927bd5e41035b85fd278790b47))
|
||||||
|
* Refactor date filtering logic for pagination in ActivityFeed and ActivityService ([52cb2b6](https://github.com/useplunk/plunk/commit/52cb2b6c77bc3912b356887bdd48e9c8b9b1728a))
|
||||||
|
* Update language validation regex to support locale variants ([7834b9e](https://github.com/useplunk/plunk/commit/7834b9e7ef615ba029a1a091b0c37c8f40eb543c))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* Add content negotiation to apps/wiki ([545d733](https://github.com/useplunk/plunk/commit/545d7337948f41f006f9cb0270b4bfff2b08ed2c))
|
||||||
|
|
||||||
|
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Add multi-branch workflow conditions (switch/case) ([92949b7](https://github.com/useplunk/plunk/commit/92949b7596740b73d04c30e0e2bcc6305bbdf4a7))
|
||||||
|
* **i18n:** add Chinese translations (zh-TW, zh-HK, zh-CN) ([33df563](https://github.com/useplunk/plunk/commit/33df5632a3d4068e641687c8d404a6eef4aa4d0e))
|
||||||
|
* **i18n:** add Italian translation ([7b6872c](https://github.com/useplunk/plunk/commit/7b6872c4b79b0fde4491f02851a2049c846c83d6))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Adapt SNS Webhook validation regex pattern to also support AWS eusc partition ([7b6540a](https://github.com/useplunk/plunk/commit/7b6540a748a90c10f41b931450ac809e7ae23c01))
|
||||||
|
* add alt text to email badge image ([3b39d84](https://github.com/useplunk/plunk/commit/3b39d847cd278317723b4b226434c26c49becebe))
|
||||||
|
* broken links to next-wiki.useplunk.com ([e3ea5fb](https://github.com/useplunk/plunk/commit/e3ea5fb6dcea95b5028f140b77e54d5938497223))
|
||||||
|
* Connect branches smoothly to original node ([2c3e897](https://github.com/useplunk/plunk/commit/2c3e8970cbc4ad2eb39fd505a4292345c6cae1c0))
|
||||||
|
* Enhance email step configuration validation and recipient handling ([b50b5af](https://github.com/useplunk/plunk/commit/b50b5af763d8a44b09c7022e1d9135bfca7907f6))
|
||||||
|
* Enhance email step configuration validation and recipient handling ([a99cde8](https://github.com/useplunk/plunk/commit/a99cde88392450bf719112cb94cc1b9e7c7c1478))
|
||||||
|
* normalize field references and handle undefined values in JSON ([b9d692c](https://github.com/useplunk/plunk/commit/b9d692cf5b491ab22a40ec69e77ff7bdc62eec63))
|
||||||
|
* Prevent switching if nodes are attached to multi-branch ([c32b54f](https://github.com/useplunk/plunk/commit/c32b54f588db79a501db456e6075a337dee8ed26))
|
||||||
|
* update old references to next.useplunk.com ([44987d2](https://github.com/useplunk/plunk/commit/44987d2b5e8028c6f8f26c39e9dec386e1ccf6e1))
|
||||||
|
* visual editor preview ([4451e92](https://github.com/useplunk/plunk/commit/4451e921caff45dab126006ba103d91816bed696))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* Fix broken links on api overview page ([dc2ce02](https://github.com/useplunk/plunk/commit/dc2ce02d1a4e4d1641728b720ca856cedba719a7))
|
||||||
|
* Improve self-hosting env variable documentation ([f0ef346](https://github.com/useplunk/plunk/commit/f0ef34646e7593f07a780141e14d6d2b735c1101))
|
||||||
|
|
||||||
|
## [0.7.1](https://github.com/useplunk/plunk/compare/v0.7.0...v0.7.1) (2026-03-10)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Align preview and actual email for templates and campaigns ([cc7fcdb](https://github.com/useplunk/plunk/commit/cc7fcdb4ef31e9dfb4f7403aa93479b6df56719d))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* Add inbound to docs ([6c4cc29](https://github.com/useplunk/plunk/commit/6c4cc295b88db6da8d9edcf23064a3fc8e9f5c36))
|
||||||
|
* Improve webhook documentation ([f6a32ce](https://github.com/useplunk/plunk/commit/f6a32ce610559050c1ca9978607bd57ac51e906f))
|
||||||
|
|
||||||
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
|
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
|||||||
- Background cron jobs (workflow processor, domain verification)
|
- Background cron jobs (workflow processor, domain verification)
|
||||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||||
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
||||||
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
||||||
|
|
||||||
### Background Job Architecture
|
### Background Job Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
|
|||||||
|
|
||||||
- **api**: Express.js API server with background worker process (BullMQ)
|
- **api**: Express.js API server with background worker process (BullMQ)
|
||||||
- **web**: Next.js dashboard application (app.useplunk.com)
|
- **web**: Next.js dashboard application (app.useplunk.com)
|
||||||
- **landing**: Next.js marketing site (next.useplunk.com)
|
- **landing**: Next.js marketing site (www.useplunk.com)
|
||||||
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
|
- **wiki**: Next.js documentation site (docs.useplunk.com)
|
||||||
|
|
||||||
### Shared Packages (`packages/`)
|
### Shared Packages (`packages/`)
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ between groups.
|
|||||||
|
|
||||||
## Need Help?
|
## Need Help?
|
||||||
|
|
||||||
- Check the [documentation](https://next-wiki.useplunk.com)
|
- Check the [documentation](https://docs.useplunk.com)
|
||||||
- Open an issue for bugs or feature requests
|
- Open an issue for bugs or feature requests
|
||||||
- Join our community discussions
|
- Join our community discussions
|
||||||
|
|
||||||
|
|||||||
@@ -97,8 +97,8 @@ ARG TARGETPLATFORM
|
|||||||
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
||||||
ARG API_URI=https://next-api.useplunk.com
|
ARG API_URI=https://next-api.useplunk.com
|
||||||
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||||
ARG LANDING_URI=https://next.useplunk.com
|
ARG LANDING_URI=https://www.useplunk.com
|
||||||
ARG WIKI_URI=https://next-wiki.useplunk.com
|
ARG WIKI_URI=https://docs.useplunk.com
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ The easiest way to self-host Plunk is by using the `plunk` Docker image.
|
|||||||
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
|
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
|
||||||
|
|
||||||
A complete guide on how to deploy Plunk can be found in
|
A complete guide on how to deploy Plunk can be found in
|
||||||
the [documentation](https://next-wiki.useplunk.com/self-hosting/introduction).
|
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mailchecker": "^6.0.19",
|
"mailchecker": "^6.0.19",
|
||||||
|
"mailparser": "^3.9.8",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
"signale": "^1.4.0",
|
"signale": "^1.4.0",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.5",
|
||||||
"@types/helmet": "^4.0.0",
|
"@types/helmet": "^4.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.6",
|
||||||
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/signale": "^1.4.7",
|
"@types/signale": "^1.4.7",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
||||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
|
||||||
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export class Campaigns {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async create(req: Request, res: Response, _next: NextFunction) {
|
private async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
|
||||||
CampaignSchemas.create.parse(req.body);
|
CampaignSchemas.create.parse(req.body);
|
||||||
|
|
||||||
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
||||||
@@ -42,6 +42,7 @@ export class Campaigns {
|
|||||||
from,
|
from,
|
||||||
fromName,
|
fromName,
|
||||||
replyTo,
|
replyTo,
|
||||||
|
type,
|
||||||
audienceType,
|
audienceType,
|
||||||
audienceCondition,
|
audienceCondition,
|
||||||
segmentId,
|
segmentId,
|
||||||
@@ -109,7 +110,7 @@ export class Campaigns {
|
|||||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
|
||||||
req.body;
|
req.body;
|
||||||
|
|
||||||
// Validate audience-specific fields if audienceType is being updated
|
// Validate audience-specific fields if audienceType is being updated
|
||||||
@@ -134,6 +135,7 @@ export class Campaigns {
|
|||||||
from,
|
from,
|
||||||
fromName,
|
fromName,
|
||||||
replyTo,
|
replyTo,
|
||||||
|
type: type as TemplateType | undefined,
|
||||||
audienceType,
|
audienceType,
|
||||||
audienceCondition,
|
audienceCondition,
|
||||||
segmentId,
|
segmentId,
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import {DomainSchemas, UtilitySchemas} from '@plunk/shared';
|
|||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {NotFound} from '../exceptions/index.js';
|
import {NotAllowed, NotFound} from '../exceptions/index.js';
|
||||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {DomainService} from '../services/DomainService.js';
|
import {DomainService} from '../services/DomainService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
import {MembershipService} from '../services/MembershipService.js';
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@Controller('domains')
|
@Controller('domains')
|
||||||
@@ -47,6 +48,14 @@ export class Domains {
|
|||||||
// Verify user has admin access to this project
|
// Verify user has admin access to this project
|
||||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
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
|
// Check if domain is already linked to another project
|
||||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||||
|
|
||||||
@@ -125,6 +134,14 @@ export class Domains {
|
|||||||
// Verify user has admin access to the project this domain belongs to
|
// Verify user has admin access to the project this domain belongs to
|
||||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
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 DomainService.removeDomain(id);
|
||||||
|
|
||||||
await redis.del(Keys.Domain.id(id));
|
await redis.del(Keys.Domain.id(id));
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
|||||||
import * as S3Service from '../services/S3Service.js';
|
import * as S3Service from '../services/S3Service.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
|
const MAGIC_BYTES: Record<string, Buffer[]> = {
|
||||||
|
'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||||
|
'image/jpg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||||
|
'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47])],
|
||||||
|
'image/gif': [Buffer.from('GIF87a'), Buffer.from('GIF89a')],
|
||||||
|
'image/webp': [Buffer.from('RIFF')],
|
||||||
|
};
|
||||||
|
|
||||||
|
function validateMagicBytes(buffer: Buffer, mimetype: string): boolean {
|
||||||
|
const signatures = MAGIC_BYTES[mimetype];
|
||||||
|
if (!signatures) return false;
|
||||||
|
return signatures.some(sig => buffer.subarray(0, sig.length).equals(sig));
|
||||||
|
}
|
||||||
|
|
||||||
// Configure multer for file uploads (memory storage)
|
// Configure multer for file uploads (memory storage)
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
@@ -13,12 +27,12 @@ const upload = multer({
|
|||||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
||||||
},
|
},
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP, SVG)'));
|
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP)'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -48,6 +62,12 @@ export class Uploads {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!validateMagicBytes(req.file.buffer, req.file.mimetype)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'File contents do not match the declared image type',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Upload file to S3/Minio
|
// Upload file to S3/Minio
|
||||||
const result = await S3Service.uploadFile({
|
const result = await S3Service.uploadFile({
|
||||||
file: req.file.buffer,
|
file: req.file.buffer,
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import {Controller, Post} from '@overnightjs/core';
|
|||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import type {Request, Response} from 'express';
|
import type {Request, Response} from 'express';
|
||||||
|
import {simpleParser} from 'mailparser';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
|
|
||||||
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
import {ProjectDisabledPaymentEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {MeterService} from '../services/MeterService.js';
|
import {MeterService} from '../services/MeterService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
@@ -31,6 +36,13 @@ export class Webhooks {
|
|||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async receiveSNSWebhook(req: Request, res: Response) {
|
public async receiveSNSWebhook(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
|
// Verify SNS message signature before processing anything
|
||||||
|
const signatureValid = await SecurityService.verifySnsSignature(req.body as Record<string, string>);
|
||||||
|
if (!signatureValid) {
|
||||||
|
signale.warn('[WEBHOOK] SNS signature verification failed — request rejected');
|
||||||
|
return res.status(403).json({success: false, message: 'Invalid SNS signature'});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||||
signale.info('SNS Subscription Confirmation received');
|
signale.info('SNS Subscription Confirmation received');
|
||||||
@@ -53,8 +65,8 @@ export class Webhooks {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only allow HTTPS requests to official AWS SNS endpoints.
|
// Only allow HTTPS requests to official AWS SNS endpoints.
|
||||||
// The hostname must be exactly sns.<region>.amazonaws.com.
|
// The hostname must be exactly sns.<region>.amazonaws.com or sns.<region>.amazonaws.eu
|
||||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.com$/;
|
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|eu)$/;
|
||||||
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||||
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||||
@@ -142,6 +154,21 @@ export class Webhooks {
|
|||||||
const senderEmail = body.mail?.source;
|
const senderEmail = body.mail?.source;
|
||||||
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
|
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
|
// Process inbound email for each project that has this domain verified
|
||||||
for (const domainRecord of domainRecords) {
|
for (const domainRecord of domainRecords) {
|
||||||
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
|
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
|
||||||
@@ -167,13 +194,13 @@ export class Webhooks {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create an Email record for tracking (no actual email content since it's inbound)
|
// Create an Email record for tracking with parsed content
|
||||||
const inboundEmail = await prisma.email.create({
|
const inboundEmail = await prisma.email.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: domainRecord.projectId,
|
projectId: domainRecord.projectId,
|
||||||
contactId: contact!.id,
|
contactId: contact!.id,
|
||||||
subject: body.mail?.commonHeaders?.subject || '(No subject)',
|
subject: body.mail?.commonHeaders?.subject || '(No subject)',
|
||||||
body: '', // Inbound emails don't have body content in our system
|
body: htmlBody || '', // Store HTML body in the body field
|
||||||
from: recipientEmail, // The recipient address that received the email
|
from: recipientEmail, // The recipient address that received the email
|
||||||
sourceType: EmailSourceType.INBOUND,
|
sourceType: EmailSourceType.INBOUND,
|
||||||
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
|
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
|
||||||
@@ -193,7 +220,7 @@ export class Webhooks {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare event data with all inbound email details
|
// Prepare event data with all inbound email details including body content
|
||||||
const eventData = {
|
const eventData = {
|
||||||
messageId: body.mail?.messageId,
|
messageId: body.mail?.messageId,
|
||||||
from: senderEmail,
|
from: senderEmail,
|
||||||
@@ -203,6 +230,8 @@ export class Webhooks {
|
|||||||
timestamp: body.mail?.timestamp,
|
timestamp: body.mail?.timestamp,
|
||||||
recipients: body.receipt?.recipients,
|
recipients: body.receipt?.recipients,
|
||||||
hasContent: !!body.content,
|
hasContent: !!body.content,
|
||||||
|
// Email body content
|
||||||
|
body: htmlBody,
|
||||||
// Security verdicts
|
// Security verdicts
|
||||||
spamVerdict: body.receipt?.spamVerdict?.status,
|
spamVerdict: body.receipt?.spamVerdict?.status,
|
||||||
virusVerdict: body.receipt?.virusVerdict?.status,
|
virusVerdict: body.receipt?.virusVerdict?.status,
|
||||||
@@ -269,6 +298,7 @@ export class Webhooks {
|
|||||||
from: email.from,
|
from: email.from,
|
||||||
fromName: email.fromName,
|
fromName: email.fromName,
|
||||||
messageId: email.messageId,
|
messageId: email.messageId,
|
||||||
|
emailId: email.id,
|
||||||
templateId: email.templateId,
|
templateId: email.templateId,
|
||||||
campaignId: email.campaignId,
|
campaignId: email.campaignId,
|
||||||
sourceType: email.sourceType,
|
sourceType: email.sourceType,
|
||||||
@@ -519,6 +549,16 @@ export class Webhooks {
|
|||||||
const invoice = event.data.object;
|
const invoice = event.data.object;
|
||||||
const customerId = invoice.customer as string;
|
const customerId = invoice.customer as string;
|
||||||
|
|
||||||
|
// Only disable projects that are already consuming (recurring billing).
|
||||||
|
// If billing_reason is 'subscription_create', this is a first-time payment
|
||||||
|
// attempt and the project has never had an active subscription — don't disable.
|
||||||
|
if (invoice.billing_reason === 'subscription_create') {
|
||||||
|
signale.info(
|
||||||
|
`[WEBHOOK] Payment failed on initial subscription attempt for customer ${customerId}, skipping disable`,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// Find project by customer ID
|
// Find project by customer ID
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: {customer: customerId},
|
where: {customer: customerId},
|
||||||
@@ -529,10 +569,33 @@ export class Webhooks {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id})`);
|
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id}), disabling project`);
|
||||||
|
|
||||||
// Send notification about payment failure
|
await prisma.project.update({
|
||||||
await NtfyService.notifyPaymentFailed(project.name, project.id);
|
where: {id: project.id},
|
||||||
|
data: {disabled: true},
|
||||||
|
});
|
||||||
|
|
||||||
|
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
|
||||||
|
|
||||||
|
// Send email notification to project members
|
||||||
|
try {
|
||||||
|
const members = await MembershipService.getMembers(project.id);
|
||||||
|
const emails = members.map(m => m.email);
|
||||||
|
if (emails.length > 0) {
|
||||||
|
const template = React.createElement(ProjectDisabledPaymentEmail, {
|
||||||
|
projectName: project.name,
|
||||||
|
projectId: project.id,
|
||||||
|
dashboardUrl: DASHBOARD_URI,
|
||||||
|
landingUrl: LANDING_URI,
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Payment Failed', template)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (emailError) {
|
||||||
|
signale.error(`[WEBHOOK] Failed to send project disabled email:`, emailError);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export async function createEmailWorker() {
|
|||||||
include: {
|
include: {
|
||||||
contact: true,
|
contact: true,
|
||||||
project: true,
|
project: true,
|
||||||
|
template: {select: {type: true}},
|
||||||
|
campaign: {select: {type: true}},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -105,11 +107,15 @@ export async function createEmailWorker() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Compile HTML with unsubscribe footer and badge
|
// Compile HTML with unsubscribe footer and badge
|
||||||
|
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
|
||||||
const compiledHtml = EmailService.compile({
|
const compiledHtml = EmailService.compile({
|
||||||
content: formattedEmail.body,
|
content: formattedEmail.body,
|
||||||
contact: email.contact,
|
contact: email.contact,
|
||||||
project: email.project,
|
project: email.project,
|
||||||
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
|
includeUnsubscribe:
|
||||||
|
email.sourceType !== EmailSourceType.TRANSACTIONAL &&
|
||||||
|
email.template?.type !== 'HEADLESS' &&
|
||||||
|
email.campaign?.type !== 'HEADLESS',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use fromName from database if available, otherwise fall back to project name
|
// Use fromName from database if available, otherwise fall back to project name
|
||||||
@@ -182,6 +188,7 @@ export async function createEmailWorker() {
|
|||||||
from: email.from,
|
from: email.from,
|
||||||
fromName: email.fromName,
|
fromName: email.fromName,
|
||||||
messageId: result.messageId,
|
messageId: result.messageId,
|
||||||
|
emailId: email.id,
|
||||||
templateId: email.templateId,
|
templateId: email.templateId,
|
||||||
campaignId: email.campaignId,
|
campaignId: email.campaignId,
|
||||||
sourceType: email.sourceType,
|
sourceType: email.sourceType,
|
||||||
|
|||||||
@@ -67,10 +67,14 @@ export class ActivityService {
|
|||||||
const fetchLimit = effectiveLimit;
|
const fetchLimit = effectiveLimit;
|
||||||
|
|
||||||
// Default date range to last 30 days if not specified
|
// 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 now = new Date();
|
||||||
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||||
const dateFilter: Prisma.DateTimeFilter = {
|
const dateFilter: Prisma.DateTimeFilter = {
|
||||||
gte: startDate || defaultStartDate,
|
// Only apply start date filter on initial load (no cursor)
|
||||||
|
// This allows pagination to go back indefinitely
|
||||||
|
...(cursor ? {} : {gte: startDate || defaultStartDate}),
|
||||||
...(endDate ? {lte: endDate} : {}),
|
...(endDate ? {lte: endDate} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -364,6 +368,14 @@ export class ActivityService {
|
|||||||
const where: Prisma.EmailWhereInput = {
|
const where: Prisma.EmailWhereInput = {
|
||||||
projectId,
|
projectId,
|
||||||
...(contactId ? {contactId} : {}),
|
...(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
|
// Build OR conditions to filter by the appropriate timestamp field for each activity type
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
|
||||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
@@ -59,6 +59,7 @@ export class CampaignService {
|
|||||||
from: data.from,
|
from: data.from,
|
||||||
fromName: data.fromName,
|
fromName: data.fromName,
|
||||||
replyTo: data.replyTo,
|
replyTo: data.replyTo,
|
||||||
|
type: data.type ?? TemplateType.MARKETING,
|
||||||
audienceType: data.audienceType,
|
audienceType: data.audienceType,
|
||||||
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
||||||
segmentId: data.segmentId,
|
segmentId: data.segmentId,
|
||||||
@@ -100,6 +101,10 @@ export class CampaignService {
|
|||||||
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
|
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
|
||||||
|
|
||||||
// Handle campaign-specific fields
|
// Handle campaign-specific fields
|
||||||
|
if (data.type !== undefined) {
|
||||||
|
updateData.type = data.type;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.audienceType !== undefined) {
|
if (data.audienceType !== undefined) {
|
||||||
updateData.audienceType = data.audienceType;
|
updateData.audienceType = data.audienceType;
|
||||||
}
|
}
|
||||||
@@ -262,6 +267,7 @@ export class CampaignService {
|
|||||||
from: campaign.from,
|
from: campaign.from,
|
||||||
fromName: campaign.fromName,
|
fromName: campaign.fromName,
|
||||||
replyTo: campaign.replyTo,
|
replyTo: campaign.replyTo,
|
||||||
|
type: campaign.type,
|
||||||
audienceType: campaign.audienceType,
|
audienceType: campaign.audienceType,
|
||||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||||
segmentId: campaign.segmentId,
|
segmentId: campaign.segmentId,
|
||||||
@@ -482,6 +488,7 @@ export class CampaignService {
|
|||||||
from: campaign.from,
|
from: campaign.from,
|
||||||
fromName: campaign.fromName || undefined,
|
fromName: campaign.fromName || undefined,
|
||||||
replyTo: campaign.replyTo || undefined,
|
replyTo: campaign.replyTo || undefined,
|
||||||
|
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||||
@@ -708,7 +715,8 @@ export class CampaignService {
|
|||||||
): Promise<Prisma.ContactWhereInput> {
|
): Promise<Prisma.ContactWhereInput> {
|
||||||
const baseWhere: Prisma.ContactWhereInput = {
|
const baseWhere: Prisma.ContactWhereInput = {
|
||||||
projectId,
|
projectId,
|
||||||
subscribed: true, // Only send to subscribed contacts
|
// Transactional campaigns send to all contacts regardless of subscription status
|
||||||
|
...(campaign.type !== TemplateType.TRANSACTIONAL && {subscribed: true}),
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (campaign.audienceType) {
|
switch (campaign.audienceType) {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface SendEmailParams {
|
|||||||
workflowExecutionId?: string;
|
workflowExecutionId?: string;
|
||||||
workflowStepExecutionId?: string;
|
workflowStepExecutionId?: string;
|
||||||
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
||||||
|
isTransactional?: boolean; // Override source type to TRANSACTIONAL (e.g. for transactional campaigns)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,10 +117,12 @@ export class EmailService {
|
|||||||
* Send a campaign email
|
* Send a campaign email
|
||||||
*/
|
*/
|
||||||
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
|
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
|
||||||
// Check if template is transactional to determine source type
|
// Check if campaign or template is transactional to determine source type
|
||||||
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
|
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
|
||||||
|
|
||||||
if (params.templateId) {
|
if (params.isTransactional) {
|
||||||
|
sourceType = EmailSourceType.TRANSACTIONAL;
|
||||||
|
} else if (params.templateId) {
|
||||||
const template = await prisma.template.findUnique({
|
const template = await prisma.template.findUnique({
|
||||||
where: {id: params.templateId},
|
where: {id: params.templateId},
|
||||||
select: {type: true},
|
select: {type: true},
|
||||||
@@ -290,9 +293,8 @@ export class EmailService {
|
|||||||
include: {
|
include: {
|
||||||
contact: true,
|
contact: true,
|
||||||
project: true,
|
project: true,
|
||||||
template: {
|
template: {select: {type: true}},
|
||||||
select: {type: true},
|
campaign: {select: {type: true}},
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -354,11 +356,15 @@ export class EmailService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Compile HTML with unsubscribe footer and badge
|
// Compile HTML with unsubscribe footer and badge
|
||||||
|
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
|
||||||
const compiledHtml = this.compile({
|
const compiledHtml = this.compile({
|
||||||
content: formattedEmail.body,
|
content: formattedEmail.body,
|
||||||
contact: email.contact,
|
contact: email.contact,
|
||||||
project: email.project,
|
project: email.project,
|
||||||
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
|
includeUnsubscribe:
|
||||||
|
email.sourceType !== EmailSourceType.TRANSACTIONAL &&
|
||||||
|
email.template?.type !== 'HEADLESS' &&
|
||||||
|
email.campaign?.type !== 'HEADLESS',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use explicit fromName if provided, otherwise fall back to project name
|
// Use explicit fromName if provided, otherwise fall back to project name
|
||||||
@@ -650,6 +656,378 @@ export class EmailService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
||||||
|
* rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts.
|
||||||
|
*/
|
||||||
|
private static detectCustomHtmlPatterns(html: string): boolean {
|
||||||
|
if (!html || html.trim() === '') return false;
|
||||||
|
|
||||||
|
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
||||||
|
|
||||||
|
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
||||||
|
let hasCustomClasses = false;
|
||||||
|
for (const match of classMatches) {
|
||||||
|
const classValue = match[1];
|
||||||
|
if (!classValue) continue;
|
||||||
|
const classes = classValue.split(/\s+/).filter((c: string) => c.length > 0);
|
||||||
|
const allowedPrefixes = ['prose', 'variable-', 'email-image', 'ProseMirror', 'resizable-image', 'selected', 'resize-handle'];
|
||||||
|
const hasDisallowedClass = classes.some((cls: string) => !allowedPrefixes.some((prefix: string) => cls.startsWith(prefix)));
|
||||||
|
if (hasDisallowedClass) {
|
||||||
|
hasCustomClasses = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
||||||
|
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html);
|
||||||
|
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
||||||
|
const hasMediaQueries = /@media/i.test(html);
|
||||||
|
const hasStyleTags = /<style[^>]*>/i.test(html);
|
||||||
|
|
||||||
|
return (
|
||||||
|
hasInlineStyles ||
|
||||||
|
hasCustomClasses ||
|
||||||
|
hasCustomAttributes ||
|
||||||
|
hasComplexTables ||
|
||||||
|
hasCustomElements ||
|
||||||
|
hasMediaQueries ||
|
||||||
|
hasStyleTags
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps visual editor content with a full HTML document and prose styles.
|
||||||
|
* Mirrors wrapEmailWithStyles() in apps/web/src/lib/emailStyles.ts so sent emails
|
||||||
|
* match the preview modal exactly.
|
||||||
|
*/
|
||||||
|
private static wrapWithEmailStyles(htmlBody: string): string {
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
/* Base reset */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tailwind Typography (prose) base styles */
|
||||||
|
.prose {
|
||||||
|
color: #374151;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.prose [class~="lead"] {
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 1.25em;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 1.2em;
|
||||||
|
margin-bottom: 1.2em;
|
||||||
|
}
|
||||||
|
.prose a {
|
||||||
|
color: #3b82f6;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.prose strong {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.prose ol, .prose ul {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
padding-left: 1.625em;
|
||||||
|
}
|
||||||
|
.prose li {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.prose ol > li {
|
||||||
|
padding-left: 0.375em;
|
||||||
|
}
|
||||||
|
.prose ul > li {
|
||||||
|
padding-left: 0.375em;
|
||||||
|
}
|
||||||
|
.prose > ul > li p {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
.prose > ul > li > *:first-child {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
}
|
||||||
|
.prose > ul > li > *:last-child {
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
}
|
||||||
|
.prose > ol > li > *:first-child {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
}
|
||||||
|
.prose > ol > li > *:last-child {
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
}
|
||||||
|
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
.prose hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin-top: 3em;
|
||||||
|
margin-bottom: 3em;
|
||||||
|
}
|
||||||
|
.prose blockquote {
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
color: #111827;
|
||||||
|
border-left-width: 0.25rem;
|
||||||
|
border-left-color: #e5e7eb;
|
||||||
|
quotes: "\\201C""\\201D""\\2018""\\2019";
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 1.6em;
|
||||||
|
padding-left: 1em;
|
||||||
|
}
|
||||||
|
.prose h1 {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 2.25em;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.8888889em;
|
||||||
|
line-height: 1.1111111;
|
||||||
|
}
|
||||||
|
.prose h2 {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5em;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
line-height: 1.3333333;
|
||||||
|
}
|
||||||
|
.prose h3 {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.25em;
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 0.6em;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.prose h4 {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.prose img {
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.prose figure {
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.prose figure > * {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.prose code {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
.prose code::before {
|
||||||
|
content: "\`";
|
||||||
|
}
|
||||||
|
.prose code::after {
|
||||||
|
content: "\`";
|
||||||
|
}
|
||||||
|
.prose pre {
|
||||||
|
color: #e5e7eb;
|
||||||
|
background-color: #1f2937;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.875em;
|
||||||
|
line-height: 1.7142857;
|
||||||
|
margin-top: 1.7142857em;
|
||||||
|
margin-bottom: 1.7142857em;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding-top: 0.8571429em;
|
||||||
|
padding-right: 1.1428571em;
|
||||||
|
padding-bottom: 0.8571429em;
|
||||||
|
padding-left: 1.1428571em;
|
||||||
|
}
|
||||||
|
.prose pre code {
|
||||||
|
background-color: transparent;
|
||||||
|
border-width: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-weight: 400;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
.prose pre code::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
.prose pre code::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
.prose table {
|
||||||
|
width: 100%;
|
||||||
|
table-layout: auto;
|
||||||
|
text-align: left;
|
||||||
|
margin-top: 2em;
|
||||||
|
margin-bottom: 2em;
|
||||||
|
font-size: 0.875em;
|
||||||
|
line-height: 1.7142857;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.prose thead {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: #d1d5db;
|
||||||
|
}
|
||||||
|
.prose thead th {
|
||||||
|
color: #111827;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-align: bottom;
|
||||||
|
padding-right: 0.5714286em;
|
||||||
|
padding-bottom: 0.5714286em;
|
||||||
|
padding-left: 0.5714286em;
|
||||||
|
}
|
||||||
|
.prose tbody tr {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
.prose tbody tr:last-child {
|
||||||
|
border-bottom-width: 0;
|
||||||
|
}
|
||||||
|
.prose tbody td {
|
||||||
|
vertical-align: top;
|
||||||
|
padding-top: 0.5714286em;
|
||||||
|
padding-right: 0.5714286em;
|
||||||
|
padding-bottom: 0.5714286em;
|
||||||
|
padding-left: 0.5714286em;
|
||||||
|
}
|
||||||
|
.prose p {
|
||||||
|
margin-top: 1.25em;
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* prose-sm modifier */
|
||||||
|
.prose-sm {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.7142857;
|
||||||
|
}
|
||||||
|
.prose-sm p {
|
||||||
|
margin-top: 1.1428571em;
|
||||||
|
margin-bottom: 1.1428571em;
|
||||||
|
}
|
||||||
|
.prose-sm h1 {
|
||||||
|
font-size: 2.1428571em;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.prose-sm h2 {
|
||||||
|
font-size: 1.4285714em;
|
||||||
|
margin-top: 1.6em;
|
||||||
|
margin-bottom: 0.8em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.prose-sm h3 {
|
||||||
|
font-size: 1.2857143em;
|
||||||
|
margin-top: 1.5555556em;
|
||||||
|
margin-bottom: 0.4444444em;
|
||||||
|
line-height: 1.5555556;
|
||||||
|
}
|
||||||
|
.prose-sm h4 {
|
||||||
|
margin-top: 1.4285714em;
|
||||||
|
margin-bottom: 0.5714286em;
|
||||||
|
line-height: 1.4285714;
|
||||||
|
}
|
||||||
|
.prose-sm img {
|
||||||
|
margin-top: 1.7142857em;
|
||||||
|
margin-bottom: 1.7142857em;
|
||||||
|
}
|
||||||
|
.prose-sm ol, .prose-sm ul {
|
||||||
|
margin-top: 1.1428571em;
|
||||||
|
margin-bottom: 1.1428571em;
|
||||||
|
padding-left: 1.5714286em;
|
||||||
|
}
|
||||||
|
.prose-sm li {
|
||||||
|
margin-top: 0.2857143em;
|
||||||
|
margin-bottom: 0.2857143em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* max-w-none utility */
|
||||||
|
.max-w-none {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom editor styles */
|
||||||
|
.variable-highlight, .variable-placeholder, .variable-mention {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th, .prose td {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose th {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose .resizable-image-wrapper {
|
||||||
|
display: block;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose .resizable-image-container {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose .resizable-image-container img {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="prose prose-sm max-w-none">
|
||||||
|
${htmlBody}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compile HTML email with optional unsubscribe footer and badge
|
* Compile HTML email with optional unsubscribe footer and badge
|
||||||
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
* Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled)
|
||||||
@@ -665,7 +1043,9 @@ export class EmailService {
|
|||||||
project: Project;
|
project: Project;
|
||||||
includeUnsubscribe?: boolean;
|
includeUnsubscribe?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
let html = content;
|
// Wrap visual editor content with prose styles so the sent email matches the preview modal.
|
||||||
|
// Custom HTML (from the HTML editor) already carries its own styles and is used as-is.
|
||||||
|
let html = this.detectCustomHtmlPatterns(content) ? content : this.wrapWithEmailStyles(content);
|
||||||
|
|
||||||
const unsubscribeHtml = includeUnsubscribe
|
const unsubscribeHtml = includeUnsubscribe
|
||||||
? (() => {
|
? (() => {
|
||||||
@@ -719,7 +1099,7 @@ export class EmailService {
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="width:180px;">
|
<td style="width:180px;">
|
||||||
<a href="${LANDING_URI}?ref=badge" target="_blank">
|
<a href="${LANDING_URI}?ref=badge" target="_blank">
|
||||||
<img height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
<img alt="Powered by Plunk" height="auto" src="https://cdn.useplunk.com/badge.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="180" />
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -170,6 +170,17 @@ export class NtfyService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify about project disabled due to payment failure
|
||||||
|
*/
|
||||||
|
public static async notifyProjectDisabledForPayment(projectName: string, projectId: string): Promise<void> {
|
||||||
|
await this.sendUrgent(
|
||||||
|
'Project Disabled - Payment Failed',
|
||||||
|
`Project "${projectName}" (${projectId}) was automatically disabled due to a failed recurring payment`,
|
||||||
|
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.ERROR],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify about successful invoice payment - MIN priority (routine)
|
* Notify about successful invoice payment - MIN priority (routine)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
@@ -15,7 +17,7 @@ import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants
|
|||||||
* These limits protect AWS SES reputation and prevent account suspension
|
* These limits protect AWS SES reputation and prevent account suspension
|
||||||
*/
|
*/
|
||||||
const SECURITY_THRESHOLDS = {
|
const SECURITY_THRESHOLDS = {
|
||||||
// Minimum emails required before enforcing limits (prevents false positives)
|
// Minimum emails required before enforcing rate-based limits (prevents false positives)
|
||||||
MIN_EMAILS_FOR_ENFORCEMENT: 100,
|
MIN_EMAILS_FOR_ENFORCEMENT: 100,
|
||||||
|
|
||||||
// Bounce rate thresholds (hard bounces only)
|
// Bounce rate thresholds (hard bounces only)
|
||||||
@@ -31,11 +33,37 @@ const SECURITY_THRESHOLDS = {
|
|||||||
COMPLAINT_ALLTIME_CRITICAL: 0.12,
|
COMPLAINT_ALLTIME_CRITICAL: 0.12,
|
||||||
|
|
||||||
// Minimum absolute counts (prevents small sample size false positives)
|
// Minimum absolute counts (prevents small sample size false positives)
|
||||||
// Both percentage AND absolute count must be exceeded to trigger
|
// Both percentage AND absolute count must be exceeded to trigger rate-based checks
|
||||||
MIN_BOUNCES_FOR_CRITICAL: 10,
|
MIN_BOUNCES_FOR_CRITICAL: 10,
|
||||||
MIN_BOUNCES_FOR_WARNING: 5,
|
MIN_BOUNCES_FOR_WARNING: 5,
|
||||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
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;
|
} as const;
|
||||||
|
|
||||||
interface RateData {
|
interface RateData {
|
||||||
@@ -50,15 +78,84 @@ interface SecurityStatus {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
isHealthy: boolean;
|
isHealthy: boolean;
|
||||||
shouldDisable: boolean;
|
shouldDisable: boolean;
|
||||||
|
twentyFourHour: RateData;
|
||||||
sevenDay: RateData;
|
sevenDay: RateData;
|
||||||
allTime: RateData;
|
allTime: RateData;
|
||||||
|
isNewProject: boolean;
|
||||||
violations: string[];
|
violations: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SNS_CERT_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|cn)$/;
|
||||||
|
const snsSigningCertCache = new Map<string, string>();
|
||||||
|
|
||||||
|
async function fetchSigningCert(certUrl: string): Promise<string> {
|
||||||
|
const cached = snsSigningCertCache.get(certUrl);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const response = await fetch(certUrl);
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch SNS signing cert: ${response.statusText}`);
|
||||||
|
|
||||||
|
const pem = await response.text();
|
||||||
|
snsSigningCertCache.set(certUrl, pem);
|
||||||
|
return pem;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSnsStringToSign(message: Record<string, string>): string {
|
||||||
|
const fields =
|
||||||
|
message['Type'] === 'Notification'
|
||||||
|
? ['Message', 'MessageId', 'Subject', 'Timestamp', 'TopicArn', 'Type']
|
||||||
|
: ['Message', 'MessageId', 'SubscribeURL', 'Timestamp', 'Token', 'TopicArn', 'Type'];
|
||||||
|
|
||||||
|
return fields
|
||||||
|
.filter(key => message[key] !== undefined)
|
||||||
|
.map(key => `${key}\n${message[key]}\n`)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
export class SecurityService {
|
export class SecurityService {
|
||||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an AWS SNS message signature. Returns false if the cert URL is
|
||||||
|
* untrusted, or the signature doesn't match.
|
||||||
|
*/
|
||||||
|
public static async verifySnsSignature(body: Record<string, string>): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const certUrl = body['SigningCertURL'];
|
||||||
|
const signature = body['Signature'];
|
||||||
|
|
||||||
|
if (!certUrl || !signature) {
|
||||||
|
signale.warn('[SNS] Missing SigningCertURL or Signature');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(certUrl);
|
||||||
|
} catch {
|
||||||
|
signale.warn('[SNS] Unparseable SigningCertURL');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedUrl.protocol !== 'https:' || !SNS_CERT_HOST_RE.test(parsedUrl.hostname)) {
|
||||||
|
signale.warn(`[SNS] Untrusted SigningCertURL host: ${parsedUrl.hostname}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pem = await fetchSigningCert(certUrl);
|
||||||
|
const stringToSign = buildSnsStringToSign(body);
|
||||||
|
const algorithm = body['SignatureVersion'] === '2' ? 'RSA-SHA256' : 'RSA-SHA1';
|
||||||
|
|
||||||
|
const verifier = crypto.createVerify(algorithm);
|
||||||
|
verifier.update(stringToSign, 'utf8');
|
||||||
|
return verifier.verify(pem, signature, 'base64');
|
||||||
|
} catch (err) {
|
||||||
|
signale.error('[SNS] Signature verification error:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get security status for a project (with caching)
|
* Get security status for a project (with caching)
|
||||||
*/
|
*/
|
||||||
@@ -86,6 +183,13 @@ export class SecurityService {
|
|||||||
projectId,
|
projectId,
|
||||||
isHealthy: true,
|
isHealthy: true,
|
||||||
shouldDisable: false,
|
shouldDisable: false,
|
||||||
|
twentyFourHour: {
|
||||||
|
total: 0,
|
||||||
|
bounces: 0,
|
||||||
|
complaints: 0,
|
||||||
|
bounceRate: 0,
|
||||||
|
complaintRate: 0,
|
||||||
|
},
|
||||||
sevenDay: {
|
sevenDay: {
|
||||||
total: 0,
|
total: 0,
|
||||||
bounces: 0,
|
bounces: 0,
|
||||||
@@ -100,6 +204,7 @@ export class SecurityService {
|
|||||||
bounceRate: 0,
|
bounceRate: 0,
|
||||||
complaintRate: 0,
|
complaintRate: 0,
|
||||||
},
|
},
|
||||||
|
isNewProject: false,
|
||||||
violations: [],
|
violations: [],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
};
|
};
|
||||||
@@ -133,12 +238,20 @@ export class SecurityService {
|
|||||||
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
|
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
|
||||||
status.violations,
|
status.violations,
|
||||||
);
|
);
|
||||||
|
signale.info(
|
||||||
|
`[SECURITY] 24-hour stats: ${status.twentyFourHour.bounces} bounces, ${status.twentyFourHour.complaints} complaints out of ${status.twentyFourHour.total} emails`,
|
||||||
|
);
|
||||||
signale.info(
|
signale.info(
|
||||||
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
|
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
|
||||||
);
|
);
|
||||||
signale.info(
|
signale.info(
|
||||||
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
|
`[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
|
// Send notification about critical security violations
|
||||||
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
|
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
|
||||||
@@ -201,11 +314,17 @@ export class SecurityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a project's security metrics (for admin/dashboard display)
|
* Get a project's security metrics (for dashboard display)
|
||||||
|
* Does NOT expose internal thresholds — only computed health levels
|
||||||
*/
|
*/
|
||||||
public static async getProjectSecurityMetrics(projectId: string): Promise<{
|
public static async getProjectSecurityMetrics(projectId: string): Promise<{
|
||||||
status: SecurityStatus;
|
status: SecurityStatus;
|
||||||
thresholds: typeof SECURITY_THRESHOLDS;
|
levels: {
|
||||||
|
bounce7Day: 'healthy' | 'warning' | 'critical';
|
||||||
|
bounceAllTime: 'healthy' | 'warning' | 'critical';
|
||||||
|
complaint7Day: 'healthy' | 'warning' | 'critical';
|
||||||
|
complaintAllTime: 'healthy' | 'warning' | 'critical';
|
||||||
|
};
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
}> {
|
}> {
|
||||||
const [status, project] = await Promise.all([
|
const [status, project] = await Promise.all([
|
||||||
@@ -216,13 +335,55 @@ 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 {
|
return {
|
||||||
status,
|
status: sanitizedStatus,
|
||||||
thresholds: SECURITY_THRESHOLDS,
|
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,
|
||||||
|
),
|
||||||
|
},
|
||||||
isDisabled: project?.disabled ?? false,
|
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
|
* Calculate bounce and complaint rates for a project
|
||||||
*/
|
*/
|
||||||
@@ -270,10 +431,21 @@ export class SecurityService {
|
|||||||
*/
|
*/
|
||||||
private static async calculateSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
private static async calculateSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
||||||
const now = new Date();
|
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);
|
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Get 7-day and all-time rates in parallel
|
// Get project age to determine if stricter new-project thresholds apply
|
||||||
const [sevenDay, allTime] = await Promise.all([
|
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),
|
||||||
this.calculateRates(projectId, sevenDaysAgo),
|
this.calculateRates(projectId, sevenDaysAgo),
|
||||||
this.calculateRates(projectId),
|
this.calculateRates(projectId),
|
||||||
]);
|
]);
|
||||||
@@ -281,13 +453,91 @@ export class SecurityService {
|
|||||||
const violations: string[] = [];
|
const violations: string[] = [];
|
||||||
const warnings: 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
|
// Only enforce if minimum emails threshold is met
|
||||||
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||||
const hasMinimumVolume7Day = sevenDay.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)
|
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
||||||
if (hasMinimumVolume7Day) {
|
if (hasMinimumVolume7Day) {
|
||||||
// Critical: requires BOTH rate AND absolute count thresholds
|
|
||||||
if (
|
if (
|
||||||
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
|
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
|
||||||
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||||
@@ -307,7 +557,6 @@ export class SecurityService {
|
|||||||
|
|
||||||
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
||||||
if (hasMinimumVolume7Day) {
|
if (hasMinimumVolume7Day) {
|
||||||
// Critical: requires BOTH rate AND absolute count thresholds
|
|
||||||
if (
|
if (
|
||||||
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
|
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
|
||||||
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||||
@@ -327,7 +576,6 @@ export class SecurityService {
|
|||||||
|
|
||||||
// Check all-time rates (only if all-time volume is sufficient)
|
// Check all-time rates (only if all-time volume is sufficient)
|
||||||
if (hasMinimumVolumeAllTime) {
|
if (hasMinimumVolumeAllTime) {
|
||||||
// Check all-time bounce rate - requires BOTH rate AND absolute count
|
|
||||||
if (
|
if (
|
||||||
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
|
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
|
||||||
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||||
@@ -344,7 +592,6 @@ export class SecurityService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all-time complaint rate - requires BOTH rate AND absolute count
|
|
||||||
if (
|
if (
|
||||||
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
|
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
|
||||||
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||||
@@ -366,8 +613,10 @@ export class SecurityService {
|
|||||||
projectId,
|
projectId,
|
||||||
isHealthy: violations.length === 0,
|
isHealthy: violations.length === 0,
|
||||||
shouldDisable: violations.length > 0,
|
shouldDisable: violations.length > 0,
|
||||||
|
twentyFourHour,
|
||||||
sevenDay,
|
sevenDay,
|
||||||
allTime,
|
allTime,
|
||||||
|
isNewProject,
|
||||||
violations,
|
violations,
|
||||||
warnings,
|
warnings,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
|
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
|
||||||
import {toPrismaJson} from '@plunk/types';
|
import {toPrismaJson} from '@plunk/types';
|
||||||
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
|
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
|
||||||
|
import dns from 'node:dns/promises';
|
||||||
|
import net from 'node:net';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
@@ -531,12 +533,9 @@ export class WorkflowExecutionService {
|
|||||||
throw new Error('No template configured for SEND_EMAIL step');
|
throw new Error('No template configured for SEND_EMAIL step');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse step config to get recipient configuration
|
// Parse and validate step config using schema
|
||||||
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
const stepConfig = WorkflowStepConfigSchemas.sendEmail.parse(config);
|
||||||
const recipientConfig =
|
const recipientConfig = stepConfig.recipient || {type: 'CONTACT' as const};
|
||||||
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
|
|
||||||
? (stepConfig.recipient as {type?: string; customEmail?: string})
|
|
||||||
: {type: 'CONTACT'};
|
|
||||||
|
|
||||||
// Get contact data for variable substitution
|
// Get contact data for variable substitution
|
||||||
const contact = execution.contact;
|
const contact = execution.contact;
|
||||||
@@ -566,19 +565,13 @@ export class WorkflowExecutionService {
|
|||||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||||
|
|
||||||
// Determine recipient email
|
// Determine recipient email
|
||||||
let recipientEmail = contact.email;
|
// Schema validation ensures customEmail exists when type is CUSTOM
|
||||||
let recipientContactId = contact.id;
|
const recipientEmail = recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail! : contact.email;
|
||||||
|
|
||||||
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
|
|
||||||
recipientEmail = recipientConfig.customEmail;
|
|
||||||
// For custom recipients, we don't associate with a contact
|
|
||||||
recipientContactId = contact.id; // Keep original contact for tracking
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send email via EmailService
|
// Send email via EmailService
|
||||||
const email = await EmailService.sendWorkflowEmail({
|
const email = await EmailService.sendWorkflowEmail({
|
||||||
projectId: execution.workflow.projectId,
|
projectId: execution.workflow.projectId,
|
||||||
contactId: recipientContactId,
|
contactId: contact.id, // Keep original contact for tracking
|
||||||
workflowExecutionId: execution.id,
|
workflowExecutionId: execution.id,
|
||||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||||
templateId: step.template.id,
|
templateId: step.template.id,
|
||||||
@@ -588,7 +581,7 @@ export class WorkflowExecutionService {
|
|||||||
fromName: step.template.fromName || undefined,
|
fromName: step.template.fromName || undefined,
|
||||||
replyTo: step.template.replyTo || undefined,
|
replyTo: step.template.replyTo || undefined,
|
||||||
// Pass custom recipient email if specified
|
// Pass custom recipient email if specified
|
||||||
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
|
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientConfig.customEmail : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -727,7 +720,7 @@ export class WorkflowExecutionService {
|
|||||||
_stepExecution: WorkflowStepExecution,
|
_stepExecution: WorkflowStepExecution,
|
||||||
config: StepConfig,
|
config: StepConfig,
|
||||||
): Promise<StepResult> {
|
): Promise<StepResult> {
|
||||||
const {field, operator, value} = WorkflowStepConfigSchemas.condition.parse(config);
|
const parsed = WorkflowStepConfigSchemas.condition.parse(config);
|
||||||
|
|
||||||
// Get the value to evaluate
|
// Get the value to evaluate
|
||||||
const contact = execution.contact;
|
const contact = execution.contact;
|
||||||
@@ -743,7 +736,7 @@ export class WorkflowExecutionService {
|
|||||||
// - data.firstName, data.lastName, etc.
|
// - data.firstName, data.lastName, etc.
|
||||||
// - workflow.* (execution context - alias for event data)
|
// - workflow.* (execution context - alias for event data)
|
||||||
// - event.* (event data that triggered the workflow)
|
// - event.* (event data that triggered the workflow)
|
||||||
const actualValue = this.resolveField(field, {
|
const fieldData = {
|
||||||
contact: {
|
contact: {
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
subscribed: contact.subscribed,
|
subscribed: contact.subscribed,
|
||||||
@@ -751,16 +744,47 @@ export class WorkflowExecutionService {
|
|||||||
data: contactData,
|
data: contactData,
|
||||||
workflow: context,
|
workflow: context,
|
||||||
event: context, // Alias for easier access to event data
|
event: context, // Alias for easier access to event data
|
||||||
});
|
};
|
||||||
|
|
||||||
// Evaluate the condition
|
// Multi-branch mode (switch/case)
|
||||||
|
if ('mode' in parsed && parsed.mode === 'multi') {
|
||||||
|
const actualValue = this.resolveField(parsed.field, fieldData);
|
||||||
|
|
||||||
|
for (const branch of parsed.branches) {
|
||||||
|
if (this.evaluateCondition(actualValue, branch.operator, branch.value)) {
|
||||||
|
return {
|
||||||
|
field: parsed.field,
|
||||||
|
mode: 'multi',
|
||||||
|
matchedBranch: branch.name,
|
||||||
|
actualValue,
|
||||||
|
branch: branch.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No branch matched — use default
|
||||||
|
return {
|
||||||
|
field: parsed.field,
|
||||||
|
mode: 'multi',
|
||||||
|
matchedBranch: 'default',
|
||||||
|
actualValue,
|
||||||
|
branch: 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy binary mode (if/else)
|
||||||
|
const field = parsed.field;
|
||||||
|
const operator = 'operator' in parsed ? parsed.operator : 'equals';
|
||||||
|
const value = 'value' in parsed ? parsed.value : undefined;
|
||||||
|
const actualValue = this.resolveField(field, fieldData);
|
||||||
const result = this.evaluateCondition(actualValue, operator, value);
|
const result = this.evaluateCondition(actualValue, operator, value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
field,
|
field,
|
||||||
operator,
|
operator,
|
||||||
expectedValue: value,
|
expectedValue: value,
|
||||||
actualValue,
|
// Convert undefined to null so it's preserved in JSON (JSON.stringify removes undefined)
|
||||||
|
actualValue: actualValue === undefined ? null : actualValue,
|
||||||
result,
|
result,
|
||||||
branch: result ? 'yes' : 'no',
|
branch: result ? 'yes' : 'no',
|
||||||
};
|
};
|
||||||
@@ -800,6 +824,89 @@ export class WorkflowExecutionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that an IP address is not in a private/reserved range to prevent SSRF.
|
||||||
|
* Blocks loopback, private, link-local, and cloud metadata ranges.
|
||||||
|
*/
|
||||||
|
private static isPrivateIp(ip: string): boolean {
|
||||||
|
// Normalize IPv6-mapped IPv4 (e.g. ::ffff:192.168.1.1)
|
||||||
|
const addr = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
|
||||||
|
|
||||||
|
if (net.isIPv4(addr)) {
|
||||||
|
const parts = addr.split('.').map(Number);
|
||||||
|
const a = parts[0] ?? -1;
|
||||||
|
const b = parts[1] ?? -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
a === 127 || // 127.0.0.0/8 loopback
|
||||||
|
a === 10 || // 10.0.0.0/8 private
|
||||||
|
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 private
|
||||||
|
(a === 192 && b === 168) || // 192.168.0.0/16 private
|
||||||
|
(a === 169 && b === 254) || // 169.254.0.0/16 link-local / cloud metadata
|
||||||
|
(a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 shared address space
|
||||||
|
a === 0 || // 0.0.0.0/8
|
||||||
|
a >= 224 // 224.0.0.0+ multicast and reserved
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (net.isIPv6(addr)) {
|
||||||
|
const normalized = addr.toLowerCase();
|
||||||
|
return (
|
||||||
|
normalized === '::1' || // loopback
|
||||||
|
normalized.startsWith('fe80:') || // link-local
|
||||||
|
normalized.startsWith('fc') || // unique local
|
||||||
|
normalized.startsWith('fd') || // unique local
|
||||||
|
normalized.startsWith('ff') // multicast
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown format — reject to be safe
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSRF-safe fetch. Resolves the hostname, validates the IP is not internal,
|
||||||
|
* and manually follows redirects re-validating each hop.
|
||||||
|
*/
|
||||||
|
private static async safeFetch(url: string, options: RequestInit): Promise<Response> {
|
||||||
|
const MAX_REDIRECTS = 5;
|
||||||
|
let currentUrl = url;
|
||||||
|
|
||||||
|
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
|
||||||
|
const parsed = new URL(currentUrl);
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||||
|
throw new Error(`Webhook URL scheme not allowed: ${parsed.protocol}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {address} = await dns.lookup(parsed.hostname);
|
||||||
|
|
||||||
|
if (WorkflowExecutionService.isPrivateIp(address)) {
|
||||||
|
throw new Error(`Webhook URL resolves to a private/internal IP address: ${address}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(currentUrl, {
|
||||||
|
...options,
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) {
|
||||||
|
throw new Error('Redirect with no Location header');
|
||||||
|
}
|
||||||
|
// Resolve relative redirects against the current URL
|
||||||
|
currentUrl = new URL(location, currentUrl).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Too many redirects');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WEBHOOK step - Call an external webhook
|
* WEBHOOK step - Call an external webhook
|
||||||
*/
|
*/
|
||||||
@@ -837,7 +944,7 @@ export class WorkflowExecutionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Make HTTP request
|
// Make HTTP request
|
||||||
const response = await fetch(url, {
|
const response = await WorkflowExecutionService.safeFetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -996,7 +1103,15 @@ export class WorkflowExecutionService {
|
|||||||
* Helper: Resolve field value from object using dot notation
|
* Helper: Resolve field value from object using dot notation
|
||||||
*/
|
*/
|
||||||
private static resolveField(field: string, data: Record<string, unknown>): unknown {
|
private static resolveField(field: string, data: Record<string, unknown>): unknown {
|
||||||
const parts = field.split('.');
|
// Handle legacy "contact.data.X" format by converting to "data.X"
|
||||||
|
// The UI historically showed examples like "contact.data.plan" but the field structure
|
||||||
|
// has "data" as a top-level key, not nested under "contact"
|
||||||
|
let normalizedField = field;
|
||||||
|
if (field.startsWith('contact.data.')) {
|
||||||
|
normalizedField = field.substring(8); // Remove "contact." prefix, leaving "data.X"
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = normalizedField.split('.');
|
||||||
let value: unknown = data;
|
let value: unknown = data;
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
|
import {beforeEach, describe, expect, it, vi, type Mock} from 'vitest';
|
||||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus, TemplateType} from '@plunk/db';
|
||||||
import {ActionSchemas} from '@plunk/shared';
|
import {ActionSchemas} from '@plunk/shared';
|
||||||
import {EmailService} from '../EmailService';
|
import {EmailService} from '../EmailService';
|
||||||
import {sendRawEmail} from '../SESService';
|
import {sendRawEmail} from '../SESService';
|
||||||
@@ -187,6 +187,84 @@ describe('EmailService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Headless Email Behaviour', () => {
|
||||||
|
it('should NOT send headless workflow emails to unsubscribed contacts', async () => {
|
||||||
|
const unsubscribedContact = await factories.createContact({
|
||||||
|
projectId,
|
||||||
|
subscribed: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headlessTemplate = await factories.createTemplate({
|
||||||
|
projectId,
|
||||||
|
type: 'HEADLESS',
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = await factories.createWorkflow({projectId});
|
||||||
|
const execution = await factories.createWorkflowExecution(workflow.id, unsubscribedContact.id);
|
||||||
|
|
||||||
|
const email = await EmailService.sendWorkflowEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: unsubscribedContact.id,
|
||||||
|
templateId: headlessTemplate.id,
|
||||||
|
subject: 'Newsletter',
|
||||||
|
body: 'Content',
|
||||||
|
from: '[email protected]',
|
||||||
|
workflowExecutionId: execution.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(email.status).toBe(EmailStatus.FAILED);
|
||||||
|
expect(email.error).toMatch(/unsubscribed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep CAMPAIGN sourceType when campaign type is HEADLESS (no template)', async () => {
|
||||||
|
const contact = await factories.createContact({projectId, subscribed: true});
|
||||||
|
|
||||||
|
// Campaign typed HEADLESS directly — no template involved (inline body)
|
||||||
|
const campaign = await factories.createCampaign({
|
||||||
|
projectId,
|
||||||
|
type: TemplateType.HEADLESS,
|
||||||
|
body: 'Content with <a href="https://example.com/unsubscribe">unsubscribe</a>',
|
||||||
|
});
|
||||||
|
|
||||||
|
const email = await EmailService.sendCampaignEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
campaignId: campaign.id,
|
||||||
|
subject: 'Newsletter',
|
||||||
|
body: campaign.body,
|
||||||
|
from: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(email.sourceType).toBe(EmailSourceType.CAMPAIGN);
|
||||||
|
expect(email.status).toBe(EmailStatus.PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep CAMPAIGN sourceType when campaign uses headless template', async () => {
|
||||||
|
const contact = await factories.createContact({projectId, subscribed: true});
|
||||||
|
|
||||||
|
const headlessTemplate = await factories.createTemplate({
|
||||||
|
projectId,
|
||||||
|
type: 'HEADLESS',
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaign = await factories.createCampaign({projectId});
|
||||||
|
|
||||||
|
const email = await EmailService.sendCampaignEmail({
|
||||||
|
projectId,
|
||||||
|
contactId: contact.id,
|
||||||
|
campaignId: campaign.id,
|
||||||
|
templateId: headlessTemplate.id,
|
||||||
|
subject: 'Newsletter',
|
||||||
|
body: 'Content with <a href="https://example.com/unsubscribe">unsubscribe</a>',
|
||||||
|
from: '[email protected]',
|
||||||
|
});
|
||||||
|
|
||||||
|
// HEADLESS is not transactional — sourceType stays CAMPAIGN
|
||||||
|
expect(email.sourceType).toBe(EmailSourceType.CAMPAIGN);
|
||||||
|
expect(email.status).toBe(EmailStatus.PENDING);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Template Type Determines Email Type', () => {
|
describe('Template Type Determines Email Type', () => {
|
||||||
it('should use TRANSACTIONAL sourceType when campaign uses transactional template', async () => {
|
it('should use TRANSACTIONAL sourceType when campaign uses transactional template', async () => {
|
||||||
const contact = await factories.createContact({
|
const contact = await factories.createContact({
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -193,6 +193,7 @@ describe('TemplateService', () => {
|
|||||||
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
|
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
|
||||||
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
|
await factories.createTemplate({projectId, type: TemplateType.MARKETING});
|
||||||
await factories.createTemplate({projectId, type: TemplateType.TRANSACTIONAL});
|
await factories.createTemplate({projectId, type: TemplateType.TRANSACTIONAL});
|
||||||
|
await factories.createTemplate({projectId, type: TemplateType.HEADLESS});
|
||||||
|
|
||||||
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
|
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
|
||||||
expect(marketingResult.total).toBe(2);
|
expect(marketingResult.total).toBe(2);
|
||||||
@@ -201,6 +202,10 @@ describe('TemplateService', () => {
|
|||||||
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
|
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
|
||||||
expect(transactionalResult.total).toBe(1);
|
expect(transactionalResult.total).toBe(1);
|
||||||
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
|
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
|
||||||
|
|
||||||
|
const headlessResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.HEADLESS);
|
||||||
|
expect(headlessResult.total).toBe(1);
|
||||||
|
expect(headlessResult.data[0].type).toBe(TemplateType.HEADLESS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should combine search and type filters', async () => {
|
it('should combine search and type filters', async () => {
|
||||||
@@ -293,6 +298,19 @@ describe('TemplateService', () => {
|
|||||||
expect(updated.type).toBe(TemplateType.TRANSACTIONAL);
|
expect(updated.type).toBe(TemplateType.TRANSACTIONAL);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should update template type to HEADLESS', async () => {
|
||||||
|
const template = await factories.createTemplate({
|
||||||
|
projectId,
|
||||||
|
type: TemplateType.MARKETING,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await TemplateService.update(projectId, template.id, {
|
||||||
|
type: TemplateType.HEADLESS,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.type).toBe(TemplateType.HEADLESS);
|
||||||
|
});
|
||||||
|
|
||||||
it('should update email fields (from, fromName, replyTo)', async () => {
|
it('should update email fields (from, fromName, replyTo)', async () => {
|
||||||
const template = await factories.createTemplate({projectId});
|
const template = await factories.createTemplate({projectId});
|
||||||
|
|
||||||
|
|||||||
@@ -722,4 +722,260 @@ describe('Workflow CONDITION Step - Comprehensive Operator Tests', () => {
|
|||||||
expect(branch).toBe('yes');
|
expect(branch).toBe('yes');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// MULTI-BRANCH CONDITIONS (switch/case)
|
||||||
|
// ========================================
|
||||||
|
describe('Multi-Branch Conditions', () => {
|
||||||
|
/**
|
||||||
|
* Helper to create a workflow with a multi-branch condition step
|
||||||
|
* Creates one exit step per branch + one for default
|
||||||
|
*/
|
||||||
|
async function createMultiBranchWorkflow(
|
||||||
|
contactData: Record<string, unknown>,
|
||||||
|
conditionConfig: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const contact = await factories.createContact({
|
||||||
|
projectId,
|
||||||
|
data: contactData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflow = await factories.createWorkflow({projectId});
|
||||||
|
const triggerStep = await prisma.workflowStep.findFirstOrThrow({
|
||||||
|
where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER},
|
||||||
|
});
|
||||||
|
|
||||||
|
const conditionStep = await prisma.workflowStep.create({
|
||||||
|
data: {
|
||||||
|
workflowId: workflow.id,
|
||||||
|
type: WorkflowStepType.CONDITION,
|
||||||
|
name: 'Multi Condition',
|
||||||
|
position: {x: 100, y: 0},
|
||||||
|
config: conditionConfig,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create exit steps for each branch + default
|
||||||
|
const branches = conditionConfig.branches as Array<{id: string; name: string; operator: string; value?: unknown}>;
|
||||||
|
const branchExits: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < branches.length; i++) {
|
||||||
|
const branch = branches[i]!;
|
||||||
|
const exitStep = await prisma.workflowStep.create({
|
||||||
|
data: {
|
||||||
|
workflowId: workflow.id,
|
||||||
|
type: WorkflowStepType.EXIT,
|
||||||
|
name: `${branch.name} Path`,
|
||||||
|
position: {x: 200, y: i * 100},
|
||||||
|
config: {reason: branch.name},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
branchExits[branch.id] = exitStep.id;
|
||||||
|
|
||||||
|
await prisma.workflowTransition.create({
|
||||||
|
data: {
|
||||||
|
fromStepId: conditionStep.id,
|
||||||
|
toStepId: exitStep.id,
|
||||||
|
condition: {branch: branch.id},
|
||||||
|
priority: i,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create default exit
|
||||||
|
const defaultExit = await prisma.workflowStep.create({
|
||||||
|
data: {
|
||||||
|
workflowId: workflow.id,
|
||||||
|
type: WorkflowStepType.EXIT,
|
||||||
|
name: 'Default Path',
|
||||||
|
position: {x: 200, y: branches.length * 100},
|
||||||
|
config: {reason: 'default'},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.workflowTransition.create({
|
||||||
|
data: {
|
||||||
|
fromStepId: conditionStep.id,
|
||||||
|
toStepId: defaultExit.id,
|
||||||
|
condition: {branch: 'default'},
|
||||||
|
priority: branches.length,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect trigger to condition
|
||||||
|
await prisma.workflowTransition.create({
|
||||||
|
data: {fromStepId: triggerStep.id, toStepId: conditionStep.id},
|
||||||
|
});
|
||||||
|
|
||||||
|
const execution = await prisma.workflowExecution.create({
|
||||||
|
data: {
|
||||||
|
workflowId: workflow.id,
|
||||||
|
contactId: contact.id,
|
||||||
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
|
currentStepId: triggerStep.id,
|
||||||
|
context: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {execution, triggerStep, conditionStep, contact, branchExits, defaultExitId: defaultExit.id};
|
||||||
|
}
|
||||||
|
|
||||||
|
const planBranches = {
|
||||||
|
mode: 'multi' as const,
|
||||||
|
field: 'data.plan',
|
||||||
|
branches: [
|
||||||
|
{id: 'br-premium', name: 'Premium', operator: 'equals', value: 'premium'},
|
||||||
|
{id: 'br-free', name: 'Free', operator: 'equals', value: 'free'},
|
||||||
|
{id: 'br-enterprise', name: 'Enterprise', operator: 'equals', value: 'enterprise'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should match the first matching branch', async () => {
|
||||||
|
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||||
|
{plan: 'premium'},
|
||||||
|
planBranches,
|
||||||
|
);
|
||||||
|
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||||
|
|
||||||
|
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||||
|
expect(branch).toBe('br-premium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match a non-first branch correctly', async () => {
|
||||||
|
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||||
|
{plan: 'enterprise'},
|
||||||
|
planBranches,
|
||||||
|
);
|
||||||
|
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||||
|
|
||||||
|
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||||
|
expect(branch).toBe('br-enterprise');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall through to default when no branch matches', async () => {
|
||||||
|
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||||
|
{plan: 'starter'},
|
||||||
|
planBranches,
|
||||||
|
);
|
||||||
|
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||||
|
|
||||||
|
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||||
|
expect(branch).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fall through to default when field is missing', async () => {
|
||||||
|
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||||
|
{other: 'value'},
|
||||||
|
planBranches,
|
||||||
|
);
|
||||||
|
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||||
|
|
||||||
|
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||||
|
expect(branch).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support mixed operators across branches', async () => {
|
||||||
|
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||||
|
{score: 85},
|
||||||
|
{
|
||||||
|
mode: 'multi',
|
||||||
|
field: 'data.score',
|
||||||
|
branches: [
|
||||||
|
{id: 'br-high', name: 'High', operator: 'greaterThanOrEqual', value: 90},
|
||||||
|
{id: 'br-medium', name: 'Medium', operator: 'greaterThanOrEqual', value: 70},
|
||||||
|
{id: 'br-low', name: 'Low', operator: 'lessThan', value: 70},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||||
|
|
||||||
|
// 85 >= 90 is false, 85 >= 70 is true → should match "Medium"
|
||||||
|
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||||
|
expect(branch).toBe('br-medium');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect branch evaluation order (first match wins)', async () => {
|
||||||
|
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||||
|
{plan: 'premium'},
|
||||||
|
{
|
||||||
|
mode: 'multi',
|
||||||
|
field: 'data.plan',
|
||||||
|
branches: [
|
||||||
|
{id: 'br-first', name: 'First', operator: 'contains', value: 'prem'},
|
||||||
|
{id: 'br-second', name: 'Second', operator: 'equals', value: 'premium'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||||
|
|
||||||
|
// Both match, but first one should win
|
||||||
|
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||||
|
expect(branch).toBe('br-first');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support exists/notExists operators in branches', async () => {
|
||||||
|
const {execution, triggerStep, conditionStep} = await createMultiBranchWorkflow(
|
||||||
|
{plan: 'premium'},
|
||||||
|
{
|
||||||
|
mode: 'multi',
|
||||||
|
field: 'data.plan',
|
||||||
|
branches: [
|
||||||
|
{id: 'br-has-plan', name: 'Has Plan', operator: 'exists'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, conditionStep.id);
|
||||||
|
|
||||||
|
const branch = await getConditionBranch(execution.id, conditionStep.id);
|
||||||
|
expect(branch).toBe('br-has-plan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to correct exit step via transitions', async () => {
|
||||||
|
const {execution, triggerStep, branchExits, defaultExitId} = await createMultiBranchWorkflow(
|
||||||
|
{plan: 'free'},
|
||||||
|
planBranches,
|
||||||
|
);
|
||||||
|
|
||||||
|
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||||
|
|
||||||
|
// Check the execution completed and ended at the correct exit step
|
||||||
|
const completedExecution = await prisma.workflowExecution.findUnique({
|
||||||
|
where: {id: execution.id},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workflow completed after reaching exit step
|
||||||
|
expect(['EXITED', 'COMPLETED']).toContain(completedExecution?.status);
|
||||||
|
|
||||||
|
// Verify it went through the "free" branch exit, not default
|
||||||
|
const exitStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||||
|
where: {
|
||||||
|
executionId: execution.id,
|
||||||
|
stepId: branchExits['br-free'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(exitStepExecution).not.toBeNull();
|
||||||
|
|
||||||
|
// Verify it did NOT go through the default exit
|
||||||
|
const defaultStepExecution = await prisma.workflowStepExecution.findFirst({
|
||||||
|
where: {
|
||||||
|
executionId: execution.id,
|
||||||
|
stepId: defaultExitId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(defaultStepExecution).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import {toPrismaJson} from '@plunk/types';
|
|||||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
|
vi.mock('node:dns/promises', () => ({
|
||||||
|
default: {
|
||||||
|
lookup: vi.fn(async () => ({address: '1.2.3.4', family: 4})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Integration Tests: Workflow Execution Engine
|
* Integration Tests: Workflow Execution Engine
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** @type {import('next-sitemap').IConfig} */
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
|
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
|
||||||
generateRobotsTxt: true,
|
generateRobotsTxt: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.11.0",
|
"@tiptap/starter-kit": "^3.11.0",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.1.5",
|
"next": "^16.2.3",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
@@ -1,7 +1,3 @@
|
|||||||
<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="1080" height="1080" viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="1080" height="1080" fill="white"/>
|
<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"/>
|
||||||
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -13,26 +13,21 @@ interface ComparisonTableProps {
|
|||||||
rows: ComparisonRow[];
|
rows: ComparisonRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reusable comparison table component for competitor pages
|
|
||||||
*/
|
|
||||||
export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
|
export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
|
||||||
return (
|
return (
|
||||||
<div className={'overflow-hidden rounded-xl border border-neutral-200'}>
|
<div className={'overflow-hidden rounded-[24px] border border-neutral-200'}>
|
||||||
{/* Header */}
|
|
||||||
<div className={'grid grid-cols-3 gap-px bg-neutral-200'}>
|
<div className={'grid grid-cols-3 gap-px bg-neutral-200'}>
|
||||||
<div className={'bg-white p-6'}>
|
<div className={'bg-neutral-50 p-6'}>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Feature</span>
|
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Feature</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={'bg-white p-6 text-center'}>
|
<div className={'bg-neutral-900 p-6 text-center'}>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Plunk</span>
|
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-white'}>Plunk</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={'bg-white p-6 text-center'}>
|
<div className={'bg-neutral-50 p-6 text-center'}>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>{competitorName}</span>
|
<span style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>{competitorName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
|
||||||
<div className={'grid gap-px bg-neutral-200'}>
|
<div className={'grid gap-px bg-neutral-200'}>
|
||||||
{rows.map((row, index) => (
|
{rows.map((row, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -40,22 +35,22 @@ export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
|
|||||||
initial={{opacity: 0, y: 10}}
|
initial={{opacity: 0, y: 10}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.4, delay: index * 0.04, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'grid grid-cols-3 gap-px bg-neutral-200'}
|
className={'grid grid-cols-3 gap-px bg-neutral-200'}
|
||||||
>
|
>
|
||||||
<div className={'bg-white p-6'}>
|
<div className={'bg-white p-6'}>
|
||||||
<span className={'text-sm text-neutral-600'}>{row.feature}</span>
|
<span className={'text-sm text-neutral-700'}>{row.feature}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={'bg-white p-6'}>
|
<div className={'bg-neutral-50/70 p-6'}>
|
||||||
<div className={'flex justify-center'}>
|
<div className={'flex justify-center'}>
|
||||||
{typeof row.plunk === 'boolean' ? (
|
{typeof row.plunk === 'boolean' ? (
|
||||||
row.plunk ? (
|
row.plunk ? (
|
||||||
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
|
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
|
||||||
) : (
|
) : (
|
||||||
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
|
<X className="h-5 w-5 text-neutral-300" strokeWidth={2} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className={'text-sm text-neutral-900'}>{row.plunk}</span>
|
<span className={'text-sm font-medium text-neutral-900'}>{row.plunk}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -65,10 +60,10 @@ export function ComparisonTable({competitorName, rows}: ComparisonTableProps) {
|
|||||||
row.competitor ? (
|
row.competitor ? (
|
||||||
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
|
<Check className="h-5 w-5 text-neutral-900" strokeWidth={2} />
|
||||||
) : (
|
) : (
|
||||||
<X className="h-5 w-5 text-neutral-400" strokeWidth={2} />
|
<X className="h-5 w-5 text-neutral-300" strokeWidth={2} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className={'text-sm text-neutral-900'}>{row.competitor}</span>
|
<span className={'text-sm text-neutral-600'}>{row.competitor}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ interface FAQSectionProps {
|
|||||||
schemaId?: string;
|
schemaId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reusable FAQ section component with structured data support
|
|
||||||
*/
|
|
||||||
export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
|
export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -37,34 +34,44 @@ export function FAQSection({faqs, schemaId = 'faq-schema'}: FAQSectionProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section className={'py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl'}
|
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'}
|
||||||
>
|
>
|
||||||
<h2 className={'mb-16 text-center text-5xl font-bold tracking-tight text-neutral-900'}>
|
|
||||||
Frequently asked questions
|
Frequently asked questions
|
||||||
</h2>
|
</h2>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className={'space-y-8'}>
|
<div className={'mx-auto max-w-3xl divide-y divide-neutral-200'}>
|
||||||
{faqs.map((faq, index) => (
|
{faqs.map((faq, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={index}
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 16}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, delay: index * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'border-b border-neutral-200 pb-8 last:border-b-0'}
|
className={'py-8'}
|
||||||
>
|
>
|
||||||
<h3 className={'text-xl font-semibold text-neutral-900'}>{faq.question}</h3>
|
<h3
|
||||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>{faq.answer}</p>
|
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>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ export default function Footer() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<footer className={'border-t border-neutral-200 bg-white'}>
|
<footer className={'border-t border-neutral-200 bg-white'}>
|
||||||
<div className="mx-auto max-w-7xl px-8 py-20 xl:px-0">
|
<div className="mx-auto max-w-[88rem] px-6 py-20 sm:px-10">
|
||||||
<div className="grid gap-12 lg:grid-cols-12">
|
<div className="grid gap-12 lg:grid-cols-12">
|
||||||
{/* Logo and description */}
|
{/* Logo and description */}
|
||||||
<div className="space-y-6 lg:col-span-4">
|
<div className="space-y-6 lg:col-span-3">
|
||||||
<div className={'relative h-8 w-8'}>
|
<div className={'relative h-8 w-8'}>
|
||||||
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
<Image src={logo} alt={'Plunk logo'} fill className={'object-contain'} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed text-neutral-600">
|
<p className="text-sm leading-relaxed text-neutral-600">
|
||||||
Open-source email automation platform that scales
|
Open-source email platform for transactional, marketing, and automation. EU-hosted, GDPR compliant.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Link
|
<Link
|
||||||
@@ -57,9 +57,9 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links */}
|
{/* Links */}
|
||||||
<div className="grid grid-cols-2 gap-8 lg:col-span-8 lg:grid-cols-3">
|
<div className="grid grid-cols-2 gap-8 lg:col-span-9 lg:grid-cols-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Product</h3>
|
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Product</h3>
|
||||||
<ul role="list" className="mt-6 space-y-4">
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
<li>
|
<li>
|
||||||
<Link href={'/pricing'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
<Link href={'/pricing'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
@@ -67,27 +67,102 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href={WIKI_URI} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
href={'/made-by-humans'}
|
Documentation
|
||||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
Made by humans
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href={'/guides'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
href={WIKI_URI}
|
Guides
|
||||||
target={'_blank'}
|
</Link>
|
||||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
</li>
|
||||||
>
|
<li>
|
||||||
Documentation
|
<Link href={'/tools'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Tools
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/made-by-humans'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Made by humans
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Community</h3>
|
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">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">
|
||||||
|
Email editor
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/features/workflows'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Workflows
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/features/segments'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Segments
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/features/smtp'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
SMTP
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/features/inbound-email'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Inbound email
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">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">
|
||||||
|
All comparisons
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/mailchimp'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs Mailchimp
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/sendgrid'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs SendGrid
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/resend'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs Resend
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/brevo'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs Brevo
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/mailgun'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs Mailgun
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/vs/convertkit'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
vs ConvertKit
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Community</h3>
|
||||||
<ul role="list" className="mt-6 space-y-4">
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
<li>
|
<li>
|
||||||
<Link href={'/discord'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
<Link href={'/discord'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
@@ -95,28 +170,18 @@ export default function Footer() {
|
|||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href={'https://github.com/useplunk'} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
href={'https://github.com/useplunk'}
|
|
||||||
target={'_blank'}
|
|
||||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
GitHub
|
GitHub
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<Link href={'https://status.useplunk.com'} target={'_blank'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
href={'https://status.useplunk.com'}
|
|
||||||
target={'_blank'}
|
|
||||||
className="text-sm text-neutral-600 transition hover:text-neutral-900"
|
|
||||||
>
|
|
||||||
Status
|
Status
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<h3 style={{fontFamily: 'var(--font-mono)'}} className="mt-8 text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">Legal</h3>
|
||||||
<h3 className="text-sm font-semibold text-neutral-900">Legal</h3>
|
|
||||||
<ul role="list" className="mt-6 space-y-4">
|
<ul role="list" className="mt-6 space-y-4">
|
||||||
<li>
|
<li>
|
||||||
<Link href={'/privacy'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
<Link href={'/privacy'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
@@ -135,6 +200,47 @@ export default function Footer() {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-500">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">
|
||||||
|
Email deliverability
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/transactional-vs-marketing-email'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Transactional vs marketing
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/what-is-dkim'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
What is DKIM?
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/what-is-spf'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
What is SPF?
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/what-is-dmarc'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
What is DMARC?
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/email-open-rate'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Email open rates
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/guides/email-bounce-rate'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
Email bounce rates
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,52 @@ import {AnimatePresence, motion} from 'framer-motion';
|
|||||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import logo from '../../../public/assets/logo.svg';
|
import logo from '../../../public/assets/logo.svg';
|
||||||
|
import {ChevronDown, GitBranch, Inbox, Mail, Server, Users} from 'lucide-react';
|
||||||
|
|
||||||
|
const featuresMenu = [
|
||||||
|
{
|
||||||
|
title: 'Email Editor',
|
||||||
|
description: 'Create beautiful emails with visual or code editing',
|
||||||
|
href: '/features/email-editor',
|
||||||
|
icon: <Mail className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Workflows',
|
||||||
|
description: 'Automate email sequences with triggers and conditions',
|
||||||
|
href: '/features/workflows',
|
||||||
|
icon: <GitBranch className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Inbound Email',
|
||||||
|
description: 'Receive and process incoming emails',
|
||||||
|
href: '/features/inbound-email',
|
||||||
|
icon: <Inbox className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Segments',
|
||||||
|
description: 'Organize contacts with dynamic filtering',
|
||||||
|
href: '/features/segments',
|
||||||
|
icon: <Users className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SMTP',
|
||||||
|
description: 'Send emails via SMTP or API',
|
||||||
|
href: '/features/smtp',
|
||||||
|
icon: <Server className="h-5 w-5" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [featuresOpen, setFeaturesOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className={'relative top-0 z-40 mx-auto max-w-7xl px-8 xl:px-0'}>
|
<header className={'sticky top-0 z-40 w-full border-b border-neutral-200 bg-white/95 backdrop-blur-sm'}>
|
||||||
<div className={'z-40 py-6'}>
|
<div className={'relative mx-auto max-w-[88rem] px-6 sm:px-10'}>
|
||||||
|
<div className={'py-5'}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-12">
|
<div className="flex items-center gap-12">
|
||||||
<div className="flex flex-shrink-0 items-center">
|
<div className="flex flex-shrink-0 items-center">
|
||||||
@@ -25,6 +61,55 @@ export default function Navbar() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden items-center gap-8 md:flex">
|
<div className="hidden items-center gap-8 md:flex">
|
||||||
|
<div className={'relative'}>
|
||||||
|
<button
|
||||||
|
onMouseEnter={() => setFeaturesOpen(true)}
|
||||||
|
onMouseLeave={() => setFeaturesOpen(false)}
|
||||||
|
className={
|
||||||
|
'flex items-center gap-1.5 text-sm font-medium text-neutral-600 transition hover:text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Features
|
||||||
|
<ChevronDown className={`h-4 w-4 transition-transform ${featuresOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{featuresOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: -10}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
exit={{opacity: 0, y: -10}}
|
||||||
|
transition={{duration: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mt-0.5 flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<div className={'flex-1'}>
|
||||||
|
<div className={'text-sm font-semibold text-neutral-900'}>{feature.title}</div>
|
||||||
|
<div className={'mt-0.5 text-xs text-neutral-600'}>{feature.description}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={'/made-by-humans'}
|
href={'/made-by-humans'}
|
||||||
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
className={'text-sm font-medium text-neutral-600 transition hover:text-neutral-900'}
|
||||||
@@ -81,7 +166,7 @@ export default function Navbar() {
|
|||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800'
|
'rounded-full bg-neutral-900 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-neutral-800'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Get started
|
Get started
|
||||||
@@ -94,7 +179,7 @@ export default function Navbar() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-neutral-900"
|
className="inline-flex items-center justify-center rounded-lg p-2 text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-neutral-900"
|
||||||
aria-controls="mobile-menu"
|
aria-controls="mobile-menu"
|
||||||
aria-expanded="false"
|
aria-expanded={mobileOpen}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Open main menu</span>
|
<span className="sr-only">Open main menu</span>
|
||||||
|
|
||||||
@@ -131,7 +216,7 @@ export default function Navbar() {
|
|||||||
animate={{height: 'auto', opacity: 1}}
|
animate={{height: 'auto', opacity: 1}}
|
||||||
exit={{height: 0, opacity: 0}}
|
exit={{height: 0, opacity: 0}}
|
||||||
transition={{duration: 0.2}}
|
transition={{duration: 0.2}}
|
||||||
className="absolute left-0 top-full z-50 mt-2 w-full rounded-lg border border-neutral-200 bg-white shadow-lg backdrop-blur-sm sm:hidden"
|
className="absolute left-0 top-full z-50 w-full overflow-hidden border-t border-neutral-100 bg-white shadow-lg md:hidden"
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0}}
|
initial={{opacity: 0}}
|
||||||
@@ -140,6 +225,32 @@ export default function Navbar() {
|
|||||||
transition={{duration: 0.2}}
|
transition={{duration: 0.2}}
|
||||||
className="space-y-1 p-4"
|
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">
|
||||||
|
Features
|
||||||
|
</div>
|
||||||
|
{featuresMenu.map(feature => (
|
||||||
|
<Link
|
||||||
|
key={feature.href}
|
||||||
|
href={feature.href}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="flex items-start gap-3 rounded-lg px-4 py-3 transition hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-neutral-900 text-white'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{feature.icon}
|
||||||
|
</div>
|
||||||
|
<div className={'flex-1'}>
|
||||||
|
<div className={'text-sm font-semibold text-neutral-900'}>{feature.title}</div>
|
||||||
|
<div className={'mt-0.5 text-xs text-neutral-600'}>{feature.description}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={'/made-by-humans'}
|
href={'/made-by-humans'}
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
@@ -182,7 +293,7 @@ export default function Navbar() {
|
|||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Get started
|
Get started
|
||||||
</a>
|
</a>
|
||||||
@@ -191,6 +302,7 @@ export default function Navbar() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</nav>
|
</div>
|
||||||
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ export function GuideLayout({
|
|||||||
readTime,
|
readTime,
|
||||||
children,
|
children,
|
||||||
canonical,
|
canonical,
|
||||||
ogImage = 'https://next.useplunk.com/assets/card.png',
|
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||||
}: GuideLayoutProps) {
|
}: GuideLayoutProps) {
|
||||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string>('');
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
@@ -74,8 +74,8 @@ export function GuideLayout({
|
|||||||
|
|
||||||
// Generate breadcrumb items
|
// Generate breadcrumb items
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
|
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
|
||||||
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
|
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
|
||||||
{position: 3, name: title, item: canonical || ''},
|
{position: 3, name: title, item: canonical || ''},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -114,7 +114,7 @@ export function GuideLayout({
|
|||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-4 sm:px-8 w-full overflow-x-hidden'}>
|
<main className={'mx-auto max-w-[88rem] 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'}>
|
<div className={'flex flex-col lg:flex-row gap-8 lg:gap-12 py-8 sm:py-16 w-full'}>
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<article className={'flex-1 max-w-full lg:max-w-4xl w-full'}>
|
<article className={'flex-1 max-w-full lg:max-w-4xl w-full'}>
|
||||||
@@ -148,7 +148,10 @@ export function GuideLayout({
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-8 sm:mb-12 w-full'}
|
className={'mb-8 sm:mb-12 w-full'}
|
||||||
>
|
>
|
||||||
<h1 className={'text-2xl sm:text-4xl font-bold tracking-tight text-neutral-900 break-words max-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'}
|
||||||
|
>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p
|
||||||
@@ -199,16 +202,16 @@ export function GuideLayout({
|
|||||||
<div className={'rounded-xl border border-neutral-200 bg-white p-6 shadow-sm'}>
|
<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>
|
<h2 className={'text-sm font-semibold text-neutral-900 mb-4 uppercase tracking-wide'}>On this page</h2>
|
||||||
<nav>
|
<nav>
|
||||||
<ul className={'space-y-1'}>
|
<ul className={'space-y-0.5'}>
|
||||||
{headings.map(heading => (
|
{headings.map(heading => (
|
||||||
<li key={heading.id} className={heading.level === 3 ? 'ml-4 mt-0.5' : 'mt-2 first:mt-0'}>
|
<li key={heading.id} className={heading.level === 3 ? 'ml-3' : ''}>
|
||||||
<a
|
<a
|
||||||
href={`#${heading.id}`}
|
href={`#${heading.id}`}
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const element = document.getElementById(heading.id);
|
const element = document.getElementById(heading.id);
|
||||||
if (element) {
|
if (element) {
|
||||||
const offset = 100; // Account for fixed header
|
const offset = 100;
|
||||||
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
const elementPosition = element.getBoundingClientRect().top + window.scrollY;
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: elementPosition - offset,
|
top: elementPosition - offset,
|
||||||
@@ -216,14 +219,14 @@ export function GuideLayout({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`block py-1 border-l-2 -ml-px pl-3 transition-all duration-200 ${
|
className={`block rounded px-2 py-1.5 transition-all duration-200 ${
|
||||||
heading.level === 2
|
heading.level === 2
|
||||||
? activeId === heading.id
|
? activeId === heading.id
|
||||||
? 'border-neutral-900 text-neutral-900 font-semibold text-sm'
|
? 'bg-neutral-100 text-sm font-semibold text-neutral-900'
|
||||||
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 font-medium text-sm'
|
: 'text-sm font-medium text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
|
||||||
: activeId === heading.id
|
: activeId === heading.id
|
||||||
? 'border-neutral-700 text-neutral-800 font-medium text-xs'
|
? 'bg-neutral-50 text-xs font-medium text-neutral-800'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 hover:border-neutral-200 text-xs'
|
: 'text-xs text-neutral-500 hover:bg-neutral-50 hover:text-neutral-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{heading.text}
|
{heading.text}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export * from './Footer';
|
|||||||
export * from './ComparisonTable';
|
export * from './ComparisonTable';
|
||||||
export * from './FAQSection';
|
export * from './FAQSection';
|
||||||
export * from './CodeBlock';
|
export * from './CodeBlock';
|
||||||
|
export * from './SectionHeader';
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
export const MARKDOWN_PAGES: Record<string, string> = {
|
||||||
|
index: `# Plunk — The Open-Source Email Platform
|
||||||
|
|
||||||
|
Transactional emails, marketing campaigns, and workflow automation — in one platform.
|
||||||
|
Self-hostable, $0.001 per email, no contact limits.
|
||||||
|
|
||||||
|
[Get started free](https://next-app.useplunk.com/auth/signup) | [Read the docs](https://docs.useplunk.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key facts
|
||||||
|
|
||||||
|
| Metric | Value |
|
||||||
|
|--------|-------|
|
||||||
|
| Price | $0.001 / email |
|
||||||
|
| GitHub stars | 5,000+ |
|
||||||
|
| Contacts | Unlimited, always free |
|
||||||
|
| Setup time | < 5 minutes |
|
||||||
|
| License | AGPL-3.0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Replace your email stack
|
||||||
|
|
||||||
|
One tool in place of Resend, SendGrid, Mailchimp, Customer.io, and Mailgun.
|
||||||
|
|
||||||
|
- [Plunk vs Resend](/vs/resend)
|
||||||
|
- [Plunk vs SendGrid](/vs/sendgrid)
|
||||||
|
- [Plunk vs Mailchimp](/vs/mailchimp)
|
||||||
|
- [Plunk vs Customer.io](/vs/customerio)
|
||||||
|
- [Plunk vs Mailgun](/vs/mailgun)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Plunk
|
||||||
|
|
||||||
|
### Setup — < 5 min
|
||||||
|
Most email platforms take days to configure. Plunk is running in under five minutes — domain, DKIM, and first send included.
|
||||||
|
|
||||||
|
### Pricing — 0 limits
|
||||||
|
Other platforms charge more as your list grows. Plunk stores unlimited contacts for free and bills only on send.
|
||||||
|
|
||||||
|
### Ownership — AGPL-3.0
|
||||||
|
Closed-source platforms own your stack. Plunk is fully inspectable, forkable, and self-hostable on your own infra.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Workflow Automation
|
||||||
|
Visual builder for complex email sequences with triggers, delays, and conditional logic. No code required.
|
||||||
|
|
||||||
|
### Dynamic Segments
|
||||||
|
Real-time audience segmentation based on contact data and behavior.
|
||||||
|
|
||||||
|
### Campaign Management
|
||||||
|
Broadcast emails with scheduling and performance tracking.
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
Detailed metrics on opens, clicks, bounces, and conversions across campaigns.
|
||||||
|
|
||||||
|
### Inbound Email
|
||||||
|
Receive and process incoming emails with webhook notifications.
|
||||||
|
|
||||||
|
### Custom Domains
|
||||||
|
Brand consistency with DKIM authentication and custom sending domains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Every interaction flows into a single contact record — transactional, campaign, and workflow events unified in one source of truth.
|
||||||
|
|
||||||
|
- **Transactional** — Receipts, password resets, event-driven sends
|
||||||
|
- **Campaigns** — Newsletters, product launches, announcements
|
||||||
|
- **Workflows** — Onboarding sequences, drip campaigns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open source
|
||||||
|
|
||||||
|
- **License**: AGPL-3.0
|
||||||
|
- **Hosting**: EU-hosted, GDPR compliant
|
||||||
|
- **Deployment**: Self-hostable via Docker Compose
|
||||||
|
- **Community**: 5,000+ GitHub stars
|
||||||
|
|
||||||
|
[View on GitHub](https://github.com/useplunk/plunk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pricing comparison
|
||||||
|
|
||||||
|
**Plunk: $0.001 / email**
|
||||||
|
|
||||||
|
| Provider | Price / email |
|
||||||
|
|----------|--------------|
|
||||||
|
| Plunk | $0.001 |
|
||||||
|
| Mailgun | $0.003 |
|
||||||
|
| SendGrid | $0.002 |
|
||||||
|
| Mailchimp | $0.004 |
|
||||||
|
|
||||||
|
*Based on plans matching Plunk at 10,000 emails per month.*
|
||||||
|
|
||||||
|
[Full pricing details](/pricing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testimonials
|
||||||
|
|
||||||
|
> "Transparent and intuitive UI, extremely easy setup & automation and great support."
|
||||||
|
> — Artur Czemiel, Founder at GraphQL Editor
|
||||||
|
|
||||||
|
> "I've been using Plunk for building & sending out marketing emails and genuinely love it!"
|
||||||
|
> — Joe Ashwell, Founder at UnwindHR
|
||||||
|
|
||||||
|
> "I loved the ease of use, beautiful UI and great UX. Everything simply works."
|
||||||
|
> — Alisson Leal, Founder at Brapi
|
||||||
|
|
||||||
|
> "Clean design, easy to understand, fair pricing."
|
||||||
|
> — Pierre Jacquel, Founder at Landingly
|
||||||
|
|
||||||
|
> "Simple to use, efficient and no regrets!"
|
||||||
|
> — Noah Di Gesu, Founder at Smoothey
|
||||||
|
|
||||||
|
> "Lots of care put into Plunk"
|
||||||
|
> — Jonni Lundy, Founding Operations Manager at Resend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
**How is Plunk different from other email automation tools?**
|
||||||
|
Plunk is built for SaaS businesses, indie hackers, and developers. Create complex automated email flows and trigger them from anywhere through a single API call.
|
||||||
|
|
||||||
|
**Can I use Plunk for transactional emails?**
|
||||||
|
Yes. Plunk handles transactional emails — sign-ups, cancellations, plan changes — alongside marketing emails in the same platform.
|
||||||
|
|
||||||
|
**Can I use Plunk for newsletters?**
|
||||||
|
Yes. Plunk lets you send newsletters and broadcast emails to your full list or any segment.
|
||||||
|
|
||||||
|
**What programming languages does Plunk support?**
|
||||||
|
Any language capable of HTTP requests. Plunk provides a REST API plus SDKs for Node.js, Python, and more.
|
||||||
|
|
||||||
|
**How much does Plunk cost?**
|
||||||
|
Free plan includes 1,000 emails/month. Paid plan is $0.001 per email with no contact limits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Start sending in 5 minutes. Free plan available. No contact limits, no surprises.
|
||||||
|
|
||||||
|
[Create free account](https://next-app.useplunk.com/auth/signup) | [Read the docs](https://docs.useplunk.com)
|
||||||
|
`,
|
||||||
|
|
||||||
|
pricing: `# Plunk Pricing — Simple, Transparent
|
||||||
|
|
||||||
|
Free plan: 1,000 emails/month. Paid plan: $0.001/email. Unlimited contacts, no hidden fees.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plans
|
||||||
|
|
||||||
|
### Free forever
|
||||||
|
|
||||||
|
**1,000 emails / month** — No credit card required
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
- Transactional emails
|
||||||
|
- Workflow automation
|
||||||
|
- Campaign broadcasts
|
||||||
|
- Custom domains
|
||||||
|
- Click & open tracking
|
||||||
|
- Unlimited contacts
|
||||||
|
- Plunk branding on emails (removed on paid)
|
||||||
|
|
||||||
|
[Start for free](https://next-app.useplunk.com/auth/signup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pay as you grow
|
||||||
|
|
||||||
|
**$0.001 / email** — No base fee
|
||||||
|
|
||||||
|
Includes everything in Free, plus:
|
||||||
|
- No Plunk branding
|
||||||
|
- Monthly spend cap
|
||||||
|
- Unlimited emails
|
||||||
|
|
||||||
|
[Get started](https://next-app.useplunk.com/auth/signup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Every feature, every plan
|
||||||
|
|
||||||
|
No feature tiers, no add-ons, no surprises.
|
||||||
|
|
||||||
|
| Feature | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Transactional emails | API and SMTP delivery for receipts, password resets, and event-driven email. |
|
||||||
|
| Workflow automation | Event-triggered sequences with delays, conditions, and branching logic. |
|
||||||
|
| Campaign broadcasts | Send newsletters and announcements to your full list or a targeted segment. |
|
||||||
|
| Unlimited contacts | Store as many contacts as you need. Growing your list never costs more. |
|
||||||
|
| Full API access | REST API with SDKs for Node.js, Python, and more. |
|
||||||
|
| Custom domains | Send from your own domain with DKIM, SPF, and DMARC set up automatically. |
|
||||||
|
| Audience segmentation | Dynamic segments built on behavior, attributes, and engagement data. |
|
||||||
|
| Analytics & tracking | Opens, clicks, bounces, and unsubscribes. Real data, no guessing. |
|
||||||
|
| Open source | AGPL-3.0 licensed. Inspect the code, self-host it, or contribute. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-host for free
|
||||||
|
|
||||||
|
Run Plunk on your own infrastructure — full data ownership, no per-email costs, GDPR compliance by default.
|
||||||
|
Deploy with Docker Compose in minutes.
|
||||||
|
|
||||||
|
[View on GitHub](https://github.com/useplunk/plunk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Start sending in 5 minutes. Free plan, no credit card required.
|
||||||
|
|
||||||
|
[Create free account](https://next-app.useplunk.com/auth/signup) | [Self-host for free](https://github.com/useplunk/plunk)
|
||||||
|
`,
|
||||||
|
|
||||||
|
'features/workflows': `# Workflow Automation — Plunk
|
||||||
|
|
||||||
|
Visual builder for complex email sequences with triggers, delays, and conditional logic. No code required.
|
||||||
|
|
||||||
|
[Get started free](https://next-app.useplunk.com/auth/signup) | [Documentation](https://docs.useplunk.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What are workflows?
|
||||||
|
|
||||||
|
Workflows are automated email sequences that trigger based on events in your application. Define who receives emails, when they receive them, and what conditions must be met — all without writing code.
|
||||||
|
|
||||||
|
## Key capabilities
|
||||||
|
|
||||||
|
- **Event-triggered**: Start workflows from any API event
|
||||||
|
- **Delays**: Wait minutes, hours, or days between steps
|
||||||
|
- **Conditional branching**: Branch paths based on contact data or behavior
|
||||||
|
- **Visual builder**: Drag-and-drop interface, no code required
|
||||||
|
- **Unlimited steps**: As complex as your use case demands
|
||||||
|
|
||||||
|
## Use cases
|
||||||
|
|
||||||
|
- **Onboarding sequences**: Welcome new users and guide them to activation
|
||||||
|
- **Drip campaigns**: Nurture leads over time with educational content
|
||||||
|
- **Re-engagement**: Win back inactive contacts automatically
|
||||||
|
- **Lifecycle emails**: Upgrades, renewals, cancellations
|
||||||
|
|
||||||
|
[Back to features](/features) | [Pricing](/pricing)
|
||||||
|
`,
|
||||||
|
|
||||||
|
'features/segments': `# Dynamic Segments — Plunk
|
||||||
|
|
||||||
|
Real-time audience segmentation based on contact data and behavior.
|
||||||
|
|
||||||
|
[Get started free](https://next-app.useplunk.com/auth/signup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What are segments?
|
||||||
|
|
||||||
|
Segments are dynamic groups of contacts that update automatically as contact data changes. Target campaigns and workflows to exactly the right audience without manual list management.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
- **Attribute-based**: Filter by any contact property
|
||||||
|
- **Behavior-based**: Segment by email opens, clicks, and engagement
|
||||||
|
- **Real-time**: Segments update instantly as contact data changes
|
||||||
|
- **Combinable**: AND/OR logic for complex targeting
|
||||||
|
- **Unlimited contacts**: No per-segment contact limits
|
||||||
|
|
||||||
|
[Back to features](/features) | [Pricing](/pricing)
|
||||||
|
`,
|
||||||
|
|
||||||
|
'features/inbound-email': `# Inbound Email — Plunk
|
||||||
|
|
||||||
|
Receive and process incoming emails with webhook notifications.
|
||||||
|
|
||||||
|
[Get started free](https://next-app.useplunk.com/auth/signup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is inbound email?
|
||||||
|
|
||||||
|
Inbound email lets your application receive emails sent to your Plunk domain. When a message arrives, Plunk parses it and delivers the content to your webhook endpoint in real time.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
- **Webhook delivery**: Parsed email payload posted to your endpoint
|
||||||
|
- **Attachment handling**: Access attachments programmatically
|
||||||
|
- **Custom addresses**: Route different addresses to different webhooks
|
||||||
|
- **Reply detection**: Track email threads automatically
|
||||||
|
|
||||||
|
[Back to features](/features) | [Pricing](/pricing)
|
||||||
|
`,
|
||||||
|
|
||||||
|
'features/email-editor': `# Email Editor — Plunk
|
||||||
|
|
||||||
|
Design beautiful emails with a drag-and-drop builder or write in Markdown.
|
||||||
|
|
||||||
|
[Get started free](https://next-app.useplunk.com/auth/signup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is the email editor?
|
||||||
|
|
||||||
|
Plunk's email editor lets you build professional emails without writing HTML. Choose from the visual drag-and-drop builder or write in Markdown — both export to responsive HTML email.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
- **Drag-and-drop builder**: Compose layouts with blocks and columns
|
||||||
|
- **Markdown mode**: Write in plain text, render as rich email
|
||||||
|
- **Responsive**: Emails adapt to any screen size automatically
|
||||||
|
- **Preview**: See how your email looks on desktop and mobile
|
||||||
|
- **Reusable templates**: Save and reuse your designs
|
||||||
|
|
||||||
|
[Back to features](/features) | [Pricing](/pricing)
|
||||||
|
`,
|
||||||
|
|
||||||
|
'features/smtp': `# SMTP — Plunk
|
||||||
|
|
||||||
|
Send emails through Plunk using standard SMTP — no API changes required.
|
||||||
|
|
||||||
|
[Get started free](https://next-app.useplunk.com/auth/signup)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SMTP access
|
||||||
|
|
||||||
|
Plunk provides SMTP credentials so you can send emails from any application or framework that supports standard SMTP. Drop in your Plunk credentials and start sending immediately — no code changes needed beyond configuration.
|
||||||
|
|
||||||
|
## Details
|
||||||
|
|
||||||
|
- **Host**: SMTP endpoint provided in your dashboard
|
||||||
|
- **Port**: 587 (TLS) or 465 (SSL)
|
||||||
|
- **Authentication**: Username and password from your Plunk project
|
||||||
|
- **Compatible with**: Any language, framework, or tool that supports SMTP
|
||||||
|
|
||||||
|
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
|
||||||
|
`,
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
type Negotiated = 'markdown' | 'html' | 'none';
|
||||||
|
|
||||||
|
function parseAccept(accept: string): Array<{ type: string; q: number }> {
|
||||||
|
return accept.split(',').map(part => {
|
||||||
|
const segments = part.trim().split(';');
|
||||||
|
const type = segments[0]?.trim().toLowerCase() ?? '';
|
||||||
|
const qParam = segments.slice(1).find(s => s.trim().startsWith('q='));
|
||||||
|
const q = qParam ? parseFloat(qParam.split('=')[1] ?? '1') : 1;
|
||||||
|
return { type, q: isNaN(q) ? 1 : q };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQ(types: Array<{ type: string; q: number }>, target: string): number {
|
||||||
|
const exact = types.find(t => t.type === target);
|
||||||
|
if (exact) return exact.q;
|
||||||
|
const [main] = target.split('/');
|
||||||
|
const sub = types.find(t => t.type === `${main}/*`);
|
||||||
|
if (sub) return sub.q;
|
||||||
|
const wildcard = types.find(t => t.type === '*/*');
|
||||||
|
return wildcard ? wildcard.q : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function negotiate(accept: string): Negotiated {
|
||||||
|
if (!accept) return 'html';
|
||||||
|
const types = parseAccept(accept);
|
||||||
|
const mdQ = getQ(types, 'text/markdown');
|
||||||
|
const htmlQ = getQ(types, 'text/html');
|
||||||
|
if (mdQ <= 0 && htmlQ <= 0) return 'none';
|
||||||
|
if (mdQ > 0 && mdQ >= htmlQ) return 'markdown';
|
||||||
|
return 'html';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const accept = request.headers.get('accept') ?? '';
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
const result = negotiate(accept);
|
||||||
|
|
||||||
|
if (result === 'none') {
|
||||||
|
return new NextResponse(null, { status: 406, headers: { 'Vary': 'Accept' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result === 'markdown') {
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.set('x-md-path', pathname);
|
||||||
|
const response = NextResponse.rewrite(new URL('/api/md', request.url), { request: { headers } });
|
||||||
|
response.headers.set('Vary', 'Accept');
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = NextResponse.next();
|
||||||
|
response.headers.set('Vary', 'Accept');
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js)).*)',],
|
||||||
|
};
|
||||||
@@ -6,6 +6,29 @@ import {toast, Toaster} from 'sonner';
|
|||||||
import {SWRConfig} from 'swr';
|
import {SWRConfig} from 'swr';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
import {DefaultSeo} from 'next-seo';
|
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
|
* Main app component
|
||||||
@@ -15,10 +38,6 @@ import {DefaultSeo} from 'next-seo';
|
|||||||
*/
|
*/
|
||||||
function App({Component, pageProps}: AppProps) {
|
function App({Component, pageProps}: AppProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams(window.location.search);
|
const searchParams = new URLSearchParams(window.location.search);
|
||||||
const message = searchParams.get('message');
|
const message = searchParams.get('message');
|
||||||
|
|
||||||
@@ -28,7 +47,7 @@ function App({Component, pageProps}: AppProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Plunk | The Open-Source Email Platform</title>
|
<title>Plunk | The Open-Source Email Platform</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
|
||||||
@@ -36,7 +55,7 @@ function App({Component, pageProps}: AppProps) {
|
|||||||
<Toaster position={'top-right'} />
|
<Toaster position={'top-right'} />
|
||||||
|
|
||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,11 +82,18 @@ export default function WithProviders(props: AppProps) {
|
|||||||
title: 'Plunk | The Open-Source Email Platform',
|
title: 'Plunk | The Open-Source Email Platform',
|
||||||
description:
|
description:
|
||||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||||
}}
|
}}
|
||||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Script
|
||||||
|
defer
|
||||||
|
src="https://analytics.driaug.com/script.js"
|
||||||
|
data-website-id="6ed9fa6c-3a75-4926-ad4d-f607557f79f1"
|
||||||
|
data-domains="www.useplunk.com"
|
||||||
|
/>
|
||||||
|
|
||||||
<App {...props} />
|
<App {...props} />
|
||||||
</SWRConfig>
|
</SWRConfig>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,14 +6,6 @@ export default class MyDocument extends Document {
|
|||||||
return (
|
return (
|
||||||
<Html lang="en">
|
<Html lang="en">
|
||||||
<Head>
|
<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 */}
|
{/* Start favicon */}
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
<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" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||||
|
|
||||||
|
import { MARKDOWN_PAGES } from '../../content/pages';
|
||||||
|
|
||||||
|
export default function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
|
const pathname = req.headers['x-md-path'];
|
||||||
|
if (typeof pathname !== 'string') {
|
||||||
|
res.status(406).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
|
||||||
|
|
||||||
|
res.setHeader('Vary', 'Accept');
|
||||||
|
|
||||||
|
const content = MARKDOWN_PAGES[slug];
|
||||||
|
if (!content) {
|
||||||
|
res.status(406).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
|
||||||
|
res.status(200).send(content);
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Code2, Eye, Mail, Palette, Sparkles, Type, Zap} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Type className="h-6 w-6" strokeWidth={1.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} />,
|
||||||
|
title: 'Full HTML Editor',
|
||||||
|
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} />,
|
||||||
|
title: 'Smart Mode Switching',
|
||||||
|
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} />,
|
||||||
|
title: 'Powerful Variables',
|
||||||
|
description: 'Autocomplete with {{variable}} syntax. Supports fallbacks, nested properties, and custom contact fields.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Eye className="h-6 w-6" strokeWidth={1.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} />,
|
||||||
|
title: 'Email-Safe HTML',
|
||||||
|
description: 'Automatic CSS inlining and email-client-friendly code generation. Yes, even in Outlook.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Palette className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
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} />,
|
||||||
|
title: 'For Teams',
|
||||||
|
description:
|
||||||
|
'One tool for everyone. Developers can code, marketers can design, everyone can preview. Reusable templates across campaigns and workflows.',
|
||||||
|
example: 'Launch announcements → Feature updates → User engagement → Lifecycle emails',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function EmailEditorFeature() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Email Editor - Create Beautiful Emails Without Fighting Your Tools | Plunk</title>
|
||||||
|
<meta
|
||||||
|
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: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."
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<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%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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)'}}
|
||||||
|
className={
|
||||||
|
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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'}
|
||||||
|
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)'}}
|
||||||
|
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'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>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Bell, Database, Inbox, Mail, Shield, Zap} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Database className="h-6 w-6" strokeWidth={1.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} />,
|
||||||
|
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} />,
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Bell className="h-6 w-6" strokeWidth={1.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} />,
|
||||||
|
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} />,
|
||||||
|
title: 'Real-Time Processing',
|
||||||
|
description: 'Emails are processed instantly and can trigger workflows or webhooks in real-time.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
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} />,
|
||||||
|
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} />,
|
||||||
|
title: 'Two-Way Conversations',
|
||||||
|
description:
|
||||||
|
'Let customers reply to your campaign emails and automatically trigger engagement workflows. Tag contacts as "engaged" when they respond.',
|
||||||
|
benefits: ['Build conversation history', 'Track engagement', 'Personalized responses'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InboundEmailFeature() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Inbound Email - Receive & Process Incoming Emails | Plunk</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Receive emails at your custom domain and automatically process them. Capture leads, create support tickets, and trigger workflows from incoming emails."
|
||||||
|
/>
|
||||||
|
<meta property="og:title" content="Inbound Email - Turn Incoming Emails into Automated Actions | Plunk" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Receive emails at your custom domain and automatically process them. Capture leads, create support tickets, and trigger workflows from incoming emails."
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<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%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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)'}}
|
||||||
|
className={
|
||||||
|
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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'}
|
||||||
|
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)'}}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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'}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Filter, GitBranch, Mail, Target, TrendingUp, Users} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Filter className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
title: 'Dynamic Filtering',
|
||||||
|
description: 'Create segments based on contact data, custom fields, email activity, and events with powerful AND/OR logic.',
|
||||||
|
featured: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <TrendingUp className="h-6 w-6" strokeWidth={1.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} />,
|
||||||
|
title: 'Workflow Integration',
|
||||||
|
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} />,
|
||||||
|
title: 'Campaign Targeting',
|
||||||
|
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} />,
|
||||||
|
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} />,
|
||||||
|
title: 'Behavior-Based',
|
||||||
|
description: 'Segment by email engagement — who opened, clicked, bounced, or never received your emails.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filterExamples = [
|
||||||
|
{
|
||||||
|
title: 'Active Users',
|
||||||
|
description: 'Target users who signed up recently and are actively engaging',
|
||||||
|
filters: ['Created within 30 days', 'Opened email within 7 days', 'Custom field: plan equals "pro"'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Re-engagement Needed',
|
||||||
|
description: 'Find inactive users who need a nudge to come back',
|
||||||
|
filters: ['Last activity older than 60 days', 'Email sent but not opened', 'Subscribed equals true'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'High-Value Customers',
|
||||||
|
description: 'Identify your most valuable customers for special treatment',
|
||||||
|
filters: ['Custom field: totalSpent greater than 1000', 'Triggered event: purchase', 'Plan equals "enterprise"'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
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.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <GitBranch className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
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} />,
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Audience Segmentation - Target the Right Contacts | Plunk</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
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."
|
||||||
|
/>
|
||||||
|
<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."
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<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%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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)'}}
|
||||||
|
className={
|
||||||
|
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Start segmenting
|
||||||
|
<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)'}}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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'}>
|
||||||
|
Build precise audience segments and watch your open rates climb. 1,000 emails 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 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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Code2, Lock, Mail, Server, Settings, Shield} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Settings className="h-6 w-6" strokeWidth={1.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} />,
|
||||||
|
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} />,
|
||||||
|
title: 'Universal Compatibility',
|
||||||
|
description: 'Works with Outlook, Thunderbird, Apple Mail, or any SMTP-compatible application.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Server className="h-6 w-6" strokeWidth={1.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} />,
|
||||||
|
title: 'Full Feature Support',
|
||||||
|
description: 'Attachments, custom headers, HTML emails, and multiple recipients.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Code2 className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
title: 'Same Infrastructure',
|
||||||
|
description: 'SMTP emails use the same reliable delivery infrastructure as API emails with full tracking.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const comparisonData = [
|
||||||
|
{feature: 'Protocol', traditional: 'SMTP', plunkSMTP: 'SMTP', plunkAPI: 'HTTP/REST'},
|
||||||
|
{feature: 'Setup Complexity', traditional: 'Medium', plunkSMTP: 'Easy', plunkAPI: 'Easy'},
|
||||||
|
{feature: 'Tracking & Analytics', traditional: '✗', plunkSMTP: '✓', plunkAPI: '✓'},
|
||||||
|
{feature: 'Works with Email Clients', traditional: '✓', plunkSMTP: '✓', plunkAPI: '✗'},
|
||||||
|
{feature: 'Domain Verification', traditional: 'Manual', plunkSMTP: 'Automatic', plunkAPI: 'Automatic'},
|
||||||
|
{feature: 'Attachments', traditional: '✓', plunkSMTP: '✓', plunkAPI: '✓'},
|
||||||
|
{feature: 'Template Support', traditional: '✗', plunkSMTP: '✗', plunkAPI: '✓'},
|
||||||
|
{feature: 'Workflow Automation', traditional: '✗', plunkSMTP: '✗', plunkAPI: '✓'},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <Settings className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
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.',
|
||||||
|
benefit: 'Zero code changes required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Mail className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
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} />,
|
||||||
|
title: 'Framework Compatibility',
|
||||||
|
description:
|
||||||
|
'Works with any framework or language that supports SMTP. Perfect for older systems or platforms without HTTP API support.',
|
||||||
|
benefit: 'Universal protocol support',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SMTPFeature() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>SMTP Email Sending - Send via SMTP or API | Plunk</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Send emails via SMTP or API. Works with any email client or application. Secure TLS/SSL connections with automatic domain validation and full tracking."
|
||||||
|
/>
|
||||||
|
<meta property="og:title" content="SMTP Email Sending - Flexible Sending Options | Plunk" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Send emails via SMTP or API. Works with any email client or application. Secure TLS/SSL connections with automatic domain validation and full tracking."
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<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%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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)'}}
|
||||||
|
className={
|
||||||
|
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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'}
|
||||||
|
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)'}}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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'}>
|
||||||
|
<table className={'w-full'}>
|
||||||
|
<thead className={'border-b border-neutral-200 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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className={'divide-y divide-neutral-100'}>
|
||||||
|
{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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
import {Footer, Navbar} from '../../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
|
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import {ArrowRight, Clock, GitBranch, Mail, RefreshCw, UserPlus, Webhook, Zap} from 'lucide-react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: <Zap className="h-6 w-6" strokeWidth={1.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} />,
|
||||||
|
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} />,
|
||||||
|
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} />,
|
||||||
|
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} />,
|
||||||
|
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} />,
|
||||||
|
title: 'Re-entry Control',
|
||||||
|
description: 'Decide whether contacts can enter workflows multiple times or just once. No spam, just strategy.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const useCases = [
|
||||||
|
{
|
||||||
|
icon: <UserPlus className="h-6 w-6" strokeWidth={1.5} />,
|
||||||
|
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} />,
|
||||||
|
title: 'Abandoned Cart Recovery',
|
||||||
|
description:
|
||||||
|
'Automatically remind customers about items left in their cart with timely follow-ups and special incentives.',
|
||||||
|
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} />,
|
||||||
|
title: 'Re-engagement Campaigns',
|
||||||
|
description:
|
||||||
|
'Win back inactive users with targeted campaigns based on their last activity and engagement patterns.',
|
||||||
|
example: 'Trigger on 30 days inactive → Check if opened last email → Yes: Send update / No: Send special offer',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WorkflowsFeature() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Email Workflow Automation | Plunk</title>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Build sophisticated email automation workflows with visual no-code builder. Create event-driven sequences, conditional branching, and time-based delays."
|
||||||
|
/>
|
||||||
|
<meta property="og:title" content="Email Workflow Automation - Automate Your Email Marketing | Plunk" />
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Build sophisticated email automation workflows with visual no-code builder. Create event-driven sequences, conditional branching, and time-based delays."
|
||||||
|
/>
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<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%)]'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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)'}}
|
||||||
|
className={
|
||||||
|
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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)'}}
|
||||||
|
className={
|
||||||
|
'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</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'}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
|
|||||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="12 min"
|
readTime="12 min"
|
||||||
canonical="https://next.useplunk.com/guides/email-api-guide"
|
canonical="https://www.useplunk.com/guides/email-api-guide"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -78,7 +78,7 @@ export default function EmailAPIGuide() {
|
|||||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How Email APIs Work</h2>
|
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How Email APIs Work</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Authentication</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Authentication</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
You authenticate requests using an API key (usually passed in headers). This identifies your account and
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Make HTTP Request</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Make HTTP Request</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Send a POST request to the API endpoint with email details (recipient, subject, body, etc.) as JSON.
|
Send a POST request to the API endpoint with email details (recipient, subject, body, etc.) as JSON.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. API Validates & Queues</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. API Validates & Queues</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
The API validates your request, queues the email for delivery, and returns a response with the email ID
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Email Delivery</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Email Delivery</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
The service handles SMTP connections, retry logic, and delivery to the recipient's mail server.
|
The service handles SMTP connections, retry logic, and delivery to the recipient's mail server.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Webhooks & Tracking</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Webhooks & Tracking</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
You receive webhook notifications for delivery events (delivered, bounced, opened, clicked) and can query
|
You receive webhook notifications for delivery events (delivered, bounced, opened, clicked) and can query
|
||||||
@@ -535,7 +535,7 @@ func main() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Deliverability Reputation</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Deliverability Reputation</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Choose providers with strong deliverability rates and sender reputation. Poor deliverability means your
|
Choose providers with strong deliverability rates and sender reputation. Poor deliverability means your
|
||||||
@@ -543,7 +543,7 @@ func main() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Feature Set</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Feature Set</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Ensure the API supports your needs: templates, webhooks, analytics, scheduling, attachments, etc. Some
|
Ensure the API supports your needs: templates, webhooks, analytics, scheduling, attachments, etc. Some
|
||||||
@@ -551,7 +551,7 @@ func main() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Pricing Model</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Pricing Model</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Understand pricing: per-email charges, monthly tiers, overage fees. Calculate costs for your expected
|
Understand pricing: per-email charges, monthly tiers, overage fees. Calculate costs for your expected
|
||||||
@@ -559,7 +559,7 @@ func main() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Developer Experience</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Developer Experience</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Good documentation, SDKs in your language, clear error messages, and responsive support make
|
Good documentation, SDKs in your language, clear error messages, and responsive support make
|
||||||
@@ -567,7 +567,7 @@ func main() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Scalability & Reliability</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Scalability & Reliability</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Can the provider handle your peak volumes? What's their uptime guarantee (SLA)? Do they have redundancy
|
Can the provider handle your peak volumes? What's their uptime guarantee (SLA)? Do they have redundancy
|
||||||
@@ -575,7 +575,7 @@ func main() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Compliance & Security</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">Compliance & Security</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Ensure the provider complies with GDPR, CAN-SPAM, and other relevant regulations. Check their security
|
Ensure the provider complies with GDPR, CAN-SPAM, and other relevant regulations. Check their security
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
|
|||||||
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="9 min"
|
readTime="9 min"
|
||||||
canonical="https://next.useplunk.com/guides/email-bounce-rate"
|
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
|
|||||||
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="10 min"
|
readTime="10 min"
|
||||||
canonical="https://next.useplunk.com/guides/email-click-through-rate"
|
canonical="https://www.useplunk.com/guides/email-click-through-rate"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -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>
|
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Click-Through Rates?</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Call-to-Action (CTA) Design</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Call-to-Action (CTA) Design</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Your CTA's design, placement, and copy directly impact clicks. Clear, prominent, action-oriented CTAs
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Email Relevance</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Email Relevance</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Targeted, personalized content generates 2-3x higher CTR than generic blasts. Segmentation and
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Value Proposition</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Value Proposition</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Recipients need a clear reason to click. Compelling value propositions—exclusive content, limited offers,
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Mobile Optimization</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Mobile Optimization</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
60%+ of emails are read on mobile. Unoptimized emails with small links or poorly formatted content see 50%
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Content Scannability</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Content Scannability</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Most people skim emails. Clear structure with headers, bullet points, and whitespace helps readers find
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Number of CTAs</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Number of CTAs</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
More CTAs = divided attention. Emails with one primary CTA convert 371% better than those with multiple
|
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>
|
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common CTR Mistakes to Avoid</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Too Many CTAs</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Too Many CTAs</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Multiple competing CTAs confuse readers and reduce overall clicks. Focus on one primary action per email.
|
Multiple competing CTAs confuse readers and reduce overall clicks. Focus on one primary action per email.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Vague CTA Copy</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Vague CTA Copy</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
"Click here" and "Learn more" don't communicate value. Be specific: "Download free guide" or "Start 14-day
|
"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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Image-Only CTAs</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Image-Only CTAs</h3>
|
||||||
<p className="text-neutral-700">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Small, Hard-to-Tap Links</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Small, Hard-to-Tap Links</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Tiny text links are difficult to tap on mobile. Use large buttons (minimum 44x44px) for easy tapping.
|
Tiny text links are difficult to tap on mobile. Use large buttons (minimum 44x44px) for easy tapping.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ No Clear Value Proposition</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ No Clear Value Proposition</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Readers won't click if they don't know why they should. Clearly communicate the benefit of clicking before
|
Readers won't click if they don't know why they should. Clearly communicate the benefit of clicking before
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
|
|||||||
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="12 min"
|
readTime="12 min"
|
||||||
canonical="https://next.useplunk.com/guides/email-deliverability"
|
canonical="https://www.useplunk.com/guides/email-deliverability"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -71,7 +71,7 @@ export default function EmailDeliverability() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Sender Reputation</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Sender Reputation</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Email providers track your sending behavior over time. High engagement rates, low spam complaints, and few
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Email Authentication</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Email Authentication</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
SPF, DKIM, and DMARC authenticate your emails and prove they're legitimate. Without proper authentication,
|
SPF, DKIM, and DMARC authenticate your emails and prove they're legitimate. Without proper authentication,
|
||||||
@@ -87,7 +87,7 @@ export default function EmailDeliverability() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Email Content</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Email Content</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Spam filters analyze your subject lines, body content, links, and images. Spammy language, excessive
|
Spam filters analyze your subject lines, body content, links, and images. Spammy language, excessive
|
||||||
@@ -95,7 +95,7 @@ export default function EmailDeliverability() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. List Quality & Engagement</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. List Quality & Engagement</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Sending to engaged subscribers who want your emails is crucial. High open rates and clicks signal quality.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Technical Infrastructure</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Technical Infrastructure</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Your sending IP address, domain reputation, and email infrastructure affect how providers perceive your
|
Your sending IP address, domain reputation, and email infrastructure affect how providers perceive your
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
|
|||||||
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="15 min"
|
readTime="15 min"
|
||||||
canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
|
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -107,7 +107,7 @@ export default function EmailMarketingBestPractices() {
|
|||||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Email Content Best Practices</h2>
|
<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="space-y-6 mb-8">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Craft Compelling Subject Lines</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Craft Compelling Subject Lines</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
Your subject line determines whether emails get opened. Keep them under 50 characters, create curiosity,
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Personalize Beyond First Names</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Personalize Beyond First Names</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Use behavioral data, purchase history, browsing activity, or preferences. Personalized emails deliver 6x
|
Use behavioral data, purchase history, browsing activity, or preferences. Personalized emails deliver 6x
|
||||||
@@ -133,7 +133,7 @@ export default function EmailMarketingBestPractices() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Focus on One Primary Goal</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Focus on One Primary Goal</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Each email should have one clear call-to-action (CTA). Multiple CTAs confuse readers and reduce conversion
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Write Scannable Content</h3>
|
<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>
|
<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">
|
<ul className="list-disc list-inside space-y-1 text-neutral-700">
|
||||||
@@ -153,7 +153,7 @@ export default function EmailMarketingBestPractices() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Create Compelling CTAs</h3>
|
<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>
|
<p className="text-neutral-700 mb-3">Effective CTAs are:</p>
|
||||||
<ul className="list-disc list-inside space-y-1 text-neutral-700">
|
<ul className="list-disc list-inside space-y-1 text-neutral-700">
|
||||||
@@ -172,7 +172,7 @@ export default function EmailMarketingBestPractices() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Provide Real Value</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Provide Real Value</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Every email should benefit the reader. Share insights, solve problems, offer exclusive content, or provide
|
Every email should benefit the reader. Share insights, solve problems, offer exclusive content, or provide
|
||||||
@@ -361,7 +361,7 @@ export default function EmailMarketingBestPractices() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">What to A/B Test</h3>
|
<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">
|
<ul className="space-y-2 text-neutral-700">
|
||||||
<li>
|
<li>
|
||||||
@@ -385,7 +385,7 @@ export default function EmailMarketingBestPractices() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">A/B Testing Best Practices</h3>
|
<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">
|
<ul className="space-y-2 text-neutral-700">
|
||||||
<li>• Test one variable at a time for clear results</li>
|
<li>• Test one variable at a time for clear results</li>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
|
|||||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="10 min"
|
readTime="10 min"
|
||||||
canonical="https://next.useplunk.com/guides/email-open-rate"
|
canonical="https://www.useplunk.com/guides/email-open-rate"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -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>
|
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Email Open Rates?</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Subject Line</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Subject Line</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
Your subject line is the #1 factor. It must be compelling, relevant, and create curiosity without being
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Sender Name</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Sender Name</h3>
|
||||||
<p className="text-neutral-700">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Send Time & Day</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Send Time & Day</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Timing impacts visibility. B2B emails typically perform best Tuesday-Thursday, 10am-2pm. B2C varies more
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. List Quality & Segmentation</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. List Quality & Segmentation</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Engaged subscribers open more. Segmenting by behavior, interests, or demographics ensures relevance.
|
Engaged subscribers open more. Segmenting by behavior, interests, or demographics ensures relevance.
|
||||||
@@ -185,7 +185,7 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Deliverability & Sender Reputation</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Deliverability & Sender Reputation</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
If emails land in spam, they won't be opened. Maintain good sender reputation through proper
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Email Frequency</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Email Frequency</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Too frequent leads to fatigue and unsubscribes. Too infrequent and subscribers forget you. Find the sweet
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">7. Mobile Optimization</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">7. Mobile Optimization</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
60%+ of emails are opened on mobile. Use short subject lines (40-50 characters), clear preview text, and
|
60%+ of emails are opened on mobile. Use short subject lines (40-50 characters), clear preview text, and
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
|
|||||||
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="11 min"
|
readTime="11 min"
|
||||||
canonical="https://next.useplunk.com/guides/email-sender-reputation"
|
canonical="https://www.useplunk.com/guides/email-sender-reputation"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -70,7 +70,7 @@ export default function EmailSenderReputation() {
|
|||||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Sender Reputation?</h2>
|
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Sender Reputation?</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Spam Complaint Rate</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Spam Complaint Rate</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: Critical.</strong> When recipients mark your emails as spam, it severely damages
|
<strong>Impact: Critical.</strong> When recipients mark your emails as spam, it severely damages
|
||||||
@@ -81,7 +81,7 @@ export default function EmailSenderReputation() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Engagement Metrics</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Engagement Metrics</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: Very High.</strong> Email providers track opens, clicks, replies, forwards, and deletes.
|
<strong>Impact: Very High.</strong> Email providers track opens, clicks, replies, forwards, and deletes.
|
||||||
@@ -92,7 +92,7 @@ export default function EmailSenderReputation() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Bounce Rate</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Bounce Rate</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: High.</strong> High bounce rates (especially hard bounces) indicate poor list hygiene,
|
<strong>Impact: High.</strong> High bounce rates (especially hard bounces) indicate poor list hygiene,
|
||||||
@@ -103,7 +103,7 @@ export default function EmailSenderReputation() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Email Authentication</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Email Authentication</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: High.</strong> Proper SPF, DKIM, and DMARC authentication proves your emails are
|
<strong>Impact: High.</strong> Proper SPF, DKIM, and DMARC authentication proves your emails are
|
||||||
@@ -114,7 +114,7 @@ export default function EmailSenderReputation() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Spam Trap Hits</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">5. Spam Trap Hits</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: Critical.</strong> Spam traps are email addresses used to catch senders with poor
|
<strong>Impact: Critical.</strong> Spam traps are email addresses used to catch senders with poor
|
||||||
@@ -125,7 +125,7 @@ export default function EmailSenderReputation() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Sending Volume & Consistency</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">6. Sending Volume & Consistency</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: Moderate.</strong> Sudden spikes in volume look suspicious. Inconsistent sending (long
|
<strong>Impact: Moderate.</strong> Sudden spikes in volume look suspicious. Inconsistent sending (long
|
||||||
@@ -136,7 +136,7 @@ export default function EmailSenderReputation() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">7. Content Quality</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">7. Content Quality</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: Moderate.</strong> Spammy content (excessive links, misleading subject lines, all caps)
|
<strong>Impact: Moderate.</strong> Spammy content (excessive links, misleading subject lines, all caps)
|
||||||
@@ -147,7 +147,7 @@ export default function EmailSenderReputation() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">8. Blacklist Status</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">8. Blacklist Status</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: Critical.</strong> Being listed on major blacklists (Spamhaus, Barracuda, SURBL) can block
|
<strong>Impact: Critical.</strong> Being listed on major blacklists (Spamhaus, Barracuda, SURBL) can block
|
||||||
@@ -158,7 +158,7 @@ export default function EmailSenderReputation() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">9. Sending History</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">9. Sending History</h3>
|
||||||
<p className="text-neutral-700 mb-3">
|
<p className="text-neutral-700 mb-3">
|
||||||
<strong>Impact: Cumulative.</strong> Reputation is built over time. New domains/IPs have no history (zero
|
<strong>Impact: Cumulative.</strong> Reputation is built over time. New domains/IPs have no history (zero
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface Guide {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: React.ComponentType<{className?: string}>;
|
icon: React.ComponentType<{className?: string; strokeWidth?: number}>;
|
||||||
badge?: string;
|
badge?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,170 +94,190 @@ const guides: Guide[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
const badgeOrder = ['Authentication', 'Deliverability', 'Analytics', 'Technical', 'Best Practices', 'Fundamentals'];
|
||||||
* Email Guides hub page
|
|
||||||
*/
|
|
||||||
export default function GuidesIndex() {
|
export default function GuidesIndex() {
|
||||||
|
const categories = badgeOrder
|
||||||
|
.map(name => ({name, guides: guides.filter(g => g.badge === name)}))
|
||||||
|
.filter(c => c.guides.length > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="Email Marketing & Deliverability Guides | Plunk"
|
title="Email Marketing & Deliverability Guides | Plunk"
|
||||||
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
|
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
|
||||||
canonical="https://next.useplunk.com/guides"
|
canonical="https://www.useplunk.com/guides"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Email Marketing & Deliverability Guides | Plunk',
|
title: 'Email Marketing & Deliverability Guides | Plunk',
|
||||||
description:
|
description:
|
||||||
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
||||||
url: 'https://next.useplunk.com/guides',
|
url: 'https://www.useplunk.com/guides',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* Hero Section */}
|
|
||||||
<section className={'relative py-32 sm:py-48'}>
|
{/* Hero */}
|
||||||
|
<section className={'relative overflow-hidden'}>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden
|
||||||
className={
|
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%)]'
|
'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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 16}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
|
||||||
>
|
>
|
||||||
<div
|
<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)'}}
|
||||||
className={
|
className={
|
||||||
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
|
'text-[clamp(2.75rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Book className="h-4 w-4 text-neutral-600" />
|
Master email,
|
||||||
<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 />
|
<br />
|
||||||
marketing & deliverability
|
start to finish.
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
|
||||||
<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.
|
||||||
Free guides on email authentication, deliverability, best practices, and technical implementation. Learn
|
|
||||||
from the experts.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
<motion.a
|
<motion.a
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.015}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.985}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
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'
|
'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
|
Try Plunk free
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</span>
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Guides Grid */}
|
{/* Guides - Categorized */}
|
||||||
<section className={'py-32'}>
|
<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) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
key={category.name}
|
||||||
|
initial={{opacity: 0, y: 16}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.6, delay: catIndex * 0.06, 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>
|
<div className={'mb-6 flex items-center gap-4'}>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to master email</p>
|
<span
|
||||||
</motion.div>
|
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={'grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'divide-y divide-neutral-100'}>
|
||||||
{guides.map((guide, index) => {
|
{category.guides.map(guide => {
|
||||||
const Icon = guide.icon;
|
const Icon = guide.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
|
||||||
key={guide.href}
|
|
||||||
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]}}
|
|
||||||
>
|
|
||||||
<Link
|
<Link
|
||||||
|
key={guide.href}
|
||||||
href={guide.href}
|
href={guide.href}
|
||||||
className={
|
className={
|
||||||
'group block h-full rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
'group -mx-4 flex items-center gap-5 rounded-lg px-4 py-5 transition hover:bg-neutral-50 sm:gap-6'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={'flex items-start justify-between mb-4'}>
|
<Icon
|
||||||
<div
|
className={'h-5 w-5 shrink-0 text-neutral-400 transition group-hover:text-neutral-600'}
|
||||||
className={
|
strokeWidth={1.5}
|
||||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
/>
|
||||||
}
|
<div className={'min-w-0 flex-1'}>
|
||||||
|
<h3
|
||||||
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
|
className={'font-bold tracking-[-0.01em] text-neutral-900'}
|
||||||
>
|
>
|
||||||
<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}
|
{guide.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className={'text-sm text-neutral-600 leading-relaxed'}>{guide.description}</p>
|
<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>
|
</Link>
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
|
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to get started?</h2>
|
Put it into practice.
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
</motion.h2>
|
||||||
Put these guides into practice with Plunk's modern email platform. Start free, no credit card required.
|
<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>
|
</p>
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<motion.a
|
<motion.a
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.015}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.985}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
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'}
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Start free trial
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href={'/pricing'}
|
||||||
className={
|
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'}
|
||||||
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
View pricing
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function TransactionalVsMarketingEmail() {
|
|||||||
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
|
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="10 min"
|
readTime="10 min"
|
||||||
canonical="https://next.useplunk.com/guides/transactional-vs-marketing-email"
|
canonical="https://www.useplunk.com/guides/transactional-vs-marketing-email"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function WhatIsDKIM() {
|
|||||||
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="8 min"
|
readTime="8 min"
|
||||||
canonical="https://next.useplunk.com/guides/what-is-dkim"
|
canonical="https://www.useplunk.com/guides/what-is-dkim"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -33,7 +33,7 @@ export default function WhatIsDKIM() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6 mb-8">
|
<div className="space-y-6 mb-8">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. The Sending Server Signs the Email</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. The Sending Server Signs the Email</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
When you send an email, your email server adds a DKIM signature to the email header. This signature is
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. The Signature is Added to Headers</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. The Signature is Added to Headers</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
The DKIM signature includes a hash of specific email components (like the subject, body, and sender) and
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. The Receiving Server Verifies</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. The Receiving Server Verifies</h3>
|
||||||
<p className="text-neutral-700">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Delivery Decision</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Delivery Decision</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Passing DKIM verification improves your sender reputation and deliverability. Failing or missing DKIM may
|
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>
|
<h2 className="text-3xl font-bold text-neutral-900 mb-6">DKIM Best Practices</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-green-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✓ Use 2048-bit Keys</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✓ Use 2048-bit Keys</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
While 1024-bit keys still work, 2048-bit keys provide better security and are recommended by Gmail and
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-green-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✓ Implement SPF and DMARC Too</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✓ Implement SPF and DMARC Too</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
DKIM works best when combined with SPF and DMARC for comprehensive email authentication. Use all three for
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-green-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✓ Monitor DKIM Status</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✓ Monitor DKIM Status</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Regularly check that your DKIM signatures are passing. Most email platforms provide authentication
|
Regularly check that your DKIM signatures are passing. Most email platforms provide authentication
|
||||||
@@ -276,7 +276,7 @@ export default function WhatIsDKIM() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-green-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✓ Rotate Keys Periodically</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✓ Rotate Keys Periodically</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
For enhanced security, rotate your DKIM keys every 6-12 months. Plan key rotation carefully to avoid
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Don't Share Private Keys</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Don't Share Private Keys</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Your DKIM private key should never be shared or stored insecurely. Treat it like a password.
|
Your DKIM private key should never be shared or stored insecurely. Treat it like a password.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Don't Use the Same Key Across Domains</h3>
|
<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">
|
<p className="text-neutral-700">
|
||||||
Each domain should have its own unique DKIM key pair for security and proper authentication.
|
Each domain should have its own unique DKIM key pair for security and proper authentication.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function WhatIsDMARC() {
|
|||||||
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
|
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="9 min"
|
readTime="9 min"
|
||||||
canonical="https://next.useplunk.com/guides/what-is-dmarc"
|
canonical="https://www.useplunk.com/guides/what-is-dmarc"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -32,7 +32,7 @@ export default function WhatIsDMARC() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6 mb-8">
|
<div className="space-y-6 mb-8">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Email Authentication</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. Email Authentication</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
When an email is received, the server first checks SPF and DKIM authentication. At least one of these must
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Alignment Check</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. Alignment Check</h3>
|
||||||
<p className="text-neutral-700">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Policy Application</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. Policy Application</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
If authentication and alignment pass, the email is delivered. If they fail, the receiving server follows
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Reporting</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Reporting</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Receiving servers send daily reports to your specified email address, showing authentication results for
|
Receiving servers send daily reports to your specified email address, showing authentication results for
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function WhatIsSPF() {
|
|||||||
description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices."
|
description="Learn how SPF records work, prevent email spoofing, and improve deliverability. Complete guide with setup examples and best practices."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="7 min"
|
readTime="7 min"
|
||||||
canonical="https://next.useplunk.com/guides/what-is-spf"
|
canonical="https://www.useplunk.com/guides/what-is-spf"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -32,7 +32,7 @@ export default function WhatIsSPF() {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-6 mb-8">
|
<div className="space-y-6 mb-8">
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. You Publish an SPF Record</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">1. You Publish an SPF Record</h3>
|
||||||
<p className="text-neutral-700">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. An Email is Sent</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">2. An Email is Sent</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
When someone sends an email claiming to be from your domain, the receiving server notes the IP address of
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. The Receiving Server Checks SPF</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">3. The Receiving Server Checks SPF</h3>
|
||||||
<p className="text-neutral-700">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-neutral-900 pl-6">
|
<div className="">
|
||||||
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Pass or Fail</h3>
|
<h3 className="text-xl font-semibold text-neutral-900 mb-2">4. Pass or Fail</h3>
|
||||||
<p className="text-neutral-700">
|
<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,
|
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>
|
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Common SPF Mistakes to Avoid</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Multiple SPF Records</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Multiple SPF Records</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Never create multiple SPF TXT records. You can only have ONE SPF record per domain. Combine all authorized
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Exceeding 10 DNS Lookups</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Exceeding 10 DNS Lookups</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Each <code>include:</code> mechanism counts toward the 10 lookup limit. Too many includes will cause SPF
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Forgetting to Update SPF</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Forgetting to Update SPF</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
When you add new email services, remember to update your SPF record. Outdated SPF records cause legitimate
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-l-4 border-red-500 pl-6 py-2">
|
<div className="py-2">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Using +all</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-2">✗ Using +all</h3>
|
||||||
<p className="text-neutral-700">
|
<p className="text-neutral-700">
|
||||||
Never use <code>+all</code> (pass all). This completely defeats the purpose of SPF by allowing anyone to
|
Never use <code>+all</code> (pass all). This completely defeats the purpose of SPF by allowing anyone to
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {Footer, Navbar} from '../components';
|
import {Footer, Navbar} from '../components';
|
||||||
|
import {motion} from 'framer-motion';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import dries from '../../public/assets/dries.png';
|
import dries from '../../public/assets/dries.png';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
/**
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
export default function Index() {
|
export default function Index() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -31,23 +29,66 @@ export default function Index() {
|
|||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
<main>
|
|
||||||
<section className={'py-32'}>
|
|
||||||
<div className="mx-auto max-w-4xl">
|
|
||||||
<div className={'mb-20 text-center'}>
|
|
||||||
<h1 className="text-5xl font-bold tracking-tight text-neutral-900 sm:text-6xl lg:text-7xl">
|
|
||||||
Made by Humans
|
|
||||||
</h1>
|
|
||||||
<p className="mx-auto mt-6 max-w-2xl text-xl text-neutral-600">Plunk is an indie SaaS run by a human</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Hero */}
|
||||||
<div className="space-y-12">
|
<section className={'relative overflow-hidden'}>
|
||||||
{/* About Section */}
|
<div
|
||||||
<div className="rounded-xl border border-neutral-200 bg-white p-10">
|
aria-hidden
|
||||||
<h2 className="text-2xl font-bold text-neutral-900">Built with care</h2>
|
className={
|
||||||
<div className="mt-6 space-y-4 text-lg leading-relaxed text-neutral-600">
|
'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'}
|
||||||
|
>
|
||||||
|
Made by Humans
|
||||||
|
</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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Software built
|
||||||
|
<br />
|
||||||
|
by a person.
|
||||||
|
</h1>
|
||||||
|
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
|
||||||
|
Plunk is an indie SaaS run by a human. No VC funding, no aggressive growth tactics. Just a platform built
|
||||||
|
with care.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<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 max-w-4xl space-y-4'}>
|
||||||
|
|
||||||
|
{/* About */}
|
||||||
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 20}}
|
||||||
|
whileInView={{opacity: 1, y: 0}}
|
||||||
|
viewport={{once: true}}
|
||||||
|
transition={{duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={'rounded-[24px] border border-neutral-200 bg-white p-10'}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
|
className={'text-2xl font-bold tracking-[-0.025em] text-neutral-900'}
|
||||||
|
>
|
||||||
|
Built with care
|
||||||
|
</h2>
|
||||||
|
<div className={'mt-6 space-y-4 text-lg leading-relaxed text-neutral-600'}>
|
||||||
<p>
|
<p>
|
||||||
Plunk is a small, independent SaaS run by a human. I am not a big corporation, nor am I backed by
|
Plunk is a small, independent SaaS run by a human. I am not a big corporation, nor am I backed by
|
||||||
millions of dollars in venture capital. I am just a person who loves building software and helping
|
millions of dollars in venture capital. I am just a person who loves building software and helping
|
||||||
@@ -59,28 +100,69 @@ export default function Index() {
|
|||||||
everyone. Every change I make in Plunk starts from this fundamental belief.
|
everyone. Every change I make in Plunk starts from this fundamental belief.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Values Section */}
|
{/* Values */}
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className={'grid gap-4 sm:grid-cols-2'}>
|
||||||
<div className="rounded-xl border border-neutral-200 bg-white p-8">
|
<motion.div
|
||||||
<h3 className="text-xl font-semibold text-neutral-900">Transparent</h3>
|
initial={{opacity: 0, y: 20}}
|
||||||
<p className="mt-3 text-neutral-600">
|
whileInView={{opacity: 1, y: 0}}
|
||||||
I am not perfect. I make mistakes. I am learning and growing. Trying to put the bar a little
|
viewport={{once: true}}
|
||||||
higher every day. Your feedback is always welcome and appreciated.
|
transition={{duration: 0.5, delay: 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||||
</p>
|
className={'rounded-[24px] border border-neutral-200 bg-white p-8'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
|
className={'mb-4 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||||
|
>
|
||||||
|
01
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-neutral-200 bg-white p-8">
|
<h3
|
||||||
<h3 className="text-xl font-semibold text-neutral-900">Grateful</h3>
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
<p className="mt-3 text-neutral-600">
|
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||||
|
>
|
||||||
|
Transparent
|
||||||
|
</h3>
|
||||||
|
<p className={'mt-3 text-neutral-600'}>
|
||||||
|
I am not perfect. I make mistakes. I am learning and growing. Trying to put the bar a little higher
|
||||||
|
every day. Your feedback is always welcome and appreciated.
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<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-[24px] border border-neutral-200 bg-white p-8'}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
|
className={'mb-4 text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||||
|
>
|
||||||
|
02
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
|
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||||
|
>
|
||||||
|
Grateful
|
||||||
|
</h3>
|
||||||
|
<p className={'mt-3 text-neutral-600'}>
|
||||||
I want to sincerely thank you for supporting a small business. I am grateful for your business and
|
I want to sincerely thank you for supporting a small business. I am grateful for your business and
|
||||||
do not take it for granted.
|
do not take it for granted.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Signature */}
|
{/* Signature */}
|
||||||
<div className="rounded-xl border border-neutral-200 bg-white p-10">
|
<motion.div
|
||||||
|
initial={{opacity: 0, y: 20}}
|
||||||
|
whileInView={{opacity: 1, y: 0}}
|
||||||
|
viewport={{once: true}}
|
||||||
|
transition={{duration: 0.6, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
className={'rounded-[24px] border border-neutral-200 bg-white p-10'}
|
||||||
|
>
|
||||||
<div className={'flex items-center gap-6'}>
|
<div className={'flex items-center gap-6'}>
|
||||||
<Image
|
<Image
|
||||||
src={dries}
|
src={dries}
|
||||||
@@ -112,23 +194,25 @@ export default function Index() {
|
|||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<div className="text-sm text-neutral-500">Founder of Plunk</div>
|
<div className="mt-1 text-sm text-neutral-500">Founder of Plunk</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
{/* Attribution */}
|
{/* Attribution */}
|
||||||
<div className="text-center text-sm text-neutral-500">
|
<p className={'text-center text-sm text-neutral-400'}>
|
||||||
This page is inspired by{' '}
|
This page is inspired by{' '}
|
||||||
<Link href={'https://logsnag.com/run-by-a-human'} target={'_blank'} className="underline">
|
<Link href={'https://logsnag.com/run-by-a-human'} target={'_blank'} className="underline hover:text-neutral-600 transition">
|
||||||
Shayan from LogSnag
|
Shayan from LogSnag
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +1,73 @@
|
|||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import {Footer, Navbar} from '../components';
|
import {Footer, Navbar, SectionHeader} from '../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
|
|
||||||
import {DASHBOARD_URI} from '../lib/constants';
|
import {DASHBOARD_URI} from '../lib/constants';
|
||||||
|
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';
|
import {GithubIcon} from 'lucide-react';
|
||||||
|
|
||||||
/**
|
const includedFeatures = [
|
||||||
*
|
{
|
||||||
*/
|
icon: <Mail className="h-5 w-5" />,
|
||||||
export default function Index() {
|
title: 'Transactional emails',
|
||||||
|
description: 'API and SMTP delivery for receipts, password resets, and any event-driven email.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Zap className="h-5 w-5" />,
|
||||||
|
title: 'Workflow automation',
|
||||||
|
description: 'Build event-triggered sequences with delays, conditions, and branching logic.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarChart3 className="h-5 w-5" />,
|
||||||
|
title: 'Campaign broadcasts',
|
||||||
|
description: 'Send newsletters and announcements to your full list or a targeted segment.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Users className="h-5 w-5" />,
|
||||||
|
title: 'Unlimited contacts',
|
||||||
|
description: 'Store as many contacts as you need. Growing your list never costs more.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Code2 className="h-5 w-5" />,
|
||||||
|
title: 'Full API access',
|
||||||
|
description: 'REST API with SDKs for Node.js, Python, and more. Comprehensive documentation.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Shield className="h-5 w-5" />,
|
||||||
|
title: 'Custom domains',
|
||||||
|
description: 'Send from your own domain with DKIM, SPF, and DMARC set up automatically.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <Globe className="h-5 w-5" />,
|
||||||
|
title: 'Audience segmentation',
|
||||||
|
description: 'Dynamic segments built on behavior, attributes, and engagement data.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <BarChart3 className="h-5 w-5" />,
|
||||||
|
title: 'Analytics & tracking',
|
||||||
|
description: 'Opens, clicks, bounces, and unsubscribes. Real data, no guessing.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: <PackageOpen className="h-5 w-5" />,
|
||||||
|
title: 'Open source',
|
||||||
|
description: 'AGPL-3.0 licensed. Inspect the code, self-host it, or contribute to it.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Pricing() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
@@ -20,121 +78,90 @@ export default function Index() {
|
|||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Plunk Pricing | The Open-Source Email Platform',
|
title: 'Plunk Pricing | The Open-Source Email Platform',
|
||||||
description:
|
description:
|
||||||
'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.',
|
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 1,000 emails/month. No hidden fees.',
|
||||||
}}
|
}}
|
||||||
additionalMetaTags={[
|
additionalMetaTags={[{property: 'title', content: 'Plunk Pricing | The Open-Source Email Platform'}]}
|
||||||
{
|
|
||||||
property: 'title',
|
|
||||||
content: 'Plunk Pricing | The Open-Source Email Platform',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
<main>
|
|
||||||
<section className={'py-32'}>
|
{/* Hero */}
|
||||||
<div className={'mx-auto max-w-4xl text-center'}>
|
<section className={'relative overflow-hidden'}>
|
||||||
<h1 className="text-5xl font-bold tracking-tight text-neutral-900 sm:text-6xl lg:text-7xl">
|
<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]}}
|
||||||
|
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
|
Simple, transparent pricing
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mx-auto mt-6 max-w-2xl text-xl text-neutral-600">Start free, pay only for what you use</p>
|
<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>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Pricing Tiers */}
|
{/* Pricing tiers */}
|
||||||
<div className="mx-auto mt-20 grid max-w-5xl gap-8 lg:grid-cols-2">
|
<section className={'border-t border-neutral-200'}>
|
||||||
{/* Free Tier */}
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
transition={{delay: 0.2}}
|
transition={{delay: 0.1, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className="relative flex flex-col overflow-hidden rounded-2xl bg-gradient-to-br from-neutral-900 via-neutral-800 to-neutral-900 p-10 shadow-2xl"
|
className={'flex flex-col bg-white p-10'}
|
||||||
>
|
>
|
||||||
<div className="absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/5 blur-3xl" />
|
<p
|
||||||
<div className="absolute -bottom-10 -left-10 h-40 w-40 rounded-full bg-white/5 blur-3xl" />
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
|
className={'text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-400'}
|
||||||
<div className="relative flex flex-1 flex-col">
|
>
|
||||||
<div className="mb-6 inline-block self-start rounded-full border border-white/20 bg-white/10 px-4 py-1.5 text-sm font-semibold text-white backdrop-blur-sm">
|
Free forever
|
||||||
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>
|
</div>
|
||||||
|
<p className={'mt-2 text-sm text-neutral-500'}>No credit card required</p>
|
||||||
|
|
||||||
<div className="mb-2 flex items-baseline gap-2">
|
<ul className={'mt-8 flex-1 space-y-3'}>
|
||||||
<span className="text-6xl font-bold tracking-tight text-white">1,000</span>
|
{[
|
||||||
<span className="text-xl font-medium text-white/60">emails</span>
|
'Transactional emails',
|
||||||
</div>
|
'Workflow automation',
|
||||||
<p className="mb-8 text-base text-white/60">per month</p>
|
'Campaign broadcasts',
|
||||||
|
'Custom domains',
|
||||||
<ul className="flex-1 space-y-3">
|
'Click & open tracking',
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
'Unlimited contacts',
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
].map(item => (
|
||||||
<path
|
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-600'}>
|
||||||
fillRule="evenodd"
|
<Check className={'h-4 w-4 flex-shrink-0 text-neutral-900'} />
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
{item}
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Transactional emails</span>
|
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
))}
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
<li className={'flex items-center gap-3 text-sm text-neutral-400'}>
|
||||||
<path
|
<X className={'h-4 w-4 flex-shrink-0'} />
|
||||||
fillRule="evenodd"
|
Plunk branding on emails
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Workflow automation</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Campaign broadcasts</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Custom domains</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Click & open tracking</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/80">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>No credit card required</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-white/60">
|
|
||||||
<svg className="mt-0.5 h-5 w-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Plunk branding</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -142,163 +169,196 @@ export default function Index() {
|
|||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.02}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
className="mt-8 block w-full rounded-lg border-2 border-white bg-white px-6 py-3 text-center font-semibold text-neutral-900 transition hover:bg-white/90"
|
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
|
Start for free
|
||||||
</motion.a>
|
</motion.a>
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Pay as you grow */}
|
{/* Pay as you grow */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
transition={{delay: 0.3}}
|
transition={{delay: 0.2, duration: 0.6, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className="relative flex flex-col overflow-hidden rounded-2xl border-2 border-neutral-200 bg-white p-10 shadow-lg"
|
className={'flex flex-col bg-neutral-900 p-10 text-white'}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
|
className={'text-[11px] font-semibold uppercase tracking-[0.18em] text-neutral-400'}
|
||||||
>
|
>
|
||||||
<div className="mb-6 inline-block self-start rounded-full bg-neutral-100 px-4 py-1.5 text-sm font-semibold text-neutral-900">
|
|
||||||
Pay as you grow
|
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>
|
</div>
|
||||||
|
<p className={'mt-2 text-sm'}> </p>
|
||||||
|
|
||||||
<div className="mb-2 flex items-baseline gap-2">
|
<ul className={'mt-8 flex-1 space-y-3'}>
|
||||||
<span className="text-6xl font-bold tracking-tight text-neutral-900">$0.001</span>
|
{['Everything in Free', 'No Plunk branding', 'Monthly spend cap', 'Unlimited emails'].map(item => (
|
||||||
<span className="text-xl font-medium text-neutral-500">per email</span>
|
<li key={item} className={'flex items-center gap-3 text-sm text-neutral-300'}>
|
||||||
</div>
|
<Check className={'h-4 w-4 flex-shrink-0 text-white'} />
|
||||||
<p className="mb-8 text-base text-neutral-500">No contact limits, pay only for what you send</p>
|
{item}
|
||||||
|
|
||||||
<ul className="flex-1 space-y-3">
|
|
||||||
<li className="flex items-start gap-3 text-neutral-600">
|
|
||||||
<svg
|
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Everything in Free</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-neutral-600">
|
|
||||||
<svg
|
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>No Plunk branding</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-neutral-600">
|
|
||||||
<svg
|
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Billing limits</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-3 text-neutral-600">
|
|
||||||
<svg
|
|
||||||
className="mt-0.5 h-5 w-5 flex-shrink-0 text-neutral-900"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Unlimited emails</span>
|
|
||||||
</li>
|
</li>
|
||||||
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<motion.a
|
<motion.a
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.02}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
className="mt-8 block w-full rounded-lg bg-neutral-900 px-6 py-3 text-center font-semibold text-white transition hover:bg-neutral-800"
|
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'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Start for free
|
Get started
|
||||||
</motion.a>
|
</motion.a>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Features Grid */}
|
{/* Every feature included */}
|
||||||
<div className="mx-auto mt-24 max-w-5xl">
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<h2 className="mb-12 text-center text-2xl font-bold text-neutral-900">Everything included</h2>
|
<div className={'mx-auto max-w-[88rem] px-6 py-20 sm:px-10'}>
|
||||||
<div className="grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3">
|
<SectionHeader
|
||||||
<div className="bg-white p-8">
|
number={'01'}
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Unlimited emails</h3>
|
label={'Included'}
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
title={'Every feature, every plan.'}
|
||||||
Send unlimited transactional, campaign, and automated emails
|
subtitle={'No feature tiers, no add-ons, no surprises.'}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Unlimited contacts</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
|
||||||
No limits on your audience size. Grow without restrictions
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Custom domains</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">Send from your own domain with full authentication</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Analytics & tracking</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">Detailed metrics on opens, clicks, and conversions</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Workflow automation</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">Build complex email sequences with visual editor</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-8">
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">API access</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-600">Full REST API with comprehensive documentation</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-12 overflow-hidden rounded-lg border border-neutral-200">
|
<ul className={'mt-16 divide-y divide-neutral-200 border-y border-neutral-200'}>
|
||||||
<div className="flex flex-col items-center space-y-6 p-10 sm:flex-row sm:space-y-0">
|
{includedFeatures.map((feature, index) => (
|
||||||
<div className="flex-1 text-center sm:text-left">
|
<motion.li
|
||||||
<h2 className={'text-2xl font-bold text-neutral-900'}>Self-host</h2>
|
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>
|
||||||
|
</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'}>
|
<p className={'mt-2 text-neutral-600'}>
|
||||||
Host Plunk on your own infrastructure. The perfect solution for when you require full control.
|
Full data ownership, no per-email costs, and GDPR compliance by default. Deploy with Docker Compose
|
||||||
|
in minutes.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={'sm:ml-auto'}>
|
<div className={'sm:ml-auto sm:pl-8'}>
|
||||||
<motion.button
|
<motion.button
|
||||||
onClick={() => {
|
onClick={() => window.open('https://github.com/useplunk/plunk', '_blank')}
|
||||||
window.open('https://github.com/useplunk/plunk', '_blank');
|
|
||||||
}}
|
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.02}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.98}}
|
||||||
className={
|
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'
|
'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={20} />
|
<GithubIcon size={18} />
|
||||||
View GitHub
|
View on GitHub
|
||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</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-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 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'}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,268 +1,323 @@
|
|||||||
import {Footer, Navbar} from '../../components';
|
import {Footer, Navbar, SectionHeader} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI} from '../../lib/constants';
|
import {DASHBOARD_URI} from '../../lib/constants';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, Code2, Mail, Search, Sparkles, Wrench} from 'lucide-react';
|
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'],
|
||||||
|
});
|
||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
{
|
{
|
||||||
name: 'Markdown to Email',
|
name: 'Markdown to Email',
|
||||||
slug: 'markdown-to-email',
|
slug: 'markdown-to-email',
|
||||||
description: 'Convert rich text to email-safe HTML with our visual editor and instant preview.',
|
description: 'Convert rich text to email-safe HTML with a visual editor and instant preview.',
|
||||||
features: ['Visual Editor', 'Email-Safe HTML', 'Inline CSS', 'Copy to Clipboard'],
|
|
||||||
icon: Code2,
|
icon: Code2,
|
||||||
|
number: '01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Email Verification',
|
name: 'Email Verification',
|
||||||
slug: 'verify-email',
|
slug: 'verify-email',
|
||||||
description: 'Verify email addresses instantly. Check DNS, MX records, typos, and disposable domains.',
|
description: 'Verify email addresses instantly. Check DNS, MX records, typos, and disposable domains.',
|
||||||
features: ['DNS Validation', 'Typo Detection', 'MX Records', 'Disposable Check'],
|
|
||||||
icon: Search,
|
icon: Search,
|
||||||
|
number: '02',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Free Email Tools index page
|
|
||||||
*/
|
|
||||||
export default function ToolsIndex() {
|
export default function ToolsIndex() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="Free Email Tools | Markdown to Email & Email Verification | Plunk"
|
title="Free Email Tools | Markdown to Email & Email Verification | Plunk"
|
||||||
description="Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly. No sign-up required."
|
description="Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly. No sign-up required."
|
||||||
canonical="https://next.useplunk.com/tools"
|
canonical="https://www.useplunk.com/tools"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
|
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
|
||||||
description:
|
description:
|
||||||
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
|
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
|
||||||
url: 'https://next.useplunk.com/tools',
|
url: 'https://www.useplunk.com/tools',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||||
{/* Hero Section */}
|
<main className={'text-neutral-800'}>
|
||||||
<section className={'relative py-32 sm:py-48'}>
|
{/* ========== HERO ========== */}
|
||||||
|
<section className={'relative overflow-hidden'}>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden
|
||||||
className={
|
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%)]'
|
'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-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 8}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
className={
|
||||||
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
|
'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'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Wrench className="h-4 w-4 text-neutral-600" />
|
<span className={'font-medium text-neutral-900'}>§ Tools — Plunk</span>
|
||||||
<span className={'text-sm text-neutral-600'}>Free Email Tools</span>
|
</motion.div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<motion.div
|
||||||
Free tools for
|
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(3rem,9vw,8rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Free email
|
||||||
<br />
|
<br />
|
||||||
email developers
|
developer tools
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
Convert markdown to email-safe HTML, verify addresses, and more. No sign-up required.
|
||||||
Build better emails with our free tools. Convert markdown to email-safe HTML, verify email addresses, and
|
|
||||||
more. No sign-up required.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'mt-10 flex flex-wrap justify-center gap-3'}>
|
||||||
<motion.a
|
<motion.a
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.015}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.985}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
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'
|
'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
|
Try Plunk free
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</span>
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/guides"
|
href="/guides"
|
||||||
className={
|
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'
|
'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
|
Browse guides
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Tools Grid */}
|
{/* ========== TOOLS LIST ========== */}
|
||||||
<section className={'py-32'}>
|
<section className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||||
<motion.div
|
<SectionHeader
|
||||||
initial={{opacity: 0, y: 20}}
|
number={'01'}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
label={'Tools'}
|
||||||
viewport={{once: true}}
|
title={'Available tools.'}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
subtitle={'Everything you need to work with email. No sign-up, no limits.'}
|
||||||
className={'mb-16 text-center'}
|
/>
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Available Tools</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need to work with emails</p>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className={'grid gap-8 md:grid-cols-2 lg:grid-cols-2 max-w-4xl mx-auto'}>
|
<ul className={'mt-20 divide-y divide-neutral-200 border-y border-neutral-200'}>
|
||||||
{tools.map((tool, index) => {
|
{tools.map((tool, i) => (
|
||||||
const Icon = tool.icon;
|
<motion.li
|
||||||
return (
|
key={tool.slug}
|
||||||
<Link key={tool.slug} href={`/tools/${tool.slug}`}>
|
initial={{opacity: 0, y: 12}}
|
||||||
<motion.div
|
|
||||||
initial={{opacity: 0, y: 20}}
|
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, delay: i * 0.06, ease: [0.22, 1, 0.36, 1]}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/tools/${tool.slug}`}
|
||||||
className={
|
className={
|
||||||
'group rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg cursor-pointer h-full'
|
'group flex items-center justify-between gap-6 py-6 transition-colors hover:bg-neutral-50 sm:py-8'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className={'flex items-start justify-between mb-4'}>
|
<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) => (
|
||||||
|
<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
|
<div
|
||||||
className={
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
className={'text-5xl font-extrabold tracking-[-0.035em] text-neutral-900 sm:text-6xl'}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
{item.big}
|
||||||
</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>
|
||||||
</motion.div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Why Use These Tools */}
|
{/* ========== CTA ========== */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why use these tools?</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Built by email experts for email developers</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={
|
className={
|
||||||
'rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Mail className="h-8 w-8 text-blue-500 mb-4" />
|
Need production-grade email?
|
||||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Free Forever</h3>
|
</motion.h2>
|
||||||
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 16}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.5, delay: 0.2, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={
|
className={'flex max-w-md flex-col gap-6'}
|
||||||
'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" />
|
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Developer-Focused</h3>
|
Templates, scheduling, automation, analytics, and deliverability. Start free, scale as you grow.
|
||||||
<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>
|
</p>
|
||||||
</motion.div>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
|
|
||||||
<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'}>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
|
<motion.a
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.015}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.985}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'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
|
Start with Plunk
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href="/pricing"
|
||||||
className={
|
className={
|
||||||
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
'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
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,21 +1,41 @@
|
|||||||
import {Footer, Navbar} from '../../components';
|
import {Footer, Navbar, SectionHeader} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI} from '../../lib/constants';
|
import {DASHBOARD_URI} from '../../lib/constants';
|
||||||
import React, {useMemo, useState} from 'react';
|
import React, {useMemo, useState} from 'react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, Check, Code2, Copy, Sparkles} from 'lucide-react';
|
import {ArrowRight, Check, Code2, Copy} from 'lucide-react';
|
||||||
import {MarkdownEmailEditor} from '../../components/tools/MarkdownEmailEditor';
|
import {MarkdownEmailEditor} from '../../components/tools/MarkdownEmailEditor';
|
||||||
import {convertToCompleteEmailHtml} from '../../lib/emailHtmlConverter';
|
import {convertToCompleteEmailHtml} from '../../lib/emailHtmlConverter';
|
||||||
import {Button} from '@plunk/ui';
|
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() {
|
export default function MarkdownToEmail() {
|
||||||
const [editorContent, setEditorContent] = useState('<p>Hello!</p><p>Try editing this text...</p>');
|
const [editorContent, setEditorContent] = useState('<p>Hello!</p><p>Try editing this text...</p>');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Convert editor content to complete, ready-to-send email HTML
|
const emailSafeHtml = useMemo(() => convertToCompleteEmailHtml(editorContent), [editorContent]);
|
||||||
const emailSafeHtml = useMemo(() => {
|
|
||||||
return convertToCompleteEmailHtml(editorContent);
|
|
||||||
}, [editorContent]);
|
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async () => {
|
||||||
await navigator.clipboard.writeText(emailSafeHtml);
|
await navigator.clipboard.writeText(emailSafeHtml);
|
||||||
@@ -28,57 +48,73 @@ export default function MarkdownToEmail() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Free Markdown to Email HTML Converter | Plunk"
|
title="Free Markdown to Email HTML Converter | Plunk"
|
||||||
description="Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview. Perfect for email developers and marketers."
|
description="Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview. Perfect for email developers and marketers."
|
||||||
canonical="https://next.useplunk.com/tools/markdown-to-email"
|
canonical="https://www.useplunk.com/tools/markdown-to-email"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Free Markdown to Email HTML Converter | Plunk',
|
title: 'Free Markdown to Email HTML Converter | Plunk',
|
||||||
description:
|
description:
|
||||||
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
|
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
|
||||||
url: 'https://next.useplunk.com/tools/markdown-to-email',
|
url: 'https://www.useplunk.com/tools/markdown-to-email',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||||
{/* Hero Section */}
|
<main className={'text-neutral-800'}>
|
||||||
<section className={'relative py-32 sm:py-48'}>
|
{/* ========== HERO ========== */}
|
||||||
|
<section className={'relative overflow-hidden'}>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden
|
||||||
className={
|
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%)]'
|
'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-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 8}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
className={
|
||||||
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
|
'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'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-4 w-4 text-neutral-600" />
|
<span className={'font-medium text-neutral-900'}>§ T-01 — Tool</span>
|
||||||
<span className={'text-sm text-neutral-600'}>Free Tool</span>
|
<Link
|
||||||
</div>
|
href="/tools"
|
||||||
|
className={'text-neutral-500 transition hover:text-neutral-900'}
|
||||||
|
>
|
||||||
|
← All tools
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<motion.div
|
||||||
Markdown to Email
|
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 />
|
<br />
|
||||||
HTML Converter
|
Email HTML
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
Format your content with the visual editor and get production-ready HTML with inlined styles that works across all email clients.
|
||||||
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>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Editor Section */}
|
{/* ========== EDITOR ========== */}
|
||||||
<section className={'py-16'}>
|
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -88,11 +124,15 @@ export default function MarkdownToEmail() {
|
|||||||
>
|
>
|
||||||
{/* Left: Editor */}
|
{/* Left: Editor */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<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-3'}>
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
<Code2 className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||||
<Code2 className="h-4 w-4" />
|
<span
|
||||||
<span>Format your content</span>
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
|
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||||
|
>
|
||||||
|
Visual Editor
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownEmailEditor value={editorContent} onChange={setEditorContent} />
|
<MarkdownEmailEditor value={editorContent} onChange={setEditorContent} />
|
||||||
@@ -100,63 +140,150 @@ export default function MarkdownToEmail() {
|
|||||||
|
|
||||||
{/* Right: Email-Safe HTML Output */}
|
{/* Right: Email-Safe HTML Output */}
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className={'mb-4 flex items-center justify-between'}>
|
||||||
<h2 className="text-xl font-semibold text-neutral-900">Email-Safe HTML</h2>
|
<span
|
||||||
<Button onClick={handleCopy} size="sm" variant="outline" className="gap-2">
|
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 ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="h-4 w-4" />
|
<Check className={'h-4 w-4'} />
|
||||||
Copied!
|
Copied!
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className={'h-4 w-4'} />
|
||||||
Copy
|
Copy
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="border border-neutral-200 rounded-lg overflow-hidden bg-white">
|
<div className={'overflow-hidden rounded-[16px] border border-neutral-200 bg-white'}>
|
||||||
<pre className="p-4 text-xs font-mono overflow-x-auto min-h-[500px] max-h-[500px] overflow-y-auto">
|
<pre className={'max-h-[500px] min-h-[500px] overflow-x-auto overflow-y-auto p-4 text-xs'}>
|
||||||
<code className="text-neutral-700">{emailSafeHtml}</code>
|
<code style={{fontFamily: 'var(--font-mono)'}} className={'text-neutral-700'}>
|
||||||
|
{emailSafeHtml}
|
||||||
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* ========== ABOUT ========== */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<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.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
key={item.tag}
|
||||||
|
initial={{opacity: 0, y: 16}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.6, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
className={'flex flex-col gap-6'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to send great emails?</h2>
|
<span
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
This tool is great for creating email HTML, but Plunk handles everything: templates, sending, tracking,
|
className={'text-[11px] uppercase tracking-[0.2em] text-neutral-500'}
|
||||||
and deliverability. Start free, no credit card required.
|
>
|
||||||
|
/ {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>
|
||||||
|
</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>
|
</p>
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<motion.a
|
<motion.a
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.015}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.985}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'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'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className={'flex items-center gap-2'}>
|
|
||||||
Start with Plunk
|
Start with Plunk
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className={'h-4 w-4'} />
|
||||||
</span>
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Footer, Navbar} from '../../components';
|
import {Footer, Navbar, SectionHeader} from '../../components';
|
||||||
import {motion} from 'framer-motion';
|
import {motion} from 'framer-motion';
|
||||||
import {DASHBOARD_URI} from '../../lib/constants';
|
import {DASHBOARD_URI} from '../../lib/constants';
|
||||||
import React, {useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
@@ -9,6 +9,29 @@ import {verifyEmail} from '../../lib/emailVerification';
|
|||||||
import {EmailVerificationResult} from '../../components/tools/EmailVerificationResult';
|
import {EmailVerificationResult} from '../../components/tools/EmailVerificationResult';
|
||||||
import {Button, Input} from '@plunk/ui';
|
import {Button, Input} from '@plunk/ui';
|
||||||
import {EMAIL_VERIFICATION_FEATURES} from '../../lib/toolsContent';
|
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() {
|
export default function VerifyEmailPage() {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -37,56 +60,69 @@ export default function VerifyEmailPage() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Free Email Verification Tool | Check Email Validity | Plunk"
|
title="Free Email Verification Tool | Check Email Validity | Plunk"
|
||||||
description="Verify email addresses instantly. Check for typos, disposable domains, MX records, and more. Free email validation tool with detailed results."
|
description="Verify email addresses instantly. Check for typos, disposable domains, MX records, and more. Free email validation tool with detailed results."
|
||||||
canonical="https://next.useplunk.com/tools/verify-email"
|
canonical="https://www.useplunk.com/tools/verify-email"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
|
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
|
||||||
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
|
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
|
||||||
url: 'https://next.useplunk.com/tools/verify-email',
|
url: 'https://www.useplunk.com/tools/verify-email',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||||
{/* Hero Section */}
|
<main className={'text-neutral-800'}>
|
||||||
<section className={'relative py-32 sm:py-48'}>
|
{/* ========== HERO ========== */}
|
||||||
|
<section className={'relative overflow-hidden'}>
|
||||||
<div
|
<div
|
||||||
|
aria-hidden
|
||||||
className={
|
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%)]'
|
'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-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 8}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
style={{fontFamily: 'var(--font-mono)'}}
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
className={
|
||||||
'mb-6 inline-flex items-center gap-2 rounded-full border border-neutral-200 bg-white px-4 py-2'
|
'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'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4 text-neutral-600" />
|
<span className={'font-medium text-neutral-900'}>§ T-02 — Tool</span>
|
||||||
<span className={'text-sm text-neutral-600'}>Free Tool</span>
|
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||||
</div>
|
← All tools
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
<motion.div
|
||||||
Email Verification
|
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 />
|
<br />
|
||||||
Tool
|
verification
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
Check for typos, disposable domains, DNS configuration, and MX records. Detailed results in seconds.
|
||||||
Verify email addresses instantly. Check for typos, disposable domains, DNS configuration, and more. Get
|
|
||||||
detailed verification results in seconds.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Verification Tool Section */}
|
{/* ========== VERIFICATION TOOL ========== */}
|
||||||
<section className={'pb-16'}>
|
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
@@ -94,12 +130,23 @@ export default function VerifyEmailPage() {
|
|||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-2xl'}
|
className={'mx-auto max-w-2xl'}
|
||||||
>
|
>
|
||||||
{/* Input Form */}
|
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-8 shadow-lg">
|
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||||
<form onSubmit={handleVerify} className="space-y-4">
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleVerify} className={'space-y-4 p-8'}>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-neutral-900 mb-2">
|
<label htmlFor="email" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||||
Email Address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
@@ -109,40 +156,38 @@ export default function VerifyEmailPage() {
|
|||||||
placeholder="[email protected]"
|
placeholder="[email protected]"
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full"
|
className={'w-full'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={loading || !email} className="w-full gap-2">
|
<Button type="submit" disabled={loading || !email} className={'w-full gap-2'}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className={'h-4 w-4 animate-spin'} />
|
||||||
Verifying...
|
Verifying…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="h-4 w-4" />
|
<CheckCircle className={'h-4 w-4'} />
|
||||||
Verify Email
|
Verify email
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Error Display */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 p-4">
|
<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>
|
<p className={'text-sm text-red-700'}>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results Display */}
|
|
||||||
{result && (
|
{result && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 16}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
transition={{duration: 0.5}}
|
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className="mt-8"
|
className={'mt-6'}
|
||||||
>
|
>
|
||||||
<EmailVerificationResult result={result} />
|
<EmailVerificationResult result={result} />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -150,82 +195,94 @@ export default function VerifyEmailPage() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Educational Content */}
|
{/* ========== WHY VERIFY ========== */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<SectionHeader
|
||||||
whileInView={{opacity: 1, y: 0}}
|
number={'01'}
|
||||||
viewport={{once: true}}
|
label={'Why verify'}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
title={'Protect your'}
|
||||||
className={'mb-16 text-center'}
|
titleAccent={'sender reputation.'}
|
||||||
>
|
subtitle={'Invalid addresses hurt deliverability. Verification keeps your list clean before you send.'}
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>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'}>
|
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
{EMAIL_VERIFICATION_FEATURES.map((feature, index) => {
|
{EMAIL_VERIFICATION_FEATURES.map((feature, index) => {
|
||||||
const Icon = feature.icon;
|
const Icon = feature.icon;
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={feature.title}
|
key={feature.title}
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 16}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, delay: index * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={
|
className={
|
||||||
'group block h-full rounded-2xl border border-neutral-200 bg-white p-8 transition hover:border-neutral-300 hover:shadow-lg'
|
'flex flex-col gap-8 rounded-[20px] border border-neutral-200 bg-white p-8 transition hover:border-neutral-900'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div className={'text-neutral-900'}>
|
||||||
className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white mb-4'}
|
<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'}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
{feature.title}
|
||||||
|
</h3>
|
||||||
|
<p className={'mt-2 text-sm leading-relaxed text-neutral-600'}>{feature.description}</p>
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA Section */}
|
{/* ========== CTA ========== */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-32 sm:px-10 sm:py-40'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
|
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
Ready for production-grade verification?
|
||||||
Ready for production-grade email verification?
|
</motion.h2>
|
||||||
</h2>
|
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<motion.div
|
||||||
This tool is great for testing individual emails, but Plunk offers bulk verification, real-time
|
initial={{opacity: 0, y: 16}}
|
||||||
validation, and seamless integration with your email workflows. Start free, no credit card required.
|
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'}>
|
||||||
|
Bulk verification, real-time validation, and seamless integration with your email workflows. Start free, no credit card required.
|
||||||
</p>
|
</p>
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<motion.a
|
<motion.a
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.015}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.985}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
className={
|
||||||
'group rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
'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'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className={'flex items-center gap-2'}>
|
|
||||||
Start with Plunk
|
Start with Plunk
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className={'h-4 w-4'} />
|
||||||
</span>
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Workflow, Zap} from 'lucide-react';
|
import {ArrowRight, Check, Code2, DollarSign, Globe, PackageOpen, Workflow, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -25,12 +25,12 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'When should I choose ActiveCampaign over Plunk?',
|
question: 'When should I choose ActiveCampaign over Plunk?',
|
||||||
answer:
|
answer:
|
||||||
'Choose ActiveCampaign if you need a full CRM with sales automation, lead scoring, and machine learning features. ActiveCampaign is designed for marketing and sales teams that need an all-in-one platform. Choose Plunk if you need powerful email automation without the complexity—perfect for developers who want to integrate email into their product without CRM overhead.',
|
'Choose ActiveCampaign if you need a full CRM with sales automation, lead scoring, and machine learning features. ActiveCampaign is designed for marketing and sales teams that need an all-in-one platform. Choose Plunk if you need powerful email automation without the complexity. It is built for developers who want to integrate email into their product without CRM overhead.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What is the price difference between Plunk and ActiveCampaign?',
|
question: 'What is the price difference between Plunk and ActiveCampaign?',
|
||||||
answer:
|
answer:
|
||||||
'ActiveCampaign starts at $29/month for basic features and quickly scales to $149/month or more for automation features. Plunk uses pay-as-you-go pricing—you only pay for emails sent. For email-focused needs, Plunk typically costs 50-80% less than ActiveCampaign while providing the same email automation capabilities.',
|
'ActiveCampaign starts at $29/month for basic features and quickly scales to $149/month or more for automation features. Plunk uses pay-as-you-go pricing. You only pay for emails sent. For email-focused needs, Plunk typically costs 50-80% less than ActiveCampaign while providing the same email automation capabilities.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: "Can Plunk match ActiveCampaign's automation capabilities?",
|
question: "Can Plunk match ActiveCampaign's automation capabilities?",
|
||||||
@@ -40,7 +40,7 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'Is migration from ActiveCampaign to Plunk difficult?',
|
question: 'Is migration from ActiveCampaign to Plunk difficult?',
|
||||||
answer:
|
answer:
|
||||||
"It depends on your ActiveCampaign usage. If you're primarily using email automation and campaigns, migration is straightforward—export contacts, recreate workflows, and integrate Plunk's API. If you heavily rely on CRM, lead scoring, or sales automation, you'll need separate tools for those features. Most developers migrate in a day or less.",
|
"It depends on your ActiveCampaign usage. If you're primarily using email automation and campaigns, migration is straightforward: export contacts, recreate workflows, and integrate Plunk's API. If you heavily rely on CRM, lead scoring, or sales automation, you'll need separate tools for those features. Most developers migrate in a day or less.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What ActiveCampaign features does Plunk not have?',
|
question: 'What ActiveCampaign features does Plunk not have?',
|
||||||
@@ -49,380 +49,150 @@ const faqs: FAQ[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Plunk vs ActiveCampaign comparison page
|
|
||||||
*/
|
|
||||||
export default function ActiveCampaignComparison() {
|
export default function ActiveCampaignComparison() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="ActiveCampaign Alternative: Open-Source & Affordable | Plunk"
|
title="ActiveCampaign Alternative: Open-Source & Affordable | Plunk"
|
||||||
description="Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing. No CRM bloat."
|
description="Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing. No CRM bloat."
|
||||||
canonical="https://next.useplunk.com/vs/activecampaign"
|
canonical="https://www.useplunk.com/vs/activecampaign"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'ActiveCampaign Alternative: Open-Source & Affordable | Plunk',
|
title: 'ActiveCampaign Alternative: Open-Source & Affordable | Plunk',
|
||||||
description:
|
description:
|
||||||
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
|
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
|
||||||
url: 'https://next.useplunk.com/vs/activecampaign',
|
url: 'https://www.useplunk.com/vs/activecampaign',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs ActiveCampaign
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for ActiveCampaign
|
for ActiveCampaign
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
ActiveCampaign is powerful but expensive and complex. Plunk delivers essential email automation without
|
<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'}>
|
||||||
CRM bloat, sales features, or enterprise pricing. Built for developers, not marketing departments.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
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'}>
|
|
||||||
Enterprise Features Without Enterprise Prices
|
Enterprise Features Without Enterprise Prices
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Email automation that doesn't break the bank</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Email automation that doesn't break the bank</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Simple pay-as-you-go</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-300'}>Only pay for emails sent. All features included, no tier gating, no forced upgrades.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>ActiveCampaign</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Expensive subscription tiers</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Complex pricing with feature gating. Automation requires higher tiers.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>From $29/month</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>ActiveCampaign</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<p className={'mt-4 text-lg text-neutral-600'}>Powerful email workflows without the enterprise overhead</p>
|
||||||
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'}>Automation Without Complexity</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
{[
|
||||||
initial={{opacity: 0, y: 20}}
|
{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.'},
|
||||||
whileInView={{opacity: 1, y: 0}}
|
{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.'},
|
||||||
viewport={{once: true}}
|
{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.'},
|
||||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
{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.'},
|
||||||
className={'group bg-white p-12 transition hover:bg-neutral-50'}
|
{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.'},
|
||||||
<div
|
].map((item, i) => (
|
||||||
className={
|
<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'}>
|
||||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
<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>
|
||||||
<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>
|
||||||
|
))}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature Comparison</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="ActiveCampaign" rows={comparisonData} />
|
<ComparisonTable competitorName="ActiveCampaign" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Email automation, not enterprise complexity.
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
Get email automation without the bloat
|
<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'}>
|
||||||
</h2>
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
<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={
|
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/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
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, Code2, DollarSign, Globe, Mail, PackageOpen, Zap} from 'lucide-react';
|
import {ArrowRight, Check, Code2, DollarSign, Globe, PackageOpen, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -25,17 +25,17 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'When should I choose Bento over Plunk?',
|
question: 'When should I choose Bento over Plunk?',
|
||||||
answer:
|
answer:
|
||||||
"Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you're a developer who needs focused email automation without CRM bloat—perfect for integrating email into your product.",
|
"Choose Bento if you need an all-in-one platform with CRM, live chat, and customer relationship features alongside email. Bento is designed for small businesses and creators who want everything in one place. Choose Plunk if you're a developer who needs focused email automation without CRM bloat. It's built specifically for integrating email into your product.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'What is the pricing difference between Plunk and Bento?',
|
question: 'What is the pricing difference between Plunk and Bento?',
|
||||||
answer:
|
answer:
|
||||||
'Bento offers a 30-day unlimited trial, then switches to subscription pricing. Plunk uses pay-as-you-go pricing with no trials needed—you only pay for emails sent. For email-focused needs, Plunk is typically more cost-effective. Bento includes CRM and live chat in the price, so if you need those features, Bento may offer better value.',
|
'Bento offers a 30-day unlimited trial, then switches to subscription pricing. Plunk uses pay-as-you-go pricing with no trials needed. You only pay for emails sent. For email-focused needs, Plunk is typically more cost-effective. Bento includes CRM and live chat in the price, so if you need those features, Bento may offer better value.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can Plunk handle email automation like Bento?',
|
question: 'Can Plunk handle email automation like Bento?',
|
||||||
answer:
|
answer:
|
||||||
'Yes. Plunk supports workflow automation, event-based triggers, transactional emails, and marketing campaigns—all the email features Bento offers. The difference is Plunk focuses purely on email, while Bento includes CRM, live chat, and customer relationship management. If you only need email automation, Plunk is simpler.',
|
'Yes. Plunk supports workflow automation, event-based triggers, transactional emails, and marketing campaigns. All the email features Bento offers. The difference is Plunk focuses purely on email, while Bento includes CRM, live chat, and customer relationship management. If you only need email automation, Plunk is simpler.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How does the developer experience compare?',
|
question: 'How does the developer experience compare?',
|
||||||
@@ -49,378 +49,149 @@ const faqs: FAQ[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Plunk vs Bento comparison page
|
|
||||||
*/
|
|
||||||
export default function BentoComparison() {
|
export default function BentoComparison() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="Bento Alternative: Developer-First Email Platform | Plunk"
|
title="Bento Alternative: Developer-First Email Platform | Plunk"
|
||||||
description="Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing, built for developers."
|
description="Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing, built for developers."
|
||||||
canonical="https://next.useplunk.com/vs/bento"
|
canonical="https://www.useplunk.com/vs/bento"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Bento Alternative: Developer-First Email Platform | Plunk',
|
title: 'Bento Alternative: Developer-First Email Platform | Plunk',
|
||||||
description:
|
description:
|
||||||
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
|
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
|
||||||
url: 'https://next.useplunk.com/vs/bento',
|
url: 'https://www.useplunk.com/vs/bento',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Bento
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Bento
|
for Bento
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Bento is an all-in-one platform. Plunk is focused on email. Get powerful email automation without CRM,
|
<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'}>
|
||||||
live chat, or multi-channel complexity. Developer-first, API-driven, transparent pricing.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
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'}>
|
|
||||||
Focused Email vs All-in-One Complexity
|
Focused Email vs All-in-One Complexity
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not what you don't need</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not what you don't need</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Bento</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Trial then subscription</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>30-day unlimited trial, then subscription pricing. Includes CRM, chat, and multi-channel features.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>30-day trial</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Bento</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<p className={'mt-4 text-lg text-neutral-600'}>Everything you need for email, nothing you don't</p>
|
||||||
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'}>Developer-First Email Platform</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
{[
|
||||||
initial={{opacity: 0, y: 20}}
|
{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.'},
|
||||||
whileInView={{opacity: 1, y: 0}}
|
{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.'},
|
||||||
viewport={{once: true}}
|
{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.'},
|
||||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
{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.'},
|
||||||
className={'group bg-white p-12 transition hover:bg-neutral-50'}
|
{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) => (
|
||||||
<div
|
<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'}>
|
||||||
className={
|
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>{item.icon}</div>
|
||||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
<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>
|
||||||
>
|
|
||||||
<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>
|
||||||
|
))}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature Comparison</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Bento" rows={comparisonData} />
|
<ComparisonTable competitorName="Bento" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Focused email. No bloat.
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Get focused email automation</h2>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
Join developers choosing focused email solutions over all-in-one complexity. Start free, no credit card
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
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'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/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
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
import {ArrowRight, BarChart3, Check, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -57,368 +57,165 @@ export default function BrevoComparison() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
|
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
|
||||||
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
|
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
|
||||||
canonical="https://next.useplunk.com/vs/brevo"
|
canonical="https://www.useplunk.com/vs/brevo"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
|
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
|
||||||
description:
|
description:
|
||||||
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
|
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
|
||||||
url: 'https://next.useplunk.com/vs/brevo',
|
url: 'https://www.useplunk.com/vs/brevo',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Brevo
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Brevo
|
for Brevo
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Simpler pricing, cleaner API, open-source code. All the email power, none of the bloat. Built for
|
<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'}>
|
||||||
developers, not marketing teams.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
viewport={{once: true}}
|
Pricing That Scales With You
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</h2>
|
||||||
className={'mb-16 text-center'}
|
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight 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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-300'}>Simple pay-as-you-go pricing. Unlimited contacts, pay only for emails sent.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>$0.001/email</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Brevo</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Tiered by contacts</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription tiers based on contact count with feature restrictions on lower plans.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Contact-based</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Brevo</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Why Choose Plunk Over Brevo</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Focus on email, not feature overload</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Focus on email, not feature overload</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Simpler Pricing</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Developer-First</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Unlimited Contacts</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Email-Focused</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>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>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Brevo" rows={comparisonData} />
|
<ComparisonTable competitorName="Brevo" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Make the switch to simplicity
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Make the switch to simplicity</h2>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
Join developers choosing focused tools over bloated marketing suites. Start free, no credit card required.
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
</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 free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/pricing"
|
View 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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
import {ArrowRight, Check, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -30,12 +30,12 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'What is the pricing difference between Plunk and ConvertKit?',
|
question: 'What is the pricing difference between Plunk and ConvertKit?',
|
||||||
answer:
|
answer:
|
||||||
'ConvertKit charges based on subscriber count—your cost increases as your list grows, regardless of how many emails you send. Plunk uses pay-as-you-go pricing where you only pay for emails actually sent. This means with ConvertKit, a list of 10,000 subscribers costs $119/month even if you rarely email them. With Plunk, you only pay when you send.',
|
'ConvertKit charges based on subscriber count. Your cost increases as your list grows, regardless of how many emails you send. Plunk uses pay-as-you-go pricing where you only pay for emails actually sent. This means with ConvertKit, a list of 10,000 subscribers costs $119/month even if you rarely email them. With Plunk, you only pay when you send.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can Plunk handle creator workflows like ConvertKit?',
|
question: 'Can Plunk handle creator workflows like ConvertKit?',
|
||||||
answer:
|
answer:
|
||||||
"Yes, Plunk supports email automation, workflows, and segmentation similar to ConvertKit. You can create automated sequences, trigger emails based on events, and segment your audience. The main difference is Plunk doesn't include landing page builders or product selling features—it's focused purely on email delivery and automation.",
|
"Yes, Plunk supports email automation, workflows, and segmentation similar to ConvertKit. You can create automated sequences, trigger emails based on events, and segment your audience. The main difference is Plunk doesn't include landing page builders or product selling features. It's focused purely on email delivery and automation.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Does Plunk have landing pages like ConvertKit?',
|
question: 'Does Plunk have landing pages like ConvertKit?',
|
||||||
@@ -57,370 +57,165 @@ export default function ConvertkitComparison() {
|
|||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="ConvertKit Alternative for Developers | Plunk"
|
title="ConvertKit Alternative for Developers | Plunk"
|
||||||
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation—no landing page bloat."
|
description="Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation. No landing page bloat."
|
||||||
canonical="https://next.useplunk.com/vs/convertkit"
|
canonical="https://www.useplunk.com/vs/convertkit"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'ConvertKit Alternative for Developers | Plunk',
|
title: 'ConvertKit Alternative for Developers | Plunk',
|
||||||
description:
|
description:
|
||||||
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
|
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
|
||||||
url: 'https://next.useplunk.com/vs/convertkit',
|
url: 'https://www.useplunk.com/vs/convertkit',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs ConvertKit
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for ConvertKit
|
for ConvertKit
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
ConvertKit is built for creators. Plunk is built for developers who need powerful email automation without
|
<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'}>
|
||||||
the creator-focused bloat. Same automation, better DX, pay-as-you-go.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
viewport={{once: true}}
|
Pay for Emails, Not Subscribers
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</h2>
|
||||||
className={'mb-16 text-center'}
|
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight 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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pricing that grows with usage, not list size</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>ConvertKit</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Pay per subscriber count</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription based on total subscribers, whether you email them or not.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>From $25/month</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>ConvertKit</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Built for Developers, Not Creators</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Developer-First API</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>5-Minute Setup</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Unlimited Contacts</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature Comparison</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="ConvertKit" rows={comparisonData} />
|
<ComparisonTable competitorName="ConvertKit" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
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 for a developer-first email platform?
|
Ready for a developer-first email platform?
|
||||||
</h2>
|
</motion.h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
Join developers who've switched from ConvertKit to Plunk for better API design and transparent
|
<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>
|
||||||
pay-as-you-go pricing.
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
</p>
|
<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'}>
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
Get started free <ArrowRight className="h-4 w-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 free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/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
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, BarChart3, Code2, DollarSign, Globe, PackageOpen, Zap} from 'lucide-react';
|
import {ArrowRight, BarChart3, Check, Code2, DollarSign, Globe, PackageOpen, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -53,368 +53,165 @@ export default function CustomerioComparison() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
|
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
|
||||||
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
|
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
|
||||||
canonical="https://next.useplunk.com/vs/customerio"
|
canonical="https://www.useplunk.com/vs/customerio"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
|
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
|
||||||
description:
|
description:
|
||||||
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
|
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
|
||||||
url: 'https://next.useplunk.com/vs/customerio',
|
url: 'https://www.useplunk.com/vs/customerio',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Customer.io
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Customer.io
|
for Customer.io
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Customer.io is powerful but complex. Plunk gives developers the automation they need without the
|
<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'}>
|
||||||
enterprise overhead.
|
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
viewport={{once: true}}
|
Simple Pricing That Scales
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</h2>
|
||||||
className={'mb-16 text-center'}
|
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight 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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Transparent pay-as-you-go vs complex enterprise tiers</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Customer.io</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Complex usage-based pricing</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Pricing based on profiles, messages, and feature tiers.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Tiered complexity</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Customer.io</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature-by-Feature</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<ComparisonTable competitorName="Customer.io" rows={comparisonData} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 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'}>Key Advantages</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Why developers choose Plunk</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Why developers choose Plunk</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Developer-First Experience</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.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={'bg-white p-10'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Transparent Pricing</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Quick Setup</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hosting Option</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Simplicity at Scale</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.div>
|
||||||
</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="Customer.io" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Try Plunk free
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
1,000 emails/month free. No credit card required. Developer-friendly from day one.
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
</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 free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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={WIKI_URI}
|
View pricing
|
||||||
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -87,235 +87,245 @@ const competitors = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Plunk vs Competitors index page
|
|
||||||
*/
|
|
||||||
export default function CompetitorsIndex() {
|
export default function CompetitorsIndex() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="Plunk vs Email Competitors | Compare Email Platforms"
|
title="Plunk vs Email Competitors | Compare Email Platforms"
|
||||||
description="Compare Plunk with Postmark, SendGrid, Mailgun, Mailchimp, ConvertKit, ActiveCampaign, Klaviyo, MailerLite, and more. See why Plunk offers transactional emails plus marketing features in one open-source platform."
|
description="Compare Plunk with Postmark, SendGrid, Mailgun, Mailchimp, ConvertKit, ActiveCampaign, Klaviyo, MailerLite, and more. See why Plunk offers transactional emails plus marketing features in one open-source platform."
|
||||||
canonical="https://next.useplunk.com/vs"
|
canonical="https://www.useplunk.com/vs"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Plunk vs Email Competitors | Compare Email Platforms',
|
title: 'Plunk vs Email Competitors | Compare Email Platforms',
|
||||||
description:
|
description:
|
||||||
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
|
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
|
||||||
url: 'https://next.useplunk.com/vs',
|
url: 'https://www.useplunk.com/vs',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 16}}
|
||||||
animate={{opacity: 1, y: 0}}
|
animate={{opacity: 1, y: 0}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
|
||||||
>
|
>
|
||||||
<div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'mb-6 text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>
|
||||||
className={
|
Comparisons
|
||||||
'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'}>Email Platform Comparisons</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
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
|
Plunk vs the
|
||||||
<br />
|
<br />
|
||||||
competition
|
competition.
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className={'mt-6 max-w-2xl text-xl text-neutral-600'}>
|
||||||
<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.
|
||||||
Compare Plunk with leading email platforms. See why developers choose our open-source alternative for
|
|
||||||
transactional emails plus marketing features.
|
|
||||||
</p>
|
</p>
|
||||||
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
|
||||||
<motion.a
|
<motion.a
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.015}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.985}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
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'}
|
||||||
'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
|
Get started free
|
||||||
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-1" />
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</span>
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href={WIKI_URI}
|
href={WIKI_URI}
|
||||||
target={'_blank'}
|
target={'_blank'}
|
||||||
className={
|
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'}
|
||||||
'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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Competitors Grid */}
|
{/* Competitors grid */}
|
||||||
<section className={'py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-10'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<h2
|
||||||
Compare Plunk with Industry Leaders
|
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>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>See how Plunk stacks up against popular email platforms</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>See how Plunk stacks up against popular email platforms</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-8 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'}>
|
<div className={'grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'}>
|
||||||
{competitors.map((competitor, index) => (
|
{competitors.map((competitor, index) => (
|
||||||
<Link key={competitor.slug} href={`/vs/${competitor.slug}`}>
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
key={competitor.slug}
|
||||||
|
initial={{opacity: 0, y: 16}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.5, delay: index * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, delay: index * 0.04, 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={'mb-4 flex items-center justify-between'}>
|
<Link
|
||||||
<h3 className={'text-xl font-bold text-neutral-900'}>{competitor.name}</h3>
|
href={`/vs/${competitor.slug}`}
|
||||||
</div>
|
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'}
|
||||||
<p className={'mb-6 leading-relaxed text-neutral-600'}>{competitor.description}</p>
|
>
|
||||||
<div className={'mb-6 space-y-2'}>
|
<div className={'flex items-start justify-between'}>
|
||||||
{competitor.features.map(feature => (
|
<h3
|
||||||
<div key={feature} className={'flex items-center gap-2 text-sm text-neutral-600'}>
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
<div className={'h-1.5 w-1.5 rounded-full bg-neutral-900'} />
|
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||||
<span>{feature}</span>
|
>
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
</motion.div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Why Choose Plunk */}
|
{/* Why Plunk */}
|
||||||
<section className={'py-32'}>
|
<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
|
<motion.div
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mb-16 text-center'}
|
className={'mb-10'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Why Choose Plunk</h2>
|
<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>
|
<p className={'mt-4 text-lg text-neutral-600'}>One platform for all your email needs</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-8 lg:grid-cols-3'}>
|
<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) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key={item.title}
|
||||||
initial={{opacity: 0, y: 20}}
|
initial={{opacity: 0, y: 20}}
|
||||||
whileInView={{opacity: 1, y: 0}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.5, delay: i * 0.1, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={
|
className={'bg-white p-10'}
|
||||||
'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" />
|
<div className={'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white'}>
|
||||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Transactional + Marketing</h3>
|
{item.icon}
|
||||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
</div>
|
||||||
Send transactional emails with the same reliability as dedicated providers, plus marketing campaigns,
|
<h3
|
||||||
automation, and segmentation—all in one platform.
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
</p>
|
className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||||
</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-green-500 mb-4" />
|
{item.title}
|
||||||
<h3 className={'text-2xl font-bold text-neutral-900'}>Open Source & Self-Hostable</h3>
|
</h3>
|
||||||
<p className={'mt-4 leading-relaxed text-neutral-600'}>
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||||
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-yellow-500 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>
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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}}
|
whileInView={{opacity: 1, y: 0}}
|
||||||
viewport={{once: true}}
|
viewport={{once: true}}
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
style={{fontFamily: 'var(--font-display)'}}
|
||||||
|
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||||
>
|
>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Ready to try Plunk?</h2>
|
Ready to try Plunk?
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
</motion.h2>
|
||||||
Join thousands of developers using Plunk for reliable email delivery. Start free, scale as you grow.
|
<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>
|
</p>
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<motion.a
|
<motion.a
|
||||||
whileHover={{scale: 1.02}}
|
whileHover={{scale: 1.015}}
|
||||||
whileTap={{scale: 0.98}}
|
whileTap={{scale: 0.985}}
|
||||||
href={`${DASHBOARD_URI}/auth/signup`}
|
href={`${DASHBOARD_URI}/auth/signup`}
|
||||||
className={
|
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'}
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Start free trial
|
Get started free
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<Link
|
||||||
href="/pricing"
|
href={'/pricing'}
|
||||||
className={
|
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'}
|
||||||
'rounded-lg border border-neutral-300 px-8 py-4 text-base font-semibold text-neutral-900 transition hover:border-neutral-400'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
View pricing
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, Code2, DollarSign, Globe, Package, PackageOpen, Zap} from 'lucide-react';
|
import {ArrowRight, Check, Code2, DollarSign, Globe, Package, PackageOpen, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -25,12 +25,12 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'When should I choose Klaviyo over Plunk?',
|
question: 'When should I choose Klaviyo over Plunk?',
|
||||||
answer:
|
answer:
|
||||||
'Choose Klaviyo if you need deep native e-commerce integrations (Shopify, WooCommerce), SMS marketing, and AI-powered predictive analytics. Klaviyo excels for established e-commerce brands with dedicated marketing teams and budget for enterprise features. Choose Plunk if you need powerful email automation for e-commerce (or any use case) without the premium price tag—perfect for developers and growing businesses.',
|
'Choose Klaviyo if you need deep native e-commerce integrations (Shopify, WooCommerce), SMS marketing, and AI-powered predictive analytics. Klaviyo excels for established e-commerce brands with dedicated marketing teams and budget for enterprise features. Choose Plunk if you need powerful email automation for e-commerce (or any use case) without the premium price tag. It is built for developers and growing businesses who want the capabilities without the cost.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How much cheaper is Plunk compared to Klaviyo?',
|
question: 'How much cheaper is Plunk compared to Klaviyo?',
|
||||||
answer:
|
answer:
|
||||||
'Klaviyo can be dramatically more expensive. With 10,000 contacts, Klaviyo costs $150-300+/month depending on email volume. Plunk uses pay-as-you-go pricing—you only pay for emails sent, not contacts stored. For most use cases, Plunk costs 60-90% less than Klaviyo while providing the same email automation capabilities. The savings increase as your contact list grows.',
|
'Klaviyo can be dramatically more expensive. With 10,000 contacts, Klaviyo costs $150-300+/month depending on email volume. Plunk uses pay-as-you-go pricing. You only pay for emails sent, not contacts stored. For most use cases, Plunk costs 60-90% less than Klaviyo while providing the same email automation capabilities. The savings increase as your contact list grows.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can Plunk handle e-commerce emails?',
|
question: 'Can Plunk handle e-commerce emails?',
|
||||||
@@ -49,382 +49,150 @@ const faqs: FAQ[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Plunk vs Klaviyo comparison page
|
|
||||||
*/
|
|
||||||
export default function KlaviyoComparison() {
|
export default function KlaviyoComparison() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="Klaviyo Alternative: Affordable E-commerce Email | Plunk"
|
title="Klaviyo Alternative: Affordable E-commerce Email | Plunk"
|
||||||
description="E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable. Perfect for developers building e-commerce."
|
description="E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable. Perfect for developers building e-commerce."
|
||||||
canonical="https://next.useplunk.com/vs/klaviyo"
|
canonical="https://www.useplunk.com/vs/klaviyo"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Klaviyo Alternative: Affordable E-commerce Email | Plunk',
|
title: 'Klaviyo Alternative: Affordable E-commerce Email | Plunk',
|
||||||
description:
|
description:
|
||||||
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
|
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
|
||||||
url: 'https://next.useplunk.com/vs/klaviyo',
|
url: 'https://www.useplunk.com/vs/klaviyo',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Klaviyo
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Klaviyo
|
for Klaviyo
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Klaviyo is powerful for e-commerce but extremely expensive. Plunk delivers email automation for any use
|
<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'}>
|
||||||
case—including e-commerce—at a fraction of the cost. No hidden fees, no contact-based pricing.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
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'}>
|
|
||||||
E-commerce Email Without the Premium Price
|
E-commerce Email Without the Premium Price
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not contacts stored</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Klaviyo</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Contact-based tiers</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Expensive pricing based on contact count. Costs escalate quickly as your list grows.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>From $20/month</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Klaviyo</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<p className={'mt-4 text-lg text-neutral-600'}>All the automation power, at a fraction of the cost</p>
|
||||||
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'}>
|
|
||||||
E-commerce Email, Developer-Friendly
|
|
||||||
</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
{[
|
||||||
initial={{opacity: 0, y: 20}}
|
{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.'},
|
||||||
whileInView={{opacity: 1, y: 0}}
|
{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.'},
|
||||||
viewport={{once: true}}
|
{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.'},
|
||||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
{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.'},
|
||||||
className={'group bg-white p-12 transition hover:bg-neutral-50'}
|
{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.'},
|
||||||
<div
|
].map((item, i) => (
|
||||||
className={
|
<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'}>
|
||||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
<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>
|
||||||
<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>
|
||||||
|
))}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature Comparison</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Klaviyo" rows={comparisonData} />
|
<ComparisonTable competitorName="Klaviyo" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
E-commerce email at a fraction of the cost.
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
E-commerce email at a fraction of the cost
|
<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'}>
|
||||||
</h2>
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
<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={
|
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/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
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
import {ArrowRight, BarChart3, Check, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'Does Plunk have the same modern features as Loops?',
|
question: 'Does Plunk have the same modern features as Loops?',
|
||||||
answer:
|
answer:
|
||||||
'Yes. Plunk offers transactional emails, marketing campaigns, workflow automation, dynamic segmentation, and a modern API—just like Loops. The key difference is Plunk is open-source, self-hostable, and has no contact limits.',
|
'Yes. Plunk offers transactional emails, marketing campaigns, workflow automation, dynamic segmentation, and a modern API, just like Loops. The key difference is Plunk is open-source, self-hostable, and has no contact limits.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'How easy is it to migrate from Loops to Plunk?',
|
question: 'How easy is it to migrate from Loops to Plunk?',
|
||||||
@@ -57,371 +57,165 @@ export default function LoopsComparison() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Loops Alternative: Open Source & Self-Hostable | Plunk"
|
title="Loops Alternative: Open Source & Self-Hostable | Plunk"
|
||||||
description="Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits. Same modern features, full transparency."
|
description="Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits. Same modern features, full transparency."
|
||||||
canonical="https://next.useplunk.com/vs/loops"
|
canonical="https://www.useplunk.com/vs/loops"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Loops Alternative: Open Source & Self-Hostable | Plunk',
|
title: 'Loops Alternative: Open Source & Self-Hostable | Plunk',
|
||||||
description:
|
description:
|
||||||
'Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits.',
|
'Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits.',
|
||||||
url: 'https://next.useplunk.com/vs/loops',
|
url: 'https://www.useplunk.com/vs/loops',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Loops
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Loops
|
for Loops
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Same modern features, but open-source and self-hostable. No contact limits, no proprietary lock-in, no
|
<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'}>
|
||||||
hidden costs. Built for transparency.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
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'}>
|
|
||||||
Transparent Pricing vs Vendor Lock-In
|
Transparent Pricing vs Vendor Lock-In
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay per email, not per contact</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay per email, not per contact</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>$0.001/email</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Loops</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Subscription tiers by contacts</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription based on contact count with tier-based limits and feature restrictions.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Tiered pricing</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Loops</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Why Choose Plunk Over Loops</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Open-source transparency meets modern SaaS features</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Open-source transparency meets modern SaaS features</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source & Transparent</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Unlimited Contacts</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Predictable Pricing</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Full API Access</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All Features Included</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>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>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Loops" rows={comparisonData} />
|
<ComparisonTable competitorName="Loops" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Switch to open source
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Switch to open source</h2>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
Join developers choosing transparency and control over proprietary platforms. Start free, no credit card
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
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'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/pricing"
|
View 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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, Code2, DollarSign, Mail, PackageOpen, Zap} from 'lucide-react';
|
import {ArrowRight, Check, Code2, DollarSign, Mail, PackageOpen, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -58,364 +58,166 @@ export default function MailchimpComparison() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Mailchimp Alternative for Developers | Plunk"
|
title="Mailchimp Alternative for Developers | Plunk"
|
||||||
description="Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact. Transactional + marketing in one platform."
|
description="Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact. Transactional + marketing in one platform."
|
||||||
canonical="https://next.useplunk.com/vs/mailchimp"
|
canonical="https://www.useplunk.com/vs/mailchimp"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Mailchimp Alternative for Developers | Plunk',
|
title: 'Mailchimp Alternative for Developers | Plunk',
|
||||||
description:
|
description:
|
||||||
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
|
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
|
||||||
url: 'https://next.useplunk.com/vs/mailchimp',
|
url: 'https://www.useplunk.com/vs/mailchimp',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Mailchimp
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Mailchimp
|
for Mailchimp
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Mailchimp is for marketers. Plunk is for developers who need full control, modern API, and transparent
|
<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'}>
|
||||||
pay-as-you-go pricing. Same features, better DX.
|
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
viewport={{once: true}}
|
The Pricing Model That Makes Sense
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</h2>
|
||||||
className={'mb-16 text-center'}
|
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight 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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you store</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<div className={'grid gap-8 lg:grid-cols-2'}>
|
{/* Plunk card - DARK */}
|
||||||
<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-900 bg-neutral-900 p-10 text-white'}>
|
||||||
initial={{opacity: 0, x: -20}}
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
<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>
|
||||||
</div>
|
</ul>
|
||||||
<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>
|
||||||
|
{/* Competitor card - LIGHT */}
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, x: 20}}
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Mailchimp</div>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Pay per contact stored</h3>
|
||||||
viewport={{once: true}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Charged for every contact in your list, whether you email them or not.</p>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed subscription</div>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Mailchimp</span>
|
<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>
|
||||||
</div>
|
</ul>
|
||||||
<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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Why Developers Choose Plunk</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>API-First</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>5-Minute Setup</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All-in-One</h3>
|
||||||
viewport={{once: true}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Transactional and marketing emails in one platform. No need for separate Mandrill account.</p>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Event-Driven</h3>
|
||||||
viewport={{once: true}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Trigger workflows based on user actions. Advanced automation that Mailchimp can't match.</p>
|
||||||
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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature Comparison</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Mailchimp" rows={comparisonData} />
|
<ComparisonTable competitorName="Mailchimp" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
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 for a better developer experience?
|
Ready for a better developer experience?
|
||||||
</h2>
|
</motion.h2>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
Join developers who've switched from Mailchimp to Plunk for better DX and transparent pay-as-you-go
|
<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>
|
||||||
pricing.
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
</p>
|
<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'}>
|
||||||
<div className={'mt-12 flex flex-wrap justify-center gap-4'}>
|
Get started free <ArrowRight className="h-4 w-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 free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/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
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
import {ArrowRight, Check, Code2, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ const faqs: FAQ[] = [
|
|||||||
{
|
{
|
||||||
question: 'Is Plunk more expensive than MailerLite?',
|
question: 'Is Plunk more expensive than MailerLite?',
|
||||||
answer:
|
answer:
|
||||||
'It depends on your usage pattern. MailerLite charges based on subscriber count (e.g., $10/month for 1,000 subscribers), while Plunk charges per email sent. If you have a large subscriber list but send infrequently, Plunk is typically cheaper. If you email your entire list frequently, costs are similar. The key difference is predictability—Plunk only charges for actual usage.',
|
'It depends on your usage pattern. MailerLite charges based on subscriber count (e.g., $10/month for 1,000 subscribers), while Plunk charges per email sent. If you have a large subscriber list but send infrequently, Plunk is typically cheaper. If you email your entire list frequently, costs are similar. The key difference is predictability: Plunk only charges for actual usage.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Can I self-host Plunk unlike MailerLite?',
|
question: 'Can I self-host Plunk unlike MailerLite?',
|
||||||
@@ -49,382 +49,150 @@ const faqs: FAQ[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* Plunk vs MailerLite comparison page
|
|
||||||
*/
|
|
||||||
export default function MailerliteComparison() {
|
export default function MailerliteComparison() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NextSeo
|
<NextSeo
|
||||||
title="MailerLite Alternative: Open-Source & Self-Hostable | Plunk"
|
title="MailerLite Alternative: Open-Source & Self-Hostable | Plunk"
|
||||||
description="The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber. Modern API, full transparency."
|
description="The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber. Modern API, full transparency."
|
||||||
canonical="https://next.useplunk.com/vs/mailerlite"
|
canonical="https://www.useplunk.com/vs/mailerlite"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'MailerLite Alternative: Open-Source & Self-Hostable | Plunk',
|
title: 'MailerLite Alternative: Open-Source & Self-Hostable | Plunk',
|
||||||
description:
|
description:
|
||||||
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
|
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
|
||||||
url: 'https://next.useplunk.com/vs/mailerlite',
|
url: 'https://www.useplunk.com/vs/mailerlite',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs MailerLite
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for MailerLite
|
for MailerLite
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
MailerLite is developer-friendly. Plunk is developer-FIRST. Open-source, self-hostable, with API-first
|
<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'}>
|
||||||
design and pay-as-you-go pricing instead of subscriber tiers.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
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'}>
|
|
||||||
Open Source Meets Developer Experience
|
Open Source Meets Developer Experience
|
||||||
</h2>
|
</h2>
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not subscribers stored</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for emails sent, not subscribers stored</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Open-source and self-hostable for full control.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>MailerLite</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Pay per subscriber count</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription based on total subscribers. Proprietary cloud-only platform.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>From $10/month</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>MailerLite</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<p className={'mt-4 text-lg text-neutral-600'}>Full control, transparent code, pay only for what you send</p>
|
||||||
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'}>
|
|
||||||
The Truly Developer-First Alternative
|
|
||||||
</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
{[
|
||||||
initial={{opacity: 0, y: 20}}
|
{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.'},
|
||||||
whileInView={{opacity: 1, y: 0}}
|
{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.'},
|
||||||
viewport={{once: true}}
|
{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.'},
|
||||||
transition={{duration: 0.5, delay: 0.1, ease: [0.22, 1, 0.36, 1]}}
|
{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.'},
|
||||||
className={'group bg-white p-12 transition hover:bg-neutral-50'}
|
{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.'},
|
||||||
<div
|
].map((item, i) => (
|
||||||
className={
|
<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'}>
|
||||||
'flex h-12 w-12 items-center justify-center rounded-xl bg-neutral-900 text-white transition group-hover:scale-110'
|
<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>
|
||||||
<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>
|
||||||
|
))}
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature Comparison</h2>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<ComparisonTable competitorName="MailerLite" rows={comparisonData} />
|
<ComparisonTable competitorName="MailerLite" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Experience true developer-first email.
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
Experience true developer-first email
|
<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'}>
|
||||||
</h2>
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
<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={
|
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/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
|
View pricing
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
import {ArrowRight, BarChart3, Check, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -53,366 +53,165 @@ export default function MailgunComparison() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Mailgun Alternative: Open-Source with Marketing & Automation | Plunk"
|
title="Mailgun Alternative: Open-Source with Marketing & Automation | Plunk"
|
||||||
description="Compare Plunk and Mailgun. Mailgun focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source."
|
description="Compare Plunk and Mailgun. Mailgun focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source."
|
||||||
canonical="https://next.useplunk.com/vs/mailgun"
|
canonical="https://www.useplunk.com/vs/mailgun"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Mailgun Alternative: Open-Source with Marketing & Automation | Plunk',
|
title: 'Mailgun Alternative: Open-Source with Marketing & Automation | Plunk',
|
||||||
description:
|
description:
|
||||||
'Compare Plunk and Mailgun. Mailgun is excellent for transactional emails. Plunk adds marketing, workflows, and is open-source.',
|
'Compare Plunk and Mailgun. Mailgun is excellent for transactional emails. Plunk adds marketing, workflows, and is open-source.',
|
||||||
url: 'https://next.useplunk.com/vs/mailgun',
|
url: 'https://www.useplunk.com/vs/mailgun',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Mailgun
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Mailgun
|
for Mailgun
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Mailgun is excellent for transactional emails. Plunk adds marketing campaigns, workflow automation, and is
|
<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'}>
|
||||||
open-source.
|
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
viewport={{once: true}}
|
The Pricing Model That Makes Sense
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</h2>
|
||||||
className={'mb-16 text-center'}
|
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight 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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Mailgun</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Tiered monthly plans</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription with tiered email volume limits.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed tiers</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Mailgun</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature-by-Feature</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<ComparisonTable competitorName="Mailgun" rows={comparisonData} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 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'}>What Plunk Adds</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Campaigns</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.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={'bg-white p-10'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All-in-One Platform</h3>
|
||||||
viewport={{once: true}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>One platform for transactional, marketing, and automation. No need for multiple tools or integrations.</p>
|
||||||
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>
|
</motion.div>
|
||||||
</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="Mailgun" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Try Plunk free
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
</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 free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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={WIKI_URI}
|
View pricing
|
||||||
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
import {ArrowRight, BarChart3, Check, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -54,369 +54,165 @@ export default function PostmarkComparison() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Postmark Alternative: Transactional + Marketing Emails | Plunk"
|
title="Postmark Alternative: Transactional + Marketing Emails | Plunk"
|
||||||
description="Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation. Open-source and self-hostable."
|
description="Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation. Open-source and self-hostable."
|
||||||
canonical="https://next.useplunk.com/vs/postmark"
|
canonical="https://www.useplunk.com/vs/postmark"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
|
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
|
||||||
description:
|
description:
|
||||||
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
|
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
|
||||||
url: 'https://next.useplunk.com/vs/postmark',
|
url: 'https://www.useplunk.com/vs/postmark',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Postmark
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Postmark
|
for Postmark
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Everything Postmark offers for transactional emails, plus marketing campaigns, workflow automation, and
|
<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'}>
|
||||||
segmentation. One platform, no extra cost.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
viewport={{once: true}}
|
One Platform for All Your Emails
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</h2>
|
||||||
className={'mb-16 text-center'}
|
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight 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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Transactional reliability meets marketing power</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Transactional + Marketing</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-300'}>Reliable transactional emails plus marketing campaigns, workflows, and segmentation in one platform.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>All-in-one</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Postmark</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Transactional only</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Excellent for transactional emails, but no marketing features. Need a second service for campaigns.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Limited scope</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Postmark</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Why Choose Plunk Over Postmark</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Do more with one platform instead of two</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Do more with one platform instead of two</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Campaigns Included</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source & Transparent</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>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>
|
||||||
|
|
||||||
<ComparisonTable competitorName="Postmark" rows={comparisonData} />
|
<ComparisonTable competitorName="Postmark" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Get more from your email platform
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Get more from your email platform</h2>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
Stop juggling multiple tools. Get transactional reliability plus marketing power in one platform. Start
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
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={
|
|
||||||
'rounded-lg bg-neutral-900 px-8 py-4 text-base font-semibold text-white transition hover:bg-neutral-800'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/pricing"
|
View 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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, BarChart3, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
import {ArrowRight, BarChart3, Check, Globe, Layers, PackageOpen, Users, Workflow} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -53,366 +53,165 @@ export default function ResendComparison() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Plunk vs Resend: Open-Source Alternative with Marketing | Plunk"
|
title="Plunk vs Resend: Open-Source Alternative with Marketing | Plunk"
|
||||||
description="Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source. Choose based on your needs."
|
description="Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source. Choose based on your needs."
|
||||||
canonical="https://next.useplunk.com/vs/resend"
|
canonical="https://www.useplunk.com/vs/resend"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
|
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
|
||||||
description:
|
description:
|
||||||
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
|
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
|
||||||
url: 'https://next.useplunk.com/vs/resend',
|
url: 'https://www.useplunk.com/vs/resend',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs Resend
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for Resend
|
for Resend
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Resend is excellent for transactional emails. Plunk adds marketing campaigns, workflow automation, and is
|
<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'}>
|
||||||
open-source. Choose based on what you need.
|
Try Plunk free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
viewport={{once: true}}
|
The Pricing Model That Makes Sense
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</h2>
|
||||||
className={'mb-16 text-center'}
|
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight 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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not fixed subscriptions</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-300'}>Pay-as-you-go pricing. Only pay for emails you actually send.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>Resend</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Fixed subscription tiers</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Monthly subscription with fixed email limits per tier.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed subscription</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>Resend</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Feature-by-Feature</h2>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<ComparisonTable competitorName="Resend" rows={comparisonData} />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 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'}>What Plunk Adds</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Beyond transactional emails</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Campaigns</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.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={'bg-white p-10'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Workflow Automation</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Dynamic Segmentation</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>All-in-One Platform</h3>
|
||||||
viewport={{once: true}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>One platform for transactional, marketing, and automation. No need for multiple tools or integrations.</p>
|
||||||
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>
|
</motion.div>
|
||||||
</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="Resend" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Try Plunk free
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Try Plunk free</h2>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
1,000 emails/month free. No credit card required. Add marketing and automation when you need it.
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
</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 free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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={WIKI_URI}
|
View pricing
|
||||||
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, BarChart3, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
import {ArrowRight, BarChart3, Check, DollarSign, Globe, PackageOpen, Users, Zap} from 'lucide-react';
|
||||||
import type {ComparisonRow} from '../../components/ComparisonTable';
|
import type {ComparisonRow} from '../../components/ComparisonTable';
|
||||||
import type {FAQ} from '../../components/FAQSection';
|
import type {FAQ} from '../../components/FAQSection';
|
||||||
|
|
||||||
@@ -57,368 +57,165 @@ export default function SendGridComparison() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk"
|
title="SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk"
|
||||||
description="Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup. Open source alternative to SendGrid."
|
description="Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup. Open source alternative to SendGrid."
|
||||||
canonical="https://next.useplunk.com/vs/sendgrid"
|
canonical="https://www.useplunk.com/vs/sendgrid"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk',
|
title: 'SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk',
|
||||||
description:
|
description:
|
||||||
'Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup.',
|
'Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup.',
|
||||||
url: 'https://next.useplunk.com/vs/sendgrid',
|
url: 'https://www.useplunk.com/vs/sendgrid',
|
||||||
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className={'mx-auto max-w-7xl px-8 sm:px-0'}>
|
<main className={'text-neutral-800'}>
|
||||||
{/* 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
|
{/* Hero */}
|
||||||
initial={{opacity: 0, y: 20}}
|
<section className={'relative overflow-hidden'}>
|
||||||
animate={{opacity: 1, y: 0}}
|
<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%)]'} />
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<div className={'mx-auto max-w-[88rem] px-6 pb-20 pt-20 sm:px-10 sm:pt-28 sm:pb-28'}>
|
||||||
className={'mx-auto max-w-4xl text-center'}
|
<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'}>
|
||||||
<div
|
Plunk vs SendGrid
|
||||||
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>
|
</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'}>
|
||||||
<h1 className={'text-6xl font-bold tracking-tight text-neutral-900 sm:text-7xl lg:text-8xl'}>
|
|
||||||
Open-source alternative
|
Open-source alternative
|
||||||
<br />
|
<br />
|
||||||
for SendGrid
|
for SendGrid
|
||||||
</h1>
|
</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>
|
||||||
<p className={'mx-auto mt-8 max-w-2xl text-xl text-neutral-600'}>
|
<div className={'mt-10 flex flex-wrap gap-3'}>
|
||||||
Pay-as-you-go instead of fixed subscriptions. No complex setup, no feature gating, no enterprise sales
|
<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'}>
|
||||||
pitches. Built for developers, not procurement teams.
|
Get started free <ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
</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>
|
</motion.a>
|
||||||
<Link
|
<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'}>
|
||||||
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
|
View documentation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Pricing Model Comparison */}
|
{/* Pricing comparison */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h2 style={{fontFamily: 'var(--font-display)'}} className={'text-[clamp(2rem,5vw,4rem)] font-extrabold leading-[0.95] tracking-[-0.03em] text-neutral-900'}>
|
||||||
viewport={{once: true}}
|
The Pricing Model That Makes Sense
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</h2>
|
||||||
className={'mb-16 text-center'}
|
|
||||||
>
|
|
||||||
<h2 className={'text-5xl font-bold tracking-tight 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>
|
<p className={'mt-4 text-lg text-neutral-600'}>Pay for what you use, not for what you might use</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className={'grid gap-4 lg:grid-cols-2'}>
|
||||||
<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-[24px] border border-neutral-900 bg-neutral-900 p-10 text-white'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}>Plunk</div>
|
||||||
initial={{opacity: 0, x: -20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-white'}>Pay per email sent</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<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>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-white'}>Pay-as-you-go</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border-2 border-neutral-900 bg-white p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-900 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-white'}>Plunk</span>
|
</ul>
|
||||||
</div>
|
|
||||||
<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>
|
||||||
|
<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'}>
|
||||||
<motion.div
|
<div style={{fontFamily: 'var(--font-mono)'}} className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}>SendGrid</div>
|
||||||
initial={{opacity: 0, x: 20}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-2xl font-bold tracking-[-0.025em] text-neutral-900'}>Monthly subscription tiers</h3>
|
||||||
whileInView={{opacity: 1, x: 0}}
|
<p className={'mt-3 leading-relaxed text-neutral-600'}>Fixed monthly fees based on email volume tiers with feature restrictions.</p>
|
||||||
viewport={{once: true}}
|
<div style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-4xl font-extrabold tracking-[-0.03em] text-neutral-900'}>Fixed subscription</div>
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
<ul className={'mt-8 space-y-3'}>
|
||||||
className={'rounded-2xl border border-neutral-200 bg-neutral-50 p-10'}
|
<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>
|
||||||
<div className={'mb-4 inline-flex items-center gap-2 rounded-full bg-neutral-200 px-4 py-1.5'}>
|
<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>
|
||||||
<span className={'text-sm font-semibold text-neutral-900'}>SendGrid</span>
|
</ul>
|
||||||
</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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Key Advantages */}
|
{/* Key advantages */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>Why Choose Plunk Over SendGrid</h2>
|
|
||||||
<p className={'mt-4 text-lg text-neutral-600'}>Built for modern developers, not enterprise sales teams</p>
|
<p className={'mt-4 text-lg text-neutral-600'}>Built for modern developers, not enterprise sales teams</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
<div className={'grid gap-px bg-neutral-200 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||||
<motion.div
|
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Simple, Transparent Pricing</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>5-Minute Setup</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Marketing Included</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Open Source & Transparent</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>Self-Hostable</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
||||||
|
|
||||||
<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'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<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>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-6 text-xl font-bold tracking-[-0.02em] text-neutral-900'}>No Feature Gating</h3>
|
||||||
viewport={{once: true}}
|
<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>
|
||||||
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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Feature Comparison */}
|
{/* Feature comparison table */}
|
||||||
<section className={'py-32'}>
|
<section className={'border-t border-neutral-200'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-16 sm:py-20 sm:px-10'}>
|
||||||
initial={{opacity: 0, y: 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'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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>
|
||||||
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'}>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>
|
||||||
|
|
||||||
<ComparisonTable competitorName="SendGrid" rows={comparisonData} />
|
<ComparisonTable competitorName="SendGrid" rows={comparisonData} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* FAQ */}
|
{/* FAQ */}
|
||||||
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
||||||
|
|
||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<section className={'border-t border-neutral-200 py-32'}>
|
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||||
<motion.div
|
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||||
initial={{opacity: 0, y: 20}}
|
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||||
whileInView={{opacity: 1, y: 0}}
|
<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]'}>
|
||||||
viewport={{once: true}}
|
Make the switch today
|
||||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
</motion.h2>
|
||||||
className={'mx-auto max-w-3xl text-center'}
|
<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>
|
||||||
<h2 className={'text-5xl font-bold tracking-tight text-neutral-900'}>Make the switch today</h2>
|
<div className={'flex flex-wrap gap-3'}>
|
||||||
<p className={'mt-6 text-lg text-neutral-600'}>
|
<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'}>
|
||||||
Join hundreds of developers who've ditched SendGrid's complexity for Plunk's simplicity.
|
Get started free <ArrowRight className="h-4 w-4" />
|
||||||
</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 free trial
|
|
||||||
</motion.a>
|
</motion.a>
|
||||||
<Link
|
<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="/pricing"
|
View 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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -161,6 +161,26 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-neutral-800;
|
@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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -34,14 +34,16 @@
|
|||||||
"@tiptap/starter-kit": "^3.11.0",
|
"@tiptap/starter-kit": "^3.11.0",
|
||||||
"@tiptap/suggestion": "^3.11.0",
|
"@tiptap/suggestion": "^3.11.0",
|
||||||
"@types/dagre": "^0.7.53",
|
"@types/dagre": "^0.7.53",
|
||||||
|
"@types/dompurify": "^3.2.0",
|
||||||
"@uiw/react-codemirror": "^4.25.3",
|
"@uiw/react-codemirror": "^4.25.3",
|
||||||
"@xyflow/react": "^12.9.3",
|
"@xyflow/react": "^12.9.3",
|
||||||
"dagre": "^0.8.5",
|
"dagre": "^0.8.5",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
"dompurify": "^3.4.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.1.5",
|
"next": "^16.2.3",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"nuqs": "^2.7.3",
|
"nuqs": "^2.7.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 24 KiB |
@@ -1,7 +1,3 @@
|
|||||||
<svg viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="1080" height="1080" viewBox="0 0 1080 1080" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<rect width="1080" height="1080" fill="white"/>
|
<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"/>
|
||||||
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -2,7 +2,8 @@ import {Button} from '@plunk/ui';
|
|||||||
import type {Activity, CursorPaginatedResponse} from '@plunk/types';
|
import type {Activity, CursorPaginatedResponse} from '@plunk/types';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
import {ActivityItem} from './ActivityItem';
|
import {ActivityItem} from './ActivityItem';
|
||||||
import {Loader2} from 'lucide-react';
|
import {EmptyState} from './EmptyState';
|
||||||
|
import {Activity as ActivityIcon, Loader2} from 'lucide-react';
|
||||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||||
|
|
||||||
export interface ActivityFeedProps {
|
export interface ActivityFeedProps {
|
||||||
@@ -43,9 +44,14 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
limit: '20', // Conservative limit to avoid overloading
|
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) {
|
if (cursor) {
|
||||||
params.set('cursor', cursor);
|
params.set('cursor', cursor);
|
||||||
}
|
}
|
||||||
@@ -74,9 +80,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
|
|
||||||
// For each new activity, reuse existing object if ID matches (preserves React component instances)
|
// 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.
|
// Otherwise use new object. This maintains correct ordering while preserving component state.
|
||||||
return result.data.map(newActivity =>
|
return result.data.map(newActivity => existingMap.get(newActivity.id) ?? newActivity);
|
||||||
existingMap.get(newActivity.id) ?? newActivity
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +125,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
|
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
|
||||||
|
|
||||||
// For each new activity, reuse existing object if ID matches
|
// For each new activity, reuse existing object if ID matches
|
||||||
return result.activities.map(newActivity =>
|
return result.activities.map(newActivity => existingMap.get(newActivity.id) ?? newActivity);
|
||||||
existingMap.get(newActivity.id) ?? newActivity
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching upcoming activities:', err);
|
console.error('Error fetching upcoming activities:', err);
|
||||||
@@ -185,11 +187,11 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
|
|
||||||
if (activities.length === 0 && upcomingActivities.length === 0) {
|
if (activities.length === 0 && upcomingActivities.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<EmptyState
|
||||||
<p className="text-neutral-500 text-sm">
|
icon={ActivityIcon}
|
||||||
No activity found for the selected filters. Activities will appear here as they happen.
|
title="No activity yet"
|
||||||
</p>
|
description="Events will appear here as contacts interact with your emails."
|
||||||
</div>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
|
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Button} from '@plunk/ui';
|
import {Button} from '@plunk/ui';
|
||||||
import {Check, Copy, Eye, EyeOff, RefreshCw} from 'lucide-react';
|
|
||||||
|
|
||||||
interface ApiKeyDisplayProps {
|
interface ApiKeyDisplayProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -28,8 +29,8 @@ export function ApiKeyDisplay({
|
|||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to copy:', error);
|
// clipboard API unavailable — silent
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,8 +40,8 @@ export function ApiKeyDisplay({
|
|||||||
try {
|
try {
|
||||||
setIsRegenerating(true);
|
setIsRegenerating(true);
|
||||||
await onRegenerate();
|
await onRegenerate();
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Failed to regenerate:', error);
|
// error surfaced via isRegenerating state reset
|
||||||
} finally {
|
} finally {
|
||||||
setIsRegenerating(false);
|
setIsRegenerating(false);
|
||||||
}
|
}
|
||||||
@@ -74,9 +75,31 @@ export function ApiKeyDisplay({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
className="h-9 w-9"
|
className="h-9 w-9 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="h-4 w-4 text-green-600" /> : <Copy className="h-4 w-4" />}
|
<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>
|
||||||
</Button>
|
</Button>
|
||||||
{showRegenerate && onRegenerate && (
|
{showRegenerate && onRegenerate && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
|||||||
{data?.data.map(campaign => (
|
{data?.data.map(campaign => (
|
||||||
<Card
|
<Card
|
||||||
key={campaign.id}
|
key={campaign.id}
|
||||||
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
|
className="cursor-pointer hover:border-neutral-400 transition-colors"
|
||||||
onClick={() => handleCampaignClick(campaign)}
|
onClick={() => handleCampaignClick(campaign)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -266,30 +266,24 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
|
<div className="flex-1 overflow-y-auto pr-2">
|
||||||
{/* Campaign Preview */}
|
{/* Campaign Preview */}
|
||||||
{selectedCampaign && (
|
{selectedCampaign && (
|
||||||
<Card className="bg-neutral-50">
|
<div className="pb-4 mb-1 border-b border-neutral-100">
|
||||||
<CardHeader className="pb-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<span className="text-sm font-medium text-neutral-900">{selectedCampaign.name}</span>
|
||||||
<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)}
|
{getStatusBadge(selectedCampaign.status)}
|
||||||
</div>
|
</div>
|
||||||
{selectedCampaign.description && (
|
{selectedCampaign.description && (
|
||||||
<CardDescription className="text-xs">{selectedCampaign.description}</CardDescription>
|
<p className="text-xs text-neutral-500 mt-1">{selectedCampaign.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Field Selection */}
|
{/* Field Selection */}
|
||||||
<div className="space-y-3">
|
<div className="divide-y divide-neutral-100">
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('subject')}
|
onClick={() => toggleField('subject')}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -302,13 +296,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
|||||||
Email Subject
|
Email Subject
|
||||||
</Label>
|
</Label>
|
||||||
{selectedCampaign?.subject && (
|
{selectedCampaign?.subject && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.subject}</p>
|
<p className="text-xs text-neutral-400 mt-0.5 truncate">{selectedCampaign.subject}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('body')}
|
onClick={() => toggleField('body')}
|
||||||
>
|
>
|
||||||
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
|
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
|
||||||
@@ -316,12 +310,12 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
|||||||
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
|
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
|
||||||
Email Body
|
Email Body
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
|
<p className="text-xs text-neutral-400 mt-0.5">Full email content and design</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('from')}
|
onClick={() => toggleField('from')}
|
||||||
>
|
>
|
||||||
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
|
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
|
||||||
@@ -330,13 +324,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
|||||||
From Email
|
From Email
|
||||||
</Label>
|
</Label>
|
||||||
{selectedCampaign?.from && (
|
{selectedCampaign?.from && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.from}</p>
|
<p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.from}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('fromName')}
|
onClick={() => toggleField('fromName')}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -349,13 +343,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
|||||||
From Name
|
From Name
|
||||||
</Label>
|
</Label>
|
||||||
{selectedCampaign?.fromName && (
|
{selectedCampaign?.fromName && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.fromName}</p>
|
<p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.fromName}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('replyTo')}
|
onClick={() => toggleField('replyTo')}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -368,13 +362,13 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
|||||||
Reply-To Email
|
Reply-To Email
|
||||||
</Label>
|
</Label>
|
||||||
{selectedCampaign?.replyTo && (
|
{selectedCampaign?.replyTo && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{selectedCampaign.replyTo}</p>
|
<p className="text-xs text-neutral-400 mt-0.5">{selectedCampaign.replyTo}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('audience')}
|
onClick={() => toggleField('audience')}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -387,7 +381,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
|||||||
Audience Settings
|
Audience Settings
|
||||||
</Label>
|
</Label>
|
||||||
{selectedCampaign && (
|
{selectedCampaign && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{getAudienceLabel(selectedCampaign)}</p>
|
<p className="text-xs text-neutral-400 mt-0.5">{getAudienceLabel(selectedCampaign)}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
|||||||
import {useUser} from '../lib/hooks/useUser';
|
import {useUser} from '../lib/hooks/useUser';
|
||||||
import {WIKI_URI} from '../lib/constants';
|
import {WIKI_URI} from '../lib/constants';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
|
import {OnboardingBanner} from './onboarding/OnboardingBanner';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
@@ -15,7 +16,6 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
User,
|
|
||||||
Users,
|
Users,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -57,7 +57,6 @@ const navigation: NavSection[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Campaigns',
|
|
||||||
items: [{name: 'Campaigns', href: '/campaigns', icon: Megaphone}],
|
items: [{name: 'Campaigns', href: '/campaigns', icon: Megaphone}],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -127,8 +126,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
|
|
||||||
// Redirect to login
|
// Redirect to login
|
||||||
await router.push('/auth/login');
|
await router.push('/auth/login');
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('Logout failed:', error);
|
|
||||||
// Even if the API call fails, try to redirect to login
|
// Even if the API call fails, try to redirect to login
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('activeProjectId');
|
localStorage.removeItem('activeProjectId');
|
||||||
@@ -179,12 +177,14 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
|
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className="h-4 w-4 text-neutral-500 flex-shrink-0" />
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 text-neutral-500 flex-shrink-0 transition-transform duration-200 ${showProjectMenu ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Project Dropdown */}
|
{/* Project Dropdown */}
|
||||||
{showProjectMenu && (
|
{showProjectMenu && (
|
||||||
<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">
|
<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">
|
||||||
{sortedProjects.map(project => (
|
{sortedProjects.map(project => (
|
||||||
<button
|
<button
|
||||||
key={project.id}
|
key={project.id}
|
||||||
@@ -196,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"
|
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 bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
<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">
|
||||||
{project.name.charAt(0).toUpperCase()}
|
{project.name.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
||||||
@@ -230,15 +230,16 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{section.items.map(item => {
|
{section.items.map(item => {
|
||||||
const isActive = router.pathname === item.href;
|
const isActive =
|
||||||
|
item.href === '/' ? router.pathname === item.href : router.pathname.startsWith(item.href);
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={() => setShowMobileMenu(false)}
|
onClick={() => setShowMobileMenu(false)}
|
||||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
isActive ? 'bg-neutral-100' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
isActive ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="h-5 w-5" />
|
<Icon className="h-5 w-5" />
|
||||||
@@ -257,7 +258,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
href={WIKI_URI}
|
href={WIKI_URI}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<BookOpen className="h-5 w-5" />
|
<BookOpen className="h-5 w-5" />
|
||||||
Documentation
|
Documentation
|
||||||
@@ -266,8 +267,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={() => setShowMobileMenu(false)}
|
onClick={() => setShowMobileMenu(false)}
|
||||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 ${
|
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' : 'hover:bg-neutral-50 hover:text-neutral-900'
|
router.pathname.startsWith('/settings')
|
||||||
|
? 'bg-neutral-100 text-neutral-900'
|
||||||
|
: 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Settings className="h-5 w-5" />
|
<Settings className="h-5 w-5" />
|
||||||
@@ -277,16 +280,23 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
<div className="relative" ref={userMenuRef}>
|
<div className="relative" ref={userMenuRef}>
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleUserMenu}
|
onClick={handleToggleUserMenu}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<User className="h-5 w-5" />
|
<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>
|
||||||
<span className="flex-1 text-left truncate">{user?.email}</span>
|
<span className="flex-1 text-left truncate">{user?.email}</span>
|
||||||
<ChevronDown className="h-4 w-4 text-neutral-500" />
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 text-neutral-500 transition-transform duration-200 ${showUserMenu ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* User Dropdown */}
|
{/* User Dropdown */}
|
||||||
{showUserMenu && (
|
{showUserMenu && (
|
||||||
<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">
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogoutClick}
|
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"
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
|
||||||
@@ -343,7 +353,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
|
|
||||||
{/* Page Content */}
|
{/* Page Content */}
|
||||||
<main className="flex-1 overflow-y-auto">
|
<main className="flex-1 overflow-y-auto">
|
||||||
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">{children}</div>
|
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
|
||||||
|
<OnboardingBanner />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ export function DataManagementSettings() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{customFields.length === 0 ? (
|
{customFields.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No custom fields found</p>
|
<p className="text-sm text-neutral-500">No custom fields found</p>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -170,7 +170,7 @@ export function DataManagementSettings() {
|
|||||||
<Badge variant="secondary">{field.type}</Badge>
|
<Badge variant="secondary">{field.type}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm text-muted-foreground">{field.coverage}%</span>
|
<span className="text-sm text-neutral-500">{field.coverage}%</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button variant="ghost" size="sm" onClick={() => openFieldDeleteDialog(field.field)}>
|
<Button variant="ghost" size="sm" onClick={() => openFieldDeleteDialog(field.field)}>
|
||||||
@@ -196,7 +196,7 @@ export function DataManagementSettings() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{customEvents.length === 0 ? (
|
{customEvents.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No custom events found</p>
|
<p className="text-sm text-neutral-500">No custom events found</p>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
|
|||||||
@@ -24,6 +24,34 @@ import {Check, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw, Trash2, XCir
|
|||||||
import {useConfig} from '../lib/hooks/useConfig';
|
import {useConfig} from '../lib/hooks/useConfig';
|
||||||
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
|
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 {
|
interface DomainsSettingsProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
@@ -419,14 +447,13 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleCopyToken(`${token}._domainkey.${domain.domain}`, index + 2000)
|
handleCopyToken(`${token}._domainkey.${domain.domain}`, index + 2000)
|
||||||
}
|
}
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copiedToken ===
|
<AnimatedCopyIcon
|
||||||
`${token}._domainkey.${domain.domain}-${index + 2000}` ? (
|
isCopied={
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
copiedToken === `${token}._domainkey.${domain.domain}-${index + 2000}`
|
||||||
) : (
|
}
|
||||||
<Copy className="h-3 w-3" />
|
/>
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -439,13 +466,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCopyToken(`${token}.dkim.amazonses.com`, index)}
|
onClick={() => handleCopyToken(`${token}.dkim.amazonses.com`, index)}
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copiedToken === `${token}.dkim.amazonses.com-${index}` ? (
|
<AnimatedCopyIcon
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
isCopied={copiedToken === `${token}.dkim.amazonses.com-${index}`}
|
||||||
) : (
|
/>
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -500,13 +525,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copiedToken === `plunk.${domain.domain}-3000` ? (
|
<AnimatedCopyIcon
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
isCopied={copiedToken === `plunk.${domain.domain}-3000`}
|
||||||
) : (
|
/>
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -524,14 +547,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
1000,
|
1000,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copiedToken ===
|
<AnimatedCopyIcon
|
||||||
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000` ? (
|
isCopied={
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
copiedToken ===
|
||||||
) : (
|
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000`
|
||||||
<Copy className="h-3 w-3" />
|
}
|
||||||
)}
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -551,13 +574,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copiedToken === `plunk.${domain.domain}-3001` ? (
|
<AnimatedCopyIcon
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
isCopied={copiedToken === `plunk.${domain.domain}-3001`}
|
||||||
) : (
|
/>
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -572,13 +593,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)
|
handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)
|
||||||
}
|
}
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
|
<AnimatedCopyIcon
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
isCopied={copiedToken === '"v=spf1 include:amazonses.com ~all"-1001'}
|
||||||
) : (
|
/>
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -632,13 +651,9 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleCopyToken(domain.domain, 3002)}
|
onClick={() => handleCopyToken(domain.domain, 3002)}
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copiedToken === `${domain.domain}-3002` ? (
|
<AnimatedCopyIcon isCopied={copiedToken === `${domain.domain}-3002`} />
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -656,14 +671,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
1002,
|
1002,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{copiedToken ===
|
<AnimatedCopyIcon
|
||||||
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002` ? (
|
isCopied={
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
copiedToken ===
|
||||||
) : (
|
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002`
|
||||||
<Copy className="h-3 w-3" />
|
}
|
||||||
)}
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -674,8 +689,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mt-3">
|
<div className="flex items-start gap-2 p-3 bg-neutral-50 rounded-lg border border-neutral-200 mt-3">
|
||||||
<div className="text-blue-600 mt-0.5">
|
<div className="text-neutral-500 mt-0.5">
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
<path
|
<path
|
||||||
fillRule="evenodd"
|
fillRule="evenodd"
|
||||||
@@ -684,7 +699,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-blue-900">
|
<p className="text-xs text-neutral-600">
|
||||||
Click the copy icon to copy record values. After adding all records to your DNS
|
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.
|
provider, use the refresh button above to verify your domain.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
import {Code2, Eye, Monitor, Smartphone, Tablet, Upload, X} from 'lucide-react';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
|
import {detectCustomHtmlPatterns, wrapEmailWithStyles} from '../../lib/emailStyles';
|
||||||
import 'tippy.js/dist/tippy.css';
|
import 'tippy.js/dist/tippy.css';
|
||||||
|
|
||||||
interface EmailEditorProps {
|
interface EmailEditorProps {
|
||||||
@@ -51,54 +52,6 @@ const commonVariables = [
|
|||||||
{name: 'manageUrl', description: 'Manage link'},
|
{name: 'manageUrl', description: 'Manage link'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const detectCustomHtmlPatterns = (html: string): boolean => {
|
|
||||||
if (!html || html.trim() === '') return false;
|
|
||||||
|
|
||||||
// Check for common patterns that indicate custom HTML
|
|
||||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
|
||||||
|
|
||||||
// Check for custom classes (exclude TipTap's generated classes)
|
|
||||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
|
||||||
let hasCustomClasses = false;
|
|
||||||
for (const match of classMatches) {
|
|
||||||
const classValue = match[1];
|
|
||||||
if (!classValue) continue;
|
|
||||||
// Split by whitespace to get individual classes
|
|
||||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
|
||||||
// Check if any class is NOT in the allowed list
|
|
||||||
const allowedPrefixes = [
|
|
||||||
'prose',
|
|
||||||
'variable-',
|
|
||||||
'email-image',
|
|
||||||
'ProseMirror',
|
|
||||||
'resizable-image',
|
|
||||||
'selected',
|
|
||||||
'resize-handle',
|
|
||||||
];
|
|
||||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
|
||||||
if (hasDisallowedClass) {
|
|
||||||
hasCustomClasses = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
|
||||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
|
|
||||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
|
||||||
const hasMediaQueries = /@media/i.test(html);
|
|
||||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
|
||||||
|
|
||||||
return (
|
|
||||||
hasInlineStyles ||
|
|
||||||
hasCustomClasses ||
|
|
||||||
hasCustomAttributes ||
|
|
||||||
hasComplexTables ||
|
|
||||||
hasCustomElements ||
|
|
||||||
hasMediaQueries ||
|
|
||||||
hasStyleTags
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) {
|
export function EmailEditor({value, onChange, placeholder, subject, from, replyTo}: EmailEditorProps) {
|
||||||
// Detect if initial value has custom HTML and start in appropriate mode
|
// Detect if initial value has custom HTML and start in appropriate mode
|
||||||
const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual';
|
const initialMode = detectCustomHtmlPatterns(value) ? 'html' : 'visual';
|
||||||
@@ -348,31 +301,7 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
|||||||
|
|
||||||
if (iframeDoc) {
|
if (iframeDoc) {
|
||||||
const previewContent = getPreviewHtml();
|
const previewContent = getPreviewHtml();
|
||||||
const fullHtml = `
|
const fullHtml = wrapEmailWithStyles(previewContent);
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
${previewContent}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
iframeDoc.open();
|
iframeDoc.open();
|
||||||
iframeDoc.write(fullHtml);
|
iframeDoc.write(fullHtml);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {Button, Dialog, DialogContent, DialogHeader, DialogTitle} from '@plunk/ui';
|
import {Button, Dialog, DialogContent, DialogHeader, DialogTitle} from '@plunk/ui';
|
||||||
import {Monitor, Smartphone, Tablet} from 'lucide-react';
|
import {Monitor, Smartphone, Tablet} from 'lucide-react';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
|
import {wrapEmailWithStyles} from '../lib/emailStyles';
|
||||||
|
|
||||||
interface EmailPreviewModalProps {
|
interface EmailPreviewModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -16,391 +17,6 @@ interface EmailPreviewModalProps {
|
|||||||
|
|
||||||
type PreviewDevice = 'mobile' | 'tablet' | 'desktop';
|
type PreviewDevice = 'mobile' | 'tablet' | 'desktop';
|
||||||
|
|
||||||
// Detects if HTML contains custom patterns that indicate it was written in the HTML editor
|
|
||||||
// rather than the visual editor. Custom HTML should render as-is without prose wrapper.
|
|
||||||
const detectCustomHtmlPatterns = (html: string): boolean => {
|
|
||||||
if (!html || html.trim() === '') return false;
|
|
||||||
|
|
||||||
// Check for common patterns that indicate custom HTML
|
|
||||||
const hasInlineStyles = /<[^>]+style\s*=\s*["'][^"']*["']/i.test(html);
|
|
||||||
|
|
||||||
// Check for custom classes (exclude TipTap's generated classes)
|
|
||||||
const classMatches = html.matchAll(/class\s*=\s*["']([^"']*)["']/gi);
|
|
||||||
let hasCustomClasses = false;
|
|
||||||
for (const match of classMatches) {
|
|
||||||
const classValue = match[1];
|
|
||||||
if (!classValue) continue;
|
|
||||||
// Split by whitespace to get individual classes
|
|
||||||
const classes = classValue.split(/\s+/).filter(c => c.length > 0);
|
|
||||||
// Check if any class is NOT in the allowed list
|
|
||||||
const allowedPrefixes = [
|
|
||||||
'prose',
|
|
||||||
'variable-',
|
|
||||||
'email-image',
|
|
||||||
'ProseMirror',
|
|
||||||
'resizable-image',
|
|
||||||
'selected',
|
|
||||||
'resize-handle',
|
|
||||||
];
|
|
||||||
const hasDisallowedClass = classes.some(cls => !allowedPrefixes.some(prefix => cls.startsWith(prefix)));
|
|
||||||
if (hasDisallowedClass) {
|
|
||||||
hasCustomClasses = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasCustomAttributes = /<[^>]+(?:data-|aria-|role=|id=)/i.test(html);
|
|
||||||
const hasComplexTables = /<table[^>]*>[\s\S]*?<table/i.test(html); // Nested tables
|
|
||||||
const hasCustomElements = /<(?:div|span|section|article|header|footer|nav|aside)[^>]*>/i.test(html);
|
|
||||||
const hasMediaQueries = /@media/i.test(html);
|
|
||||||
const hasStyleTags = /<style[^>]*>/i.test(html);
|
|
||||||
|
|
||||||
return (
|
|
||||||
hasInlineStyles ||
|
|
||||||
hasCustomClasses ||
|
|
||||||
hasCustomAttributes ||
|
|
||||||
hasComplexTables ||
|
|
||||||
hasCustomElements ||
|
|
||||||
hasMediaQueries ||
|
|
||||||
hasStyleTags
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to wrap email body with necessary styles for proper rendering
|
|
||||||
// Only wraps visual editor content - custom HTML is returned as-is
|
|
||||||
const wrapEmailWithStyles = (htmlBody: string): string => {
|
|
||||||
// If this is custom HTML (from HTML editor), return it as-is without wrapping
|
|
||||||
if (detectCustomHtmlPatterns(htmlBody)) {
|
|
||||||
return htmlBody;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For visual editor content, wrap with prose styles
|
|
||||||
return `<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<style>
|
|
||||||
/* Base reset */
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 16px;
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tailwind Typography (prose) base styles */
|
|
||||||
.prose {
|
|
||||||
color: #374151;
|
|
||||||
max-width: 65ch;
|
|
||||||
}
|
|
||||||
.prose [class~="lead"] {
|
|
||||||
color: #4b5563;
|
|
||||||
font-size: 1.25em;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-top: 1.2em;
|
|
||||||
margin-bottom: 1.2em;
|
|
||||||
}
|
|
||||||
.prose a {
|
|
||||||
color: #3b82f6;
|
|
||||||
text-decoration: underline;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.prose strong {
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.prose ol, .prose ul {
|
|
||||||
margin-top: 1.25em;
|
|
||||||
margin-bottom: 1.25em;
|
|
||||||
padding-left: 1.625em;
|
|
||||||
}
|
|
||||||
.prose li {
|
|
||||||
margin-top: 0.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
.prose ol > li {
|
|
||||||
padding-left: 0.375em;
|
|
||||||
}
|
|
||||||
.prose ul > li {
|
|
||||||
padding-left: 0.375em;
|
|
||||||
}
|
|
||||||
.prose > ul > li p {
|
|
||||||
margin-top: 0.75em;
|
|
||||||
margin-bottom: 0.75em;
|
|
||||||
}
|
|
||||||
.prose > ul > li > *:first-child {
|
|
||||||
margin-top: 1.25em;
|
|
||||||
}
|
|
||||||
.prose > ul > li > *:last-child {
|
|
||||||
margin-bottom: 1.25em;
|
|
||||||
}
|
|
||||||
.prose > ol > li > *:first-child {
|
|
||||||
margin-top: 1.25em;
|
|
||||||
}
|
|
||||||
.prose > ol > li > *:last-child {
|
|
||||||
margin-bottom: 1.25em;
|
|
||||||
}
|
|
||||||
.prose ul ul, .prose ul ol, .prose ol ul, .prose ol ol {
|
|
||||||
margin-top: 0.75em;
|
|
||||||
margin-bottom: 0.75em;
|
|
||||||
}
|
|
||||||
.prose hr {
|
|
||||||
border-color: #e5e7eb;
|
|
||||||
border-top-width: 1px;
|
|
||||||
margin-top: 3em;
|
|
||||||
margin-bottom: 3em;
|
|
||||||
}
|
|
||||||
.prose blockquote {
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: italic;
|
|
||||||
color: #111827;
|
|
||||||
border-left-width: 0.25rem;
|
|
||||||
border-left-color: #e5e7eb;
|
|
||||||
quotes: "\\201C""\\201D""\\2018""\\2019";
|
|
||||||
margin-top: 1.6em;
|
|
||||||
margin-bottom: 1.6em;
|
|
||||||
padding-left: 1em;
|
|
||||||
}
|
|
||||||
.prose h1 {
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 2.25em;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.8888889em;
|
|
||||||
line-height: 1.1111111;
|
|
||||||
}
|
|
||||||
.prose h2 {
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.5em;
|
|
||||||
margin-top: 2em;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
line-height: 1.3333333;
|
|
||||||
}
|
|
||||||
.prose h3 {
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.25em;
|
|
||||||
margin-top: 1.6em;
|
|
||||||
margin-bottom: 0.6em;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
.prose h4 {
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
.prose img {
|
|
||||||
margin-top: 2em;
|
|
||||||
margin-bottom: 2em;
|
|
||||||
}
|
|
||||||
.prose figure {
|
|
||||||
margin-top: 2em;
|
|
||||||
margin-bottom: 2em;
|
|
||||||
}
|
|
||||||
.prose figure > * {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.prose code {
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
.prose code::before {
|
|
||||||
content: "\`";
|
|
||||||
}
|
|
||||||
.prose code::after {
|
|
||||||
content: "\`";
|
|
||||||
}
|
|
||||||
.prose pre {
|
|
||||||
color: #e5e7eb;
|
|
||||||
background-color: #1f2937;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: 0.875em;
|
|
||||||
line-height: 1.7142857;
|
|
||||||
margin-top: 1.7142857em;
|
|
||||||
margin-bottom: 1.7142857em;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding-top: 0.8571429em;
|
|
||||||
padding-right: 1.1428571em;
|
|
||||||
padding-bottom: 0.8571429em;
|
|
||||||
padding-left: 1.1428571em;
|
|
||||||
}
|
|
||||||
.prose pre code {
|
|
||||||
background-color: transparent;
|
|
||||||
border-width: 0;
|
|
||||||
border-radius: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-weight: 400;
|
|
||||||
color: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
.prose pre code::before {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
.prose pre code::after {
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
.prose table {
|
|
||||||
width: 100%;
|
|
||||||
table-layout: auto;
|
|
||||||
text-align: left;
|
|
||||||
margin-top: 2em;
|
|
||||||
margin-bottom: 2em;
|
|
||||||
font-size: 0.875em;
|
|
||||||
line-height: 1.7142857;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.prose thead {
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
border-bottom-color: #d1d5db;
|
|
||||||
}
|
|
||||||
.prose thead th {
|
|
||||||
color: #111827;
|
|
||||||
font-weight: 600;
|
|
||||||
vertical-align: bottom;
|
|
||||||
padding-right: 0.5714286em;
|
|
||||||
padding-bottom: 0.5714286em;
|
|
||||||
padding-left: 0.5714286em;
|
|
||||||
}
|
|
||||||
.prose tbody tr {
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
border-bottom-color: #e5e7eb;
|
|
||||||
}
|
|
||||||
.prose tbody tr:last-child {
|
|
||||||
border-bottom-width: 0;
|
|
||||||
}
|
|
||||||
.prose tbody td {
|
|
||||||
vertical-align: top;
|
|
||||||
padding-top: 0.5714286em;
|
|
||||||
padding-right: 0.5714286em;
|
|
||||||
padding-bottom: 0.5714286em;
|
|
||||||
padding-left: 0.5714286em;
|
|
||||||
}
|
|
||||||
.prose p {
|
|
||||||
margin-top: 1.25em;
|
|
||||||
margin-bottom: 1.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* prose-sm modifier */
|
|
||||||
.prose-sm {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
line-height: 1.7142857;
|
|
||||||
}
|
|
||||||
.prose-sm p {
|
|
||||||
margin-top: 1.1428571em;
|
|
||||||
margin-bottom: 1.1428571em;
|
|
||||||
}
|
|
||||||
.prose-sm h1 {
|
|
||||||
font-size: 2.1428571em;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.8em;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
.prose-sm h2 {
|
|
||||||
font-size: 1.4285714em;
|
|
||||||
margin-top: 1.6em;
|
|
||||||
margin-bottom: 0.8em;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
.prose-sm h3 {
|
|
||||||
font-size: 1.2857143em;
|
|
||||||
margin-top: 1.5555556em;
|
|
||||||
margin-bottom: 0.4444444em;
|
|
||||||
line-height: 1.5555556;
|
|
||||||
}
|
|
||||||
.prose-sm h4 {
|
|
||||||
margin-top: 1.4285714em;
|
|
||||||
margin-bottom: 0.5714286em;
|
|
||||||
line-height: 1.4285714;
|
|
||||||
}
|
|
||||||
.prose-sm img {
|
|
||||||
margin-top: 1.7142857em;
|
|
||||||
margin-bottom: 1.7142857em;
|
|
||||||
}
|
|
||||||
.prose-sm ol, .prose-sm ul {
|
|
||||||
margin-top: 1.1428571em;
|
|
||||||
margin-bottom: 1.1428571em;
|
|
||||||
padding-left: 1.5714286em;
|
|
||||||
}
|
|
||||||
.prose-sm li {
|
|
||||||
margin-top: 0.2857143em;
|
|
||||||
margin-bottom: 0.2857143em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* max-w-none utility */
|
|
||||||
.max-w-none {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Custom editor styles */
|
|
||||||
.variable-highlight, .variable-placeholder, .variable-mention {
|
|
||||||
background-color: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-family: 'Courier New', monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td {
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
display: block;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizable-image-wrapper {
|
|
||||||
display: block;
|
|
||||||
margin: 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizable-image-container {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.resizable-image-container img {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="prose prose-sm max-w-none">
|
|
||||||
${htmlBody}
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EmailPreviewModal({
|
export function EmailPreviewModal({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
import {Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
||||||
import {BookOpen, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
|
import {BookOpen, Check, CheckCircle2, Mail, MessageCircle, Shield, Users, Zap} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useMemo} from 'react';
|
import {useMemo, useState} from 'react';
|
||||||
import {LANDING_URI, WIKI_URI} from '../lib/constants';
|
import {LANDING_URI, WIKI_URI} from '../lib/constants';
|
||||||
import type {ProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
import type {ProjectSetupState} from '../lib/hooks/useProjectSetupState';
|
||||||
import {useConfig} from '../lib/hooks/useConfig';
|
import {useConfig} from '../lib/hooks/useConfig';
|
||||||
@@ -23,6 +24,14 @@ interface QuickStartProps {
|
|||||||
|
|
||||||
// Help resources that always appear
|
// Help resources that always appear
|
||||||
function HelpResources() {
|
function HelpResources() {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyEmail = () => {
|
||||||
|
void navigator.clipboard.writeText('[email protected]');
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||||
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
<p className="text-xs font-medium text-neutral-500 mb-3">Need help?</p>
|
||||||
@@ -39,6 +48,39 @@ function HelpResources() {
|
|||||||
Join Discord
|
Join Discord
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
|
{copied ? (
|
||||||
|
<motion.span
|
||||||
|
key="copied"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
initial={{opacity: 0, y: 6}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
exit={{opacity: 0, y: -6}}
|
||||||
|
transition={{duration: 0.15}}
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||||
|
<span className="text-green-600">Copied!</span>
|
||||||
|
</motion.span>
|
||||||
|
) : (
|
||||||
|
<motion.span
|
||||||
|
key="idle"
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
initial={{opacity: 0, y: 6}}
|
||||||
|
animate={{opacity: 1, y: 0}}
|
||||||
|
exit={{opacity: 0, y: -6}}
|
||||||
|
transition={{duration: 0.15}}
|
||||||
|
>
|
||||||
|
<Mail className="h-3.5 w-3.5" />
|
||||||
|
Email support
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -188,8 +230,7 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
|
|||||||
<div className="flex-1 pt-0.5">
|
<div className="flex-1 pt-0.5">
|
||||||
<p className="text-sm font-semibold text-green-900 mb-1">All set!</p>
|
<p className="text-sm font-semibold text-green-900 mb-1">All set!</p>
|
||||||
<p className="text-xs text-green-700 leading-relaxed">
|
<p className="text-xs text-green-700 leading-relaxed">
|
||||||
Your project is fully configured and you're actively engaging your audience. Keep up the great
|
Domain verified, contacts imported, campaigns running. Everything is set up correctly.
|
||||||
work!
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
import {
|
import {Alert, AlertDescription, AlertTitle, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
AlertTitle,
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
Progress,
|
|
||||||
} from '@plunk/ui';
|
|
||||||
import {AlertCircle, AlertTriangle, CheckCircle, Shield} from 'lucide-react';
|
import {AlertCircle, AlertTriangle, CheckCircle, Shield} from 'lucide-react';
|
||||||
import type {ProjectSecurityMetrics} from '@plunk/types';
|
import type {ProjectSecurityMetrics, SecurityLevel} from '@plunk/types';
|
||||||
|
|
||||||
interface SecuritySettingsProps {
|
interface SecuritySettingsProps {
|
||||||
metrics: ProjectSecurityMetrics;
|
metrics: ProjectSecurityMetrics;
|
||||||
isLoading: boolean;
|
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) {
|
export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -32,42 +34,9 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {status, thresholds, isDisabled} = metrics;
|
const {status, levels, isDisabled} = metrics;
|
||||||
|
const overallLevel = getOverallLevel(status);
|
||||||
// Helper to get status color and icon
|
const overallConfig = STATUS_CONFIG[overallLevel];
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -75,131 +44,99 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`p-2 rounded-lg ${status.isHealthy ? 'bg-green-100' : 'bg-red-100'}`}>
|
<div className={`p-2 rounded-lg ${overallConfig.bg}`}>
|
||||||
<Shield className={`h-5 w-5 ${status.isHealthy ? 'text-green-600' : 'text-red-600'}`} />
|
<Shield className={`h-5 w-5 ${overallConfig.color}`} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Security Overview</CardTitle>
|
<CardTitle>Security Overview</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{status.isHealthy ? 'Your project is in good standing' : 'Action required to maintain project health'}
|
{overallLevel === 'healthy' && 'Your project is in good standing'}
|
||||||
|
{overallLevel === 'warning' && 'Your email health needs attention'}
|
||||||
|
{overallLevel === 'critical' && 'Action required to maintain project health'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{/* Project Disabled Alert */}
|
|
||||||
{isDisabled && (
|
{isDisabled && (
|
||||||
<Alert variant="destructive" className="mb-4">
|
<Alert variant="destructive" className="mb-4">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Project Disabled</AlertTitle>
|
<AlertTitle>Project Disabled</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
This project has been disabled due to critical security violations. Contact support to resolve.
|
This project has been disabled due to security violations. Contact support to resolve.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Violations */}
|
{overallLevel === 'critical' && !isDisabled && (
|
||||||
{status.violations.length > 0 && !isDisabled && (
|
|
||||||
<Alert variant="destructive" className="mb-4">
|
<Alert variant="destructive" className="mb-4">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Critical Violations ({status.violations.length})</AlertTitle>
|
<AlertTitle>Critical</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
|
Your bounce or complaint rates have exceeded acceptable levels. Review your contact lists and sending
|
||||||
{status.violations.map((violation, idx) => (
|
practices to avoid project suspension.
|
||||||
<li key={idx}>{violation}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warnings */}
|
{overallLevel === 'warning' && (
|
||||||
{status.warnings.length > 0 && (
|
|
||||||
<Alert variant="warning" className="mb-4">
|
<Alert variant="warning" className="mb-4">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<AlertTitle>Security Warnings ({status.warnings.length})</AlertTitle>
|
<AlertTitle>Warning</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<ul className="list-disc list-inside space-y-1 text-sm mt-2">
|
Your bounce or complaint rates are approaching limits. Review your contact lists and remove invalid
|
||||||
{status.warnings.map((warning, idx) => (
|
addresses to maintain good standing.
|
||||||
<li key={idx}>{warning}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Healthy Status */}
|
{overallLevel === 'healthy' && !isDisabled && (
|
||||||
{status.isHealthy && !isDisabled && (
|
|
||||||
<Alert>
|
<Alert>
|
||||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
All security metrics are within acceptable thresholds. Keep up the good work!
|
All security metrics are within acceptable levels. Keep up the good work!
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Bounce Rate Metrics */}
|
{/* Bounce Metrics */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Bounce Rate Metrics</CardTitle>
|
<CardTitle>Bounce Rate</CardTitle>
|
||||||
<CardDescription>Hard bounces indicate invalid or non-existent email addresses</CardDescription>
|
<CardDescription>Hard bounces indicate invalid or non-existent email addresses</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* 7-Day Bounce Rate */}
|
<HealthMetric
|
||||||
<MetricDisplay
|
label="Last 7 Days"
|
||||||
label="7-Day Bounce Rate"
|
level={levels.bounce7Day}
|
||||||
rate={status.sevenDay.bounceRate}
|
detail={`${status.sevenDay.bounceRate.toFixed(2)}% bounce rate (${status.sevenDay.bounces.toLocaleString()} of ${status.sevenDay.total.toLocaleString()} emails)`}
|
||||||
count={status.sevenDay.bounces}
|
|
||||||
total={status.sevenDay.total}
|
|
||||||
warningThreshold={thresholds.BOUNCE_7DAY_WARNING}
|
|
||||||
criticalThreshold={thresholds.BOUNCE_7DAY_CRITICAL}
|
|
||||||
status={sevenDayBounceStatus}
|
|
||||||
/>
|
/>
|
||||||
|
<HealthMetric
|
||||||
{/* All-Time Bounce Rate */}
|
label="All Time"
|
||||||
<MetricDisplay
|
level={levels.bounceAllTime}
|
||||||
label="All-Time Bounce Rate"
|
detail={`${status.allTime.bounceRate.toFixed(2)}% bounce rate (${status.allTime.bounces.toLocaleString()} of ${status.allTime.total.toLocaleString()} emails)`}
|
||||||
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Complaint Rate Metrics */}
|
{/* Complaint Metrics */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Complaint Rate Metrics</CardTitle>
|
<CardTitle>Complaint Rate</CardTitle>
|
||||||
<CardDescription>Complaints occur when recipients mark emails as spam</CardDescription>
|
<CardDescription>Complaints occur when recipients mark emails as spam</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{/* 7-Day Complaint Rate */}
|
<HealthMetric
|
||||||
<MetricDisplay
|
label="Last 7 Days"
|
||||||
label="7-Day Complaint Rate"
|
level={levels.complaint7Day}
|
||||||
rate={status.sevenDay.complaintRate}
|
detail={`${status.sevenDay.complaintRate.toFixed(3)}% complaint rate (${status.sevenDay.complaints.toLocaleString()} of ${status.sevenDay.total.toLocaleString()} emails)`}
|
||||||
count={status.sevenDay.complaints}
|
|
||||||
total={status.sevenDay.total}
|
|
||||||
warningThreshold={thresholds.COMPLAINT_7DAY_WARNING}
|
|
||||||
criticalThreshold={thresholds.COMPLAINT_7DAY_CRITICAL}
|
|
||||||
status={sevenDayComplaintStatus}
|
|
||||||
isComplaintRate
|
|
||||||
/>
|
/>
|
||||||
|
<HealthMetric
|
||||||
{/* All-Time Complaint Rate */}
|
label="All Time"
|
||||||
<MetricDisplay
|
level={levels.complaintAllTime}
|
||||||
label="All-Time Complaint Rate"
|
detail={`${status.allTime.complaintRate.toFixed(3)}% complaint rate (${status.allTime.complaints.toLocaleString()} of ${status.allTime.total.toLocaleString()} emails)`}
|
||||||
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -207,60 +144,25 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MetricDisplayProps {
|
interface HealthMetricProps {
|
||||||
label: string;
|
label: string;
|
||||||
rate: number;
|
level: SecurityLevel;
|
||||||
count: number;
|
detail: string;
|
||||||
total: number;
|
|
||||||
warningThreshold: number;
|
|
||||||
criticalThreshold: number;
|
|
||||||
status: {
|
|
||||||
color: string;
|
|
||||||
icon: React.ComponentType<{className?: string}>;
|
|
||||||
bg: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
isComplaintRate?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricDisplay({
|
function HealthMetric({label, level, detail}: HealthMetricProps) {
|
||||||
label,
|
const config = STATUS_CONFIG[level];
|
||||||
rate,
|
const Icon = config.icon;
|
||||||
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 (
|
return (
|
||||||
<div className="border border-neutral-200 rounded-lg p-4">
|
<div className="flex items-center justify-between border border-neutral-200 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-neutral-900">{label}</h3>
|
<h3 className="font-medium text-neutral-900">{label}</h3>
|
||||||
<p className="text-sm text-neutral-600 mt-1">
|
<p className="text-sm text-neutral-600 mt-1">{detail}</p>
|
||||||
{count.toLocaleString()} / {total.toLocaleString()} emails
|
|
||||||
<span className="text-neutral-400 mx-2">•</span>
|
|
||||||
<strong>{rate.toFixed(decimals)}%</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={`flex items-center gap-2 ${status.color}`}>
|
<div className={`flex items-center gap-2 ${config.color}`}>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium">{status.label}</span>
|
<span className="text-sm font-medium">{config.label}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ interface SecurityWarningBannerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
|
export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
|
||||||
// Don't show if no warnings or violations
|
|
||||||
const hasCriticalViolations = status.violations.length > 0;
|
const hasCriticalViolations = status.violations.length > 0;
|
||||||
const hasWarnings = status.warnings.length > 0;
|
const hasWarnings = status.warnings.length > 0;
|
||||||
|
|
||||||
@@ -16,12 +15,10 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine severity - critical violations take precedence
|
|
||||||
const variant = hasCriticalViolations ? 'destructive' : 'warning';
|
const variant = hasCriticalViolations ? 'destructive' : 'warning';
|
||||||
const title = hasCriticalViolations
|
const title = hasCriticalViolations
|
||||||
? 'Critical Security Violations - Immediate Action Required'
|
? 'Critical - Immediate Action Required'
|
||||||
: 'Security Warning - Action Required';
|
: 'Warning - Action Recommended';
|
||||||
const issues = hasCriticalViolations ? status.violations : status.warnings;
|
|
||||||
const messageColor = hasCriticalViolations ? 'text-red-800' : 'text-amber-800';
|
const messageColor = hasCriticalViolations ? 'text-red-800' : 'text-amber-800';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,16 +27,10 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
|
|||||||
<AlertTitle>{title}</AlertTitle>
|
<AlertTitle>{title}</AlertTitle>
|
||||||
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
||||||
<div className="space-y-2 flex-1">
|
<div className="space-y-2 flex-1">
|
||||||
<p className="text-sm font-medium">Your project has exceeded the following security thresholds:</p>
|
<p className={`text-sm ${messageColor}`}>
|
||||||
<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
|
{hasCriticalViolations
|
||||||
? 'Your project may be suspended soon. Please review the detailed metrics immediately and take action to improve your email quality.'
|
? 'Your bounce or complaint rates have exceeded acceptable levels. Review your contact lists and sending practices to avoid project suspension.'
|
||||||
: 'High bounce or complaint rates can lead to project suspension. Review the detailed metrics and take action to improve your email quality.'}
|
: 'Your bounce or complaint rates are approaching limits. Review your contact lists and remove invalid addresses to maintain good standing.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/settings?tab=security">
|
<Link href="/settings?tab=security">
|
||||||
|
|||||||
@@ -775,19 +775,9 @@ interface SegmentFilterBuilderProps {
|
|||||||
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) {
|
export function SegmentFilterBuilder({condition, onChange}: SegmentFilterBuilderProps) {
|
||||||
const {fields, loading} = useAvailableOptions();
|
const {fields, loading} = useAvailableOptions();
|
||||||
|
|
||||||
return (
|
if (loading) {
|
||||||
<div className="space-y-4">
|
return <div className="text-sm text-neutral-500 py-4">Loading available fields and events...</div>;
|
||||||
<div className="flex items-center justify-between">
|
}
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-neutral-900">Filter Conditions</h3>
|
return <FilterConditionComponent condition={condition} onChange={onChange} availableFields={fields} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -40,7 +39,8 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {MembershipSchemas} from '@plunk/shared';
|
import {MembershipSchemas} from '@plunk/shared';
|
||||||
import {AlertTriangle, Mail, MoreVertical, Trash2, UserPlus} from 'lucide-react';
|
import {MoreVertical, Trash2, UserPlus} from 'lucide-react';
|
||||||
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {useForm} from 'react-hook-form';
|
import {useForm} from 'react-hook-form';
|
||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import type {z} from 'zod';
|
import type {z} from 'zod';
|
||||||
@@ -173,23 +173,30 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
{success && (
|
{success && (
|
||||||
<Alert>
|
<motion.div
|
||||||
<Mail className="h-4 w-4" />
|
key="success"
|
||||||
<div className="ml-2">
|
initial={{opacity: 0, y: -10}}
|
||||||
<p className="text-sm font-medium">{success}</p>
|
animate={{opacity: 1, y: 0}}
|
||||||
</div>
|
exit={{opacity: 0}}
|
||||||
</Alert>
|
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<motion.div
|
||||||
<AlertTriangle className="h-4 w-4" />
|
key="error"
|
||||||
<div className="ml-2">
|
initial={{opacity: 0, y: -10}}
|
||||||
<p className="text-sm font-medium">{error}</p>
|
animate={{opacity: 1, y: 0}}
|
||||||
</div>
|
exit={{opacity: 0}}
|
||||||
</Alert>
|
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -214,10 +221,10 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex justify-center py-8">
|
<div className="flex justify-center py-8">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600" />
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-900" />
|
||||||
</div>
|
</div>
|
||||||
) : members.length === 0 ? (
|
) : members.length === 0 ? (
|
||||||
<div className="py-8 text-center text-sm text-gray-500">No members found</div>
|
<div className="py-8 text-center text-sm text-neutral-500">No members found</div>
|
||||||
) : (
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -314,12 +321,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
|
|||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(handleAddMember)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(handleAddMember)} className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">{error}</div>
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<div className="ml-2">
|
|
||||||
<p className="text-sm font-medium">{error}</p>
|
|
||||||
</div>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [searchInput, setSearchInput] = useState('');
|
const [searchInput, setSearchInput] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING'>('ALL');
|
const [typeFilter, setTypeFilter] = useState<'ALL' | 'TRANSACTIONAL' | 'MARKETING' | 'HEADLESS'>('ALL');
|
||||||
const [step, setStep] = useState<'select' | 'configure'>('select');
|
const [step, setStep] = useState<'select' | 'configure'>('select');
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||||
const [selectedFields, setSelectedFields] = useState<SelectedFields>({
|
const [selectedFields, setSelectedFields] = useState<SelectedFields>({
|
||||||
@@ -186,6 +186,17 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
>
|
>
|
||||||
Transactional
|
Transactional
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTypeFilter('HEADLESS');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
variant={typeFilter === 'HEADLESS' ? 'default' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
Headless
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -225,7 +236,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
{data?.data.map(template => (
|
{data?.data.map(template => (
|
||||||
<Card
|
<Card
|
||||||
key={template.id}
|
key={template.id}
|
||||||
className="cursor-pointer hover:border-primary/50 hover:shadow-md transition-all"
|
className="cursor-pointer hover:border-neutral-400 transition-colors"
|
||||||
onClick={() => handleTemplateClick(template)}
|
onClick={() => handleTemplateClick(template)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@@ -233,7 +244,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<CardTitle className="text-base truncate">{template.name}</CardTitle>
|
<CardTitle className="text-base truncate">{template.name}</CardTitle>
|
||||||
<Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : 'success'}>
|
<Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : template.type === 'HEADLESS' ? 'warning' : 'success'}>
|
||||||
{template.type.toLowerCase()}
|
{template.type.toLowerCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -284,15 +295,12 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Scrollable Content */}
|
{/* Scrollable Content */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-6 pr-2">
|
<div className="flex-1 overflow-y-auto pr-2">
|
||||||
{/* Template Preview */}
|
{/* Template Preview */}
|
||||||
{selectedTemplate && (
|
{selectedTemplate && (
|
||||||
<Card className="bg-neutral-50">
|
<div className="pb-4 mb-1 border-b border-neutral-100">
|
||||||
<CardHeader className="pb-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<span className="text-sm font-medium text-neutral-900">{selectedTemplate.name}</span>
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<CardTitle className="text-base">{selectedTemplate.name}</CardTitle>
|
|
||||||
<Badge
|
<Badge
|
||||||
className="capitalize"
|
className="capitalize"
|
||||||
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
|
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
|
||||||
@@ -301,18 +309,15 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{selectedTemplate.description && (
|
{selectedTemplate.description && (
|
||||||
<CardDescription className="text-xs">{selectedTemplate.description}</CardDescription>
|
<p className="text-xs text-neutral-500 mt-1">{selectedTemplate.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Field Selection */}
|
{/* Field Selection */}
|
||||||
<div className="space-y-3">
|
<div className="divide-y divide-neutral-100">
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('subject')}
|
onClick={() => toggleField('subject')}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -325,13 +330,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
Email Subject
|
Email Subject
|
||||||
</Label>
|
</Label>
|
||||||
{selectedTemplate?.subject && (
|
{selectedTemplate?.subject && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.subject}</p>
|
<p className="text-xs text-neutral-400 mt-0.5 truncate">{selectedTemplate.subject}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('body')}
|
onClick={() => toggleField('body')}
|
||||||
>
|
>
|
||||||
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
|
<Checkbox id="body" checked={selectedFields.body} onCheckedChange={() => toggleField('body')} />
|
||||||
@@ -339,12 +344,12 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
|
<Label htmlFor="body" className="text-sm font-medium cursor-pointer">
|
||||||
Email Body
|
Email Body
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">The full email content and design</p>
|
<p className="text-xs text-neutral-400 mt-0.5">Full email content and design</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('from')}
|
onClick={() => toggleField('from')}
|
||||||
>
|
>
|
||||||
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
|
<Checkbox id="from" checked={selectedFields.from} onCheckedChange={() => toggleField('from')} />
|
||||||
@@ -353,13 +358,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
From Email
|
From Email
|
||||||
</Label>
|
</Label>
|
||||||
{selectedTemplate?.from && (
|
{selectedTemplate?.from && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.from}</p>
|
<p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.from}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('fromName')}
|
onClick={() => toggleField('fromName')}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -372,13 +377,13 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
From Name
|
From Name
|
||||||
</Label>
|
</Label>
|
||||||
{selectedTemplate?.fromName && (
|
{selectedTemplate?.fromName && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.fromName}</p>
|
<p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.fromName}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center space-x-3 p-3 rounded-lg border border-neutral-200 hover:bg-neutral-50 transition-colors cursor-pointer"
|
className="flex items-center gap-3 py-3 cursor-pointer hover:text-neutral-900 transition-colors"
|
||||||
onClick={() => toggleField('replyTo')}
|
onClick={() => toggleField('replyTo')}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -391,7 +396,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
|
|||||||
Reply-To Email
|
Reply-To Email
|
||||||
</Label>
|
</Label>
|
||||||
{selectedTemplate?.replyTo && (
|
{selectedTemplate?.replyTo && (
|
||||||
<p className="text-xs text-neutral-500 mt-0.5">{selectedTemplate.replyTo}</p>
|
<p className="text-xs text-neutral-400 mt-0.5">{selectedTemplate.replyTo}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,6 +91,34 @@ const STEP_TYPE_BG = {
|
|||||||
UPDATE_CONTACT: '#e0e7ff',
|
UPDATE_CONTACT: '#e0e7ff',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Multi-branch condition helpers
|
||||||
|
function getExpectedBranches(config: any): string[] {
|
||||||
|
if (config?.mode === 'multi') {
|
||||||
|
return [...(config.branches || []).map((b: any) => b.id), 'default'];
|
||||||
|
}
|
||||||
|
return ['yes', 'no'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBranchLabel(config: any, branchId: string): string {
|
||||||
|
if (config?.mode === 'multi') {
|
||||||
|
if (branchId === 'default') return 'Default';
|
||||||
|
const branch = config.branches?.find((b: any) => b.id === branchId);
|
||||||
|
return branch?.name || branchId;
|
||||||
|
}
|
||||||
|
return branchId === 'yes' ? 'Yes' : 'No';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BRANCH_COLORS = ['#16a34a', '#dc2626', '#2563eb', '#d97706', '#7c3aed', '#0891b2', '#be185d', '#059669'];
|
||||||
|
|
||||||
|
function getBranchColor(config: any, branchId: string): string {
|
||||||
|
if (config?.mode === 'multi') {
|
||||||
|
if (branchId === 'default') return '#64748b';
|
||||||
|
const idx = config.branches?.findIndex((b: any) => b.id === branchId) ?? 0;
|
||||||
|
return BRANCH_COLORS[idx % BRANCH_COLORS.length]!;
|
||||||
|
}
|
||||||
|
return branchId === 'yes' ? '#16a34a' : '#dc2626';
|
||||||
|
}
|
||||||
|
|
||||||
// Dagre layout function
|
// Dagre layout function
|
||||||
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
||||||
const dagreGraph = new dagre.graphlib.Graph();
|
const dagreGraph = new dagre.graphlib.Graph();
|
||||||
@@ -129,6 +157,7 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Center "Add Step" nodes directly below their parent nodes
|
// Center "Add Step" nodes directly below their parent nodes
|
||||||
|
// For condition nodes with multiple branches, let dagre handle horizontal spread
|
||||||
const adjustedNodes = layoutedNodes.map(node => {
|
const adjustedNodes = layoutedNodes.map(node => {
|
||||||
if (node.type === 'addStep') {
|
if (node.type === 'addStep') {
|
||||||
// Find the parent node (the source of the edge connecting to this add node)
|
// Find the parent node (the source of the edge connecting to this add node)
|
||||||
@@ -136,7 +165,17 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
|||||||
if (parentEdge) {
|
if (parentEdge) {
|
||||||
const parentNode = layoutedNodes.find(n => n.id === parentEdge.source);
|
const parentNode = layoutedNodes.find(n => n.id === parentEdge.source);
|
||||||
if (parentNode) {
|
if (parentNode) {
|
||||||
// Center the add node below the parent node
|
// Check how many add-step siblings share this parent
|
||||||
|
const siblingAddNodes = edges.filter(
|
||||||
|
e => e.source === parentEdge.source && layoutedNodes.find(n => n.id === e.target && n.type === 'addStep'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (siblingAddNodes.length > 1) {
|
||||||
|
// Multiple branches — let dagre's spread positioning stand, only keep y
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single add node — center below parent
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
position: {
|
position: {
|
||||||
@@ -170,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="cursor-pointer hover:scale-105 transition-transform" onClick={data.onClick}>
|
||||||
<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">
|
<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 transition-colors" />
|
<Plus className="h-8 w-8 text-neutral-500 hover:text-neutral-700 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
{data.label && <div className="text-xs text-neutral-500 text-center mt-2 font-medium">{data.label}</div>}
|
{data.label && <div className="text-xs text-neutral-500 text-center mt-2 font-medium">{data.label}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -216,7 +255,7 @@ function CustomNode({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all relative group"
|
className="px-5 py-4 rounded-xl border-2 bg-white shadow-sm hover:shadow-md transition-all relative group"
|
||||||
style={{
|
style={{
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
minWidth: '280px',
|
minWidth: '280px',
|
||||||
@@ -235,7 +274,7 @@ function CustomNode({
|
|||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 shadow-md"
|
className="h-7 w-7"
|
||||||
title="Edit trigger settings"
|
title="Edit trigger settings"
|
||||||
>
|
>
|
||||||
<Settings className="h-3.5 w-3.5" />
|
<Settings className="h-3.5 w-3.5" />
|
||||||
@@ -251,7 +290,7 @@ function CustomNode({
|
|||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 shadow-md"
|
className="h-7 w-7"
|
||||||
title="Edit step"
|
title="Edit step"
|
||||||
>
|
>
|
||||||
<Settings className="h-3.5 w-3.5" />
|
<Settings className="h-3.5 w-3.5" />
|
||||||
@@ -263,7 +302,7 @@ function CustomNode({
|
|||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 shadow-md hover:bg-red-50 hover:border-red-400"
|
className="h-7 w-7 hover:bg-red-50 hover:border-red-400"
|
||||||
title="Delete step"
|
title="Delete step"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
@@ -324,9 +363,16 @@ function CustomNode({
|
|||||||
: String(data.config.field)}
|
: String(data.config.field)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{data.config.mode === 'multi' ? (
|
||||||
|
<div className="text-[10px] text-neutral-500 ml-4">
|
||||||
|
{data.config.branches?.length || 0} branch{(data.config.branches?.length || 0) !== 1 ? 'es' : ''} +
|
||||||
|
default
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="text-[10px] text-neutral-500 ml-4">
|
<div className="text-[10px] text-neutral-500 ml-4">
|
||||||
{data.config.operator} "{String(data.config.value)}"
|
{data.config.operator} "{String(data.config.value)}"
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -393,7 +439,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
const reactFlowInstance = useReactFlow();
|
const reactFlowInstance = useReactFlow();
|
||||||
const [addStepContext, setAddStepContext] = useState<{
|
const [addStepContext, setAddStepContext] = useState<{
|
||||||
fromStepId: string | null;
|
fromStepId: string | null;
|
||||||
branch?: 'yes' | 'no';
|
branch?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||||
@@ -455,41 +501,27 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
if (step.type === 'EXIT') return; // Exit steps can't have next steps
|
if (step.type === 'EXIT') return; // Exit steps can't have next steps
|
||||||
|
|
||||||
if (step.type === 'CONDITION') {
|
if (step.type === 'CONDITION') {
|
||||||
// Check for yes and no branches
|
const expectedBranches = getExpectedBranches(step.config);
|
||||||
const hasYesBranch = step.outgoingTransitions?.some(t => {
|
|
||||||
|
expectedBranches.forEach(branchId => {
|
||||||
|
const hasBranch = step.outgoingTransitions?.some(t => {
|
||||||
const condition = t.condition;
|
const condition = t.condition;
|
||||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'yes';
|
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === branchId;
|
||||||
});
|
|
||||||
const hasNoBranch = step.outgoingTransitions?.some(t => {
|
|
||||||
const condition = t.condition;
|
|
||||||
return condition && typeof condition === 'object' && 'branch' in condition && condition.branch === 'no';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hasYesBranch) {
|
if (!hasBranch) {
|
||||||
nodes.push({
|
nodes.push({
|
||||||
id: `${step.id}-add-yes`,
|
id: `${step.id}-add-${branchId}`,
|
||||||
type: 'addStep',
|
type: 'addStep',
|
||||||
position: {x: 0, y: 0},
|
position: {x: 0, y: 0},
|
||||||
draggable: false,
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
label: 'Yes',
|
label: getBranchLabel(step.config, branchId),
|
||||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
onClick: () => setAddStepContext({fromStepId: step.id, branch: branchId}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasNoBranch) {
|
|
||||||
nodes.push({
|
|
||||||
id: `${step.id}-add-no`,
|
|
||||||
type: 'addStep',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
draggable: false,
|
|
||||||
data: {
|
|
||||||
label: 'No',
|
|
||||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// For non-condition steps, add + node if no outgoing transitions
|
// For non-condition steps, add + node if no outgoing transitions
|
||||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||||
@@ -520,7 +552,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
const condition = transition.condition;
|
const condition = transition.condition;
|
||||||
const isConditional = condition && typeof condition === 'object' && 'branch' in condition;
|
const isConditional = condition && typeof condition === 'object' && 'branch' in condition;
|
||||||
const branch =
|
const branch =
|
||||||
condition && typeof condition === 'object' && 'branch' in condition ? condition.branch : undefined;
|
condition && typeof condition === 'object' && 'branch' in condition
|
||||||
|
? (condition.branch as string)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const branchColor = branch ? getBranchColor(step.config, branch) : '#94a3b8';
|
||||||
|
const branchLabel = branch ? getBranchLabel(step.config, branch) : undefined;
|
||||||
|
|
||||||
edges.push({
|
edges.push({
|
||||||
id: transition.id,
|
id: transition.id,
|
||||||
@@ -528,9 +565,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
target: transition.toStepId,
|
target: transition.toStepId,
|
||||||
type: 'smoothstep',
|
type: 'smoothstep',
|
||||||
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
|
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
|
||||||
label: isConditional ? (branch === 'yes' ? 'Yes' : 'No') : undefined,
|
label: isConditional ? branchLabel : undefined,
|
||||||
labelStyle: {
|
labelStyle: {
|
||||||
fill: branch === 'yes' ? '#16a34a' : branch === 'no' ? '#dc2626' : '#64748b',
|
fill: isConditional ? branchColor : '#64748b',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
@@ -541,12 +578,12 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
labelBgPadding: [8, 4] as [number, number],
|
labelBgPadding: [8, 4] as [number, number],
|
||||||
labelBgBorderRadius: 4,
|
labelBgBorderRadius: 4,
|
||||||
style: {
|
style: {
|
||||||
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
stroke: isConditional ? branchColor : '#94a3b8',
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
},
|
},
|
||||||
markerEnd: {
|
markerEnd: {
|
||||||
type: MarkerType.ArrowClosed,
|
type: MarkerType.ArrowClosed,
|
||||||
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
|
color: isConditional ? branchColor : '#94a3b8',
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
},
|
},
|
||||||
@@ -558,47 +595,37 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
if (step.type === 'EXIT') return;
|
if (step.type === 'EXIT') return;
|
||||||
|
|
||||||
if (step.type === 'CONDITION') {
|
if (step.type === 'CONDITION') {
|
||||||
const hasYesBranch = step.outgoingTransitions?.some(
|
const expectedBranches = getExpectedBranches(step.config);
|
||||||
|
|
||||||
|
expectedBranches.forEach(branchId => {
|
||||||
|
const hasBranch = step.outgoingTransitions?.some(
|
||||||
t =>
|
t =>
|
||||||
t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'yes',
|
t.condition &&
|
||||||
);
|
typeof t.condition === 'object' &&
|
||||||
const hasNoBranch = step.outgoingTransitions?.some(
|
'branch' in t.condition &&
|
||||||
t => t.condition && typeof t.condition === 'object' && 'branch' in t.condition && t.condition.branch === 'no',
|
t.condition.branch === branchId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasYesBranch) {
|
if (!hasBranch) {
|
||||||
|
const color = getBranchColor(step.config, branchId);
|
||||||
|
const label = getBranchLabel(step.config, branchId);
|
||||||
|
|
||||||
edges.push({
|
edges.push({
|
||||||
id: `${step.id}-add-yes-edge`,
|
id: `${step.id}-add-${branchId}-edge`,
|
||||||
source: step.id,
|
source: step.id,
|
||||||
target: `${step.id}-add-yes`,
|
target: `${step.id}-add-${branchId}`,
|
||||||
type: 'straight',
|
type: 'smoothstep',
|
||||||
animated: false,
|
animated: false,
|
||||||
label: 'Yes',
|
label,
|
||||||
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
labelStyle: {fill: color, fontWeight: 600, fontSize: 12},
|
||||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
||||||
labelBgPadding: [8, 4] as [number, number],
|
labelBgPadding: [8, 4] as [number, number],
|
||||||
labelBgBorderRadius: 4,
|
labelBgBorderRadius: 4,
|
||||||
style: {stroke: '#16a34a', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
style: {stroke: color, strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#16a34a', width: 22, height: 22},
|
markerEnd: {type: MarkerType.ArrowClosed, color, width: 22, height: 22},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasNoBranch) {
|
|
||||||
edges.push({
|
|
||||||
id: `${step.id}-add-no-edge`,
|
|
||||||
source: step.id,
|
|
||||||
target: `${step.id}-add-no`,
|
|
||||||
type: 'straight',
|
|
||||||
animated: false,
|
|
||||||
label: 'No',
|
|
||||||
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
|
||||||
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
|
|
||||||
labelBgPadding: [8, 4] as [number, number],
|
|
||||||
labelBgBorderRadius: 4,
|
|
||||||
style: {stroke: '#dc2626', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
|
||||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#dc2626', width: 22, height: 22},
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
if (!step.outgoingTransitions || step.outgoingTransitions.length === 0) {
|
||||||
edges.push({
|
edges.push({
|
||||||
@@ -645,15 +672,13 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
const connectedAddNodes = updatedNodes.filter(node => {
|
const connectedAddNodes = updatedNodes.filter(node => {
|
||||||
if (node.type !== 'addStep') return false;
|
if (node.type !== 'addStep') return false;
|
||||||
// Check if this add node is connected to the moved node
|
// Check if this add node is connected to the moved node
|
||||||
return (
|
return node.id === `${movedNode.id}-add` || node.id.startsWith(`${movedNode.id}-add-`);
|
||||||
node.id === `${movedNode.id}-add` ||
|
|
||||||
node.id === `${movedNode.id}-add-yes` ||
|
|
||||||
node.id === `${movedNode.id}-add-no`
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update positions of connected "+" nodes to stay centered below parent
|
// Update positions of connected "+" nodes to stay below parent
|
||||||
connectedAddNodes.forEach(addNode => {
|
if (connectedAddNodes.length === 1) {
|
||||||
|
// Single add node — center below parent
|
||||||
|
const addNode = connectedAddNodes[0]!;
|
||||||
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||||
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||||
const existingNode = updatedNodes[addNodeIndex];
|
const existingNode = updatedNodes[addNodeIndex];
|
||||||
@@ -665,8 +690,25 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} else if (connectedAddNodes.length > 1) {
|
||||||
|
// Multiple branches — move all add nodes by the same delta as the parent
|
||||||
|
const oldX = movedNode.position.x;
|
||||||
|
const deltaX = change.position.x - oldX;
|
||||||
|
connectedAddNodes.forEach(addNode => {
|
||||||
|
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||||
|
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||||
|
const existingNode = updatedNodes[addNodeIndex];
|
||||||
|
updatedNodes[addNodeIndex] = {
|
||||||
|
...existingNode,
|
||||||
|
position: {
|
||||||
|
x: existingNode.position.x + deltaX,
|
||||||
|
y: existingNode.position.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedNodes;
|
return updatedNodes;
|
||||||
@@ -733,7 +775,9 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
|
|
||||||
// Create the transition with proper condition
|
// Create the transition with proper condition
|
||||||
const condition = addStepContext.branch ? {branch: addStepContext.branch} : null;
|
const condition = addStepContext.branch ? {branch: addStepContext.branch} : null;
|
||||||
const priority = addStepContext.branch === 'yes' ? 0 : addStepContext.branch === 'no' ? 1 : 0;
|
const expectedBranches = fromStep.type === 'CONDITION' ? getExpectedBranches(fromStep.config) : [];
|
||||||
|
const branchIndex = addStepContext.branch ? expectedBranches.indexOf(addStepContext.branch) : -1;
|
||||||
|
const priority = branchIndex >= 0 ? branchIndex : 0;
|
||||||
|
|
||||||
await network.fetch<unknown, typeof WorkflowSchemas.createTransition>(
|
await network.fetch<unknown, typeof WorkflowSchemas.createTransition>(
|
||||||
'POST',
|
'POST',
|
||||||
@@ -861,19 +905,19 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
<Background color="#e5e7eb" gap={16} size={1} />
|
<Background color="#e5e7eb" gap={16} size={1} />
|
||||||
<Controls
|
<Controls
|
||||||
showInteractive={false}
|
showInteractive={false}
|
||||||
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
|
className="bg-white border border-neutral-200 rounded-lg shadow-md"
|
||||||
/>
|
/>
|
||||||
<MiniMap
|
<MiniMap
|
||||||
nodeColor={node => {
|
nodeColor={node => {
|
||||||
const step = steps.find(s => s.id === node.id);
|
const step = steps.find(s => s.id === node.id);
|
||||||
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
|
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
|
||||||
}}
|
}}
|
||||||
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
|
className="bg-white border border-neutral-200 rounded-lg shadow-md"
|
||||||
maskColor="rgba(0, 0, 0, 0.05)"
|
maskColor="rgba(0, 0, 0, 0.05)"
|
||||||
/>
|
/>
|
||||||
<Panel
|
<Panel
|
||||||
position="top-left"
|
position="top-left"
|
||||||
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
|
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GitBranch className="h-4 w-4 text-neutral-700" />
|
<GitBranch className="h-4 w-4 text-neutral-700" />
|
||||||
@@ -889,7 +933,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
<Panel position="top-right" className="flex gap-2">
|
<Panel position="top-right" className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleAutoLayout}
|
onClick={handleAutoLayout}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Auto Layout
|
Auto Layout
|
||||||
</button>
|
</button>
|
||||||
@@ -897,11 +941,11 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
{rawEdges.length === 0 && steps.length > 1 && (
|
{rawEdges.length === 0 && steps.length > 1 && (
|
||||||
<Panel
|
<Panel
|
||||||
position="bottom-center"
|
position="bottom-center"
|
||||||
className="bg-blue-50 border border-blue-200 px-4 py-2.5 rounded-lg shadow-lg"
|
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-sm text-blue-900">
|
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||||
<Lightbulb className="h-4 w-4" />
|
<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>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
@@ -921,16 +965,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => handleCreateStep(option.value)}
|
onClick={() => handleCreateStep(option.value)}
|
||||||
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"
|
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"
|
||||||
style={{
|
|
||||||
borderColor: 'transparent',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => {
|
|
||||||
e.currentTarget.style.borderColor = option.color;
|
|
||||||
}}
|
|
||||||
onMouseLeave={e => {
|
|
||||||
e.currentTarget.style.borderColor = 'transparent';
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-12 h-12 rounded-lg flex items-center justify-center transition-transform group-hover:scale-110"
|
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
|
<div
|
||||||
className="px-5 py-4 rounded-xl border-2 bg-white shadow-lg hover:shadow-xl transition-all cursor-grab active:cursor-grabbing"
|
className="px-5 py-4 rounded-xl border-2 bg-white shadow-sm hover:shadow-md transition-all cursor-grab active:cursor-grabbing"
|
||||||
style={{
|
style={{
|
||||||
borderColor: color,
|
borderColor: color,
|
||||||
minWidth: '250px',
|
minWidth: '250px',
|
||||||
@@ -496,11 +496,11 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
|
|||||||
<Background color="#e5e7eb" gap={16} size={1} />
|
<Background color="#e5e7eb" gap={16} size={1} />
|
||||||
<Controls
|
<Controls
|
||||||
showInteractive={false}
|
showInteractive={false}
|
||||||
className="bg-white/90 backdrop-blur-sm border border-neutral-200 rounded-lg shadow-lg"
|
className="bg-white border border-neutral-200 rounded-lg shadow-md"
|
||||||
/>
|
/>
|
||||||
<Panel
|
<Panel
|
||||||
position="top-left"
|
position="top-left"
|
||||||
className="bg-white/95 backdrop-blur-sm px-4 py-2.5 rounded-lg shadow-lg border border-neutral-200"
|
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<GitBranch className="h-4 w-4 text-neutral-700" />
|
<GitBranch className="h-4 w-4 text-neutral-700" />
|
||||||
@@ -516,9 +516,9 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
|
|||||||
{rawEdges.length === 0 && steps.length > 1 && (
|
{rawEdges.length === 0 && steps.length > 1 && (
|
||||||
<Panel
|
<Panel
|
||||||
position="bottom-center"
|
position="bottom-center"
|
||||||
className="bg-amber-50 border border-amber-200 px-4 py-2.5 rounded-lg shadow-lg"
|
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 text-sm text-amber-900">
|
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
<span>No transitions found. Connect your steps to see the flow.</span>
|
<span>No transitions found. Connect your steps to see the flow.</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@plunk/ui';
|
||||||
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
|
import {Check, Copy} from 'lucide-react';
|
||||||
|
import {useState} from 'react';
|
||||||
|
|
||||||
|
export interface CodeSnippet {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeTabsProps {
|
||||||
|
snippets: CodeSnippet[];
|
||||||
|
defaultTab?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CodeTabs({snippets, defaultTab}: CodeTabsProps) {
|
||||||
|
const [active, setActive] = useState(defaultTab ?? snippets[0]?.id ?? '');
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const activeSnippet = snippets.find(s => s.id === active) ?? snippets[0];
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!activeSnippet) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(activeSnippet.code);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1800);
|
||||||
|
} catch {
|
||||||
|
// clipboard unavailable — silent
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs value={active} onValueChange={setActive} className="w-full">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-neutral-200 px-3 py-2">
|
||||||
|
<TabsList className="bg-transparent p-0 h-auto gap-1">
|
||||||
|
{snippets.map(s => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={s.id}
|
||||||
|
value={s.id}
|
||||||
|
className="rounded-md px-2.5 py-1 text-xs font-medium text-neutral-500 data-[state=active]:bg-neutral-100 data-[state=active]:text-neutral-900 data-[state=active]:shadow-none"
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleCopy()}
|
||||||
|
className="relative flex h-7 items-center gap-1.5 rounded-md border border-neutral-200 bg-white px-2 text-xs font-medium text-neutral-600 hover:text-neutral-900 hover:border-neutral-300 transition-colors overflow-hidden"
|
||||||
|
aria-label={`Copy ${activeSnippet?.label ?? ''} snippet`}
|
||||||
|
>
|
||||||
|
<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}}
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Check className="h-3.5 w-3.5 text-green-600" />
|
||||||
|
<span className="text-green-600">Copied</span>
|
||||||
|
</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}}
|
||||||
|
className="flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
<span>Copy</span>
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{snippets.map(s => (
|
||||||
|
<TabsContent key={s.id} value={s.id} className="mt-0">
|
||||||
|
<pre className="m-0 overflow-x-auto bg-neutral-50 px-4 py-4 text-xs leading-relaxed text-neutral-900 font-mono">
|
||||||
|
<code>{s.code}</code>
|
||||||
|
</pre>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||