Compare commits

..
Author SHA1 Message Date
Dries Augustyns a5e8d239ee Merge pull request #356 from useplunk/release-please--branches--next--components--plunk 2026-05-01 20:13:21 +02:00
Dries Augustyns 532293e52a Resolve linting errors 2026-05-01 19:55:58 +02:00
github-actions[bot] fd066a6991 chore(next): release 0.10.0 2026-05-01 17:45:19 +00:00
Dries Augustyns bb72911fa2 feat: enhance contact addition with bulk email lookup and subscription options 2026-05-01 19:44:48 +02:00
Dries Augustyns 9fafeb7d4b ui: adjust spacing for customer logos section 2026-05-01 10:57:37 +02:00
Dries Augustyns 11ea9d5e12 ui: define type for customers array 2026-05-01 10:43:54 +02:00
Dries Augustyns b73927067f ui: add customer logos for SnowSEO and Viral 2026-05-01 10:39:42 +02:00
Dries Augustyns 4b65e718ad ui: add customer logos for Dodo Payments, Krumzi, and Waidwissen 2026-04-30 17:50:25 +02:00
Dries Augustyns f6f291966f Merge pull request #359 from mre/fix/list-unsubscribe-header 2026-04-30 15:31:50 +02:00
Matthias Endler 11c428d4ff fix(ses): emit List-Unsubscribe inside the header section, not the body
The raw MIME template in sendRawEmail produced a blank line between Content-Type and List-Unsubscribe whenever no custom headers were passed, because the ternary for custom headers expanded to an empty string surrounded by newlines.

Per RFC 5322 §2.1 a blank line terminates the header section, so List-Unsubscribe ended up as the first line of the body. Lenient clients (Gmail, Outlook) recover; strict clients (Thunderbird) do not, breaking one-click unsubscribe and degrading Gmail/Yahoo bulk-sender deliverability signals.

Collect optional headers (custom + List-Unsubscribe) into an array, filter empties, and append them to the Content-Type line with a single newline separator. No codepath can now produce a blank line inside the header block.
2026-04-30 15:20:06 +02:00
Dries Augustyns 2eb407993b feat: add configurable attachment limits for email service
Closes #358
2026-04-29 08:48:03 +02:00
Dries Augustyns 10946511b0 feat: enhance contact data handling by filtering empty strings and allowing null to delete fields 2026-04-27 16:59:18 +02:00
Dries Augustyns 5f741c66c7 fix: update project icon colors for improved visibility and consistency 2026-04-24 19:23:33 +02:00
Dries Augustyns d7eb85ffdd fix: update project disabled messages for clarity and consistency 2026-04-24 19:21:07 +02:00
Dries Augustyns 1c89ed083e feat: implement CommandPalette for enhanced navigation and recent pages tracking 2026-04-24 18:42:04 +02:00
Dries Augustyns f40cc86cec fix: Harmonize rings and hover states 2026-04-24 16:01:18 +02:00
Dries Augustyns 6f31fa2503 feat: add updateActiveProject function for in-place project updates 2026-04-24 14:58:06 +02:00
Dries Augustyns 98f1e80fca fix: prevent unnecessary state updates in search input effect 2026-04-24 13:49:16 +02:00
Dries Augustyns c1bcd358cc fix: Consistency across buttons and labels 2026-04-24 13:35:25 +02:00
Dries Augustyns 517753c420 fix: Consistency across cards 2026-04-24 12:54:43 +02:00
Dries Augustyns ab3edd7dc9 fix: improve layout and accessibility of workflow header and buttons 2026-04-24 12:09:54 +02:00
Dries Augustyns c910d20bb7 feat: Enhance ease of use of workflow editor 2026-04-24 11:56:43 +02:00
Dries Augustyns bdc9b30ef2 fix: standardize step type labels and update visual styles in workflow components 2026-04-24 10:17:10 +02:00
Dries Augustyns c8190b953f fix: Content negotiation for xml and txt 2026-04-23 08:04:39 +02:00
Dries Augustyns 1637d54e1b fix: add project name and sender email parameters to phishing content check 2026-04-22 22:03:57 +02:00
Dries Augustyns 15294a40d8 fix: add project name and sender email parameters to phishing content check 2026-04-22 22:03:53 +02:00
Dries Augustyns bb78c86c40 fix: update phishing confidence threshold to 95% 2026-04-22 21:59:34 +02:00
Dries Augustyns c0e0ad8bc6 fix: add new configuration options for phishing detection thresholds 2026-04-22 13:59:41 +02:00
Dries Augustyns 208c809a90 fix: Add in-memory cache for lower-confidence phishing checks 2026-04-22 07:40:23 +02:00
Dries Augustyns b7eb2549d4 fix: turn warning into success log 2026-04-21 22:08:07 +02:00
Dries Augustyns 91d0d2d297 fix: update response format for phishing analysis and improve JSON parsing comment 2026-04-21 21:53:16 +02:00
Dries Augustyns 862875428d fix: log message for projects passing phishing checks 2026-04-21 21:49:57 +02:00
Dries Augustyns 2da8b065d0 feat: implement phishing detection using OpenRouter API with configurable sampling rate 2026-04-21 21:28:31 +02:00
Dries Augustyns 60146fcc10 ui: replace custom loading spinners with IconSpinner component across multiple files 2026-04-21 16:28:03 +02:00
Dries Augustyns c278a0fd94 feat: add className prop to EmptyState component for custom styling 2026-04-21 10:31:17 +02:00
Dries Augustyns 0b3aa28101 feat: add middleware support for .md file rewrites 2026-04-21 10:29:15 +02:00
Dries Augustyns ecaf99665a ui: Update empty states 2026-04-21 10:24:35 +02:00
Dries Augustyns 3d9b4e4eb3 ui: Update empty states 2026-04-21 10:22:05 +02:00
Dries Augustyns e048fc1570 feat: implement meter event processing with queue for Stripe billing 2026-04-20 21:30:52 +02:00
Dries Augustyns 4322de929a fix: reconcile totalRecipients in CampaignService to prevent stuck campaigns
closes #348
2026-04-20 20:15:56 +02:00
Dries Augustyns bfdfde68fb Merge pull request #352 from wcatbb/next 2026-04-20 19:48:04 +02:00
Dries Augustyns 3649392b47 fix: Sync display name in TemplateSearchPicker when initialName changes 2026-04-20 19:47:39 +02:00
Dries Augustyns cbde3cce3f fix: Sync display name in TemplateSearchPicker when initialName changes 2026-04-20 19:38:20 +02:00
Dries Augustyns e450ae62a3 Merge pull request #338 from useplunk/release-please--branches--next--components--plunk 2026-04-20 19:29:33 +02:00
github-actions[bot] d56e876428 chore(next): release 0.9.0 2026-04-20 16:40:54 +00:00
Dries Augustyns b79d416667 feat: implement AWS SNS signature verification in SecurityService 2026-04-20 18:40:19 +02:00
Dries Augustyns bf12392b88 feat: integrate DOMPurify for sanitizing HTML content 2026-04-20 18:33:23 +02:00
Dries Augustyns ae9d5a6f3d Merge pull request #343 from useplunk/dependabot/npm_and_yarn/vite-7.3.2 2026-04-20 18:26:25 +02:00
Dries Augustyns 1362e4672f Merge pull request #349 from useplunk/dependabot/npm_and_yarn/lodash-4.18.1 2026-04-20 18:26:15 +02:00
Dries Augustyns 35a830fa7b Merge pull request #350 from useplunk/dependabot/npm_and_yarn/next-16.2.3 2026-04-20 18:26:00 +02:00
Dries Augustyns 742821eba9 Merge pull request #318 from useplunk/dependabot/npm_and_yarn/undici-6.24.1 2026-04-20 18:25:48 +02:00
Dries Augustyns 91f80de981 Merge pull request #340 from useplunk/dependabot/npm_and_yarn/defu-6.1.6 2026-04-20 18:25:38 +02:00
Dries Augustyns c584e42c54 Merge pull request #326 from useplunk/dependabot/npm_and_yarn/socket.io-parser-4.2.6 2026-04-20 18:25:27 +02:00
Dries Augustyns 545d733794 docs: Add content negotiation to apps/wiki 2026-04-20 17:51:02 +02:00
Dries Augustyns 37ed1e49b7 fix: Implement content negotiation for markdown and html in middleware 2026-04-20 17:20:34 +02:00
Dries Augustyns df76be1b87 feat: Add accept/markdown to apps/landing 2026-04-20 17:09:56 +02:00
Dries Augustyns bd5a085802 feat: Add onboarding flow 2026-04-19 11:26:12 +02:00
Dries Augustyns bc4c79235e Resolve linting error 2026-04-19 10:03:14 +02:00
Dries Augustyns f2ebce0ef2 feat: Enhance login forms with last used authentication method 2026-04-19 09:53:28 +02:00
Dries Augustyns e10a94b2bc Merge pull request #355 from useplunk/dev-driaug-marketing-polish 2026-04-19 09:25:46 +02:00
Doug Beatty df18979e5e extract TemplateSearchPicker to fix workflow template pagination bug 2026-04-12 10:14:34 -05:00
Doug Beatty a981a8bba4 Improve template template loading and debounced search 2026-04-12 00:33:37 -05:00
dependabot[bot] a6d5740b77 chore(deps): bump next from 16.1.7 to 16.2.3
Bumps [next](https://github.com/vercel/next.js) from 16.1.7 to 16.2.3.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.1.7...v16.2.3)

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

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-10 18:59:26 +00:00
dependabot[bot] 6114c21a3f chore(deps): bump lodash from 4.17.23 to 4.18.1
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-10 03:58:34 +00:00
dependabot[bot] 4ce4c8c2a4 chore(deps): bump vite from 7.2.7 to 7.3.2
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.2.7 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-06 21:35:49 +00:00
dependabot[bot] c3967a98b0 chore(deps): bump defu from 6.1.4 to 6.1.6
Bumps [defu](https://github.com/unjs/defu) from 6.1.4 to 6.1.6.
- [Release notes](https://github.com/unjs/defu/releases)
- [Changelog](https://github.com/unjs/defu/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unjs/defu/compare/v6.1.4...v6.1.6)

---
updated-dependencies:
- dependency-name: defu
  dependency-version: 6.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-04-04 06:52:12 +00:00
dependabot[bot] 474c822640 chore(deps): bump socket.io-parser from 4.2.4 to 4.2.6
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.4 to 4.2.6.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/[email protected]@4.2.6)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-19 01:05:14 +00:00
dependabot[bot] c83541c3eb chore(deps): bump undici from 6.23.0 to 6.24.1
Bumps [undici](https://github.com/nodejs/undici) from 6.23.0 to 6.24.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.24.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-03-14 09:11:29 +00:00
135 changed files with 6296 additions and 3160 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.8.0"
".": "0.10.0"
}
+75
View File
@@ -1,5 +1,80 @@
# Changelog
## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01)
### Features
* add className prop to EmptyState component for custom styling ([c278a0f](https://github.com/useplunk/plunk/commit/c278a0fd944977d4ea3a0c5a4a43ff4cc0a13aa3))
* add configurable attachment limits for email service ([2eb4079](https://github.com/useplunk/plunk/commit/2eb407993bd5fed3e74be73445185e517c18b994)), closes [#358](https://github.com/useplunk/plunk/issues/358)
* add middleware support for .md file rewrites ([0b3aa28](https://github.com/useplunk/plunk/commit/0b3aa28101d50a734945ce19b924f9aa30631c64))
* add updateActiveProject function for in-place project updates ([6f31fa2](https://github.com/useplunk/plunk/commit/6f31fa2503b11e3a9a37d753086bbb74b42f7bc4))
* enhance contact addition with bulk email lookup and subscription options ([bb72911](https://github.com/useplunk/plunk/commit/bb72911fa25e3a204095e74c07efbc261154ac30))
* enhance contact data handling by filtering empty strings and allowing null to delete fields ([1094651](https://github.com/useplunk/plunk/commit/10946511b019ff76ce5f785a39cc0db4ea9ce820))
* Enhance ease of use of workflow editor ([c910d20](https://github.com/useplunk/plunk/commit/c910d20bb71ff5de4abf95f3f96be9c435342cb4))
* implement CommandPalette for enhanced navigation and recent pages tracking ([1c89ed0](https://github.com/useplunk/plunk/commit/1c89ed083e49148967713e9a9c2f7f8833db07c4))
* implement meter event processing with queue for Stripe billing ([e048fc1](https://github.com/useplunk/plunk/commit/e048fc157080b36f40fbfc4440db51367a2d374b))
* implement phishing detection using OpenRouter API with configurable sampling rate ([2da8b06](https://github.com/useplunk/plunk/commit/2da8b065d08438aa2adea397d8ab2a560e996af6))
### Bug Fixes
* Add in-memory cache for lower-confidence phishing checks ([208c809](https://github.com/useplunk/plunk/commit/208c809a90d0721261e643523b338d05e23e2bf9))
* add new configuration options for phishing detection thresholds ([c0e0ad8](https://github.com/useplunk/plunk/commit/c0e0ad8bc6bfdddea5193191156d18aad440c2b0))
* add project name and sender email parameters to phishing content check ([1637d54](https://github.com/useplunk/plunk/commit/1637d54e1b132dd6864fb1c50d23a388c1c47bf5))
* add project name and sender email parameters to phishing content check ([15294a4](https://github.com/useplunk/plunk/commit/15294a40d88289fa8fd876c9e7c201eb1e2ca956))
* Consistency across buttons and labels ([c1bcd35](https://github.com/useplunk/plunk/commit/c1bcd358cc611594d851414687c04bf7a03759a1))
* Consistency across cards ([517753c](https://github.com/useplunk/plunk/commit/517753c420204510e06decec01db7749894a9265))
* Content negotiation for xml and txt ([c8190b9](https://github.com/useplunk/plunk/commit/c8190b953f9f2e28e6cfa98e72d8dcfbba7e251b))
* Harmonize rings and hover states ([f40cc86](https://github.com/useplunk/plunk/commit/f40cc86cec661899fb862b56ce3e35bf70b33e54))
* improve layout and accessibility of workflow header and buttons ([ab3edd7](https://github.com/useplunk/plunk/commit/ab3edd7dc9030eeb770ffd64087476adf10d2d06))
* log message for projects passing phishing checks ([8628754](https://github.com/useplunk/plunk/commit/862875428d21de03643e8e51126cc59abef95ccb))
* prevent unnecessary state updates in search input effect ([98f1e80](https://github.com/useplunk/plunk/commit/98f1e80fca8253c125ba24c4d84c5be86841e1fa))
* reconcile totalRecipients in CampaignService to prevent stuck campaigns ([4322de9](https://github.com/useplunk/plunk/commit/4322de929a84279370bedcbb4f6b7dcd81145aea)), closes [#348](https://github.com/useplunk/plunk/issues/348)
* **ses:** emit List-Unsubscribe inside the header section, not the body ([11c428d](https://github.com/useplunk/plunk/commit/11c428d4ffe7c8aa87aac4d24148aaa95e01507a))
* standardize step type labels and update visual styles in workflow components ([bdc9b30](https://github.com/useplunk/plunk/commit/bdc9b30ef29396870aa42515b8e688485a4673e8))
* Sync display name in TemplateSearchPicker when initialName changes ([3649392](https://github.com/useplunk/plunk/commit/3649392b475271198cd61295091e8acf708efa25))
* Sync display name in TemplateSearchPicker when initialName changes ([cbde3cc](https://github.com/useplunk/plunk/commit/cbde3cce3fcee382dae04e79a9480e67ad896725))
* turn warning into success log ([b7eb254](https://github.com/useplunk/plunk/commit/b7eb2549d433552f32783b6fb8c192f5afb48ecd))
* update phishing confidence threshold to 95% ([bb78c86](https://github.com/useplunk/plunk/commit/bb78c86c400b60fb0e84c7872e20ad6f7c43c199))
* update project disabled messages for clarity and consistency ([d7eb85f](https://github.com/useplunk/plunk/commit/d7eb85ffdd99177c5e27104779281f5a08fe36a7))
* update project icon colors for improved visibility and consistency ([5f741c6](https://github.com/useplunk/plunk/commit/5f741c66c7f0c9c25c45b26917ff68529d15f958))
* update response format for phishing analysis and improve JSON parsing comment ([91d0d2d](https://github.com/useplunk/plunk/commit/91d0d2d297cb38fe0dfdfd7f73df355e2f51096a))
## [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)
+10
View File
@@ -154,6 +154,16 @@ Required for builds and deployment (see turbo.json and .env.example):
plus-addressing, domain existence, and MX records
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
bounce/complaint rate thresholds are exceeded
- Attachment Limits (optional):
- `MAX_ATTACHMENT_SIZE_MB` (default: 10) - Maximum total attachment size in megabytes per email. AWS SES supports up to 40 MB.
- `MAX_ATTACHMENTS_COUNT` (default: 10) - Maximum number of attachments per email
- Phishing Detection (optional):
- `OPENROUTER_API_KEY` - API key for OpenRouter (enables phishing detection)
- `OPENROUTER_MODEL` (default: anthropic/claude-3-haiku) - LLM model to use for content analysis
- `PHISHING_DETECTION_SAMPLE_RATE` (default: 0.1) - Percentage of emails to check (0.0-1.0, e.g., 0.1 = 10%)
- `PHISHING_CONFIDENCE_THRESHOLD` (default: 85) - Minimum confidence percentage (0-100) to auto-disable project for single detection
- `PHISHING_CUMULATIVE_THRESHOLD` (default: 3) - Number of phishing detections within time window to trigger auto-disable
- `PHISHING_CUMULATIVE_WINDOW_MS` (default: 3600000) - Time window in milliseconds for cumulative tracking (default 1 hour)
**Important Notes:**
+16
View File
@@ -110,8 +110,24 @@ export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
// Controls whether email validation checks are performed on signup (default: false)
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
// Attachment Limits (optional)
// Maximum total attachment size in MB (default: 10). AWS SES supports up to 40 MB.
export const MAX_ATTACHMENT_SIZE_MB = Number(validateEnv('MAX_ATTACHMENT_SIZE_MB', '10'));
// Maximum number of attachments per email (default: 10)
export const MAX_ATTACHMENTS_COUNT = Number(validateEnv('MAX_ATTACHMENTS_COUNT', '10'));
// Email Verification & Password Reset
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour
export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds
// Phishing Detection (optional)
// OpenRouter API integration for content safety checks
export const OPENROUTER_API_KEY = validateEnv('OPENROUTER_API_KEY', '');
export const OPENROUTER_MODEL = validateEnv('OPENROUTER_MODEL', 'anthropic/claude-3-haiku');
export const PHISHING_DETECTION_SAMPLE_RATE = Number(validateEnv('PHISHING_DETECTION_SAMPLE_RATE', '0.1')); // Default 10% of emails
export const PHISHING_DETECTION_ENABLED = OPENROUTER_API_KEY !== '';
export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % to disable project (default 85%)
export const PHISHING_CUMULATIVE_THRESHOLD = Number(validateEnv('PHISHING_CUMULATIVE_THRESHOLD', '3')); // Number of phishing detections before auto-disable (default 3)
export const PHISHING_CUMULATIVE_WINDOW_MS = Number(validateEnv('PHISHING_CUMULATIVE_WINDOW_MS', '3600000')); // Time window for cumulative tracking in ms (default 1 hour)
+1 -1
View File
@@ -120,7 +120,7 @@ export class Actions {
* - data: object (optional) - Contact data and template variables
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only used for this email (non-persistent)
* - attachments: array (optional) - Email attachments (max 10, 10MB total)
* - attachments: array (optional) - Email attachments (configurable via MAX_ATTACHMENTS_COUNT / MAX_ATTACHMENT_SIZE_MB, defaults: 10 / 10MB)
* - filename: string (required) - Attachment filename
* - content: string (required) - Base64 encoded file content
* - contentType: string (required) - MIME type (e.g., "application/pdf")
+24
View File
@@ -273,6 +273,30 @@ export class Contacts {
});
}
/**
* POST /contacts/lookup
* Bulk-check which emails already exist in the project (max 500)
*/
@Post('lookup')
@Middleware([requireAuth, requireEmailVerified])
@CatchAsync
public async lookup(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {emails} = req.body as {emails: string[]};
if (!Array.isArray(emails) || emails.length === 0) {
return res.status(400).json({error: 'emails must be a non-empty array'});
}
if (emails.length > 500) {
return res.status(400).json({error: 'Maximum 500 emails per lookup'});
}
const result = await ContactService.lookup(auth.projectId!, emails);
return res.status(200).json(result);
}
/**
* POST /contacts/import
* Import contacts from CSV file
+10 -2
View File
@@ -52,7 +52,15 @@ export class Domains {
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.',
'This project has been disabled. Please contact support for assistance.',
);
}
// Block subdomains whose root domain belongs to a disabled project
const rootCheck = await DomainService.checkSubdomainOfDisabledRoot(domain);
if (rootCheck.blocked) {
throw new NotAllowed(
'This domain cannot be added at this time. Please contact support for assistance.',
);
}
@@ -138,7 +146,7 @@ export class Domains {
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.',
'This project has been disabled. Please contact support for assistance.',
);
}
+2 -2
View File
@@ -155,7 +155,7 @@ export class Segments {
public async addMembers(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const segmentId = req.params.id;
const {emails} = req.body;
const {emails, createMissing, subscribed} = req.body as {emails: string[]; createMissing?: boolean; subscribed?: boolean};
if (!segmentId) {
return res.status(400).json({error: 'Segment ID is required'});
@@ -165,7 +165,7 @@ export class Segments {
return res.status(400).json({error: 'emails must be a non-empty array'});
}
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails, createMissing ?? false, subscribed ?? true);
return res.status(200).json(result);
}
+3 -3
View File
@@ -68,7 +68,7 @@ export class Users {
if (hasDisabledProject) {
throw new HttpException(
403,
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
`You cannot create new projects at this time. Please contact support for assistance.`,
ErrorCode.PROJECT_DISABLED,
);
}
@@ -615,7 +615,7 @@ export class Users {
if (isDisabled) {
throw new HttpException(
403,
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
@@ -703,7 +703,7 @@ export class Users {
if (project.disabled) {
throw new HttpException(
403,
'Cannot delete a disabled project. Please contact support to resolve security violations.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
+7
View File
@@ -36,6 +36,13 @@ export class Webhooks {
@CatchAsync
public async receiveSNSWebhook(req: Request, res: Response) {
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)
if (req.body.Type === 'SubscriptionConfirmation') {
signale.info('SNS Subscription Confirmation received');
+6 -1
View File
@@ -219,12 +219,17 @@ export class Workflows {
const auth = res.locals.auth;
const workflowId = req.params.id;
const stepId = req.params.stepId;
const splice = req.query.splice === 'true';
if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
}
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
if (splice) {
await WorkflowService.spliceStep(auth.projectId!, workflowId, stepId);
} else {
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
}
return res.status(204).send();
}
+31
View File
@@ -14,6 +14,7 @@ import {EmailService} from '../services/EmailService.js';
import {EventService} from '../services/EventService.js';
import {MeterService} from '../services/MeterService.js';
import {emailQueue} from '../services/QueueService.js';
import {SecurityService} from '../services/SecurityService.js';
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
/**
@@ -146,6 +147,36 @@ export async function createEmailWorker() {
// Determine tracking based on project settings and email type
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
// Check for phishing/dangerous content before sending
const phishingCheck = await SecurityService.checkPhishingContent(
email.projectId,
email.project.name,
email.from,
formattedEmail.subject,
compiledHtml,
);
if (phishingCheck.shouldDisable) {
// Disable project immediately
await SecurityService.disableProjectForPhishing(
email.projectId,
formattedEmail.subject,
phishingCheck.confidence,
'Phishing content detected',
);
// Mark email as failed
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: 'This email could not be sent. The project has been disabled. Please contact support.',
},
});
throw new Error(`Project ${email.projectId} has been disabled due to a policy violation`);
}
// Send via AWS SES
const result = await sendRawEmail({
from: {
+58
View File
@@ -0,0 +1,58 @@
/**
* Background Job: Meter Processor
* Reliably records Stripe billing meter events with automatic retries
*/
import type {MeterEventJobData} from '@plunk/types';
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {meterQueue} from '../services/QueueService.js';
async function processMeterEvent(job: Job<MeterEventJobData>): Promise<void> {
const {customerId, value, idempotencyKey} = job.data;
if (!STRIPE_ENABLED || !stripe) {
return;
}
await stripe.billing.meterEvents.create({
event_name: STRIPE_METER_EVENT_NAME,
payload: {
stripe_customer_id: customerId,
value: value.toString(),
},
...(idempotencyKey && {identifier: idempotencyKey}),
});
signale.debug(`[METER-WORKER] Recorded ${value} email(s) for customer ${customerId}`);
}
export function createMeterWorker(): Worker {
const worker = new Worker<MeterEventJobData>(
meterQueue.name,
async (job: Job<MeterEventJobData>) => {
await processMeterEvent(job);
},
{
connection: meterQueue.opts.connection,
concurrency: 5,
limiter: {
max: 50,
duration: 1000, // Stay well within Stripe's rate limits
},
},
);
worker.on('failed', (job, error) => {
signale.error(`[METER-WORKER] Job ${job?.id} failed (attempt ${job?.attemptsMade}):`, error);
});
worker.on('error', error => {
signale.error('[METER-WORKER] Worker error:', error);
});
return worker;
}
+6
View File
@@ -15,6 +15,7 @@ import {createCampaignWorker} from './campaign-processor.js';
import {createDomainVerificationWorker} from './domain-verification-processor.js';
import {createEmailWorker} from './email-processor.js';
import {createImportWorker} from './import-processor.js';
import {createMeterWorker} from './meter-processor.js';
import {createScheduledCampaignWorker} from './scheduled-processor.js';
import {createSegmentCountWorker} from './segment-count-processor.js';
import {createWorkflowWorker} from './workflow-processor-queue.js';
@@ -70,6 +71,11 @@ async function startWorkers() {
workers.push({name: 'api-request-cleanup', worker: apiRequestCleanupWorker});
signale.success('[WORKER] API request cleanup worker started');
// Start meter worker
const meterWorker = createMeterWorker();
workers.push({name: 'meter', worker: meterWorker});
signale.success('[WORKER] Meter worker started');
signale.success('[WORKER] All workers started successfully');
} catch (error) {
signale.error('[WORKER] Failed to start workers:', error);
+4 -4
View File
@@ -135,7 +135,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
@@ -204,7 +204,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
@@ -271,7 +271,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
@@ -315,7 +315,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
if (isWriteOperation) {
throw new HttpException(
403,
'Project is disabled due to security violations. All write operations are blocked.',
'This project has been disabled. Please contact support for assistance.',
ErrorCode.PROJECT_DISABLED,
);
}
+42
View File
@@ -505,6 +505,48 @@ export class CampaignService {
limit,
cursor: nextCursor,
});
} else {
// Last batch: reconcile totalRecipients to the actual number of emails created.
// Dynamic segments re-evaluate on each batch query, so contacts that left the
// segment after totalRecipients was calculated are silently skipped. Without this
// reconciliation, sentCount can never reach the original totalRecipients and the
// campaign remains stuck in SENDING forever.
const [actualEmailCount, alreadySentCount] = await Promise.all([
prisma.email.count({where: {campaignId}}),
prisma.email.count({where: {campaignId, sentAt: {not: null}}}),
]);
await prisma.campaign.update({
where: {id: campaignId},
data: {totalRecipients: actualEmailCount},
});
// If all emails were already processed by the time the last batch finished
// (race: worker was faster than the batch chain), finalize the campaign now.
if (actualEmailCount === 0 || alreadySentCount >= actualEmailCount) {
const finalCampaign = await prisma.campaign.findUnique({
where: {id: campaignId},
include: {project: {select: {name: true}}},
});
if (finalCampaign && finalCampaign.status === CampaignStatus.SENDING) {
await prisma.campaign.update({
where: {id: campaignId},
data: {status: CampaignStatus.SENT, sentCount: alreadySentCount},
});
signale.success(
`[CAMPAIGN] Campaign ${finalCampaign.name} finalized after last batch: ${alreadySentCount}/${actualEmailCount} emails sent`,
);
await NtfyService.notifyCampaignSendCompleted(
finalCampaign.name,
finalCampaign.project.name,
finalCampaign.projectId,
alreadySentCount,
);
}
}
}
}
+69 -24
View File
@@ -76,6 +76,23 @@ export class ContactService {
return contact;
}
/**
* Bulk-check which emails exist in the project — single query, safe for up to 500 addresses.
*/
public static async lookup(
projectId: string,
emails: string[],
): Promise<{found: string[]; notFound: string[]}> {
const rows = await prisma.contact.findMany({
where: {projectId, email: {in: emails, mode: 'insensitive'}},
select: {email: true},
});
const foundSet = new Set(rows.map(r => r.email.toLowerCase()));
const found = emails.filter(e => foundSet.has(e.toLowerCase()));
const notFound = emails.filter(e => !foundSet.has(e.toLowerCase()));
return {found, notFound};
}
/**
* Find a contact by email (returns null if not found)
*/
@@ -234,10 +251,22 @@ export class ContactService {
continue;
}
// Skip empty string values - they don't provide meaningful data
// and can cause issues with template rendering and data integrity
if (value === '') {
continue;
}
// Delete field if null is passed (allows removing fields from contact data)
if (value === null) {
delete mergedData[key];
continue;
}
// Validate locale field (special user-settable field)
// Only validate type - any locale string is accepted since we default to English if unsupported
if (key === 'locale') {
if (value !== null && value !== undefined && typeof value !== 'string') {
if (value !== undefined && typeof value !== 'string') {
throw new HttpException(400, 'Locale must be a string');
}
}
@@ -265,33 +294,49 @@ export class ContactService {
const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed;
const wasSubscribed = existing.subscribed;
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
try {
const updated = await prisma.contact.update({
where: {id: existing.id},
data: {
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
...(subscribed !== undefined ? {subscribed} : {}),
},
});
// Track subscription event if status changed
if (isSubscriptionChanging) {
if (subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
} else if (!subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
// Track subscription event if status changed
if (isSubscriptionChanging) {
if (subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
} else if (!subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
}
}
}
return updated;
return updated;
} catch (error) {
// Provide helpful error message for database/validation issues
throw new HttpException(
500,
`Failed to update contact: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
} else {
return prisma.contact.create({
data: {
projectId,
email,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
subscribed: subscribed ?? defaultSubscribed,
},
});
try {
return await prisma.contact.create({
data: {
projectId,
email,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
subscribed: subscribed ?? defaultSubscribed,
},
});
} catch (error) {
// Provide helpful error message for database/validation issues
throw new HttpException(
500,
`Failed to create contact: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
}
+43
View File
@@ -388,6 +388,49 @@ export class DomainService {
return domain;
}
/**
* Extract the registrable root domain (last two labels) from a domain name.
* e.g. "mail.example.com" "example.com", "example.com" "example.com"
*/
private static rootDomain(domain: string): string {
const parts = domain.split('.');
return parts.length > 2 ? parts.slice(-2).join('.') : domain;
}
/**
* Check whether the root domain of `domain` is owned by a disabled project.
* Prevents subdomains from being added when the parent domain is flagged.
*/
public static async checkSubdomainOfDisabledRoot(
domain: string,
): Promise<{blocked: boolean; projectName?: string; projectId?: string}> {
const root = this.rootDomain(domain);
// Only relevant when the submitted domain is actually a subdomain
if (root === domain) {
return {blocked: false};
}
const rootDomainRecord = await prisma.domain.findFirst({
where: {domain: root},
include: {
project: {
select: {id: true, name: true, disabled: true},
},
},
});
if (rootDomainRecord?.project.disabled) {
return {
blocked: true,
projectName: rootDomainRecord.project.name,
projectId: rootDomainRecord.project.id,
};
}
return {blocked: false};
}
/**
* Check if a domain is already linked to another project
* Used when adding a new domain to verify if the user has access to the existing project
+5 -75
View File
@@ -1,98 +1,28 @@
import signale from 'signale';
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {STRIPE_ENABLED} from '../app/constants.js';
import {QueueService} from './QueueService.js';
/**
* Meter Service
* Handles recording usage events to Stripe billing meters for pay-as-you-go pricing
*/
export class MeterService {
/**
* Record an email sent event to Stripe meter
* This is used for usage-based billing where customers pay per email sent
*
* @param customerId - Stripe customer ID
* @param value - Number of emails sent (default: 1)
* @param idempotencyKey - Optional unique identifier to prevent duplicate recording
*/
public static async recordEmailSent(customerId: string, value = 1, idempotencyKey?: string): Promise<void> {
// Skip if billing is disabled (self-hosted mode)
if (!STRIPE_ENABLED || !stripe) {
if (!STRIPE_ENABLED) {
return;
}
// Skip if no customer ID (not subscribed)
if (!customerId) {
return;
}
try {
await stripe.billing.meterEvents.create({
event_name: STRIPE_METER_EVENT_NAME,
payload: {
stripe_customer_id: customerId,
value: value.toString(), // Must be a string
},
...(idempotencyKey && {identifier: idempotencyKey}),
});
signale.debug(`[METER] Recorded ${value} email(s) sent for customer ${customerId}`);
await QueueService.queueMeterEvent(customerId, value, idempotencyKey);
} catch (error) {
// Log error but don't throw - we don't want billing issues to break email sending
signale.error('[METER] Failed to record email sent event:', error);
// If it's a rate limit error, we might want to retry
if (error instanceof Error && error.message.includes('429')) {
signale.warn('[METER] Rate limited - consider implementing queue-based meter recording');
}
signale.error('[METER] Failed to enqueue meter event:', error);
}
}
/**
* Record multiple emails in a single batch (for campaign sending)
* More efficient than individual calls when sending bulk emails
*
* @param customerId - Stripe customer ID
* @param count - Number of emails sent in this batch
* @param batchId - Unique identifier for this batch
*/
public static async recordEmailBatch(customerId: string, count: number, batchId: string): Promise<void> {
if (count <= 0) return;
await this.recordEmailSent(customerId, count, `batch_${batchId}`);
}
/**
* Get current usage for a customer in the current billing period
* Useful for displaying usage dashboards or implementing soft limits
*
* @param customerId - Stripe customer ID
* @param meterId - The meter ID from Stripe dashboard
* @param startTime - Start of period (Unix timestamp)
* @param endTime - End of period (Unix timestamp)
*/
public static async getUsageSummary(
meterId: string,
customerId: string,
startTime: number,
endTime: number,
): Promise<unknown> {
if (!STRIPE_ENABLED || !stripe) {
return null;
}
try {
const summaries = await stripe.billing.meters.listEventSummaries(meterId, {
customer: customerId,
start_time: startTime,
end_time: endTime,
});
return summaries;
} catch (error) {
signale.error('[METER] Failed to get usage summary:', error);
return null;
}
}
}
+39
View File
@@ -7,6 +7,7 @@ import type {
CampaignBatchJobData,
ContactImportJobData,
DomainVerificationJobData,
MeterEventJobData,
ScheduledCampaignJobData,
SegmentCountJobData,
SendEmailJobData,
@@ -158,6 +159,19 @@ export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contac
},
});
export const meterQueue = new Queue<MeterEventJobData>('meter', {
connection: redisConnection,
defaultJobOptions: {
attempts: 10,
backoff: {
type: 'exponential',
delay: 5000,
},
removeOnComplete: 5000,
removeOnFail: 10000,
},
});
/**
* Queue Service - Centralized queue management
*/
@@ -313,6 +327,23 @@ export class QueueService {
};
}
/**
* Queue a Stripe meter event for reliable delivery with retries
*/
public static async queueMeterEvent(
customerId: string,
value: number,
idempotencyKey?: string,
): Promise<Job<MeterEventJobData>> {
return meterQueue.add(
'record-meter-event',
{customerId, value, idempotencyKey},
{
jobId: idempotencyKey ? `meter-${idempotencyKey}` : undefined,
},
);
}
/**
* Queue bulk contact action job
*/
@@ -390,6 +421,7 @@ export class QueueService {
domainVerificationCounts,
apiRequestCleanupCounts,
bulkContactCounts,
meterCounts,
] = await Promise.all([
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
@@ -400,6 +432,7 @@ export class QueueService {
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
meterQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
]);
return {
@@ -412,6 +445,7 @@ export class QueueService {
domainVerification: domainVerificationCounts,
apiRequestCleanup: apiRequestCleanupCounts,
bulkContact: bulkContactCounts,
meter: meterCounts,
};
}
@@ -429,6 +463,7 @@ export class QueueService {
domainVerificationQueue.pause(),
apiRequestCleanupQueue.pause(),
bulkContactQueue.pause(),
meterQueue.pause(),
]);
}
@@ -446,6 +481,7 @@ export class QueueService {
domainVerificationQueue.resume(),
apiRequestCleanupQueue.resume(),
bulkContactQueue.resume(),
meterQueue.resume(),
]);
}
@@ -472,6 +508,8 @@ export class QueueService {
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
meterQueue.clean(gracePeriod * 30, 5000, 'completed'), // Keep 30 days for billing audit
meterQueue.clean(gracePeriod * 30, 10000, 'failed'),
]);
}
@@ -557,6 +595,7 @@ export class QueueService {
domainVerificationQueue.close(),
apiRequestCleanupQueue.close(),
bulkContactQueue.close(),
meterQueue.close(),
]);
}
}
+11 -9
View File
@@ -133,21 +133,23 @@ export async function sendRawEmail({
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
}
// Build the additional headers (custom headers + List-Unsubscribe), filtering
// out empties so we never emit a blank line inside the header section.
// Per RFC 5322 §2.1, a blank line terminates the header section, so any blank
// line here would push subsequent headers (notably List-Unsubscribe) into the body.
const extraHeaderLines = [
...(headers ? Object.entries(headers).map(([key, value]) => `${key}: ${value}`) : []),
...(unsubscribeHeader ? [unsubscribeHeader] : []),
];
const extraHeaders = extraHeaderLines.length > 0 ? `\n${extraHeaderLines.join('\n')}` : '';
// Build raw MIME message
let rawMessage = `From: ${from.name} <${from.email}>
To: ${toHeader}
Reply-To: ${reply || from.email}
Subject: ${content.subject}
MIME-Version: 1.0
Content-Type: ${rootContentType}
${
headers
? Object.entries(headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
: ''
}
${unsubscribeHeader}
Content-Type: ${rootContentType}${extraHeaders}
`;
+388 -2
View File
@@ -1,3 +1,5 @@
import crypto from 'crypto';
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
import React from 'react';
import signale from 'signale';
@@ -8,7 +10,18 @@ import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js';
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
import {
AUTO_PROJECT_DISABLE,
DASHBOARD_URI,
LANDING_URI,
OPENROUTER_API_KEY,
OPENROUTER_MODEL,
PHISHING_CONFIDENCE_THRESHOLD,
PHISHING_CUMULATIVE_THRESHOLD,
PHISHING_CUMULATIVE_WINDOW_MS,
PHISHING_DETECTION_ENABLED,
PHISHING_DETECTION_SAMPLE_RATE,
} from '../app/constants.js';
/**
* Security thresholds for bounce and complaint rates
@@ -64,6 +77,58 @@ const SECURITY_THRESHOLDS = {
NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL: 20,
} as const;
/**
* Redis-based tracking for phishing detections per project
* Tracks timestamp and confidence of recent phishing detections
* Uses sorted sets for efficient time-based filtering
*/
interface PhishingDetection {
timestamp: number;
confidence: number;
subject: string;
}
/**
* Track a phishing detection for cumulative analysis using Redis
*/
async function trackPhishingDetection(projectId: string, confidence: number, subject: string): Promise<void> {
const now = Date.now();
const key = `phishing:detections:${projectId}`;
// Store detection as sorted set member (score = timestamp)
// Value is JSON with confidence and subject
const detection: PhishingDetection = {timestamp: now, confidence, subject};
await redis.zadd(key, now, JSON.stringify(detection));
// Remove detections outside the time window
const cutoff = now - PHISHING_CUMULATIVE_WINDOW_MS;
await redis.zremrangebyscore(key, '-inf', cutoff);
// Set TTL to window duration to auto-cleanup old keys
await redis.expire(key, Math.ceil(PHISHING_CUMULATIVE_WINDOW_MS / 1000));
}
/**
* Get count of recent phishing detections for a project from Redis
*/
async function getRecentPhishingCount(projectId: string): Promise<number> {
const now = Date.now();
const cutoff = now - PHISHING_CUMULATIVE_WINDOW_MS;
const key = `phishing:detections:${projectId}`;
// Count detections within the time window
const count = await redis.zcount(key, cutoff, '+inf');
return count;
}
/**
* Clear phishing detection history for a project (e.g., after disable)
*/
async function clearPhishingHistory(projectId: string): Promise<void> {
const key = `phishing:detections:${projectId}`;
await redis.del(key);
}
interface RateData {
total: number;
bounces: number;
@@ -84,9 +149,76 @@ interface SecurityStatus {
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 {
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)
*/
@@ -620,7 +752,7 @@ export class SecurityService {
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
emails.map(email => sendPlatformEmail(email, 'Project Disabled', template)),
);
}
} catch (emailError) {
@@ -630,4 +762,258 @@ export class SecurityService {
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
}
}
/**
* Check email content for phishing or dangerous content using LLM
* Uses sampling to reduce costs - only checks a percentage of emails
* @returns { isPhishing: boolean, confidence: number, shouldDisable: boolean }
*/
public static async checkPhishingContent(
projectId: string,
projectName: string,
fromEmail: string,
subject: string,
body: string,
): Promise<{isPhishing: boolean; confidence: number; shouldDisable: boolean}> {
// Default safe response
const safeResponse = {isPhishing: false, confidence: 0, shouldDisable: false};
try {
// Check if phishing detection is enabled
if (!PHISHING_DETECTION_ENABLED) {
return safeResponse;
}
// Sample-based checking - only check a percentage of emails
if (Math.random() > PHISHING_DETECTION_SAMPLE_RATE) {
return safeResponse;
}
signale.info(`[PHISHING] Checking email for project ${projectId} (sampled)`);
// Strip HTML tags for content analysis (basic HTML removal)
const strippedBody = body
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// Extract URLs from the original HTML body for domain analysis
const urlMatches = body.match(/https?:\/\/[^\s"'<>]+/g) ?? [];
const uniqueUrls = [...new Set(urlMatches.map(u => u.replace(/[.,;)]+$/, '')))].slice(0, 20);
// Extract sender domain for context
const senderDomain = fromEmail.includes('@') ? fromEmail.split('@')[1] : fromEmail;
// Call OpenRouter API
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
'HTTP-Referer': 'https://useplunk.com',
'X-Title': 'Plunk Email Platform',
},
body: JSON.stringify({
model: OPENROUTER_MODEL,
messages: [
{
role: 'system',
content: `You are a security expert analyzing emails for phishing, scams, and dangerous content. Analyze the email subject and body and respond with a JSON object in this exact format:
{
"is_phishing": true/false,
"confidence": 0-100,
"reason": "brief explanation"
}
Criteria for phishing/dangerous content:
- Credential theft attempts (fake login pages, password requests)
- Financial scams (fake invoices, lottery scams, advance-fee fraud)
- Malicious links or attachments references
- Impersonation of banks, government, or well-known companies
- Urgent threats or fear-based manipulation
- Suspicious cryptocurrency or investment schemes
- Requests for sensitive personal information
IMPORTANT - Use sender and project context when evaluating:
- The sender project name and domain are provided. Links to the sender's own domain(s) are expected and NOT suspicious.
- URLs that match or are clearly related to the project name or sender domain add credibility.
- Only flag a URL as suspicious if it is unrelated to or impersonates a different known brand.
- Lack of recognizable brand does NOT make an email phishing many legitimate businesses are not famous.
Be strict but fair. Marketing emails and legitimate transactional emails are NOT phishing.
Only flag content that is CLEARLY attempting to deceive or harm recipients.
Set confidence to 100 only if you are absolutely certain it's phishing.`,
},
{
role: 'user',
content: `Sender project name: ${projectName}
Sender domain: ${senderDomain}
${uniqueUrls.length > 0 ? `URLs found in email: ${uniqueUrls.join(', ')}` : ''}
Subject: ${subject}
Body:
${strippedBody.substring(0, 2000)}`,
},
],
temperature: 0.1,
max_tokens: 200,
response_format: {type: 'json_object'},
}),
});
if (!response.ok) {
signale.error(`[PHISHING] OpenRouter API error: ${response.status} ${response.statusText}`);
return safeResponse;
}
const data = (await response.json()) as {
choices?: Array<{message?: {content?: string}}>;
};
const content = data.choices?.[0]?.message?.content;
if (!content) {
signale.error('[PHISHING] No content in OpenRouter response');
return safeResponse;
}
// Parse the JSON response (response_format ensures clean JSON)
const result = JSON.parse(content) as {
is_phishing: boolean;
confidence: number;
reason: string;
};
const isPhishing = result.is_phishing === true;
const confidence = Math.min(100, Math.max(0, result.confidence || 0));
if (isPhishing) {
signale.warn(
`[PHISHING] Detected phishing content for project ${projectId} - Confidence: ${confidence}% - Reason: ${result.reason}`,
);
// Track this detection for cumulative analysis
await trackPhishingDetection(projectId, confidence, subject);
// Get count of recent detections
const recentCount = await getRecentPhishingCount(projectId);
signale.info(
`[PHISHING] Project ${projectId} has ${recentCount} phishing detection(s) in the last ${PHISHING_CUMULATIVE_WINDOW_MS / 1000 / 60} minutes`,
);
} else {
signale.success(`[PHISHING] Passed phishing check for project: ${projectId}`);
}
// Determine if project should be disabled
// Disable if EITHER:
// 1. Single detection with high confidence (>= threshold)
// 2. Multiple detections within time window (>= cumulative threshold)
const meetsConfidenceThreshold = isPhishing && confidence >= PHISHING_CONFIDENCE_THRESHOLD;
const recentCount = await getRecentPhishingCount(projectId);
const meetsCumulativeThreshold = isPhishing && recentCount >= PHISHING_CUMULATIVE_THRESHOLD;
const shouldDisable = meetsConfidenceThreshold || meetsCumulativeThreshold;
if (shouldDisable) {
if (meetsConfidenceThreshold) {
signale.error(
`[PHISHING] High confidence phishing detected (${confidence}% >= ${PHISHING_CONFIDENCE_THRESHOLD}%) - will disable project ${projectId}`,
);
}
if (meetsCumulativeThreshold) {
signale.error(
`[PHISHING] Cumulative threshold reached (${recentCount} >= ${PHISHING_CUMULATIVE_THRESHOLD} detections) - will disable project ${projectId}`,
);
}
// Clear history after disabling
await clearPhishingHistory(projectId);
}
return {
isPhishing,
confidence,
shouldDisable,
};
} catch (error) {
// Log error but don't throw - we don't want phishing checks to break email sending
// Better to let a phishing email through than to break legitimate emails
signale.error(`[PHISHING] Failed to check content for project ${projectId}:`, error);
return safeResponse;
}
}
/**
* Disable a project due to phishing detection
*/
public static async disableProjectForPhishing(
projectId: string,
subject: string,
confidence: number,
reason?: string,
): Promise<void> {
try {
// Check if already disabled to avoid duplicate logs
const project = await prisma.project.findUnique({
where: {id: projectId},
select: {id: true, disabled: true, name: true},
});
if (!project) {
signale.error(`[PHISHING] Project ${projectId} not found`);
return;
}
if (project.disabled) {
signale.warn(`[PHISHING] Project ${projectId} (${project.name}) already disabled`);
return;
}
// Disable the project
await prisma.project.update({
where: {id: projectId},
data: {disabled: true},
});
const violation = `A policy violation was detected. Please contact support for more details.`;
// Log critical security event
signale.error(
`[PHISHING] Project ${projectId} (${project.name}) has been automatically disabled due to phishing detection:`,
violation,
);
// Cancel all pending jobs for this project
try {
await QueueService.cancelAllProjectJobs(projectId);
signale.info(`[PHISHING] Cancelled all pending jobs for project ${projectId}`);
} catch (error) {
signale.error(`[PHISHING] Failed to cancel pending jobs for project ${projectId}:`, error);
}
// Send urgent notification about project suspension
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, [violation]);
// Send email notification to project members
try {
const members = await MembershipService.getMembers(projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
const template = React.createElement(ProjectDisabledEmail, {
projectName: project.name,
projectId,
violations: [violation],
dashboardUrl: DASHBOARD_URI,
landingUrl: LANDING_URI,
});
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Project Disabled', template)),
);
}
} catch (emailError) {
signale.error(`[PHISHING] Failed to send project disabled email:`, emailError);
}
} catch (error) {
signale.error(`[PHISHING] Failed to disable project ${projectId}:`, error);
}
}
}
+23 -4
View File
@@ -328,7 +328,9 @@ export class SegmentService {
projectId: string,
segmentId: string,
emails: string[],
): Promise<{added: number; notFound: string[]}> {
createMissing = false,
subscribed = true,
): Promise<{added: number; created: number; notFound: string[]}> {
const segment = await this.get(projectId, segmentId);
if (segment.type !== 'STATIC') {
@@ -336,7 +338,7 @@ export class SegmentService {
}
// Look up contacts by email (case-insensitive)
const contacts = await prisma.contact.findMany({
let contacts = await prisma.contact.findMany({
where: {
projectId,
email: {in: emails, mode: 'insensitive'},
@@ -344,7 +346,24 @@ export class SegmentService {
select: {id: true, email: true},
});
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
let foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
const missing = emails.filter(e => !foundEmails.has(e.toLowerCase()));
let created = 0;
if (createMissing && missing.length > 0) {
await prisma.contact.createMany({
data: missing.map(email => ({projectId, email, subscribed})),
skipDuplicates: true,
});
created = missing.length;
// Re-fetch to include newly created contacts
contacts = await prisma.contact.findMany({
where: {projectId, email: {in: emails, mode: 'insensitive'}},
select: {id: true, email: true},
});
foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
}
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
if (contacts.length > 0) {
@@ -377,7 +396,7 @@ export class SegmentService {
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
}
return {added: contacts.length, notFound};
return {added: contacts.length, created, notFound};
}
/**
+60
View File
@@ -576,6 +576,66 @@ export class WorkflowService {
});
}
/**
* Splice a step out of the flow: re-wire its parent(s) directly to its child,
* then delete only the step itself. Not allowed for CONDITION or TRIGGER steps.
*/
public static async spliceStep(projectId: string, workflowId: string, stepId: string): Promise<void> {
await this.get(projectId, workflowId);
const step = await prisma.workflowStep.findUnique({
where: {id: stepId},
include: {
outgoingTransitions: true,
incomingTransitions: true,
},
});
if (step?.workflowId !== workflowId) {
throw new HttpException(404, 'Workflow step not found');
}
if (step.type === 'TRIGGER') {
throw new HttpException(400, 'Cannot remove the trigger step.');
}
if (step.type === 'CONDITION') {
throw new HttpException(400, 'Cannot splice a condition step out of the flow.');
}
// Check for active executions on this step
const executionsOnStep = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: stepId,
status: {in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING]},
},
});
if (executionsOnStep > 0) {
throw new HttpException(
409,
`Cannot remove step "${step.name}" while ${executionsOnStep} execution(s) are currently on it. ` +
'Please disable the workflow first or wait for executions to complete.',
);
}
// Re-wire: for each incoming transition, point it to our child (if we have one)
const child = step.outgoingTransitions[0];
if (child) {
for (const incoming of step.incomingTransitions) {
await prisma.workflowTransition.update({
where: {id: incoming.id},
data: {toStepId: child.toStepId},
});
}
}
// Delete the step — cascades and removes its own transitions
await prisma.workflowStep.delete({where: {id: stepId}});
}
/**
* Create a transition between two steps
*/
@@ -336,19 +336,75 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
});
});
it('should handle null values in data', async () => {
it('should delete keys when null value is passed', async () => {
const email = '[email protected]';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: null,
middleName: null, // null should delete/not store the key
lastName: 'Doe',
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).toHaveProperty('middleName', null);
expect(contact.data).not.toHaveProperty('middleName'); // Key should not exist
expect(contact.data).toHaveProperty('lastName', 'Doe');
});
it('should remove existing fields when null is passed', async () => {
const email = '[email protected]';
// Create contact with data
await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: 'Michael',
lastName: 'Doe',
});
// Update with null to remove middleName
const updated = await ContactService.upsert(projectId, email, {
middleName: null, // Should delete this field
});
expect(updated.data).toHaveProperty('firstName', 'John'); // Preserved
expect(updated.data).not.toHaveProperty('middleName'); // Removed
expect(updated.data).toHaveProperty('lastName', 'Doe'); // Preserved
});
it('should filter out empty string values from data', async () => {
const email = '[email protected]';
const contact = await ContactService.upsert(projectId, email, {
firstName: 'John',
middleName: '', // Empty string should be filtered out
lastName: 'Doe',
company: '', // Empty string should be filtered out
});
expect(contact.data).toHaveProperty('firstName', 'John');
expect(contact.data).toHaveProperty('lastName', 'Doe');
expect(contact.data).not.toHaveProperty('middleName');
expect(contact.data).not.toHaveProperty('company');
});
it('should not overwrite existing data with empty strings', async () => {
const email = '[email protected]';
// Create contact with data
await ContactService.upsert(projectId, email, {
firstName: 'John',
company: 'Acme Inc',
});
// Update with empty string - should preserve existing values
const updated = await ContactService.upsert(projectId, email, {
firstName: '', // Should be filtered out, preserving "John"
lastName: 'Doe',
});
expect(updated.data).toHaveProperty('firstName', 'John'); // Preserved
expect(updated.data).toHaveProperty('company', 'Acme Inc'); // Preserved
expect(updated.data).toHaveProperty('lastName', 'Doe'); // New value
});
});
describe('Contact CRUD Operations', () => {
@@ -787,8 +787,8 @@ describe('EmailService', () => {
}
});
it('should validate attachment size limit (10MB total)', () => {
// Exceeds ~13.3M base64 chars limit
it('should validate attachment size limit (10MB total default)', () => {
// Exceeds ~13.4M base64 chars for the default 10MB limit
const largeContent = 'A'.repeat(14000000);
const result = ActionSchemas.send.safeParse({
+1 -1
View File
@@ -27,7 +27,7 @@
"@tiptap/starter-kit": "^3.11.0",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.1.7",
"next": "^16.2.3",
"next-seo": "^6.6.0",
"react": "19.2.3",
"react-dom": "19.2.3",
+12
View File
@@ -0,0 +1,12 @@
<svg width="192" height="30" viewBox="0 0 192 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.3" y="0.3" width="29.4" height="29.4" rx="14.7" fill="#C6FE1E"/>
<rect x="0.3" y="0.3" width="29.4" height="29.4" rx="14.7" stroke="#B3E910" stroke-width="0.6"/>
<mask id="mask0_1720_1740" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="5" y="8" width="20" height="15">
<path d="M24.0038 8H5.99805V23H24.0038V8Z" fill="white"/>
</mask>
<g mask="url(#mask0_1720_1740)">
<path d="M12.9679 11.4606H12.9599C12.4637 11.3182 11.9387 11.603 11.7707 12.0734C11.585 12.579 11.8795 13.1582 12.3997 13.3182C13.6737 13.6798 14.2211 11.8542 12.9679 11.4606Z" fill="#00160D"/>
<path d="M23.8265 14.0425C22.1859 10.4505 16.9523 11.9369 16.4689 10.9961C15.0525 8.77368 12.4084 7.52568 9.54993 8.16888C9.10979 7.99288 7.59411 7.94488 6.6146 8.56088L7.20518 8.82168C7.25 8.84088 7.23719 8.83608 7.30281 8.86008C7.5701 8.96088 7.52368 8.93208 7.33162 9.03768C6.89148 9.29528 6.34251 9.59448 6 10.0441C6.0144 10.0649 6.75384 10.2425 6.75384 10.2425C6.76664 10.2457 6.90429 10.2601 6.87388 10.3209C4.45551 14.1385 8.5336 19.9001 10.5134 23.0009H16.2449C15.3598 21.4169 14.3483 19.2553 14.694 17.7881C14.7564 17.5225 14.8364 17.1849 15.1629 17.1401C15.952 17.0137 17.0083 17.0249 17.7605 16.9401C17.7605 16.9401 17.7643 16.9401 17.7717 16.9401C17.9318 16.9321 21.8498 16.4345 22.7877 18.8761C22.8678 19.1001 23.0518 18.9545 23.1254 18.8073C23.8249 17.4169 24.2762 15.2281 23.8297 14.0441L23.8265 14.0425ZM17.5013 12.8633C17.226 13.3545 17.0387 13.9929 16.9907 14.5497C16.9651 14.9033 17.0019 15.2521 17.0371 15.6057C17.0563 15.8009 17.0579 16.0473 16.9011 16.1753C16.765 16.2905 16.5393 16.3017 16.3121 16.3177C15.1965 16.3129 12.4757 16.3177 11.4273 15.6041L11.4209 15.5993C9.89564 14.6697 9.07938 12.5705 9.98527 10.9401C10.2782 10.3865 10.8175 10.0265 11.4321 9.89048C12.2228 9.70968 13.0935 9.84408 13.7993 10.2137C14.0874 10.3593 14.4315 10.5513 14.694 10.7721C15.2141 11.2265 15.6591 11.6985 16.3409 11.8441C16.6546 11.9337 16.9811 11.9209 17.2948 11.9849C17.8822 12.1257 17.7349 12.4601 17.4997 12.8601L17.5013 12.8633Z" fill="#00160D"/>
</g>
<path d="M37.392 23V7.4H43.296C47.688 7.4 50.952 10.568 50.952 15.176C50.952 19.64 47.904 23 43.632 23H37.392ZM40.056 20.48H43.44C46.176 20.48 48.168 18.272 48.168 15.2C48.168 12.08 46.08 9.848 43.2 9.848H40.056V20.48ZM57.774 23.288C54.246 23.288 51.75 20.672 51.75 17C51.75 13.328 54.246 10.712 57.774 10.712C61.302 10.712 63.822 13.328 63.822 17C63.822 20.672 61.302 23.288 57.774 23.288ZM57.774 20.816C59.766 20.816 61.206 19.232 61.206 17C61.206 14.72 59.766 13.112 57.774 13.112C55.782 13.112 54.366 14.72 54.366 17C54.366 19.232 55.782 20.816 57.774 20.816ZM69.9786 23.288C66.7866 23.288 64.5066 20.672 64.5066 17C64.5066 13.304 66.7626 10.712 69.9546 10.712C71.5866 10.712 73.0026 11.432 73.9626 12.656V6.68H76.4826V20.6L76.5306 23H74.4666L74.0826 21.2C73.1226 22.496 71.6826 23.288 69.9786 23.288ZM70.5546 20.816C72.5466 20.816 73.9866 19.208 73.9866 17C73.9866 14.744 72.5466 13.136 70.5546 13.136C68.5626 13.136 67.1226 14.744 67.1226 17C67.1226 19.208 68.5626 20.816 70.5546 20.816ZM83.8965 23.288C80.3685 23.288 77.8725 20.672 77.8725 17C77.8725 13.328 80.3685 10.712 83.8965 10.712C87.4245 10.712 89.9445 13.328 89.9445 17C89.9445 20.672 87.4245 23.288 83.8965 23.288ZM83.8965 20.816C85.8885 20.816 87.3285 19.232 87.3285 17C87.3285 14.72 85.8885 13.112 83.8965 13.112C81.9045 13.112 80.4885 14.72 80.4885 17C80.4885 19.232 81.9045 20.816 83.8965 20.816ZM95.667 23V7.4H102.603C105.507 7.4 107.571 9.488 107.571 12.416C107.571 15.368 105.435 17.48 102.483 17.48H98.331V23H95.667ZM98.331 15.2H102.315C103.755 15.2 104.787 14.096 104.787 12.536C104.787 10.976 103.755 9.848 102.315 9.848H98.331V15.2ZM111.711 23.288C109.359 23.288 107.775 21.872 107.775 19.784C107.775 18.176 109.023 16.832 110.919 16.472L114.711 15.728C115.071 15.68 115.311 15.416 115.311 15.104C115.311 13.88 114.423 12.992 113.175 12.992C111.879 12.992 110.871 13.904 110.775 15.272L108.183 14.648C108.567 12.344 110.583 10.712 113.055 10.712C115.839 10.712 117.831 12.584 117.831 15.176V20.6L117.879 23H115.887L115.455 21.368C114.711 22.544 113.343 23.288 111.711 23.288ZM110.487 19.448C110.487 20.312 111.183 20.888 112.335 20.888C114.015 20.888 115.311 19.664 115.311 17.864V17.312C115.071 17.36 114.807 17.432 114.543 17.48L111.927 17.96C111.039 18.128 110.487 18.704 110.487 19.448ZM119.784 25.64C120.84 25.568 121.728 24.992 122.16 23.984L122.712 22.616L117.984 11H120.864L123.96 19.688L126.792 11H129.624L124.44 24.728C123.696 26.696 122.376 27.944 120.744 28.088L119.784 25.64ZM130.303 23V11H132.295L132.727 12.944C133.279 11.624 134.647 10.712 136.255 10.712C137.887 10.712 139.327 11.648 139.951 13.016C140.551 11.648 142.015 10.712 143.695 10.712C146.167 10.712 147.943 12.728 147.943 15.536V23H145.423V15.872C145.423 14.336 144.439 13.208 143.071 13.208C141.535 13.208 140.431 14.504 140.431 16.28V23H137.959V15.92C137.959 14.336 136.951 13.208 135.559 13.208C133.975 13.208 132.823 14.528 132.823 16.376V23H130.303ZM155.115 23.288C151.587 23.288 149.115 20.672 149.115 17C149.115 13.328 151.539 10.712 154.947 10.712C158.763 10.712 160.971 13.976 160.635 17.792H151.803C152.115 19.616 153.411 20.84 155.139 20.84C156.507 20.84 157.611 20.072 157.899 18.944L160.467 19.856C159.843 21.896 157.707 23.288 155.115 23.288ZM151.827 15.92H157.995C157.755 14.24 156.507 13.112 154.947 13.112C153.363 13.112 152.163 14.24 151.827 15.92ZM162.027 23V11H164.043L164.427 12.968C165.075 11.624 166.587 10.712 168.315 10.712C170.955 10.712 172.827 12.8 172.827 15.656V23H170.307V16.064C170.307 14.384 169.203 13.184 167.595 13.184C165.843 13.184 164.547 14.552 164.547 16.472V23H162.027ZM180.599 23.024C178.103 23.792 175.175 23.024 175.175 19.976V13.328H173.375V11H174.071C174.815 11 175.295 10.448 175.295 9.608V7.808H177.695V11H180.455V13.328H177.695V19.4C177.695 20.792 179.015 21.176 180.311 20.696L180.599 23.024ZM186.24 23.288C183.288 23.288 181.08 21.68 180.888 19.448L183.768 18.776C183.84 20.072 184.944 21.032 186.408 21.032C187.536 21.032 188.376 20.432 188.376 19.568C188.376 17.096 181.464 18.032 181.464 14.072C181.464 12.08 183.312 10.712 185.928 10.712C188.616 10.712 190.56 12.104 190.752 14.048L188.016 14.672C187.992 13.616 187.104 12.896 185.856 12.896C184.8 12.896 184.08 13.424 184.08 14.192C184.08 16.592 191.088 15.608 191.088 19.664C191.088 21.776 189.072 23.288 186.24 23.288Z" fill="#00160D"/>
</svg>

After

Width:  |  Height:  |  Size: 6.3 KiB

+33
View File
@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="100%" viewBox="0 0 2400 531" enable-background="new 0 0 2400 531" xml:space="preserve">
<path fill="#5A17DD" opacity="1.000000" stroke="none" d=" M177.531342,1.000000 C179.057083,2.016129 179.936691,3.463687 181.198273,3.982726 C196.529205,10.290123 206.246201,22.020750 211.758423,37.067310 C214.062714,43.357273 214.598770,50.496723 214.855927,57.290924 C215.321609,69.593918 214.975113,81.927650 214.610489,94.993073 C214.374802,105.590721 214.496323,115.443848 214.617844,125.296967 C214.731262,134.873642 214.844681,144.450302 214.696503,154.805176 C213.707352,157.641037 212.979782,159.698669 211.949341,161.867874 C185.808609,176.848602 159.744293,191.340149 134.183105,206.670563 C89.674866,233.364563 87.927750,297.038086 131.449005,324.829407 C157.666046,341.570770 185.279068,356.126007 212.344147,371.739380 C212.423538,371.815125 212.480209,372.027130 212.369049,372.418213 C212.169983,374.965424 212.006592,377.121552 212.005508,379.277710 C211.989853,410.547516 212.059357,441.817566 211.942383,473.086914 C211.919479,479.210052 212.244370,485.631836 210.591049,491.394318 C206.096237,507.060730 196.645874,519.126404 181.619370,526.280518 C177.450226,528.265381 173.208450,530.097656 169.000000,532.000000 C126.979103,532.000000 84.958214,532.000000 42.345772,531.676025 C41.124565,530.844910 40.572868,530.118103 39.853806,529.862976 C23.633379,524.108398 11.737153,513.795288 5.170585,497.604126 C4.555796,496.088257 2.426259,495.186737 1.000005,494.000000 C1.000000,341.645782 1.000000,189.291550 1.334632,36.704620 C2.153957,35.664326 2.605743,34.834736 3.128503,34.052578 C7.677545,27.246292 11.359032,19.558378 17.063332,13.917211 C22.027281,9.008199 29.292238,6.433895 35.493198,2.748482 C35.874008,2.522156 35.840652,1.598958 36.000000,1.000000 C83.020897,1.000000 130.041794,1.000000 177.531342,1.000000 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2401.000000,108.531357 C2398.361084,112.502449 2395.684814,115.977570 2393.088867,119.511719 C2376.352051,142.298141 2344.292480,145.556335 2323.355225,127.012016 C2308.465820,113.824196 2304.169189,97.286690 2308.368164,78.826729 C2311.976562,62.963165 2329.512207,45.781548 2347.566895,44.760101 C2359.529297,44.083317 2370.719727,44.744938 2380.889160,51.499744 C2388.212158,56.364056 2393.808350,62.843418 2397.873047,70.647614 C2398.621338,72.084328 2399.502930,73.451706 2400.661621,74.925537 C2401.000000,86.020897 2401.000000,97.041786 2401.000000,108.531357 z"/>
<path fill="#6515E3" opacity="1.000000" stroke="none" d=" M169.449982,532.000000 C173.208450,530.097656 177.450226,528.265381 181.619370,526.280518 C196.645874,519.126404 206.096237,507.060730 210.591049,491.394318 C212.244370,485.631836 211.919479,479.210052 211.942383,473.086914 C212.059357,441.817566 211.989853,410.547516 212.005508,379.277710 C212.006592,377.121552 212.169983,374.965424 212.549744,372.721863 C214.349487,374.014984 215.857407,375.395538 217.365311,376.776123 C216.627380,376.596680 215.889450,376.417236 214.968765,376.193359 C214.968765,408.622589 215.150024,440.841736 214.894073,473.057465 C214.761459,489.751343 210.865372,505.497742 198.480194,517.622131 C194.147690,521.863403 188.375244,524.644470 183.216888,528.026001 C181.331070,529.262268 179.319336,530.306396 177.682526,531.719116 C175.299988,532.000000 172.599976,532.000000 169.449982,532.000000 z"/>
<path fill="#000000" opacity="1.000000" stroke="none" d=" M35.531342,1.000000 C35.840652,1.598958 35.874008,2.522156 35.493198,2.748482 C29.292238,6.433895 22.027281,9.008199 17.063332,13.917211 C11.359032,19.558378 7.677545,27.246292 3.128503,34.052578 C2.605743,34.834736 2.153957,35.664326 1.334632,36.235962 C1.000000,24.406746 1.000000,12.813491 1.000000,1.000000 C12.353279,1.000000 23.707981,1.000000 35.531342,1.000000 z"/>
<path fill="#000000" opacity="1.000000" stroke="none" d=" M35.000000,532.000000 C23.740093,532.000000 12.480186,532.000000 1.000000,532.000000 C1.000000,520.980103 1.000000,509.958710 1.273971,498.144836 C3.225362,500.256165 4.904092,503.159210 6.579990,506.063904 C12.840562,516.914856 21.889536,524.612366 33.221706,529.786804 C33.990940,530.138062 34.415260,531.244690 35.000000,532.000000 z"/>
<path fill="#6515E3" opacity="1.000000" stroke="none" d=" M35.437500,532.000000 C34.415260,531.244690 33.990940,530.138062 33.221706,529.786804 C21.889536,524.612366 12.840562,516.914856 6.579990,506.063904 C4.904092,503.159210 3.225362,500.256165 1.273971,497.676147 C1.000000,496.933319 1.000000,495.866669 1.000005,494.399994 C2.426259,495.186737 4.555796,496.088257 5.170585,497.604126 C11.737153,513.795288 23.633379,524.108398 39.853806,529.862976 C40.572868,530.118103 41.124565,530.844910 41.877113,531.676025 C39.958332,532.000000 37.916668,532.000000 35.437500,532.000000 z"/>
<path fill="#F98F03" opacity="1.000000" stroke="none" d=" M217.701797,376.986237 C215.857407,375.395538 214.349487,374.014984 212.660889,372.330750 C212.480209,372.027130 212.423538,371.815125 212.456863,371.226196 C212.517105,301.832153 212.545151,233.027054 212.557709,164.221970 C212.557846,163.400101 212.358459,162.578186 212.252228,161.756302 C212.979782,159.698669 213.707352,157.641037 214.620636,155.265472 C215.344513,159.168213 217.859421,156.155777 218.749405,155.648453 C226.304214,151.342041 233.649414,146.669876 241.170090,142.301239 C255.990524,133.692307 270.865234,125.176292 285.751129,116.680748 C300.302887,108.375900 314.910339,100.168633 329.466583,91.871597 C338.091431,86.955467 346.691559,81.994705 355.254517,76.971642 C368.468292,69.220383 381.515259,61.175720 394.862762,53.663818 C410.999268,44.582294 426.814056,34.545036 443.863586,27.617456 C453.705078,23.618645 465.243103,23.051941 476.905975,25.483866 C488.326538,27.865265 498.757507,31.301670 507.881378,38.191151 C513.293030,42.277500 517.835571,47.514904 522.653503,52.659882 C522.633423,54.161774 522.340271,55.516911 522.882446,56.273506 C530.810730,67.338020 534.825439,79.932739 534.852600,93.238861 C535.088684,209.045868 535.109619,324.853851 534.812317,440.660553 C534.781067,452.832825 531.410339,464.720093 523.805542,474.833038 C520.241638,479.572327 516.591858,484.247070 512.647461,489.038055 C501.482788,497.758301 489.668152,504.786011 475.582489,505.381836 C465.246460,505.819031 454.537109,505.528076 444.535706,503.217438 C436.519775,501.365479 429.139435,496.303467 421.788544,492.118835 C400.427094,479.958344 379.239807,467.492371 357.951324,455.203186 C333.209625,440.920685 308.448273,426.672119 283.665619,412.460846 C270.255615,404.771027 256.805481,397.150024 243.301727,389.626770 C242.091843,388.952698 240.352737,389.228546 238.859421,389.063232 C238.616684,389.002136 238.373947,388.941010 237.917542,388.412506 C236.750076,387.639465 235.796280,387.333801 234.842499,387.028076 C229.241089,383.750854 223.639679,380.473602 217.701797,376.986237 M296.716461,310.239075 C304.943359,323.504425 315.864471,333.778931 329.811554,340.975372 C340.928680,346.711548 352.553436,350.545807 365.039307,350.861145 C373.992004,351.087280 382.719025,349.423523 391.553467,347.250214 C407.133850,343.417419 419.549103,334.649506 430.206177,323.768677 C438.862549,314.930573 445.481720,304.050262 449.011292,291.869385 C449.959442,291.079254 451.318390,290.469421 451.762421,289.457977 C452.481628,287.819763 452.588501,285.915344 452.974182,284.127716 C459.194275,255.296036 451.962433,229.585983 432.394684,208.282761 C406.602570,180.203156 367.770386,170.884338 331.654480,188.451065 C300.026855,203.834702 283.702362,229.894516 282.017212,265.013763 C281.409424,277.680542 284.214142,289.821014 289.155273,301.371735 C290.547760,304.626892 293.721344,307.120148 296.716461,310.239075 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1744.174194,276.125854 C1743.397339,250.037003 1716.894165,228.103333 1693.279297,227.846497 C1678.464111,227.685364 1665.452637,231.062683 1653.901001,240.373459 C1641.084229,250.704025 1634.178711,264.113495 1634.095581,280.405457 C1633.831787,332.062531 1634.002563,383.721802 1633.999146,435.380219 C1633.998535,444.009430 1633.983765,444.016296 1625.421631,444.019470 C1604.758423,444.027161 1584.093994,443.917572 1563.432617,444.113464 C1558.626099,444.159027 1556.942017,442.406403 1556.949707,437.716370 C1557.035278,385.225494 1556.844727,332.733521 1557.119385,280.243896 C1557.196655,265.465637 1559.156006,250.830490 1564.858765,236.920059 C1573.162476,216.665894 1585.719849,199.820221 1603.551514,186.918121 C1617.478394,176.841232 1632.717773,169.587479 1649.459961,166.024292 C1667.382202,162.209900 1685.510498,161.295441 1703.915283,162.719238 C1720.459717,163.999130 1736.135376,167.906235 1750.562622,175.621109 C1760.132935,180.738770 1768.686768,187.785461 1777.553345,194.166153 C1779.232666,195.374664 1780.243896,197.511505 1780.926636,198.395660 C1791.135864,191.360275 1800.034424,184.745819 1809.402466,178.881607 C1824.746704,169.276382 1841.720825,164.639603 1859.721313,162.872437 C1878.759521,161.003418 1897.559570,162.090714 1916.189331,165.925613 C1932.132935,169.207581 1946.731934,175.813446 1960.179443,185.075195 C1974.098267,194.661621 1985.271973,206.717941 1993.764893,221.264038 C2001.136719,233.890305 2005.305786,247.682434 2007.892090,261.974640 C2008.567871,265.708923 2008.931152,269.553040 2008.935791,273.347351 C2008.999023,325.839020 2008.968994,378.330811 2008.993652,430.822571 C2008.994629,432.803070 2009.134277,434.820038 2009.523438,436.756927 C2010.615234,442.191498 2009.403442,443.998505 2003.981934,444.011566 C1981.818970,444.064911 1959.654907,443.918945 1937.493530,444.109344 C1932.375488,444.153351 1931.930420,441.525269 1931.935669,437.517609 C1932.002563,386.192505 1931.280640,334.853943 1932.255737,283.547241 C1932.811768,254.288528 1910.071411,232.334351 1888.143066,228.585251 C1855.772339,223.050873 1827.595581,243.656631 1822.208252,272.253021 C1821.964111,273.548920 1822.057983,274.908508 1821.744141,277.005432 C1821.331299,279.082092 1821.027954,280.391724 1821.026855,281.701599 C1820.988525,331.474060 1820.969482,381.246521 1821.002930,431.018982 C1821.003906,432.622162 1821.579956,434.224945 1821.888062,435.827911 C1822.129761,443.746552 1821.848755,444.027374 1813.989990,444.025299 C1793.012451,444.019684 1772.034912,444.036743 1751.057373,444.017181 C1744.453125,444.011047 1744.104248,443.630005 1744.039062,436.847382 C1744.013428,434.184235 1744.070435,431.520264 1744.308838,428.041931 C1744.685913,425.915863 1744.981689,424.604614 1744.982422,423.293274 C1745.006958,375.850342 1745.012085,328.407440 1744.969727,280.964539 C1744.968384,279.351410 1744.451050,277.738739 1744.174194,276.125854 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M997.272705,222.229736 C981.579956,246.333069 966.124878,270.164185 950.641724,293.976990 C948.382446,297.451782 947.862183,300.378418 950.462341,304.391327 C965.829834,328.109100 980.822998,352.069336 995.963135,375.934570 C1008.594299,395.844666 1021.280762,415.719696 1033.861938,435.661285 C1035.171021,437.736084 1035.930542,440.157471 1036.944946,442.418121 C1034.288818,442.960144 1031.634888,443.960419 1028.976196,443.972900 C1003.812378,444.090881 978.647278,443.964325 953.483704,444.114227 C949.271606,444.139343 946.969055,442.605499 944.811951,438.979584 C933.002319,419.128113 920.879944,399.462677 908.860474,379.736115 C901.511658,367.675049 894.033752,355.690063 886.872803,343.518524 C884.639282,339.722260 882.293396,337.716003 877.502808,337.876617 C865.683594,338.272888 853.836426,338.240082 842.013184,337.916168 C837.057373,337.780365 835.857788,339.551331 835.884644,344.231659 C836.061646,375.061371 835.986694,405.892578 835.980469,436.723297 C835.979004,444.027771 835.955444,444.024841 828.814270,444.025848 C807.982849,444.028748 787.151489,444.039551 766.320068,444.035645 C759.405029,444.034332 759.030701,443.712067 759.028259,436.617920 C759.003723,365.124664 759.001770,293.631439 759.000061,222.138184 C758.998840,167.810226 759.013245,113.482269 759.004761,59.154316 C759.003479,51.066788 758.963196,51.056705 766.879456,51.054558 C787.877686,51.048862 808.877380,51.187645 829.872925,50.951572 C834.715637,50.897114 836.080383,52.406761 836.071045,57.188786 C835.936829,126.182701 835.980042,195.176971 835.979553,264.171143 C835.979492,271.954742 835.976196,271.973450 843.903870,271.988312 C854.902405,272.008881 865.901855,271.923706 876.899048,272.045898 C880.193970,272.082520 882.186279,271.132050 884.099304,268.093018 C900.273743,242.399155 916.729797,216.882706 933.067810,191.291580 C937.070435,185.022079 940.811890,178.580414 944.982178,172.427185 C946.032654,170.877335 948.267334,169.177643 949.969910,169.164948 C975.798828,168.972351 1001.629822,169.045746 1027.460327,169.085663 C1028.082520,169.086624 1028.704102,169.418045 1030.490356,169.927292 C1020.262573,188.101135 1008.265137,204.650833 997.272705,222.229736 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2204.980713,168.953308 C2221.043945,167.723450 2235.611572,171.417221 2248.408691,181.608231 C2262.422852,192.768524 2271.056885,206.990784 2273.989014,224.687134 C2275.840088,235.861588 2275.574219,246.926849 2271.864502,257.679626 C2266.530029,273.142853 2256.308350,284.991730 2243.103271,294.200104 C2227.181641,305.303131 2210.964111,315.983337 2194.834961,326.786987 C2176.219727,339.255676 2157.545654,351.635986 2138.938721,364.116943 C2136.184814,365.964355 2133.499512,367.985107 2131.103760,370.263489 C2130.050293,371.265289 2128.992188,373.410431 2129.392334,374.503479 C2129.902588,375.897949 2131.739502,377.129395 2133.278320,377.731842 C2134.744141,378.305695 2136.562012,378.041351 2138.225098,378.041504 C2163.891357,378.043518 2189.561035,377.755280 2215.222656,378.099121 C2238.070801,378.405243 2258.320312,385.447876 2273.743652,403.282074 C2282.235352,413.101044 2287.425049,424.725464 2288.739502,437.407593 C2290.328369,452.744629 2290.283447,468.249268 2291.014404,483.678101 C2291.190674,487.394714 2289.923584,489.159607 2285.892822,489.130707 C2267.590576,488.999359 2249.286865,489.078583 2230.983643,489.046204 C2224.881592,489.035400 2224.096680,488.253265 2224.054443,482.091431 C2223.998291,473.925415 2224.121826,465.757599 2224.010254,457.592712 C2223.896973,449.294525 2218.450439,444.013275 2210.188721,444.013000 C2177.356201,444.011871 2144.513184,444.532867 2111.694092,443.876923 C2084.124512,443.325928 2064.515137,429.566284 2052.293457,404.840302 C2045.920166,391.946564 2044.336182,378.257721 2046.364868,364.535309 C2048.635010,349.178345 2056.038330,335.666229 2068.413086,326.050751 C2082.422852,315.164703 2097.209473,305.262756 2111.828857,295.180389 C2134.516357,279.533936 2157.348389,264.096436 2180.119873,248.571442 C2182.999268,246.608231 2185.907227,244.685928 2188.759277,242.683517 C2192.981934,239.718597 2193.051514,238.521896 2189.006348,233.956650 C2186.638428,232.871384 2184.622803,232.059738 2182.605957,232.056213 C2141.426758,231.984299 2100.247803,232.004745 2059.068604,232.006760 C2059.047607,212.932755 2059.107666,193.858063 2058.941895,174.785309 C2058.905029,170.534119 2060.306885,168.788834 2064.690674,168.936020 C2071.814453,169.175186 2078.952881,168.985855 2087.021729,168.979980 C2126.965820,168.972794 2165.973389,168.963058 2204.980713,168.953308 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1324.748779,324.688202 C1325.002808,341.574371 1330.428589,356.530090 1342.807983,368.154205 C1360.779785,385.029358 1382.398315,389.132507 1404.756104,380.121887 C1424.917969,371.996246 1438.287231,356.643585 1440.149292,333.570282 C1440.200928,332.929443 1440.546387,332.312317 1441.273193,331.148987 C1442.192749,328.741425 1442.941895,326.869507 1442.944336,324.996613 C1443.008179,275.239044 1443.006592,225.481339 1442.951050,175.723755 C1442.948853,173.800507 1442.302612,171.877975 1441.956543,169.955124 C1443.713989,169.636307 1445.470459,169.045197 1447.228882,169.040070 C1468.894775,168.976868 1490.562500,169.132477 1512.226074,168.895340 C1516.973755,168.843384 1518.081055,170.485931 1518.072388,174.928711 C1517.967285,229.093658 1518.430786,283.263855 1517.764526,337.420654 C1517.596558,351.070282 1513.692871,364.537415 1507.974609,377.318054 C1497.651611,400.390869 1481.522705,418.155945 1460.118652,431.234528 C1445.469849,440.185425 1429.464355,445.989227 1412.473145,447.821259 C1397.478638,449.437988 1382.223877,450.636871 1367.222656,449.728302 C1344.637451,448.360382 1322.903198,442.137604 1303.746826,429.729980 C1278.831665,413.592529 1261.509277,391.529053 1253.387329,362.369171 C1249.724243,349.217041 1248.143799,336.007904 1248.088867,322.576202 C1247.889404,273.744965 1247.997314,224.912491 1247.979248,176.080475 C1247.976685,169.203812 1248.125977,169.004700 1254.911133,169.001877 C1275.577271,168.993271 1296.243408,168.999054 1316.909546,169.004456 C1325.084473,169.006592 1325.084473,169.012848 1324.666870,178.200836 C1324.302979,188.990707 1324.174561,198.985367 1324.046143,208.980026 C1324.038940,237.012619 1324.031982,265.045227 1324.020264,294.014618 C1324.010010,297.109589 1323.947144,299.269409 1324.009399,301.425629 C1324.232910,309.180481 1324.498901,316.934113 1324.748779,324.688202 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1199.972778,168.956024 C1203.770874,168.731750 1207.571411,168.541504 1211.366333,168.272598 C1214.481812,168.051849 1216.882690,169.153198 1216.878052,172.471832 C1216.850830,192.436768 1216.646729,212.401474 1215.937500,232.479248 C1214.740112,232.415009 1214.107422,232.084122 1213.473877,232.082840 C1192.925293,232.041290 1172.376465,231.980743 1151.829224,232.138947 C1150.552002,232.148788 1149.288086,233.913574 1148.018066,234.861877 C1140.789795,237.727463 1137.886230,242.948029 1137.907104,250.794800 C1138.072021,312.592224 1138.000488,374.390259 1137.996826,436.188110 C1137.996338,444.058746 1137.983398,444.035858 1130.363525,444.031433 C1109.375732,444.019287 1088.385986,443.880737 1067.401245,444.133820 C1062.243896,444.196014 1060.914062,442.400757 1060.923218,437.485229 C1061.046265,371.523376 1060.580811,305.557281 1061.192627,239.600922 C1061.443604,212.545868 1073.475708,190.881134 1097.595825,177.444977 C1107.154053,172.120529 1117.863037,169.110855 1129.092651,169.056427 C1133.423340,169.035431 1137.753906,169.004639 1143.021240,168.980804 C1162.629395,168.974197 1181.301147,168.965103 1199.972778,168.956024 z"/>
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2380.845947,169.001526 C2381.656006,169.001328 2381.989258,169.004852 2382.322510,169.000549 C2393.011719,168.862213 2393.027100,168.862091 2393.027344,180.050995 C2393.030029,265.520142 2393.027588,350.989288 2393.020996,436.458435 C2393.020508,444.034637 2393.002197,444.032440 2385.673828,444.031891 C2364.681641,444.030304 2343.689697,444.034119 2322.697510,444.028229 C2316.246338,444.026428 2316.014404,443.808044 2316.014404,437.219147 C2316.015137,349.750580 2316.058105,262.281952 2315.937256,174.813583 C2315.930664,170.164124 2317.346436,168.858414 2321.891113,168.908417 C2341.381592,169.122910 2360.876221,169.000931 2380.845947,169.001526 z"/>
<path fill="#F69B04" opacity="1.000000" stroke="none" d=" M512.981018,488.951050 C516.591858,484.247070 520.241638,479.572327 523.805542,474.833038 C531.410339,464.720093 534.781067,452.832825 534.812317,440.660553 C535.109619,324.853851 535.088684,209.045868 534.852600,93.238861 C534.825439,79.932739 530.810730,67.338020 522.882446,56.273506 C522.340271,55.516911 522.633423,54.161774 522.811768,52.966721 C532.746765,66.097198 537.132141,80.735252 537.097900,97.310219 C536.859070,213.109009 537.071228,328.908752 536.783203,444.707336 C536.766357,451.480072 534.288208,458.484283 531.837341,464.957458 C528.213989,474.527679 522.099121,482.680542 514.226868,489.753632 C513.494446,489.452698 513.237732,489.201874 512.981018,488.951050 z"/>
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M512.647461,489.038055 C513.237732,489.201874 513.494446,489.452698 513.919800,489.912903 C506.566620,496.667297 498.159180,501.780334 488.607025,504.629608 C483.231537,506.233032 477.625824,507.324402 472.046051,507.931946 C458.426300,509.414917 445.249451,507.433197 433.044342,500.998230 C424.097961,496.281494 415.389160,491.111206 406.608948,486.082886 C393.976715,478.848572 381.364380,471.579254 368.764679,464.288452 C356.062378,456.938293 343.425201,449.474762 330.683655,442.193573 C322.712036,437.638092 314.510223,433.482849 306.571594,428.872711 C293.298065,421.164520 280.196411,413.160675 266.937836,405.426239 C257.755310,400.069580 248.430557,394.956635 239.014374,389.399048 C240.352737,389.228546 242.091843,388.952698 243.301727,389.626770 C256.805481,397.150024 270.255615,404.771027 283.665619,412.460846 C308.448273,426.672119 333.209625,440.920685 357.951324,455.203186 C379.239807,467.492371 400.427094,479.958344 421.788544,492.118835 C429.139435,496.303467 436.519775,501.365479 444.535706,503.217438 C454.537109,505.528076 465.246460,505.819031 475.582489,505.381836 C489.668152,504.786011 501.482788,497.758301 512.647461,489.038055 z"/>
<path fill="#281F4A" opacity="1.000000" stroke="none" d=" M1441.719482,170.298798 C1442.302612,171.877975 1442.948853,173.800507 1442.951050,175.723755 C1443.006592,225.481339 1443.008179,275.239044 1442.944336,324.996613 C1442.941895,326.869507 1442.192749,328.741425 1441.453369,330.780579 C1441.090210,278.826324 1441.062012,226.705276 1441.058960,174.584229 C1441.058838,173.270309 1441.334961,171.956390 1441.719482,170.298798 z"/>
<path fill="#2F194A" opacity="1.000000" stroke="none" d=" M2059.285156,232.323334 C2100.247803,232.004745 2141.426758,231.984299 2182.605957,232.056213 C2184.622803,232.059738 2186.638428,232.871384 2188.822266,233.657455 C2148.397949,234.004074 2107.805420,234.024551 2067.213379,233.937744 C2064.641846,233.932251 2062.072266,233.091873 2059.285156,232.323334 z"/>
<path fill="#430043" opacity="1.000000" stroke="none" d=" M1821.915771,435.376678 C1821.579956,434.224945 1821.003906,432.622162 1821.002930,431.018982 C1820.969482,381.246521 1820.988525,331.474060 1821.026855,281.701599 C1821.027954,280.391724 1821.331299,279.082092 1821.718506,277.440002 C1821.943604,329.713562 1821.943481,382.319489 1821.915771,435.376678 z"/>
<path fill="#2E002E" opacity="1.000000" stroke="none" d=" M1744.130127,276.579529 C1744.451050,277.738739 1744.968384,279.351410 1744.969727,280.964539 C1745.012085,328.407440 1745.006958,375.850342 1744.982422,423.293274 C1744.981689,424.604614 1744.685913,425.915863 1744.323242,427.571960 C1744.107910,377.622253 1744.097046,327.327728 1744.130127,276.579529 z"/>
<path fill="#2F194A" opacity="1.000000" stroke="none" d=" M1148.413940,234.743088 C1149.288086,233.913574 1150.552002,232.148788 1151.829224,232.138947 C1172.376465,231.980743 1192.925293,232.041290 1213.473877,232.082840 C1214.107422,232.084122 1214.740112,232.415009 1215.717163,232.834854 C1214.148804,233.389725 1212.237549,233.966492 1210.324219,233.973190 C1191.774780,234.038177 1173.224854,233.989151 1154.675293,234.031494 C1152.719727,234.035950 1150.764893,234.417633 1148.413940,234.743088 z"/>
<path fill="#000080" opacity="1.000000" stroke="none" d=" M2204.687988,168.654999 C2165.973389,168.963058 2126.965820,168.972794 2087.491943,168.968307 C2088.258301,168.637283 2089.490723,168.045258 2090.723877,168.043686 C2127.796387,167.996536 2164.868896,168.005508 2201.941406,168.024231 C2202.759277,168.024643 2203.577393,168.241028 2204.687988,168.654999 z"/>
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1199.681885,168.656769 C1181.301147,168.965103 1162.629395,168.974197 1143.491089,168.969086 C1143.591187,168.664795 1144.158691,168.120102 1144.724731,168.121521 C1162.946899,168.167679 1181.168945,168.266556 1199.681885,168.656769 z"/>
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1324.344482,208.687668 C1324.174561,198.985367 1324.302979,188.990707 1324.605469,178.658905 C1324.734009,188.346268 1324.688477,198.370789 1324.344482,208.687668 z"/>
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1324.820923,324.249207 C1324.498901,316.934113 1324.232910,309.180481 1324.009399,301.425629 C1323.947144,299.269409 1324.010010,297.109589 1324.030762,294.484253 C1324.363892,297.208466 1324.900757,300.396149 1324.954712,303.591980 C1325.068115,310.329132 1324.928711,317.070526 1324.820923,324.249207 z"/>
<path fill="#FF00FF" opacity="1.000000" stroke="none" d=" M214.788666,124.825363 C214.496323,115.443848 214.374802,105.590721 214.562286,95.472626 C214.900681,104.923019 214.930084,114.638390 214.788666,124.825363 z"/>
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M234.931915,387.307251 C235.796280,387.333801 236.750076,387.639465 237.613724,388.191864 C236.689499,388.154510 235.855423,387.870422 234.931915,387.307251 z"/>
<path fill="#221538" opacity="1.000000" stroke="none" d=" M211.949326,161.867874 C212.358459,162.578186 212.557846,163.400101 212.557709,164.221970 C212.545151,233.027054 212.517105,301.832153 212.377472,371.150421 C185.279068,356.126007 157.666046,341.570770 131.449005,324.829407 C87.927750,297.038086 89.674866,233.364563 134.183105,206.670563 C159.744293,191.340149 185.808609,176.848602 211.949326,161.867874 z"/>
<path fill="#000000" opacity="1.000000" stroke="none" d=" M448.520416,291.576965 C445.481720,304.050262 438.862549,314.930573 430.206177,323.768677 C419.549103,334.649506 407.133850,343.417419 391.553467,347.250214 C382.719025,349.423523 373.992004,351.087280 365.039307,350.861145 C352.553436,350.545807 340.928680,346.711548 329.811554,340.975372 C315.864471,333.778931 304.943359,323.504425 296.341583,309.739136 C293.122955,302.430634 289.375671,295.847046 287.613251,288.769196 C283.192322,271.015137 283.284637,253.339325 290.253174,235.913177 C294.830750,224.466003 301.326721,214.610947 310.311005,206.244354 C329.706665,188.182205 352.128204,179.648621 378.927307,182.865051 C395.792664,184.889221 410.374329,191.639160 422.842163,202.571182 C436.770416,214.783829 446.404449,229.586563 450.512085,248.381592 C453.334473,261.295746 452.945831,273.469971 450.115540,286.313507 C450.094208,287.099060 450.145050,287.549835 450.121063,288.296448 C449.537628,289.587189 449.029022,290.582062 448.520416,291.576965 z"/>
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M448.765869,291.723175 C449.029022,290.582062 449.537628,289.587189 450.257416,288.114380 C450.374969,287.083923 450.281342,286.531342 450.187683,285.978760 C452.945831,273.469971 453.334473,261.295746 450.512085,248.381592 C446.404449,229.586563 436.770416,214.783829 422.842163,202.571182 C410.374329,191.639160 395.792664,184.889221 378.927307,182.865051 C352.128204,179.648621 329.706665,188.182205 310.311005,206.244354 C301.326721,214.610947 294.830750,224.466003 290.253174,235.913177 C283.284637,253.339325 283.192322,271.015137 287.613251,288.769196 C289.375671,295.847046 293.122955,302.430634 296.022339,309.601044 C293.721344,307.120148 290.547760,304.626892 289.155273,301.371735 C284.214142,289.821014 281.409424,277.680542 282.017212,265.013763 C283.702362,229.894516 300.026855,203.834702 331.654480,188.451065 C367.770386,170.884338 406.602570,180.203156 432.394684,208.282761 C451.962433,229.585983 459.194275,255.296036 452.974182,284.127716 C452.588501,285.915344 452.481628,287.819763 451.762421,289.457977 C451.318390,290.469421 449.959442,291.079254 448.765869,291.723175 z"/>
<path fill="#F98F03" opacity="1.000000" stroke="none" d=" M450.115540,286.313507 C450.281342,286.531342 450.374969,287.083923 450.332275,287.818573 C450.145050,287.549835 450.094208,287.099060 450.115540,286.313507 z"/>
</svg>

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="840" height="262">
<path d="M0 0 C8.69163006 7.71008755 13.14490087 16.89394432 14.22265625 28.37109375 C14.64705043 39.90354435 11.2436751 50.02540655 4 59 C-2.73007826 65.71611544 -11.11233981 69.18417403 -20.5625 69.4375 C-29.02193816 69.14854393 -34.47069789 67.31848169 -41 62 C-41.02505615 63.02472412 -41.0501123 64.04944824 -41.07592773 65.10522461 C-41.16984484 68.89409676 -41.27010317 72.68277881 -41.37231445 76.47143555 C-41.41572297 78.11364303 -41.45739358 79.75589739 -41.49731445 81.39819336 C-41.5548744 83.75388745 -41.61860892 86.10934428 -41.68359375 88.46484375 C-41.70866249 89.57266457 -41.70866249 89.57266457 -41.73423767 90.7028656 C-41.88612829 95.88612829 -41.88612829 95.88612829 -43 97 C-48.61 97 -54.22 97 -60 97 C-60 63.34 -60 29.68 -60 -5 C-54.39 -5 -48.78 -5 -43 -5 C-42.34 -3.35 -41.68 -1.7 -41 0 C-40.5153125 -0.33773437 -40.030625 -0.67546875 -39.53125 -1.0234375 C-26.76996544 -9.51459992 -12.64739523 -8.64871948 0 0 Z M-39.875 17.0625 C-43.59522645 22.95180763 -44.64765142 30.01356041 -43.375 36.8125 C-41.94960016 41.95313555 -39.20005286 46.63995771 -35 50 C-28.58262778 53.05106644 -21.3538322 53.05583606 -14.625 50.9375 C-9.59434454 47.22439716 -5.93848019 43.00217489 -4 37 C-3.12848553 29.65293034 -3.43441364 23.03609731 -8 17 C-11.50353653 13.33721181 -15.41705741 10.34492712 -20.515625 9.6640625 C-28.47529099 9.55035299 -34.61737158 10.70443772 -39.875 17.0625 Z " fill="#070709" transform="translate(709,111)"/>
<path d="M0 0 C8.67817735 7.43616297 12.59651032 17.33058774 13.83984375 28.484375 C14.34206719 39.50879197 11.02283105 49.29470477 4.40234375 58.1328125 C-1.24768253 64.30376319 -9.32101987 68.57263825 -17.69921875 69.203125 C-27.77294256 69.3611442 -33.59239913 67.60506725 -42 62 C-42.33 73.55 -42.66 85.1 -43 97 C-48.61 97 -54.22 97 -60 97 C-60 63.34 -60 29.68 -60 -5 C-54.39 -5 -48.78 -5 -43 -5 C-42.67 -3.02 -42.34 -1.04 -42 1 C-41.443125 0.59007813 -40.88625 0.18015625 -40.3125 -0.2421875 C-27.3317457 -9.40765253 -13.36734014 -9.00970502 0 0 Z M-39.875 17.3125 C-44.23157366 24.27418696 -44.76257396 31.08547435 -43 39 C-40.78638 44.12807721 -37.69263635 47.91273924 -33 51 C-27.15219658 52.94926781 -20.47788508 52.78007493 -14.625 50.9375 C-9.34602898 47.04111663 -5.31463083 42.57315414 -4 36 C-3.21564863 28.35613939 -4.61809235 21.89395052 -9.25 15.6875 C-13.66336941 11.37443444 -17.85242164 9.85182121 -23.9375 9.5625 C-30.64560394 9.80717248 -35.72726168 11.94341599 -39.875 17.3125 Z " fill="#08080A" transform="translate(792,111)"/>
<path d="M0 0 C0.33 -1.65 0.66 -3.3 1 -5 C6.61 -5 12.22 -5 18 -5 C18 18.76 18 42.52 18 67 C12.39 67 6.78 67 1 67 C0.67 65.35 0.34 63.7 0 62 C-0.76183594 62.50660156 -0.76183594 62.50660156 -1.5390625 63.0234375 C-2.22742188 63.46945313 -2.91578125 63.91546875 -3.625 64.375 C-4.29789062 64.81585938 -4.97078125 65.25671875 -5.6640625 65.7109375 C-13.58368891 70.0812999 -23.10758184 70.39808303 -31.75 67.9375 C-41.62995371 64.03990358 -47.55506909 56.75034748 -52.25390625 47.54296875 C-56.35291308 38.0167055 -56.62716497 26.83848226 -53.375 17 C-49.4819062 7.90703965 -42.53172578 -0.6908008 -33.375 -4.875 C-21.19078419 -8.93640527 -10.59035975 -6.93997523 0 0 Z M-34 18 C-37.50770356 23.5121056 -38.64816392 29.16310107 -37.5625 35.58984375 C-35.7557313 42.41655504 -33.20468193 46.65511078 -27.4375 50.875 C-20.99668564 52.98290288 -13.87867289 53.19665826 -7.5625 50.5 C-2.42266422 47.51247045 0.16916612 42.49250164 2 37 C2.63422426 29.45029199 2.42141667 22.41441403 -2.359375 16.24609375 C-5.96089837 12.3939489 -9.32538157 10.29977528 -14.68359375 9.69921875 C-23.26189343 9.66271535 -28.25050093 11.31814973 -34 18 Z " fill="#070709" transform="translate(467,111)"/>
<path d="M0 0 C0.66 -1.65 1.32 -3.3 2 -5 C7.28 -5 12.56 -5 18 -5 C18.95704986 -1.17180055 19.12078914 1.77386172 19.11352539 5.69628906 C19.11367142 6.36011597 19.11381744 7.02394287 19.1139679 7.70788574 C19.11327332 9.8938325 19.10550231 12.07969319 19.09765625 14.265625 C19.09579045 15.78448439 19.09436731 17.30334438 19.09336853 18.82220459 C19.0895561 22.81377839 19.0797367 26.80531562 19.06866455 30.796875 C19.05842303 34.8723908 19.05386504 38.94791253 19.04882812 43.0234375 C19.03811327 51.01564362 19.0205268 59.00781305 19 67 C13.06 67 7.12 67 1 67 C0.67 65.35 0.34 63.7 0 62 C-1.010625 62.78375 -2.02125 63.5675 -3.0625 64.375 C-10.10252051 69.32545267 -17.61014034 69.82953556 -26 69 C-34.99458455 66.97833698 -42.57884408 62.69673031 -48.04296875 55.1953125 C-54.42675603 44.84962837 -56.76481094 34.43070846 -54.5 22.328125 C-51.38068518 10.91426851 -45.31231302 1.98738781 -35.0078125 -4.1953125 C-22.99567478 -9.69420221 -10.51231494 -7.00820996 0 0 Z M-33.703125 18.0859375 C-37.69553255 23.97834623 -37.83104269 30.1032024 -37 37 C-35.40099464 42.03034735 -33.28906668 46.48368849 -28.875 49.5625 C-22.1750793 52.91246035 -15.37047301 52.88405849 -8.1875 51 C-3.21333977 47.87895829 0.14016889 43.57949332 2 38 C2.61486532 30.06722942 3.08216386 22.53421067 -2 16 C-6.55661235 11.56022386 -11.09995043 9.7144683 -17.4375 9.6875 C-25.03557191 9.87083149 -28.89903754 12.42951194 -33.703125 18.0859375 Z " fill="#080809" transform="translate(617,111)"/>
<path d="M0 0 C6.27 0 12.54 0 19 0 C19.86882812 2.09859375 20.73765625 4.1971875 21.6328125 6.359375 C22.49571507 8.43516367 23.36009471 10.51033151 24.22460938 12.58544922 C24.81566696 14.00635935 25.40523461 15.42789035 25.99316406 16.85009766 C29.67484115 25.75271955 33.54281316 34.45146929 38 43 C38.33209473 42.2251123 38.66418945 41.45022461 39.00634766 40.65185547 C42.10668945 33.41772461 42.10668945 33.41772461 45.20703125 26.18359375 C45.46780457 25.57501526 45.72857788 24.96643677 45.99725342 24.3394165 C46.52595131 23.10600543 47.05496437 21.8727294 47.58428955 20.6395874 C48.96437415 17.42332337 50.33821498 14.20459532 51.70214844 10.98144531 C51.97634613 10.33563507 52.25054382 9.68982483 52.53305054 9.02444458 C53.29495211 7.22937564 54.05452493 5.43331881 54.81396484 3.63720703 C56 1 56 1 57 0 C58.34267413 -0.08631874 59.68974322 -0.10706473 61.03515625 -0.09765625 C61.84404297 -0.09443359 62.65292969 -0.09121094 63.48632812 -0.08789062 C64.33646484 -0.07951172 65.18660156 -0.07113281 66.0625 -0.0625 C66.91650391 -0.05798828 67.77050781 -0.05347656 68.65039062 -0.04882812 C70.76695663 -0.03700373 72.88348685 -0.01906769 75 0 C73.16191407 5.91473461 70.9830678 11.44726207 68.25 17 C63.77345085 26.26927146 59.49910534 35.62503109 55.25 45 C54.92825806 45.70946777 54.60651611 46.41893555 54.27502441 47.14990234 C52.74027556 50.53437081 51.20654663 53.9192991 49.67382812 57.3046875 C47.45465601 62.20559819 45.22751178 67.10287487 43 72 C39.37 72 35.74 72 32 72 C26.23725499 60.94458283 21.08034555 49.63843719 16.02001953 38.24780273 C15.46135932 36.99067986 14.90260272 35.73359981 14.34375 34.4765625 C14.06707489 33.85417191 13.79039978 33.23178131 13.50534058 32.5905304 C11.61205991 28.34301709 9.67729179 24.11638141 7.71875 19.8984375 C7.20739502 18.79628906 6.69604004 17.69414063 6.16918945 16.55859375 C5.20099592 14.47830973 4.2283629 12.40008459 3.25073242 10.32421875 C2.60648315 8.93783203 2.60648315 8.93783203 1.94921875 7.5234375 C1.57112061 6.71825684 1.19302246 5.91307617 0.8034668 5.08349609 C0 3 0 3 0 0 Z " fill="#080809" transform="translate(250,106)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 35.64 17 71.28 17 108 C11.39 108 5.78 108 0 108 C0 72.36 0 36.72 0 0 Z " fill="#08080A" transform="translate(499,70)"/>
<path d="M0 0 C1.58203125 1.66796875 1.58203125 1.66796875 1.58203125 5.66796875 C-0.24609375 8.0625 -0.24609375 8.0625 -2.66796875 10.48046875 C-3.456875 11.29128906 -4.24578125 12.10210937 -5.05859375 12.9375 C-7.41796875 14.66796875 -7.41796875 14.66796875 -9.58984375 14.4921875 C-10.87844933 13.91121361 -12.15367922 13.30011351 -13.41796875 12.66796875 C-18.59558143 12.15189951 -23.07162336 12.11500546 -27.91796875 14.10546875 C-33.66913025 20.00040929 -33.87356406 28.09995626 -33.9296875 35.953125 C-33.9476387 36.8948204 -33.9655899 37.83651581 -33.98408508 38.80674744 C-34.03777974 41.78127717 -34.07195171 44.75565028 -34.10546875 47.73046875 C-34.13867794 49.76108932 -34.17317088 51.79168932 -34.20898438 53.82226562 C-34.29348231 58.77071953 -34.36048689 63.71913029 -34.41796875 68.66796875 C-40.02796875 68.66796875 -45.63796875 68.66796875 -51.41796875 68.66796875 C-51.41796875 44.90796875 -51.41796875 21.14796875 -51.41796875 -3.33203125 C-45.80796875 -3.33203125 -40.19796875 -3.33203125 -34.41796875 -3.33203125 C-34.08796875 -1.68203125 -33.75796875 -0.03203125 -33.41796875 1.66796875 C-32.67546875 1.04921875 -31.93296875 0.43046875 -31.16796875 -0.20703125 C-21.53059322 -7.65409416 -9.77403562 -6.65866875 0 0 Z " fill="#070709" transform="translate(412.41796875,109.33203125)"/>
<path d="M0 0 C3.62365347 3.99851417 3.44730059 8.25868181 3.28125 13.40625 C2.92171053 16.72200287 2.29410204 18.55295783 0 21 C-4.52162441 23.74080002 -7.47400283 24.31760393 -12.7109375 23.8359375 C-13.3530014 23.77965179 -13.99506531 23.72336609 -14.65658569 23.66537476 C-16.77451134 23.46887205 -18.8865471 23.23974308 -21 23 C-22.10486755 22.87558533 -22.10486755 22.87558533 -23.23205566 22.74865723 C-27.90893854 22.20777275 -32.5744168 21.59966694 -37.23779297 20.953125 C-39.65576921 20.62077073 -42.07569435 20.30634824 -44.49609375 19.9921875 C-46.05223146 19.77972796 -47.60822721 19.56622547 -49.1640625 19.3515625 C-50.23595322 19.21544052 -50.23595322 19.21544052 -51.32949829 19.0765686 C-55.51250758 18.4749119 -58.59393869 17.53147784 -62 15 C-63.43735246 12.12529507 -63.38172649 10.18105412 -63 7 C-58.54839678 1.78836696 -52.21129528 1.72201371 -45.8125 0.875 C-44.07432007 0.6279834 -44.07432007 0.6279834 -42.30102539 0.37597656 C-38.8697716 -0.10371979 -35.43624001 -0.55757005 -32 -1 C-31.06575195 -1.12149414 -30.13150391 -1.24298828 -29.16894531 -1.36816406 C-26.75978753 -1.6751507 -24.34975685 -1.96848703 -21.9375 -2.25 C-21.25598877 -2.33008301 -20.57447754 -2.41016602 -19.87231445 -2.49267578 C-4.80907763 -4.10186033 -4.80907763 -4.10186033 0 0 Z " fill="#FE7039" transform="translate(199,120)"/>
<path d="M0 0 C0.85850555 0.1038652 1.71701111 0.20773041 2.60153198 0.31474304 C4.40186289 0.53467384 6.20184063 0.75751383 8.00146484 0.9831543 C10.74057943 1.32401864 13.48138243 1.64772427 16.22265625 1.97070312 C17.98189995 2.18775813 19.74101944 2.4058228 21.5 2.625 C22.31058868 2.71888504 23.12117737 2.81277008 23.95632935 2.90950012 C28.99308043 3.56175949 33.10027078 4.64497567 37.6171875 7.01171875 C39.6171875 10.01171875 39.6171875 10.01171875 39.3671875 13.57421875 C38.6171875 17.01171875 38.6171875 17.01171875 36.6171875 19.01171875 C34.55200195 19.55834961 34.55200195 19.55834961 31.98046875 19.9765625 C31.02132568 20.13543945 30.06218262 20.29431641 29.07397461 20.45800781 C28.03635986 20.62010742 26.99874512 20.78220703 25.9296875 20.94921875 C24.86564697 21.1203418 23.80160645 21.29146484 22.70532227 21.46777344 C15.1682882 22.66019807 7.61816829 23.7511583 0.0546875 24.76171875 C-1.36106567 24.95302368 -1.36106567 24.95302368 -2.80541992 25.14819336 C-17.31186215 27.01302693 -17.31186215 27.01302693 -23.3828125 23.01171875 C-27.02637584 19.12525119 -26.88210576 14.75758051 -26.73828125 9.67578125 C-26.23821448 5.92802806 -24.9286388 3.76969724 -22.3828125 1.01171875 C-15.84749844 -2.95266934 -7.24058287 -0.89735419 0 0 Z " fill="#FE7139" transform="translate(48.3828125,117.98828125)"/>
<path d="M0 0 C2.69924999 1.79949999 3.97268929 2.98831891 5.625 5.75 C6.99344145 15.86914699 5.01332723 26.66429063 3.65356445 36.71191406 C3.30949244 39.27238125 2.98705575 41.83499184 2.66601562 44.3984375 C2.44920051 46.04704435 2.23114667 47.69548888 2.01171875 49.34375 C1.91783371 50.10006714 1.82394867 50.85638428 1.72721863 51.63562012 C1.09337 56.17414888 0.04108984 59.76523478 -2.375 63.75 C-5.4582635 65.29163175 -7.98378099 65.04747535 -11.375 64.75 C-15.22166785 61.31318029 -15.3272904 56.46613107 -15.98046875 51.62890625 C-16.10940018 50.73316391 -16.2383316 49.83742157 -16.37117004 48.91453552 C-16.64146628 47.01650682 -16.90586639 45.11763044 -17.16479492 43.21801758 C-17.55711089 40.35187129 -17.97132834 37.48954926 -18.38867188 34.62695312 C-22.01941843 9.12762749 -22.01941843 9.12762749 -19.36669922 3.67895508 C-14.53713333 -2.53836515 -6.89205685 -2.55261365 0 0 Z " fill="#FE723B" transform="translate(119.375,41.25)"/>
<path d="M0 0 C2.90652991 2.90652991 2.876536 6.26706827 3.4375 10.1875 C3.62372925 11.43889771 3.62372925 11.43889771 3.8137207 12.71557617 C4.21906167 15.47570672 4.60956571 18.23772456 5 21 C5.14115234 21.96454102 5.28230469 22.92908203 5.42773438 23.92285156 C9.97022281 55.07281183 9.97022281 55.07281183 5.8125 61.25 C0.715773 64.4212968 -2.9058144 65.63043299 -9 65 C-12.29013805 63.71053175 -15.20895719 62.21495778 -17.18669128 59.17736816 C-19.85591311 52.03139945 -17.79685661 42.79141254 -16.79296875 35.40625 C-16.60387688 33.9753302 -16.60387688 33.9753302 -16.41096497 32.51550293 C-16.14485659 30.52187219 -15.87493824 28.52874651 -15.60131836 26.53613281 C-15.18498991 23.48158379 -14.79028617 20.42471259 -14.39648438 17.3671875 C-14.134117 15.42169863 -13.87049062 13.47637904 -13.60546875 11.53125 C-13.48970993 10.61879517 -13.37395111 9.70634033 -13.25468445 8.76623535 C-13.13478653 7.92105347 -13.01488861 7.07587158 -12.89135742 6.20507812 C-12.7914299 5.46326294 -12.69150238 4.72144775 -12.58854675 3.95715332 C-11.8654499 1.55256711 -10.95241701 0.55505439 -9 -1 C-5.19825711 -2.26724763 -3.6488868 -1.4750819 0 0 Z " fill="#FE723B" transform="translate(117,156)"/>
<path d="M0 0 C5.94 0 11.88 0 18 0 C18 23.76 18 47.52 18 72 C12.06 72 6.12 72 0 72 C0 48.24 0 24.48 0 0 Z " fill="#070708" transform="translate(330,106)"/>
<path d="M0 0 C5.04330987 4.1028907 7.71774978 9.39439884 10.75 15.0625 C11.24371094 15.96033203 11.73742187 16.85816406 12.24609375 17.78320312 C17.43420605 27.34212236 17.43420605 27.34212236 16.3125 31.625 C15 34 15 34 13.3125 35.375 C7.93375248 36.82871554 3.75347415 35.06129862 -0.91796875 32.453125 C-2.68187675 31.39768267 -4.43827823 30.32960785 -6.1875 29.25 C-7.07630859 28.7240625 -7.96511719 28.198125 -8.88085938 27.65625 C-13.35431143 24.94058564 -17.21257396 22.56892147 -20 18 C-20.61105403 13.79900352 -20.33783835 11.64968914 -18.375 7.875 C-13.06220859 1.44372619 -8.6335148 -2.08395185 0 0 Z " fill="#FE713A" transform="translate(63,62)"/>
<path d="M0 0 C6.03297915 0.33114122 9.30476624 2.08342832 13.6171875 6.29296875 C16.38968616 9.47219093 17.04213903 11.96541114 17.65234375 16.0625 C17.06814734 20.15187485 15.13009417 22.36369578 12.21484375 25.125 C9.21149624 27.28908872 6.06168268 29.11960481 2.83984375 30.9375 C2.01484375 31.42669922 1.18984375 31.91589844 0.33984375 32.41992188 C-4.43543981 35.16002506 -8.25324942 36.89804998 -13.78515625 37.125 C-16.58087127 36.16857118 -17.46165114 35.77201022 -18.78515625 33.125 C-19.67120353 26.26932103 -16.95954186 21.84338012 -13.59765625 16 C-13.11490234 15.11957031 -12.63214844 14.23914063 -12.13476562 13.33203125 C-9.4652036 8.58056917 -6.87140939 4.73948324 -2.78515625 1.125 C-1.78515625 0.125 -1.78515625 0.125 0 0 Z " fill="#FE713A" transform="translate(163.78515625,60.875)"/>
<path d="M0 0 C2.78888858 1.43213197 3.75957557 2.28489145 4.76171875 5.265625 C5.06866999 8.7880163 4.15658074 10.4564111 2.5 13.5625 C1.94570312 14.60535156 1.39140625 15.64820313 0.8203125 16.72265625 C0.21960938 17.80417969 -0.38109375 18.88570312 -1 20 C-1.60070312 21.09183594 -2.20140625 22.18367188 -2.8203125 23.30859375 C-8.27249431 32.95022261 -8.27249431 32.95022261 -12 36 C-16.40910476 37.09025136 -20.18006761 36.37843033 -24.25 34.5 C-28.79178106 30.37110812 -31.58989016 26.41964808 -32.625 20.3125 C-31.84891159 16.19923141 -30.15611216 14.52356363 -27 12 C-23.60919652 9.73474885 -20.10610667 7.70297221 -16.5625 5.6875 C-15.63373047 5.13771484 -14.70496094 4.58792969 -13.74804688 4.02148438 C-5.16887017 -0.89398698 -5.16887017 -0.89398698 0 0 Z " fill="#FE713A" transform="translate(75,163)"/>
<path d="M0 0 C2.54541016 1.03808594 2.54541016 1.03808594 5.0390625 2.484375 C5.94914063 3.00773438 6.85921875 3.53109375 7.796875 4.0703125 C9.19679687 4.90175781 9.19679687 4.90175781 10.625 5.75 C11.56601563 6.28882813 12.50703125 6.82765625 13.4765625 7.3828125 C23.65542022 13.31084043 23.65542022 13.31084043 26 18 C26.23675113 23.05412198 25.48527501 26.97865449 22.25 30.9375 C18.41837476 34.85992204 15.48689762 35.78758605 10.0625 36.5625 C7.57335027 36.49114109 6.40429881 36.33826832 4.47973633 34.72802734 C0.68248769 30.09704219 -2.45567147 25.28818627 -5.375 20.0625 C-5.82488281 19.30259766 -6.27476562 18.54269531 -6.73828125 17.75976562 C-9.65990878 12.56033427 -10.65491643 8.96628872 -10 3 C-7.14254571 -0.51686681 -4.31419574 -0.2967049 0 0 Z " fill="#FE713A" transform="translate(155,163)"/>
<path d="M0 0 C3.53733123 2.55892046 4.75963588 4.1887711 6 8.375 C6 12.7289874 5.17207574 15.23506872 3 19 C0.12121032 21.47895778 -1.66317695 21.95713161 -5.4375 22.4375 C-9.70968693 21.91284546 -11.75031771 20.79623825 -15 18 C-17.10863429 14.35781349 -17.50962203 11.16191328 -17 7 C-15.27978264 3.68698879 -13.9086019 1.58049566 -10.75 -0.4375 C-6.93940558 -1.21693977 -3.72934532 -1.15270673 0 0 Z " fill="#060608" transform="translate(548,158)"/>
<path d="M0 0 C2.66915647 2.14834545 3.77943987 3.55633371 4.30078125 6.96875 C4.43294773 10.69723583 4.46252586 14.10920878 2 17 C-2.01955829 19.36444606 -4.10903676 20.14849387 -8.75 19.375 C-12.50920399 17.78456754 -14.19878159 16.60243682 -16 13 C-16.4128469 8.70639223 -16.2493583 6.46432234 -14.1875 2.625 C-10.27705848 -2.06752982 -5.52304925 -1.42562463 0 0 Z " fill="#060608" transform="translate(345,74)"/>
<path d="M0 0 C5.61 0 11.22 0 17 0 C19 5 19 5 19 8 C18.01 8 17.02 8 16 8 C16 5.69 16 3.38 16 1 C11.05 1 6.1 1 1 1 C1 34 1 67 1 101 C5.62 101 10.24 101 15 101 C15 101.33 15 101.66 15 102 C10.05 102 5.1 102 0 102 C0 68.34 0 34.68 0 0 Z " fill="#000000" transform="translate(649,106)"/>
<path d="M0 0 C1.48831242 0.00667405 2.97660807 0.01863442 4.46484375 0.03515625 C5.22345703 0.03966797 5.98207031 0.04417969 6.76367188 0.04882812 C8.64326246 0.06064945 10.52281274 0.07858547 12.40234375 0.09765625 C13.52283021 3.69273097 13.52281589 7.0427002 13.51586914 10.79394531 C13.51601517 11.45777222 13.51616119 12.12159912 13.51631165 12.80554199 C13.51561707 14.99148875 13.50784606 17.17734944 13.5 19.36328125 C13.4981342 20.88214064 13.49671106 22.40100063 13.49571228 23.91986084 C13.49189985 27.91143464 13.48208045 31.90297187 13.4710083 35.89453125 C13.46076678 39.97004705 13.45620879 44.04556878 13.45117188 48.12109375 C13.44045702 56.11329987 13.42287055 64.1054693 13.40234375 72.09765625 C7.46234375 72.09765625 1.52234375 72.09765625 -4.59765625 72.09765625 C-4.92765625 69.78765625 -5.25765625 67.47765625 -5.59765625 65.09765625 C-4.11265625 64.60265625 -4.11265625 64.60265625 -2.59765625 64.09765625 C-2.59765625 66.40765625 -2.59765625 68.71765625 -2.59765625 71.09765625 C2.02234375 71.09765625 6.64234375 71.09765625 11.40234375 71.09765625 C11.40234375 47.99765625 11.40234375 24.89765625 11.40234375 1.09765625 C6.78234375 1.09765625 2.16234375 1.09765625 -2.59765625 1.09765625 C-2.59765625 3.40765625 -2.59765625 5.71765625 -2.59765625 8.09765625 C-3.25765625 8.09765625 -3.91765625 8.09765625 -4.59765625 8.09765625 C-4.64007348 5.7647085 -4.63858562 3.43063058 -4.59765625 1.09765625 C-3.59765625 0.09765625 -3.59765625 0.09765625 0 0 Z " fill="#000000" transform="translate(622.59765625,105.90234375)"/>
<path d="M0 0 C0.66 0.66 1.32 1.32 2 2 C0.3603125 1.9071875 0.3603125 1.9071875 -1.3125 1.8125 C-4.8362826 1.70189764 -4.8362826 1.70189764 -6.875 3.125 C-8.40430823 5.67384706 -8.67470941 8.07238473 -9 11 C-9.33 11 -9.66 11 -10 11 C-10.48541424 3.59743291 -10.48541424 3.59743291 -8.375 1 C-5.38358852 -0.25954167 -3.21567216 -0.34952958 0 0 Z " fill="#FE5D28" transform="translate(155,163)"/>
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.38125 1.598125 1.7625 2.19625 1.125 2.8125 C-1.13676954 5.00811144 -1.13676954 5.00811144 -3 8 C-3.36923077 3.44615385 -3.36923077 3.44615385 -1.5 1.1875 C-1.005 0.795625 -0.51 0.40375 0 0 Z " fill="#FE6B2D" transform="translate(139,124)"/>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

+343
View File
@@ -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)
`,
};
+67
View File
@@ -0,0 +1,67 @@
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;
if (pathname.endsWith('.md')) {
const headers = new Headers(request.headers);
headers.set('x-md-path', pathname.slice(0, -3));
return NextResponse.rewrite(new URL('/api/md', request.url), { request: { headers } });
}
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|xml|txt)).*)',],
};
+24
View File
@@ -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);
}
+65 -23
View File
@@ -25,7 +25,7 @@ import {
Shield,
User,
Users,
Workflow
Workflow,
} from 'lucide-react';
const display = Bricolage_Grotesque({
@@ -132,6 +132,14 @@ const competitors = [
{name: 'Mailgun', slug: 'mailgun'},
];
const customers: {name: string; url: string; logo: string; imgClassName?: string; label?: string}[] = [
{name: 'Krumzi', url: 'https://krumzi.com', logo: '/assets/krumzi.svg'},
{name: 'Waidwissen', url: 'https://waidwissen.com', logo: '/assets/waidwissen.svg'},
{name: 'Dodo Payments', url: 'https://dodopayments.com', logo: '/assets/dodo.svg'},
{name: 'SnowSEO', url: 'https://snowseo.com', logo: '/assets/snowseo.svg', imgClassName: 'h-9'},
{name: 'Viral', url: 'https://viral.app', logo: '/assets/viral.svg', imgClassName: 'h-9'},
];
const tickerItems = [
'Open Source',
'AGPL-3.0',
@@ -248,9 +256,7 @@ export default function Index() {
<Navbar />
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
<main
className={'text-neutral-800'}
>
<main className={'text-neutral-800'}>
{/* ========== HERO ========== */}
<section className={'relative overflow-hidden'}>
<div
@@ -261,19 +267,6 @@ export default function Index() {
/>
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
{/* Top meta row */}
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
style={{fontFamily: 'var(--font-mono)'}}
className={
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
}
>
<span className={'font-medium text-neutral-900'}>§ 01 &nbsp; &nbsp;Plunk</span>
</motion.div>
{/* Centered hero — single clear message, CTA directly below */}
<motion.div
initial={{opacity: 0, y: 16}}
@@ -356,6 +349,52 @@ export default function Index() {
</div>
</section>
{/* ========== CUSTOMER LOGO STRIP ========== */}
<section aria-label="Customers">
<div className={'mx-auto max-w-[88rem] px-6 pb-14 pt-8 sm:px-10 sm:pb-16 sm:pt-10'}>
<motion.div
initial={{opacity: 0, y: 8}}
whileInView={{opacity: 1, y: 0}}
viewport={{once: true}}
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
className={'flex flex-col items-center gap-4'}
>
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
>
Built into products at
</span>
<div className={'flex flex-wrap items-center justify-center gap-10 sm:gap-14'}>
{customers.map(c => (
<a
key={c.name}
href={c.url}
target={'_blank'}
rel={'noopener noreferrer'}
className={'group flex flex-col items-center gap-1.5'}
aria-label={c.name}
>
<img
src={c.logo}
alt={c.name}
className={`${c.imgClassName ?? 'h-7'} w-auto max-w-none grayscale opacity-40 transition-all duration-300 group-hover:grayscale-0 group-hover:opacity-100`}
/>
{c.label && (
<span
style={{fontFamily: 'var(--font-mono)'}}
className={'text-[10px] uppercase tracking-[0.18em] text-neutral-400 transition-colors duration-300 group-hover:text-neutral-600'}
>
{c.label}
</span>
)}
</a>
))}
</div>
</motion.div>
</div>
</section>
{/* ========== MARQUEE TICKER ========== */}
<section className={'overflow-hidden border-y border-neutral-900 bg-neutral-900 py-7'} aria-hidden>
<div className={'flex'}>
@@ -614,7 +653,10 @@ export default function Index() {
className={'rounded-[24px] border border-neutral-200 bg-white p-8 text-left'}
>
<div className={'text-neutral-900'}>{item.icon}</div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-10 text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'mt-10 text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{item.title}
</h3>
<p className={'mt-2 text-sm text-neutral-600'}>{item.sub}</p>
@@ -773,7 +815,10 @@ export default function Index() {
</span>
</div>
<div>
<h3 style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
<h3
style={{fontFamily: 'var(--font-display)'}}
className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
>
{item.title}
</h3>
<p className={'mt-2 text-sm text-neutral-600'}>{item.note}</p>
@@ -963,9 +1008,7 @@ export default function Index() {
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]'
}
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
>
Start sending in 5 minutes.
</motion.h2>
@@ -1014,4 +1057,3 @@ export default function Index() {
</>
);
}
+2 -1
View File
@@ -19,6 +19,7 @@ import {prisma} from './database/prisma.js';
const API_URI = process.env.API_URI ?? 'http://localhost:3000';
const SMTP_DOMAIN = process.env.SMTP_DOMAIN ?? '';
const MAX_RECIPIENTS = parseInt(process.env.MAX_RECIPIENTS ?? '5', 10);
const MAX_ATTACHMENT_SIZE_MB = parseInt(process.env.MAX_ATTACHMENT_SIZE_MB ?? '10', 10);
const PORT_SECURE = parseInt(process.env.PORT_SECURE ?? '465', 10);
const PORT_SUBMISSION = parseInt(process.env.PORT_SUBMISSION ?? '587', 10);
const CERT_PATH = process.env.CERT_PATH ?? '/certs';
@@ -441,7 +442,7 @@ function createSMTPServer(options: {secure: boolean; port: number; key?: Buffer;
onRcptTo: handleRcptTo,
onData: handleData,
banner: 'Plunk SMTP Relay',
size: 10 * 1024 * 1024, // 10MB max message size
size: MAX_ATTACHMENT_SIZE_MB * 1024 * 1024,
authOptional: false, // Require authentication
};
+3 -1
View File
@@ -34,14 +34,16 @@
"@tiptap/starter-kit": "^3.11.0",
"@tiptap/suggestion": "^3.11.0",
"@types/dagre": "^0.7.53",
"@types/dompurify": "^3.2.0",
"@uiw/react-codemirror": "^4.25.3",
"@xyflow/react": "^12.9.3",
"dagre": "^0.8.5",
"dayjs": "^1.11.19",
"dompurify": "^3.4.0",
"framer-motion": "^12.23.24",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.1.7",
"next": "^16.2.3",
"next-seo": "^6.6.0",
"nuqs": "^2.7.3",
"react": "19.2.3",
+4 -5
View File
@@ -1,9 +1,8 @@
import {Button} from '@plunk/ui';
import {Button, EmptyState, IconSpinner} from '@plunk/ui';
import type {Activity, CursorPaginatedResponse} from '@plunk/types';
import {network} from '../lib/network';
import {ActivityItem} from './ActivityItem';
import {EmptyState} from './EmptyState';
import {Activity as ActivityIcon, Loader2} from 'lucide-react';
import {Activity as ActivityIcon} from 'lucide-react';
import {useCallback, useEffect, useMemo, useState} from 'react';
export interface ActivityFeedProps {
@@ -169,7 +168,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-neutral-400" />
<IconSpinner />
</div>
);
}
@@ -215,7 +214,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
<Button onClick={loadMore} variant="outline" disabled={isLoadingMore}>
{isLoadingMore ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<IconSpinner size="sm" className="mr-2" />
Loading...
</>
) : (
+26 -26
View File
@@ -133,8 +133,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'event.triggered':
return {
icon: Zap,
color: 'text-blue-600',
bgColor: 'bg-blue-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
description: undefined,
badge: {
@@ -150,8 +150,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.sent':
return {
icon: Send,
color: 'text-green-600',
bgColor: 'bg-green-100',
color: 'text-green-700',
bgColor: 'bg-green-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
@@ -169,8 +169,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.delivered':
return {
icon: CheckCircle,
color: 'text-green-600',
bgColor: 'bg-green-100',
color: 'text-green-700',
bgColor: 'bg-green-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
@@ -186,8 +186,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.received':
return {
icon: Inbox,
color: 'text-blue-600',
bgColor: 'bg-blue-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email received',
description: typeof metadata.from === 'string' ? `From: ${metadata.from}` : 'Inbound email',
badge: {
@@ -199,8 +199,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.opened':
return {
icon: Eye,
color: 'text-purple-600',
bgColor: 'bg-purple-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
description:
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
@@ -219,8 +219,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.clicked':
return {
icon: MousePointerClick,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
description:
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
@@ -239,8 +239,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.bounced':
return {
icon: XCircle,
color: 'text-red-600',
bgColor: 'bg-red-100',
color: 'text-red-700',
bgColor: 'bg-red-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email bounced',
description: (typeof metadata.error === 'string' ? metadata.error : undefined) || 'Email failed to deliver',
badge: {
@@ -252,8 +252,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'email.complaint':
return {
icon: ShieldAlert,
color: 'text-red-600',
bgColor: 'bg-red-100',
color: 'text-red-700',
bgColor: 'bg-red-50',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Spam complaint',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
@@ -269,8 +269,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.started':
return {
icon: Workflow,
color: 'text-indigo-600',
bgColor: 'bg-indigo-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
description: `Status: ${String(metadata.status || 'unknown')}`,
badge: {
@@ -282,8 +282,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.completed':
return {
icon: CheckCheck,
color: 'text-green-600',
bgColor: 'bg-green-100',
color: 'text-green-700',
bgColor: 'bg-green-50',
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
description: metadata.exitReason
? `Exit: ${String(metadata.exitReason)}`
@@ -297,8 +297,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'campaign.scheduled':
return {
icon: Calendar,
color: 'text-blue-600',
bgColor: 'bg-blue-50',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
description: metadata.subject
? `${String(metadata.subject)}${metadata.totalRecipients ? `${metadata.totalRecipients} recipients` : ''}`
@@ -314,8 +314,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
case 'workflow.email.scheduled':
return {
icon: Calendar,
color: 'text-indigo-600',
bgColor: 'bg-indigo-50',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
description: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? `${String(metadata.subject)}` : ''}`
@@ -353,7 +353,7 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f
<div className={`flex items-start gap-4 ${isUpcoming ? 'opacity-80' : ''}`}>
{/* Icon */}
<div
className={`h-10 w-10 rounded-lg ${config.bgColor} flex items-center justify-center flex-shrink-0 ${isUpcoming ? 'ring-2 ring-offset-2 ring-blue-200' : ''}`}
className={`h-10 w-10 rounded-lg ${config.bgColor} flex items-center justify-center flex-shrink-0 ${isUpcoming ? 'border-2 border-dashed border-neutral-300' : ''}`}
>
<Icon className={`h-5 w-5 ${config.color}`} />
</div>
@@ -410,7 +410,7 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f
)}
</div>
<span
className={`text-xs flex-shrink-0 whitespace-nowrap ${isUpcoming ? 'text-blue-600 font-medium' : 'text-neutral-400'}`}
className={`text-xs flex-shrink-0 whitespace-nowrap ${isUpcoming ? 'text-neutral-700 font-medium' : 'text-neutral-400'}`}
title={timestamp.toLocaleString()}
>
{relativeTime}
+12 -10
View File
@@ -1,4 +1,4 @@
import {Alert, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {Alert, Card, CardContent, CardDescription, CardHeader, CardTitle, IconSpinner} from '@plunk/ui';
import {AlertCircle, Coins, TrendingUp} from 'lucide-react';
import {useBillingConsumption} from '../lib/hooks/useBillingConsumption';
import {useConfig} from '../lib/hooks/useConfig';
@@ -67,7 +67,9 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
<CardDescription>View your current month email consumption and costs</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-500">Loading...</p>
<div className="flex items-center justify-center py-4">
<IconSpinner size="sm" />
</div>
</CardContent>
</Card>
);
@@ -109,8 +111,8 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
{/* Total Usage */}
<div className="border border-neutral-200 rounded-lg p-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-50 rounded-lg">
<TrendingUp className="h-5 w-5 text-blue-600" />
<div className="p-2 bg-neutral-100 rounded-lg">
<TrendingUp className="h-5 w-5 text-neutral-600" />
</div>
<div>
<p className="text-sm text-neutral-500">Total Emails Sent</p>
@@ -121,19 +123,19 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
{/* Account Credits */}
{consumptionData.credits && consumptionData.credits.hasCredits && (
<div className="border border-green-200 rounded-lg p-6 bg-green-50">
<div className="border border-neutral-200 rounded-lg p-6">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-white rounded-lg shadow-sm border border-green-200">
<Coins className="h-5 w-5 text-green-600" />
<div className="p-2 bg-neutral-100 rounded-lg">
<Coins className="h-5 w-5 text-neutral-600" />
</div>
<div>
<p className="text-sm text-green-700">Account Credits</p>
<p className="text-2xl font-bold text-green-900">
<p className="text-sm text-neutral-500">Account Credits</p>
<p className="text-2xl font-bold text-neutral-900">
{formatCurrency(consumptionData.credits.creditAmount, consumptionData.credits.currency)}
</p>
</div>
</div>
<p className="text-xs text-green-700 mt-2">
<p className="text-xs text-neutral-500 mt-2">
Credits will be automatically applied to your upcoming invoices
</p>
</div>
+8 -6
View File
@@ -1,4 +1,4 @@
import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, IconSpinner} from '@plunk/ui';
import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react';
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
@@ -41,7 +41,9 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
<CardDescription>View and download your billing history</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-500">Loading...</p>
<div className="flex items-center justify-center py-4">
<IconSpinner size="sm" />
</div>
</CardContent>
</Card>
);
@@ -88,16 +90,16 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
const getStatusBadge = (status: string, paid: boolean) => {
// Stripe invoices have status 'paid' when paid, or paid boolean is true
if (paid || status === 'paid') {
return <Badge variant="green">Paid</Badge>;
return <Badge variant="success">Paid</Badge>;
}
switch (status) {
case 'open':
return <Badge variant="orange">Unpaid</Badge>;
return <Badge variant="warning">Unpaid</Badge>;
case 'draft':
return <Badge variant="neutral">Draft</Badge>;
case 'uncollectible':
return <Badge variant="red">Uncollectible</Badge>;
return <Badge variant="destructive">Uncollectible</Badge>;
case 'void':
return <Badge variant="neutral">Void</Badge>;
default:
@@ -167,7 +169,7 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
<span className="font-medium">Total:</span>{' '}
{formatCurrency(invoice.total, invoice.currency)}
</p>
<p className="text-orange-600 font-medium">
<p className="text-amber-700 font-medium">
<span className="font-medium">Amount Due:</span>{' '}
{formatCurrency(invoice.amountDue, invoice.currency)}
</p>
+12 -9
View File
@@ -13,6 +13,7 @@ import {
Form,
FormControl,
FormDescription,
IconSpinner,
FormField,
FormItem,
FormLabel,
@@ -146,7 +147,9 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
<CardDescription>Set monthly limits for each email category</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-neutral-500">Loading...</p>
<div className="flex items-center justify-center py-4">
<IconSpinner size="sm" />
</div>
</CardContent>
</Card>
);
@@ -381,15 +384,15 @@ interface UsageDisplayProps {
const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: UsageDisplayProps) {
const statusColor = useMemo(() => {
if (usage.isBlocked) return 'text-red-600';
if (usage.isWarning) return 'text-orange-600';
return 'text-green-600';
if (usage.isBlocked) return 'text-red-700';
if (usage.isWarning) return 'text-amber-700';
return 'text-neutral-600';
}, [usage.isBlocked, usage.isWarning]);
const progressColor = useMemo(() => {
if (usage.isBlocked) return 'bg-red-600';
if (usage.isWarning) return 'bg-orange-500';
return 'bg-green-600';
if (usage.isWarning) return 'bg-amber-500';
return 'bg-neutral-900';
}, [usage.isBlocked, usage.isWarning]);
const statusIcon = useMemo(() => {
@@ -437,10 +440,10 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
<Progress value={Math.min(usage.percentage, 100)} className="h-2" indicatorClassName={progressColor} />
{usage.isBlocked && (
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
<Alert variant="destructive" className="mt-3">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className={'text-sm'}>
<p className="text-sm">
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
</p>
</div>
@@ -448,7 +451,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
)}
{usage.isWarning && !usage.isBlocked && (
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
<Alert variant="warning" className="mt-3">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm">
@@ -12,11 +12,13 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
EmptyState,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
IconSpinner,
SelectValue,
} from '@plunk/ui';
import type {Campaign} from '@plunk/db';
@@ -95,23 +97,16 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
};
const getStatusBadge = (status: CampaignStatus) => {
const variants: Record<
CampaignStatus,
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
> = {
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
const variants: Record<CampaignStatus, {variant: 'neutral' | 'default' | 'success'; label: string}> = {
DRAFT: {variant: 'neutral', label: 'Draft'},
SCHEDULED: {variant: 'default', label: 'Scheduled'},
SENDING: {variant: 'default', label: 'Sending'},
SENT: {variant: 'success', label: 'Sent'},
CANCELLED: {variant: 'neutral', label: 'Cancelled'},
};
const config = variants[status];
return (
<Badge variant={config.variant} className={config.className}>
{config.label}
</Badge>
);
return <Badge variant={config.variant}>{config.label}</Badge>;
};
const getAudienceLabel = (campaign: Campaign) => {
@@ -130,7 +125,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{step === 'configure' && (
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 w-8 p-0">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
@@ -166,33 +161,16 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
<div className="flex-1 overflow-y-auto space-y-3">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading campaigns...</p>
</div>
<IconSpinner />
</div>
)}
{!isLoading && data?.data.length === 0 && (
<div className="text-center py-12">
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No campaigns found</h3>
<p className="text-neutral-500">
{statusFilter !== 'ALL' ? 'Try adjusting your filter' : 'Create your first campaign to get started'}
</p>
</div>
<EmptyState
icon={Mail}
title="No campaigns found"
description={statusFilter !== 'ALL' ? 'Try adjusting your filter.' : 'Create your first campaign to get started.'}
/>
)}
{data?.data.map(campaign => (
+431
View File
@@ -0,0 +1,431 @@
import type {Campaign, Contact, Segment, Template, Workflow} from '@plunk/db';
import type {CursorPaginatedResponse, PaginatedResponse} from '@plunk/types';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
Kbd,
} from '@plunk/ui';
import {
Activity,
BarChart3,
BookOpen,
Clock,
Copy,
FileText,
Layers,
LayoutDashboard,
Megaphone,
Plus,
Settings,
Users,
Workflow as WorkflowIcon,
} from 'lucide-react';
import {useRouter} from 'next/router';
import {useEffect, useMemo, useRef, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {WIKI_URI} from '../lib/constants';
import {addRecentPage, getRecentPages} from '../lib/recentPages';
interface Action {
label: string;
href: string;
icon: React.ComponentType<{className?: string}>;
keywords: string;
shortcut: [string, string];
}
const NAV_ACTIONS: Action[] = [
{label: 'Dashboard', href: '/', icon: LayoutDashboard, keywords: 'home overview', shortcut: ['G', 'D']},
{label: 'Contacts', href: '/contacts', icon: Users, keywords: 'subscribers people', shortcut: ['G', 'C']},
{label: 'Segments', href: '/segments', icon: Layers, keywords: 'groups filters', shortcut: ['G', 'S']},
{label: 'Activity', href: '/activity', icon: Activity, keywords: 'log events', shortcut: ['G', 'L']},
{label: 'Analytics', href: '/analytics', icon: BarChart3, keywords: 'stats metrics', shortcut: ['G', 'A']},
{label: 'Templates', href: '/templates', icon: FileText, keywords: 'email design', shortcut: ['G', 'T']},
{label: 'Workflows', href: '/workflows', icon: WorkflowIcon, keywords: 'automation trigger', shortcut: ['G', 'W']},
{label: 'Campaigns', href: '/campaigns', icon: Megaphone, keywords: 'broadcast newsletter', shortcut: ['G', 'M']},
{label: 'Settings', href: '/settings', icon: Settings, keywords: 'config account billing', shortcut: ['G', ',']},
];
const CREATE_ACTIONS: Action[] = [
{label: 'New Campaign', href: '/campaigns/create', icon: Plus, keywords: 'create broadcast', shortcut: ['N', 'C']},
{label: 'New Template', href: '/templates/create', icon: Plus, keywords: 'create email design', shortcut: ['N', 'T']},
{label: 'New Segment', href: '/segments/new', icon: Plus, keywords: 'create group filter', shortcut: ['N', 'S']},
{label: 'New Workflow', href: '/workflows', icon: Plus, keywords: 'create automation trigger', shortcut: ['N', 'W']},
];
// Chord map: first-key → second-key → action
const CHORDS: Record<string, Record<string, Action>> = {};
for (const action of [...NAV_ACTIONS, ...CREATE_ACTIONS]) {
const [first, second] = action.shortcut;
const f = first.toLowerCase();
const s = second.toLowerCase();
CHORDS[f] ??= {};
CHORDS[f][s] = action;
}
function matches(text: string, query: string): boolean {
return text.toLowerCase().includes(query.toLowerCase());
}
function ShortcutHint({shortcut}: {shortcut: [string, string]}) {
return (
<span className="ml-auto flex items-center gap-0.5 shrink-0">
<Kbd>{shortcut[0]}</Kbd>
<Kbd>{shortcut[1]}</Kbd>
</span>
);
}
export function CommandPalette() {
const router = useRouter();
const {activeProject} = useActiveProject();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const chordKeyRef = useRef<string | null>(null);
const chordTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const openRef = useRef(open);
openRef.current = open;
const recentPages = useMemo(() => (open ? getRecentPages() : []), [open]);
const fireChord = (key: string): boolean => {
if (chordKeyRef.current) {
const action = CHORDS[chordKeyRef.current]?.[key];
chordKeyRef.current = null;
if (chordTimerRef.current) clearTimeout(chordTimerRef.current);
if (action) {
void router.push(action.href);
addRecentPage({label: action.label, href: action.href});
setOpen(false);
return true;
}
} else if (CHORDS[key]) {
chordKeyRef.current = key;
chordTimerRef.current = setTimeout(() => {
chordKeyRef.current = null;
}, 1500);
return true;
}
return false;
};
// ⌘K — global toggle
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen(prev => !prev);
return;
}
// Chord shortcuts when focus is not in any input (e.g. user tabbed away or clicked body)
if (openRef.current || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
if (fireChord(e.key.toLowerCase())) e.preventDefault();
};
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
useEffect(() => {
if (!open) {
const timer = setTimeout(() => {
setQuery('');
setDebouncedQuery('');
}, 150);
return () => clearTimeout(timer);
}
}, [open]);
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), 200);
return () => clearTimeout(timer);
}, [query]);
const shouldSearch = open && debouncedQuery.length > 0;
const {data: contactsData} = useSWR<CursorPaginatedResponse<Contact>>(
shouldSearch ? `/contacts?search=${encodeURIComponent(debouncedQuery)}&limit=5` : null,
{revalidateOnFocus: false},
);
const {data: templatesData} = useSWR<PaginatedResponse<Template>>(
shouldSearch ? `/templates?search=${encodeURIComponent(debouncedQuery)}&pageSize=5` : null,
{revalidateOnFocus: false},
);
const {data: workflowsData} = useSWR<PaginatedResponse<Workflow>>(
shouldSearch ? `/workflows?search=${encodeURIComponent(debouncedQuery)}&pageSize=5` : null,
{revalidateOnFocus: false},
);
const {data: segmentsData} = useSWR<Segment[]>(shouldSearch ? '/segments' : null, {
revalidateOnFocus: false,
});
const {data: campaignsData} = useSWR<PaginatedResponse<Campaign>>(shouldSearch ? '/campaigns?pageSize=20' : null, {
revalidateOnFocus: false,
});
const contacts = contactsData?.data ?? [];
const templates = templatesData?.data ?? [];
const workflows = workflowsData?.data ?? [];
const segments = (segmentsData ?? []).filter(s => matches(s.name, debouncedQuery));
const campaigns = (campaignsData?.data ?? []).filter(
c => matches(c.name, debouncedQuery) || matches(c.subject ?? '', debouncedQuery),
);
const filteredNavActions = shouldSearch
? NAV_ACTIONS.filter(a => matches(a.label, query) || matches(a.keywords, query))
: NAV_ACTIONS;
const filteredCreateActions = shouldSearch
? CREATE_ACTIONS.filter(a => matches(a.label, query) || matches(a.keywords, query))
: CREATE_ACTIONS;
const navigate = (href: string, label: string) => {
void router.push(href);
addRecentPage({label, href});
setOpen(false);
};
const copySecretKey = () => {
if (!activeProject?.secret) return;
void navigator.clipboard.writeText(activeProject.secret);
toast.success('Secret key copied');
setOpen(false);
};
const copyPublicKey = () => {
if (!activeProject?.public) return;
void navigator.clipboard.writeText(activeProject.public);
toast.success('Public key copied');
setOpen(false);
};
const hasResults = shouldSearch
? contacts.length > 0 ||
campaigns.length > 0 ||
templates.length > 0 ||
workflows.length > 0 ||
segments.length > 0 ||
filteredNavActions.length > 0 ||
filteredCreateActions.length > 0
: true;
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search or jump to…" value={query} onValueChange={setQuery} />
<CommandList className="pb-2">
{!hasResults && <CommandEmpty>No results for &ldquo;{query}&rdquo;</CommandEmpty>}
{!shouldSearch && recentPages.length > 0 && (
<CommandGroup heading="Recent">
{recentPages.map(page => (
<CommandItem
key={page.href}
value={`recent-${page.href}`}
onSelect={() => navigate(page.href, page.label)}
>
<Clock className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{page.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{shouldSearch && contacts.length > 0 && (
<>
<CommandGroup heading="Contacts">
{contacts.map(contact => (
<CommandItem
key={contact.id}
value={`contact-${contact.id}-${contact.email}`}
onSelect={() => navigate(`/contacts/${contact.id}`, contact.email)}
>
<Users className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{contact.email}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{shouldSearch && campaigns.length > 0 && (
<>
<CommandGroup heading="Campaigns">
{campaigns.map(campaign => (
<CommandItem
key={campaign.id}
value={`campaign-${campaign.id}-${campaign.name}`}
onSelect={() => navigate(`/campaigns/${campaign.id}`, campaign.name)}
>
<Megaphone className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{campaign.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{shouldSearch && templates.length > 0 && (
<>
<CommandGroup heading="Templates">
{templates.map(template => (
<CommandItem
key={template.id}
value={`template-${template.id}-${template.name}`}
onSelect={() => navigate(`/templates/${template.id}`, template.name)}
>
<FileText className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{template.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{shouldSearch && workflows.length > 0 && (
<>
<CommandGroup heading="Workflows">
{workflows.map(workflow => (
<CommandItem
key={workflow.id}
value={`workflow-${workflow.id}-${workflow.name}`}
onSelect={() => navigate(`/workflows/${workflow.id}`, workflow.name)}
>
<WorkflowIcon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{workflow.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{shouldSearch && segments.length > 0 && (
<>
<CommandGroup heading="Segments">
{segments.map(segment => (
<CommandItem
key={segment.id}
value={`segment-${segment.id}-${segment.name}`}
onSelect={() => navigate(`/segments/${segment.id}`, segment.name)}
>
<Layers className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{segment.name}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{filteredNavActions.length > 0 && (
<CommandGroup heading="Go to">
{filteredNavActions.map(action => {
const Icon = action.icon;
return (
<CommandItem
key={action.href}
value={`nav-${action.href}-${action.label}-${action.keywords}`}
onSelect={() => navigate(action.href, action.label)}
>
<Icon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{action.label}</span>
<ShortcutHint shortcut={action.shortcut} />
</CommandItem>
);
})}
</CommandGroup>
)}
{filteredCreateActions.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Create">
{filteredCreateActions.map(action => {
const Icon = action.icon;
return (
<CommandItem
key={action.href + action.label}
value={`create-${action.label}-${action.keywords}`}
onSelect={() => navigate(action.href, action.label)}
>
<Icon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>{action.label}</span>
<ShortcutHint shortcut={action.shortcut} />
</CommandItem>
);
})}
</CommandGroup>
</>
)}
{(!shouldSearch || matches('api key copy code developer', query)) && (
<>
<CommandSeparator />
<CommandGroup heading="Developer">
{activeProject?.secret && (!shouldSearch || matches('copy secret key private developer', query)) && (
<CommandItem value="copy-secret-key-private-developer" onSelect={copySecretKey}>
<Copy className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>Copy secret key</span>
<span className="ml-auto font-mono text-xs text-neutral-400 truncate max-w-[120px]">
{activeProject.secret.slice(0, 8)}
</span>
</CommandItem>
)}
{activeProject?.public &&
(!shouldSearch || matches('copy public key frontend client developer', query)) && (
<CommandItem value="copy-public-key-frontend-client-developer" onSelect={copyPublicKey}>
<Copy className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>Copy public key</span>
<span className="ml-auto font-mono text-xs text-neutral-400 truncate max-w-[120px]">
{activeProject.public.slice(0, 8)}
</span>
</CommandItem>
)}
{(!shouldSearch || matches('documentation docs wiki help', query)) && (
<CommandItem
value="open-documentation-docs-wiki-help"
onSelect={() => {
window.open(WIKI_URI, '_blank');
setOpen(false);
}}
>
<BookOpen className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
<span>Documentation</span>
</CommandItem>
)}
</CommandGroup>
</>
)}
</CommandList>
<div className="flex items-center justify-between border-t border-neutral-100 px-4 py-2.5">
<span className="text-xs text-neutral-400">
<Kbd>G</Kbd> go to · <Kbd>N</Kbd> new
</span>
<span className="flex items-center gap-3">
<span className="flex items-center gap-1 text-xs text-neutral-400">
<Kbd></Kbd> navigate
</span>
<span className="flex items-center gap-1 text-xs text-neutral-400">
<Kbd></Kbd> open
</span>
<span className="flex items-center gap-1 text-xs text-neutral-400">
<Kbd>esc</Kbd> close
</span>
</span>
</div>
</CommandDialog>
);
}
+231 -93
View File
@@ -1,35 +1,59 @@
import type {Contact} from '@plunk/db';
import type {CursorPaginatedResponse} from '@plunk/types';
import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react';
import {Button, Input, Label, Popover, PopoverContent, PopoverTrigger, Switch} from '@plunk/ui';
import {Check, ChevronsUpDown, ClipboardList, Loader2, MailCheck, MailX, Search, Sparkles, X} from 'lucide-react';
import {useEffect, useRef, useState} from 'react';
import useSWR from 'swr';
import {ContactSchemas} from '@plunk/shared';
import {network} from '../lib/network';
type Mode = 'search' | 'paste';
interface ContactPickerProps {
/** Currently selected emails */
/** Currently selected emails (search mode) */
selected: string[];
/** Called when selection changes */
/** Called when search-mode selection changes */
onChange: (emails: string[]) => void;
/** Emails already in the segment (shown as disabled) */
/**
* Called by paste mode to submit directly skips the chip staging area.
* Receives the parsed email list and whether new contacts should be subscribed.
*/
onAdd: (emails: string[], subscribed: boolean) => Promise<void>;
/** Emails already in the segment (shown as disabled in search mode) */
existing?: string[];
placeholder?: string;
}
/**
* Searchable multi-select contact picker backed by the /contacts API.
* Only fetches when the user types (safe for large contact lists).
*/
const CHIP_LIMIT = 8;
function parseEmails(raw: string): string[] {
return raw
.split(/[\n,;]+/)
.map(s => s.trim().toLowerCase())
.filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
}
export function ContactPicker({
selected,
onChange,
onAdd,
existing = [],
placeholder = 'Search contacts...',
}: ContactPickerProps) {
const [mode, setMode] = useState<Mode>('search');
// Search mode
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Paste mode
const [pasteText, setPasteText] = useState('');
const [subscribeNew, setSubscribeNew] = useState(true);
const [debouncedPaste, setDebouncedPaste] = useState('');
const pasteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300);
@@ -38,7 +62,23 @@ export function ContactPicker({
};
}, [search]);
// Only fetch when there's a search term — avoids loading all contacts on open
useEffect(() => {
if (pasteDebounceRef.current) clearTimeout(pasteDebounceRef.current);
pasteDebounceRef.current = setTimeout(() => setDebouncedPaste(pasteText), 400);
return () => {
if (pasteDebounceRef.current) clearTimeout(pasteDebounceRef.current);
};
}, [pasteText]);
const debouncedEmails = parseEmails(debouncedPaste).filter(e => !existing.includes(e));
const {data: preview, isLoading: isLooking} = useSWR(
debouncedEmails.length > 0 ? ['/contacts/lookup', ...debouncedEmails] : null,
() => network.fetch<{found: string[]; notFound: string[]}, typeof ContactSchemas.lookup>(
'POST', '/contacts/lookup', {emails: debouncedEmails},
),
{revalidateOnFocus: false},
);
const {data, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null,
{revalidateOnFocus: false},
@@ -54,97 +94,195 @@ export function ContactPicker({
}
};
const parsedEmails = parseEmails(pasteText);
const newPastedEmails = parsedEmails.filter(e => !existing.includes(e));
const handlePasteSubmit = async () => {
if (newPastedEmails.length === 0) return;
await onAdd(newPastedEmails, subscribeNew);
setPasteText('');
};
const visibleChips = selected.slice(0, CHIP_LIMIT);
const overflowCount = selected.length - CHIP_LIMIT;
return (
<div className="space-y-3">
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 hover:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-0 transition-colors"
>
<span>{placeholder}</span>
<ChevronsUpDown className="h-4 w-4 opacity-40 shrink-0" />
</button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{width: 'var(--radix-popover-trigger-width)'}}
align="start"
{/* Mode toggle */}
<div className="flex gap-1 p-0.5 bg-neutral-100 rounded-md w-fit">
<button
type="button"
onClick={() => setMode('search')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
mode === 'search' ? 'bg-white text-neutral-900 shadow-sm' : 'text-neutral-500 hover:text-neutral-700'
}`}
>
{/* Search input */}
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
<Input
placeholder="Type an email to search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
autoFocus
/>
</div>
<Search className="h-3.5 w-3.5" />
Search
</button>
<button
type="button"
onClick={() => setMode('paste')}
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
mode === 'paste' ? 'bg-white text-neutral-900 shadow-sm' : 'text-neutral-500 hover:text-neutral-700'
}`}
>
<ClipboardList className="h-3.5 w-3.5" />
Paste
</button>
</div>
{/* Results */}
<div className="max-h-[240px] overflow-y-auto p-1">
{debouncedSearch.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
) : isLoading ? (
<p className="py-6 text-center text-sm text-neutral-400">Searching...</p>
) : contacts.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-400">No contacts found</p>
) : (
contacts.map(contact => {
const isSelected = selected.includes(contact.email);
const isExisting = existing.includes(contact.email);
return (
<button
key={contact.id}
type="button"
disabled={isExisting}
onClick={() => toggle(contact.email)}
className="w-full flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed text-left transition-colors"
>
{contact.subscribed ? (
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
) : (
<MailX className="h-4 w-4 text-red-500 shrink-0" />
)}
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
{isExisting && (
<span className="text-xs text-neutral-400 shrink-0">already member</span>
)}
{isSelected && !isExisting && (
<Check className="h-4 w-4 text-neutral-900 shrink-0" />
)}
</button>
);
})
)}
</div>
</PopoverContent>
</Popover>
{/* Selected chips */}
{selected.length > 0 && (
<div className="flex flex-wrap gap-2">
{selected.map(email => (
<span
key={email}
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 border border-neutral-200 pl-3 pr-1.5 py-1 text-sm text-neutral-800"
>
{email}
{mode === 'search' ? (
<>
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
<PopoverTrigger asChild>
<button
type="button"
onClick={() => onChange(selected.filter(e => e !== email))}
className="rounded-full p-0.5 hover:bg-neutral-300 transition-colors"
aria-label={`Remove ${email}`}
role="combobox"
aria-expanded={open}
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 hover:border-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
>
<X className="h-3 w-3" />
<span>{placeholder}</span>
<ChevronsUpDown className="h-4 w-4 opacity-40 shrink-0" />
</button>
</span>
))}
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{width: 'var(--radix-popover-trigger-width)'}}
align="start"
>
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
<Input
placeholder="Type an email to search..."
value={search}
onChange={e => setSearch(e.target.value)}
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
autoFocus
/>
</div>
<div className="max-h-[240px] overflow-y-auto p-1">
{debouncedSearch.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
) : isLoading ? (
<p className="py-6 text-center text-sm text-neutral-400">Searching...</p>
) : contacts.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-400">No contacts found</p>
) : (
contacts.map(contact => {
const isSelected = selected.includes(contact.email);
const isExisting = existing.includes(contact.email);
return (
<button
key={contact.id}
type="button"
disabled={isExisting}
onClick={() => toggle(contact.email)}
className="w-full flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed text-left transition-colors"
>
{contact.subscribed ? (
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
) : (
<MailX className="h-4 w-4 text-red-500 shrink-0" />
)}
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
{isExisting && <span className="text-xs text-neutral-400 shrink-0">already member</span>}
{isSelected && !isExisting && <Check className="h-4 w-4 text-neutral-900 shrink-0" />}
</button>
);
})
)}
</div>
</PopoverContent>
</Popover>
{/* Chips */}
{selected.length > 0 && (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{visibleChips.map(email => (
<span
key={email}
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 border border-neutral-200 pl-3 pr-1.5 py-1 text-sm text-neutral-800"
>
{email}
<button
type="button"
onClick={() => onChange(selected.filter(e => e !== email))}
className="rounded-full p-0.5 hover:bg-neutral-300 transition-colors"
aria-label={`Remove ${email}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
{overflowCount > 0 && (
<span className="inline-flex items-center rounded-full bg-neutral-100 border border-neutral-200 px-3 py-1 text-sm text-neutral-500">
+{overflowCount} more
</span>
)}
</div>
<button
type="button"
onClick={() => onChange([])}
className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors"
>
Clear all
</button>
</div>
)}
</>
) : (
/* Paste mode — self-contained, submits directly via onAdd */
<div className="space-y-3">
<textarea
value={pasteText}
onChange={e => setPasteText(e.target.value)}
placeholder={'Paste emails here, one per line or comma-separated...\n\[email protected]\[email protected], [email protected]'}
rows={6}
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none font-mono"
/>
{/* Preview panel — toggle lives here, inline with the "new contacts" row */}
{pasteText.trim() === '' ? (
<p className="text-xs text-neutral-400">Accepts newline, comma, or semicolon-separated addresses</p>
) : isLooking ? (
<p className="flex items-center gap-1.5 text-xs text-neutral-400"><Loader2 className="h-3 w-3 animate-spin" /> Checking...</p>
) : newPastedEmails.length > 0 && preview ? (
<div className="rounded-md border border-neutral-200 divide-y divide-neutral-100 text-sm overflow-hidden">
{preview.found.length > 0 && (
<div className="flex items-center gap-2 px-3 py-2.5 text-neutral-700">
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
<span><span className="font-medium">{preview.found.length}</span> existing contact{preview.found.length !== 1 ? 's' : ''}</span>
</div>
)}
{preview.notFound.length > 0 && (
<div className="flex items-center justify-between px-3 py-2.5">
<div className="flex items-center gap-2 text-neutral-700">
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
<span><span className="font-medium">{preview.notFound.length}</span> new will be created</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Label htmlFor="subscribe-new" className="text-xs text-neutral-500 cursor-pointer">Subscribe</Label>
<Switch id="subscribe-new" checked={subscribeNew} onCheckedChange={setSubscribeNew} />
</div>
</div>
)}
</div>
) : (
<p className="text-xs text-neutral-400">
{parsedEmails.length > 0 ? 'All detected emails are already in this segment' : 'No valid emails detected'}
</p>
)}
<Button
type="button"
onClick={() => void handlePasteSubmit()}
disabled={newPastedEmails.length === 0 || isLooking}
className="w-full"
>
Add {newPastedEmails.length > 0 ? `${newPastedEmails.length} ` : ''}contact{newPastedEmails.length !== 1 ? 's' : ''}
</Button>
</div>
)}
</div>
+27 -15
View File
@@ -2,6 +2,7 @@ import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {useUser} from '../lib/hooks/useUser';
import {WIKI_URI} from '../lib/constants';
import {network} from '../lib/network';
import {OnboardingBanner} from './onboarding/OnboardingBanner';
import {
Activity,
BarChart3,
@@ -158,9 +159,17 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
) => (
<>
{/* Logo */}
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
<div className="h-16 flex items-center justify-between px-6 border-b border-neutral-200">
<div className="flex items-center gap-2">
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
</div>
<button
onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', {key: 'k', metaKey: true, bubbles: true}))}
className="hidden lg:flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-neutral-400 bg-neutral-100 border border-neutral-200 rounded hover:bg-neutral-200 hover:text-neutral-600 transition-colors cursor-pointer"
>
<span></span><span>K</span>
</button>
</div>
{/* Project Switcher */}
@@ -168,10 +177,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<div className="relative" ref={projectMenuRef}>
<button
onClick={handleToggleProjectMenu}
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-neutral-50 transition-colors"
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-neutral-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="h-8 w-8 rounded-lg bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
<div className="h-8 w-8 rounded-lg bg-neutral-100 text-neutral-700 flex items-center justify-center text-xs font-medium flex-shrink-0">
{activeProject?.name.charAt(0).toUpperCase() || 'P'}
</div>
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
@@ -193,9 +202,9 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
setActiveProject(project);
setShowProjectMenu(false);
}}
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="h-6 w-6 rounded-md bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
<div className="h-6 w-6 rounded-md bg-neutral-100 text-neutral-700 flex items-center justify-center text-xs font-medium flex-shrink-0">
{project.name.charAt(0).toUpperCase()}
</div>
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
@@ -208,7 +217,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<Link
href="/projects/create"
onClick={() => setShowProjectMenu(false)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700 whitespace-nowrap"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700 whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<Plus className="h-4 w-4" />
<span>Create project</span>
@@ -237,7 +246,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
key={item.name}
href={item.href}
onClick={() => setShowMobileMenu(false)}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
isActive ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
}`}
>
@@ -257,7 +266,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
href={WIKI_URI}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<BookOpen className="h-5 w-5" />
Documentation
@@ -266,7 +275,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<Link
href="/settings"
onClick={() => setShowMobileMenu(false)}
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
router.pathname.startsWith('/settings')
? 'bg-neutral-100 text-neutral-900'
: 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
@@ -279,7 +288,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<div className="relative" ref={userMenuRef}>
<button
onClick={handleToggleUserMenu}
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<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() ?? '?'}
@@ -298,7 +307,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
</div>
<button
onClick={handleLogoutClick}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<LogOut className="h-4 w-4" />
<span>Log out</span>
@@ -339,7 +348,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
<div className="lg:hidden h-16 bg-white border-b border-neutral-200 flex items-center px-4">
<button
onClick={() => setShowMobileMenu(true)}
className="p-2 rounded-lg hover:bg-neutral-100 transition-colors"
className="p-2 rounded-lg hover:bg-neutral-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label="Open menu"
>
<Menu className="h-6 w-6 text-neutral-900" />
@@ -352,7 +361,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
{/* Page Content */}
<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>
</div>
</div>
@@ -14,14 +14,16 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
EmptyState,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
IconSpinner,
} from '@plunk/ui';
import {AlertCircle, Loader2, Trash2} from 'lucide-react';
import {AlertCircle, Database, Trash2, Zap} from 'lucide-react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
@@ -151,7 +153,11 @@ export function DataManagementSettings() {
</CardHeader>
<CardContent>
{customFields.length === 0 ? (
<p className="text-sm text-neutral-500">No custom fields found</p>
<EmptyState
icon={Database}
title="No custom fields"
description="Custom fields appear here once contacts have data properties set via the API."
/>
) : (
<Table>
<TableHeader>
@@ -196,7 +202,11 @@ export function DataManagementSettings() {
</CardHeader>
<CardContent>
{customEvents.length === 0 ? (
<p className="text-sm text-neutral-500">No custom events found</p>
<EmptyState
icon={Zap}
title="No custom events"
description="Custom events appear here once your contacts trigger events via the API."
/>
) : (
<Table>
<TableHeader>
@@ -336,7 +346,7 @@ export function DataManagementSettings() {
isDeleting || (!!selectedField && !fieldUsage?.canDelete) || (!!selectedEvent && !eventUsage?.canDelete)
}
>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isDeleting && <IconSpinner size="sm" className="mr-2" />}
Delete
</Button>
</DialogFooter>
+11 -8
View File
@@ -11,16 +11,18 @@ import {
CardHeader,
CardTitle,
ConfirmDialog,
EmptyState,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
IconSpinner,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {Check, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
import {Check, CheckCircle2, ChevronDown, Copy, Globe, RefreshCw, Trash2, XCircle} from 'lucide-react';
import {useConfig} from '../lib/hooks/useConfig';
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
@@ -321,12 +323,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
<CardContent>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
<IconSpinner />
</div>
) : !domains || domains.length === 0 ? (
<div className="text-center py-8 text-neutral-500">
<p>No domains added yet</p>
</div>
<EmptyState
icon={Globe}
title="No domains added"
description="Add a custom domain above to send emails from your own address."
/>
) : (
<div className="space-y-4">
{domains.map(domain => {
@@ -357,7 +361,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
className="min-w-[80px]"
>
{checkingVerification === domain.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
<IconSpinner size="sm" />
) : (cooldownSeconds[domain.id] ?? 0) > 0 ? (
<span className="text-xs">{cooldownSeconds[domain.id]}s</span>
) : (
@@ -365,13 +369,12 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
)}
</Button>
<Button
variant="ghost"
variant="destructiveGhost"
size="sm"
onClick={() => {
setDomainToRemove({id: domain.id, name: domain.domain});
setShowRemoveDialog(true);
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
@@ -894,8 +894,8 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
border: 1px solid #e5e7eb;
border-radius: 8px;
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
0 4px 6px -1px rgba(0, 0, 0, 0.07),
0 2px 4px -1px rgba(0, 0, 0, 0.04);
padding: 4px;
max-height: 320px;
overflow-y: auto;
@@ -427,7 +427,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
</Button>
{showColorPicker && (
<div
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-50 max-h-96 overflow-y-auto"
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-md p-3 z-50 max-h-96 overflow-y-auto"
style={{width: '280px'}}
>
{/* Custom color input */}
@@ -483,7 +483,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
>
{selectedColor === color && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
<div className="w-2 h-2 rounded-full bg-white shadow-sm" />
</div>
)}
</button>
@@ -511,7 +511,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
<Link className="h-4 w-4" />
</Button>
{showLinkInput && (
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-md p-2 z-50 min-w-max">
<div className="flex gap-2 mb-2">
<input
type="url"
-22
View File
@@ -1,22 +0,0 @@
import type {LucideIcon} from 'lucide-react';
import type {ReactNode} from 'react';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({icon: Icon, title, description, action}: EmptyStateProps) {
return (
<div className="text-center py-14">
<div className="inline-flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 bg-neutral-50 mb-4">
<Icon className="h-5 w-5 text-neutral-400" />
</div>
<h3 className="text-sm font-semibold text-neutral-900 mb-1">{title}</h3>
<p className="text-sm text-neutral-500 max-w-xs mx-auto leading-relaxed mb-5">{description}</p>
{action}
</div>
);
}
+1 -7
View File
@@ -114,13 +114,7 @@ export function KeyValueEditor({initialData, onChange}: KeyValueEditorProps) {
className="text-sm"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removePair(pair.id)}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
<Button type="button" variant="destructiveGhost" size="icon" onClick={() => removePair(pair.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
+12 -14
View File
@@ -36,22 +36,22 @@ function HelpResources() {
<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>
<div className="flex flex-col sm:flex-row gap-2">
<Link href={WIKI_URI} target="_blank" className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<Button asChild variant="outline" size="sm" className="flex-1">
<Link href={WIKI_URI} target="_blank">
<BookOpen className="h-3.5 w-3.5" />
Documentation
</Button>
</Link>
<Link href={`${LANDING_URI}/discord`} target="_blank" className="flex-1">
<Button variant="outline" size="sm" className="w-full">
</Link>
</Button>
<Button asChild variant="outline" size="sm" className="flex-1">
<Link href={`${LANDING_URI}/discord`} target="_blank">
<MessageCircle className="h-3.5 w-3.5" />
Join Discord
</Button>
</Link>
</Link>
</Button>
<motion.button
onClick={copyEmail}
whileTap={{scale: 0.97}}
className="flex-1 relative flex items-center justify-center gap-1.5 h-9 rounded-md border border-neutral-200 bg-white px-3 text-sm font-medium text-neutral-700 overflow-hidden transition-colors hover:bg-neutral-50 hover:text-neutral-900 hover:border-neutral-300"
className="flex-1 relative flex items-center justify-center gap-1.5 h-9 rounded-md border border-neutral-200 bg-white px-3 text-xs font-medium text-neutral-700 overflow-hidden transition-colors hover:bg-neutral-50 hover:text-neutral-900 hover:border-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
@@ -274,11 +274,9 @@ export function QuickStart({setupState, isLoading}: QuickStartProps) {
</div>
<p className="text-xs text-neutral-600 leading-relaxed">{step.description}</p>
</div>
<Link href={step.link} className="flex-shrink-0">
<Button size="sm" variant={step.isCompleted ? 'outline' : 'default'}>
{step.linkText}
</Button>
</Link>
<Button asChild size="sm" variant={step.isCompleted ? 'outline' : 'default'} className="flex-shrink-0">
<Link href={step.link}>{step.linkText}</Link>
</Button>
</div>
);
})}
+34 -30
View File
@@ -9,7 +9,7 @@ interface SecuritySettingsProps {
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'},
warning: {color: 'text-amber-700', icon: AlertTriangle, bg: 'bg-amber-100', label: 'Warning'},
critical: {color: 'text-red-600', icon: AlertCircle, bg: 'bg-red-100', label: 'Critical'},
};
@@ -63,7 +63,7 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
<AlertCircle className="h-4 w-4" />
<AlertTitle>Project Disabled</AlertTitle>
<AlertDescription>
This project has been disabled due to security violations. Contact support to resolve.
This project has been disabled. Please contact support for more details.
</AlertDescription>
</Alert>
)}
@@ -73,8 +73,8 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
<AlertCircle className="h-4 w-4" />
<AlertTitle>Critical</AlertTitle>
<AlertDescription>
Your bounce or complaint rates have exceeded acceptable levels. Review your contact lists and sending
practices to avoid project suspension.
Your account requires immediate attention. Please contact support or review your sending practices to
avoid suspension.
</AlertDescription>
</Alert>
)}
@@ -84,8 +84,8 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
Your bounce or complaint rates are approaching limits. Review your contact lists and remove invalid
addresses to maintain good standing.
Your account health needs attention. Review your contact lists and sending practices to maintain good
standing.
</AlertDescription>
</Alert>
)}
@@ -107,17 +107,19 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
<CardTitle>Bounce Rate</CardTitle>
<CardDescription>Hard bounces indicate invalid or non-existent email addresses</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<HealthMetric
label="Last 7 Days"
level={levels.bounce7Day}
detail={`${status.sevenDay.bounceRate.toFixed(2)}% bounce rate (${status.sevenDay.bounces.toLocaleString()} of ${status.sevenDay.total.toLocaleString()} emails)`}
/>
<HealthMetric
label="All Time"
level={levels.bounceAllTime}
detail={`${status.allTime.bounceRate.toFixed(2)}% bounce rate (${status.allTime.bounces.toLocaleString()} of ${status.allTime.total.toLocaleString()} emails)`}
/>
<CardContent className="p-0">
<div className="divide-y divide-neutral-100">
<HealthMetric
label="Last 7 Days"
level={levels.bounce7Day}
detail={`${status.sevenDay.bounceRate.toFixed(2)}% bounce rate (${status.sevenDay.bounces.toLocaleString()} of ${status.sevenDay.total.toLocaleString()} emails)`}
/>
<HealthMetric
label="All Time"
level={levels.bounceAllTime}
detail={`${status.allTime.bounceRate.toFixed(2)}% bounce rate (${status.allTime.bounces.toLocaleString()} of ${status.allTime.total.toLocaleString()} emails)`}
/>
</div>
</CardContent>
</Card>
@@ -127,17 +129,19 @@ export function SecuritySettings({metrics, isLoading}: SecuritySettingsProps) {
<CardTitle>Complaint Rate</CardTitle>
<CardDescription>Complaints occur when recipients mark emails as spam</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<HealthMetric
label="Last 7 Days"
level={levels.complaint7Day}
detail={`${status.sevenDay.complaintRate.toFixed(3)}% complaint rate (${status.sevenDay.complaints.toLocaleString()} of ${status.sevenDay.total.toLocaleString()} emails)`}
/>
<HealthMetric
label="All Time"
level={levels.complaintAllTime}
detail={`${status.allTime.complaintRate.toFixed(3)}% complaint rate (${status.allTime.complaints.toLocaleString()} of ${status.allTime.total.toLocaleString()} emails)`}
/>
<CardContent className="p-0">
<div className="divide-y divide-neutral-100">
<HealthMetric
label="Last 7 Days"
level={levels.complaint7Day}
detail={`${status.sevenDay.complaintRate.toFixed(3)}% complaint rate (${status.sevenDay.complaints.toLocaleString()} of ${status.sevenDay.total.toLocaleString()} emails)`}
/>
<HealthMetric
label="All Time"
level={levels.complaintAllTime}
detail={`${status.allTime.complaintRate.toFixed(3)}% complaint rate (${status.allTime.complaints.toLocaleString()} of ${status.allTime.total.toLocaleString()} emails)`}
/>
</div>
</CardContent>
</Card>
</div>
@@ -155,10 +159,10 @@ function HealthMetric({label, level, detail}: HealthMetricProps) {
const Icon = config.icon;
return (
<div className="flex items-center justify-between border border-neutral-200 rounded-lg p-4">
<div className="flex items-center justify-between px-6 py-4">
<div>
<h3 className="font-medium text-neutral-900">{label}</h3>
<p className="text-sm text-neutral-600 mt-1">{detail}</p>
<p className="text-sm text-neutral-600 mt-0.5">{detail}</p>
</div>
<div className={`flex items-center gap-2 ${config.color}`}>
<Icon className="h-4 w-4" />
@@ -29,15 +29,13 @@ export function SecurityWarningBanner({status}: SecurityWarningBannerProps) {
<div className="space-y-2 flex-1">
<p className={`text-sm ${messageColor}`}>
{hasCriticalViolations
? 'Your bounce or complaint rates have exceeded acceptable levels. Review your contact lists and sending practices to avoid project suspension.'
: 'Your bounce or complaint rates are approaching limits. Review your contact lists and remove invalid addresses to maintain good standing.'}
? 'Your account requires immediate attention. Review your sending practices to avoid suspension. Contact support for more details.'
: 'Your account health needs attention. Review your contact lists and sending practices to maintain good standing.'}
</p>
</div>
<Link href="/settings?tab=security">
<Button size="sm" variant="outline" className="w-full sm:w-auto flex-shrink-0">
View Details
</Button>
</Link>
<Button asChild size="sm" variant="outline" className="w-full sm:w-auto flex-shrink-0">
<Link href="/settings?tab=security">View Details</Link>
</Button>
</AlertDescription>
</Alert>
);
@@ -528,13 +528,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRemove}
className="mt-6 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Button type="button" variant="destructiveGhost" size="sm" onClick={onRemove} className="mt-6">
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -603,12 +597,12 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
onChange(rest);
}, [group, onChange]);
const bgColors = ['bg-white', 'bg-blue-50/50', 'bg-purple-50/50', 'bg-green-50/50'];
const borderColors = ['border-neutral-300', 'border-blue-300', 'border-purple-300', 'border-green-300'];
const bgColors = ['bg-white', 'bg-neutral-50', 'bg-white', 'bg-neutral-50'];
const borderColors = ['border-neutral-200', 'border-neutral-200', 'border-neutral-300', 'border-neutral-300'];
return (
<div
className={`p-4 rounded-lg border-2 ${borderColors[depth % borderColors.length]} ${bgColors[depth % bgColors.length]}`}
className={`p-4 rounded-lg border ${borderColors[depth % borderColors.length]} ${bgColors[depth % bgColors.length]}`}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
@@ -616,13 +610,7 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
<span className="text-sm font-medium text-neutral-700">Filter Group {depth > 0 && `(Nested)`}</span>
</div>
{onRemove && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRemove}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Button type="button" variant="destructiveGhost" size="sm" onClick={onRemove}>
<Trash2 className="h-4 w-4" />
</Button>
)}
@@ -645,10 +633,10 @@ function FilterGroupComponent({group, onChange, onRemove, depth = 0, availableFi
<span className="text-xs font-medium text-neutral-600 uppercase tracking-wide">Nested Conditions</span>
<Button
type="button"
variant="ghost"
variant="destructiveGhost"
size="sm"
onClick={removeNestedCondition}
className="h-6 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
className="h-6 text-xs"
>
Remove nested
</Button>
+2 -1
View File
@@ -37,6 +37,7 @@ import {
TableHead,
TableHeader,
TableRow,
IconSpinner,
} from '@plunk/ui';
import {MembershipSchemas} from '@plunk/shared';
import {MoreVertical, Trash2, UserPlus} from 'lucide-react';
@@ -221,7 +222,7 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-neutral-200 border-t-neutral-900" />
<IconSpinner />
</div>
) : members.length === 0 ? (
<div className="py-8 text-center text-sm text-neutral-500">No members found</div>
@@ -0,0 +1,108 @@
import {Input} from '@plunk/ui';
import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {Command, CommandGroup, CommandItem, CommandList} from '@plunk/ui';
import {ChevronDown} from 'lucide-react';
import {useCallback, useRef, useState} from 'react';
import useSWR from 'swr';
interface TemplateSearchPickerProps {
/** Currently selected template ID */
value: string;
/** Display name for the pre-selected template (avoids a fetch just to show the name) */
initialName?: string;
onChange: (id: string) => void;
}
/**
* Inline combobox for picking a template.
* Fires a debounced server-side search (/templates?search=&pageSize=20)
* so it works correctly regardless of how many templates exist.
*/
export function TemplateSearchPicker({value, initialName, onChange}: TemplateSearchPickerProps) {
const [query, setQuery] = useState(initialName ?? '');
const [prevInitialName, setPrevInitialName] = useState(initialName);
const [debouncedQuery, setDebouncedQuery] = useState('');
const [open, setOpen] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
if (initialName !== prevInitialName) {
setPrevInitialName(initialName);
setQuery(initialName ?? '');
}
const handleInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val);
setOpen(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setDebouncedQuery(val), 300);
}, []);
const {data, isLoading} = useSWR<PaginatedResponse<Template>>(
open || debouncedQuery
? `/templates?pageSize=20${debouncedQuery ? `&search=${encodeURIComponent(debouncedQuery)}` : ''}`
: null,
{revalidateOnFocus: false},
);
// When closed, show the selected template's name rather than the raw query
const displayValue = open
? query
: (value ? (data?.data.find(t => t.id === value)?.name ?? initialName ?? value) : '');
return (
<div className="relative">
<Input
type="text"
value={displayValue}
onChange={handleInput}
onFocus={() => {
setOpen(true);
setDebouncedQuery(query);
}}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search templates…"
autoComplete="off"
className="pr-8"
/>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
{open && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md max-h-60 overflow-y-auto">
{isLoading ? (
<div className="px-3 py-2 text-sm text-neutral-500">Searching</div>
) : !data?.data.length ? (
<div className="px-3 py-2 text-sm text-neutral-500">No templates found</div>
) : (
<Command>
<CommandList>
<CommandGroup>
{data.data.map(t => (
<CommandItem
key={t.id}
value={t.id}
onSelect={() => {
onChange(t.id);
setQuery(t.name);
setOpen(false);
}}
>
<span className="flex-1 truncate">{t.name}</span>
<span className="ml-2 text-xs text-neutral-400 shrink-0">{t.type}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
)}
{(data?.total ?? 0) > 20 && (
<div className="px-3 py-1.5 text-xs text-neutral-400 border-t border-neutral-100">
Showing 20 of {data!.total} type to narrow results
</div>
)}
</div>
)}
</div>
);
}
@@ -12,6 +12,8 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
EmptyState,
IconSpinner,
Input,
Label,
} from '@plunk/ui';
@@ -104,7 +106,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{step === 'configure' && (
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 w-8 p-0">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
@@ -204,33 +206,15 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<div className="flex-1 overflow-y-auto space-y-3">
{isLoading && (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading templates...</p>
</div>
<IconSpinner />
</div>
)}
{!isLoading && data?.data.length === 0 && (
<div className="text-center py-12">
<FileText className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-neutral-900 mb-2">No templates found</h3>
<p className="text-neutral-500">
{search ? 'Try adjusting your search terms' : 'Create a template first to use this feature'}
</p>
</div>
<EmptyState
icon={FileText}
title="No templates found"
description={search ? 'Try adjusting your search terms.' : 'Create a template first to use this feature.'}
/>
)}
{data?.data.map(template => (
@@ -244,7 +228,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<CardTitle className="text-base truncate">{template.name}</CardTitle>
<Badge className="capitalize" variant={template.type === 'MARKETING' ? 'info' : template.type === 'HEADLESS' ? 'warning' : 'success'}>
<Badge className="capitalize" variant="neutral">
{template.type.toLowerCase()}
</Badge>
</div>
@@ -301,10 +285,7 @@ export function TemplateSelectionDialog({open, onOpenChange, onSelectTemplate}:
<div className="pb-4 mb-1 border-b border-neutral-100">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-neutral-900">{selectedTemplate.name}</span>
<Badge
className="capitalize"
variant={selectedTemplate.type === 'MARKETING' ? 'info' : 'success'}
>
<Badge className="capitalize" variant="neutral">
{selectedTemplate.type.toLowerCase()}
</Badge>
</div>
@@ -73,12 +73,7 @@ export function UnpaidInvoiceBanner({projectId, hasSubscription}: UnpaidInvoiceB
Please pay your outstanding invoices to avoid service interruption.
</p>
</div>
<Button
variant="default"
size="sm"
onClick={handlePayNow}
className="ml-4 bg-red-600 hover:bg-red-700 text-white flex items-center gap-2"
>
<Button variant="destructive" size="sm" onClick={handlePayNow} className="ml-4">
Pay Now
<ExternalLink className="h-3 w-3" />
</Button>
+164 -139
View File
@@ -12,7 +12,6 @@ import {
ReactFlow,
useEdgesState,
useNodesState,
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
@@ -24,6 +23,8 @@ import {
Link,
LogOut,
Mail,
Maximize2,
Minimize2,
Plus,
Settings,
Timer,
@@ -35,7 +36,7 @@ import {useCallback, useEffect, useMemo, useState} from 'react';
import dagre from 'dagre';
import {network} from '../lib/network';
import {toast} from 'sonner';
import {Button, ConfirmDialog, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@plunk/ui';
import {Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@plunk/ui';
import {WorkflowSchemas} from '@plunk/shared';
interface WorkflowBuilderProps {
@@ -58,6 +59,17 @@ interface WorkflowBuilderProps {
onUpdate: () => void;
}
const STEP_TYPE_LABELS: Record<string, string> = {
TRIGGER: 'Trigger',
SEND_EMAIL: 'Send Email',
DELAY: 'Delay',
WAIT_FOR_EVENT: 'Wait for Event',
CONDITION: 'Condition',
EXIT: 'Exit',
WEBHOOK: 'Webhook',
UPDATE_CONTACT: 'Update Contact',
};
const STEP_TYPE_ICONS = {
TRIGGER: GitBranch,
SEND_EMAIL: Mail,
@@ -245,13 +257,7 @@ function CustomNode({
<Handle
type="target"
position={Position.Top}
style={{
background: color,
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
/>
<div
@@ -327,7 +333,7 @@ function CustomNode({
color,
}}
>
{data.type}
{STEP_TYPE_LABELS[data.type] ?? data.type}
</span>
</div>
</div>
@@ -407,13 +413,7 @@ function CustomNode({
<Handle
type="source"
position={Position.Bottom}
style={{
background: color,
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
/>
</>
);
@@ -436,13 +436,23 @@ const STEP_TYPE_OPTIONS = [
];
export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderProps) {
const reactFlowInstance = useReactFlow();
const [addStepContext, setAddStepContext] = useState<{
fromStepId: string | null;
branch?: string;
} | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
const [deleteMode, setDeleteMode] = useState<'splice' | 'cascade'>('splice');
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (!isExpanded) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsExpanded(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isExpanded]);
// Define handlers before they are used in useMemo
const handleEditStep = useCallback(
@@ -463,10 +473,17 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
[steps],
);
const handleDeleteStepClick = useCallback((stepId: string) => {
setStepToDelete(stepId);
setShowDeleteDialog(true);
}, []);
const handleDeleteStepClick = useCallback(
(stepId: string) => {
const step = steps.find(s => s.id === stepId);
const isCondition = step?.type === 'CONDITION';
const hasChildren = (step?.outgoingTransitions?.length ?? 0) > 0;
setDeleteMode(isCondition || !hasChildren ? 'cascade' : 'splice');
setStepToDelete(stepId);
setShowDeleteDialog(true);
},
[steps],
);
// Convert workflow steps to React Flow nodes
@@ -564,7 +581,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
source: step.id,
target: transition.toStepId,
type: 'smoothstep',
animated: step.type === 'DELAY' || step.type === 'WAIT_FOR_EVENT',
animated: false,
label: isConditional ? branchLabel : undefined,
labelStyle: {
fill: isConditional ? branchColor : '#64748b',
@@ -578,14 +595,14 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: isConditional ? branchColor : '#94a3b8',
strokeWidth: 2.5,
stroke: '#94a3b8',
strokeWidth: 2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isConditional ? branchColor : '#94a3b8',
width: 22,
height: 22,
color: '#94a3b8',
width: 20,
height: 20,
},
});
});
@@ -621,8 +638,8 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
labelBgStyle: {fill: '#fff', fillOpacity: 0.95},
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {stroke: color, strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color, width: 22, height: 22},
style: {stroke: '#94a3b8', strokeWidth: 2, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 20, height: 20},
});
}
});
@@ -632,10 +649,10 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
id: `${step.id}-add-edge`,
source: step.id,
target: `${step.id}-add`,
type: 'straight',
type: 'smoothstep',
animated: false,
style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22},
style: {stroke: '#94a3b8', strokeWidth: 2, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 20, height: 20},
});
}
}
@@ -653,70 +670,6 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
// Custom nodes change handler that updates "+" node positions when parent nodes move
const handleNodesChange = useCallback(
(changes: any[]) => {
onNodesChange(changes);
// Check if any position changes occurred
const positionChanges = changes.filter(change => change.type === 'position' && change.dragging);
if (positionChanges.length > 0) {
setNodes(currentNodes => {
const updatedNodes = [...currentNodes];
const nodeWidth = 280;
positionChanges.forEach((change: any) => {
const movedNode = updatedNodes.find(n => n.id === change.id);
if (movedNode && movedNode.type === 'custom' && change.position) {
// Find all "+" nodes connected to this parent node
const connectedAddNodes = updatedNodes.filter(node => {
if (node.type !== 'addStep') return false;
// Check if this add node is connected to the moved node
return node.id === `${movedNode.id}-add` || node.id.startsWith(`${movedNode.id}-add-`);
});
// Update positions of connected "+" nodes to stay below parent
if (connectedAddNodes.length === 1) {
// Single add node — center below parent
const addNode = connectedAddNodes[0]!;
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
const existingNode = updatedNodes[addNodeIndex];
updatedNodes[addNodeIndex] = {
...existingNode,
position: {
x: change.position.x + nodeWidth / 2 - 32, // Center below parent
y: existingNode.position.y,
},
};
}
} 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;
});
}
},
[onNodesChange, setNodes],
);
// Update nodes/edges when layout changes
useEffect(() => {
@@ -839,13 +792,24 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
if (!stepToDelete) return;
try {
await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`);
const affectedSteps = getAffectedSteps(stepToDelete);
if (affectedSteps.length > 1) {
toast.success(`Deleted ${affectedSteps.length} steps`);
const url =
deleteMode === 'splice'
? `/workflows/${workflowId}/steps/${stepToDelete}?splice=true`
: `/workflows/${workflowId}/steps/${stepToDelete}`;
await network.fetch('DELETE', url);
if (deleteMode === 'cascade') {
const affectedSteps = getAffectedSteps(stepToDelete);
if (affectedSteps.length > 1) {
toast.success(`Deleted ${affectedSteps.length} steps`);
} else {
toast.success('Step deleted');
}
} else {
toast.success('Step deleted');
toast.success('Step removed from flow');
}
onUpdate();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete step');
@@ -854,17 +818,6 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
}
};
// Auto-layout on demand
const handleAutoLayout = useCallback(() => {
const {nodes: newNodes, edges: newEdges} = getLayoutedElements(nodes, edges);
setNodes(newNodes);
setEdges(newEdges);
// Fit view after layout
setTimeout(() => {
reactFlowInstance?.fitView({padding: 0.3});
}, 10);
}, [nodes, edges, setNodes, setEdges, reactFlowInstance]);
if (steps.length === 0) {
return (
@@ -878,11 +831,23 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
return (
<>
<div className="w-full h-[800px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner relative">
{isExpanded && (
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={() => setIsExpanded(false)}
/>
)}
<div
className={
isExpanded
? 'fixed inset-[5%] z-50 bg-neutral-50 rounded-xl border border-neutral-200 shadow-2xl'
: 'w-full h-[800px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner relative'
}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
@@ -893,7 +858,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
}}
minZoom={0.1}
maxZoom={2}
nodesDraggable={true}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={true}
defaultEdgeOptions={{
@@ -930,15 +895,20 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
</div>
</div>
</Panel>
<Panel position="top-right" className="flex gap-2">
<Panel position="top-right">
<button
onClick={handleAutoLayout}
className="bg-white px-4 py-2 rounded-lg shadow-md border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
onClick={() => setIsExpanded(e => !e)}
className="bg-white border border-neutral-200 rounded-lg shadow-md p-2 hover:bg-neutral-50 transition-colors"
title={isExpanded ? 'Exit fullscreen' : 'Expand to fullscreen'}
>
Auto Layout
{isExpanded ? (
<Minimize2 className="h-4 w-4 text-neutral-600" />
) : (
<Maximize2 className="h-4 w-4 text-neutral-600" />
)}
</button>
</Panel>
{rawEdges.length === 0 && steps.length > 1 && (
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
@@ -993,36 +963,91 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const affectedSteps = getAffectedSteps(stepToDelete);
const stepToDeleteData = steps.find(s => s.id === stepToDelete);
const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
const isCondition = stepToDeleteData?.type === 'CONDITION';
const hasChildren = downstreamSteps.length > 0;
const canSplice = !isCondition && hasChildren;
return (
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDeleteStep}
title="Delete Step"
description={
downstreamSteps.length > 0 ? (
<div className="space-y-3">
<p>
Deleting &quot;{stepToDeleteData?.name}&quot; will also delete {downstreamSteps.length} downstream{' '}
{downstreamSteps.length === 1 ? 'step' : 'steps'}:
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove &quot;{stepToDeleteData?.name}&quot;</DialogTitle>
</DialogHeader>
{canSplice ? (
<div className="space-y-3 py-1">
<p className="text-sm text-neutral-600">How would you like to remove this step?</p>
<div className="space-y-2">
<button
onClick={() => setDeleteMode('splice')}
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
deleteMode === 'splice'
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="text-sm font-medium text-neutral-900">Remove from flow</p>
<p className="text-xs text-neutral-500 mt-0.5">
Delete this step and connect its parent directly to its child. The rest of the workflow is
preserved.
</p>
</button>
<button
onClick={() => setDeleteMode('cascade')}
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
deleteMode === 'cascade'
? 'border-red-500 bg-red-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="text-sm font-medium text-neutral-900">
Delete with {downstreamSteps.length} downstream {downstreamSteps.length === 1 ? 'step' : 'steps'}
</p>
<p className="text-xs text-neutral-500 mt-0.5">
Permanently removes this step and everything below it. This cannot be undone.
</p>
</button>
</div>
</div>
) : isCondition && hasChildren ? (
<div className="space-y-3 py-1">
<p className="text-sm text-neutral-600">
Removing a condition step will also delete all {downstreamSteps.length} downstream{' '}
{downstreamSteps.length === 1 ? 'step' : 'steps'} across its branches:
</p>
<ul className="list-disc list-inside text-sm text-neutral-600 max-h-32 overflow-y-auto bg-neutral-50 p-3 rounded border border-neutral-200">
{downstreamSteps.map(step => (
<li key={step.id}>
{step.name} ({step.type})
{step.name} ({STEP_TYPE_LABELS[step.type] ?? step.type})
</li>
))}
</ul>
<p className="text-sm font-medium text-red-600">This action cannot be undone.</p>
</div>
) : (
`Are you sure you want to delete "${stepToDeleteData?.name}"? This action cannot be undone.`
)
}
confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'}
variant="destructive"
/>
<p className="text-sm text-neutral-600 py-1">
Are you sure you want to delete this step? This action cannot be undone.
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
variant={deleteMode === 'cascade' || !canSplice ? 'destructive' : 'default'}
onClick={() => {
setShowDeleteDialog(false);
handleDeleteStep();
}}
>
{canSplice && deleteMode === 'splice'
? 'Remove from flow'
: `Delete ${hasChildren ? `${affectedSteps.length} steps` : 'step'}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
})()}
</>
+118 -75
View File
@@ -5,6 +5,7 @@ import {
type Edge,
Handle,
MarkerType,
MiniMap,
type Node,
Panel,
Position,
@@ -14,8 +15,8 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
import {AlertTriangle, Clock, GitBranch, Hourglass, Link, LogOut, Mail, Timer, UserCog, Webhook} from 'lucide-react';
import {useEffect, useMemo} from 'react';
import {AlertTriangle, Clock, GitBranch, Hourglass, Link, LogOut, Mail, Maximize2, Minimize2, Timer, UserCog, Webhook} from 'lucide-react';
import {useEffect, useMemo, useState} from 'react';
import dagre from 'dagre';
interface WorkflowVisualizerProps {
@@ -36,6 +37,17 @@ interface WorkflowVisualizerProps {
})[];
}
const STEP_TYPE_LABELS: Record<string, string> = {
TRIGGER: 'Trigger',
SEND_EMAIL: 'Send Email',
DELAY: 'Delay',
WAIT_FOR_EVENT: 'Wait for Event',
CONDITION: 'Condition',
EXIT: 'Exit',
WEBHOOK: 'Webhook',
UPDATE_CONTACT: 'Update Contact',
};
const STEP_TYPE_ICONS = {
TRIGGER: GitBranch,
SEND_EMAIL: Mail,
@@ -129,17 +141,10 @@ function CustomNode({
return (
<>
{/* Target Handle (top) - where edges come IN */}
<Handle
type="target"
position={Position.Top}
style={{
background: color,
width: 12,
height: 12,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
/>
<div
@@ -167,7 +172,7 @@ function CustomNode({
color,
}}
>
{data.type}
{STEP_TYPE_LABELS[data.type] ?? data.type}
</span>
</div>
</div>
@@ -217,17 +222,10 @@ function CustomNode({
)}
</div>
{/* Source Handle (bottom) - where edges go OUT */}
<Handle
type="source"
position={Position.Bottom}
style={{
background: color,
width: 12,
height: 12,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
/>
</>
);
@@ -347,12 +345,12 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
stroke: '#94a3b8',
strokeWidth: 2,
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: isConditional ? (branch === 'yes' ? '#16a34a' : '#dc2626') : '#94a3b8',
color: '#94a3b8',
width: 20,
height: 20,
},
@@ -390,13 +388,13 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: '#16a34a',
stroke: '#94a3b8',
strokeWidth: 2,
strokeDasharray: '5,5',
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#16a34a',
color: '#94a3b8',
width: 20,
height: 20,
},
@@ -423,13 +421,13 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
labelBgPadding: [8, 4] as [number, number],
labelBgBorderRadius: 4,
style: {
stroke: '#dc2626',
stroke: '#94a3b8',
strokeWidth: 2,
strokeDasharray: '5,5',
},
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#dc2626',
color: '#94a3b8',
width: 20,
height: 20,
},
@@ -449,6 +447,7 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
const [isExpanded, setIsExpanded] = useState(false);
// Update nodes/edges when layout changes
useEffect(() => {
@@ -459,6 +458,15 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
setEdges(layoutedEdges);
}, [layoutedEdges, setEdges]);
useEffect(() => {
if (!isExpanded) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsExpanded(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isExpanded]);
if (steps.length === 0) {
return (
<div className="bg-neutral-50 border-2 border-dashed border-neutral-300 rounded-lg p-12 text-center">
@@ -470,61 +478,96 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
}
return (
<div className="w-full h-[700px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{
padding: 0.3,
minZoom: 0.5,
maxZoom: 1.2,
}}
minZoom={0.1}
maxZoom={2}
nodesDraggable={true}
nodesConnectable={false}
elementsSelectable={true}
defaultEdgeOptions={{
type: 'smoothstep',
}}
proOptions={{hideAttribution: true}}
>
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
<>
{isExpanded && (
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={() => setIsExpanded(false)}
/>
<Panel
position="top-left"
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
)}
<div
className={
isExpanded
? 'fixed inset-[5%] z-50 bg-neutral-50 rounded-xl border border-neutral-200 shadow-2xl'
: 'w-full h-[700px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner'
}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{
padding: 0.3,
minZoom: 0.5,
maxZoom: 1.2,
}}
minZoom={0.1}
maxZoom={2}
nodesDraggable={true}
nodesConnectable={false}
elementsSelectable={true}
defaultEdgeOptions={{
type: 'smoothstep',
}}
proOptions={{hideAttribution: true}}
>
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" />
<div className="text-sm">
<span className="font-semibold text-neutral-900">{steps.length}</span>
<span className="text-neutral-600"> step{steps.length !== 1 ? 's' : ''}</span>
<span className="text-neutral-400 mx-2">·</span>
<span className="font-semibold text-neutral-900">{rawEdges.length}</span>
<span className="text-neutral-600"> transition{rawEdges.length !== 1 ? 's' : ''}</span>
</div>
</div>
</Panel>
{rawEdges.length === 0 && steps.length > 1 && (
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
/>
<MiniMap
nodeColor={node => {
const step = steps.find(s => s.id === node.id);
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
}}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
maskColor="rgba(0, 0, 0, 0.05)"
/>
<Panel
position="bottom-center"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
position="top-left"
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
>
<div className="flex items-center gap-2 text-sm text-neutral-600">
<AlertTriangle className="h-4 w-4" />
<span>No transitions found. Connect your steps to see the flow.</span>
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" />
<div className="text-sm">
<span className="font-semibold text-neutral-900">{steps.length}</span>
<span className="text-neutral-600"> step{steps.length !== 1 ? 's' : ''}</span>
<span className="text-neutral-400 mx-2">·</span>
<span className="font-semibold text-neutral-900">{rawEdges.length}</span>
<span className="text-neutral-600"> transition{rawEdges.length !== 1 ? 's' : ''}</span>
</div>
</div>
</Panel>
)}
</ReactFlow>
</div>
<Panel position="top-right">
<button
onClick={() => setIsExpanded(e => !e)}
className="bg-white border border-neutral-200 rounded-lg shadow-md p-2 hover:bg-neutral-50 transition-colors"
title={isExpanded ? 'Exit fullscreen' : 'Expand to fullscreen'}
>
{isExpanded ? (
<Minimize2 className="h-4 w-4 text-neutral-600" />
) : (
<Maximize2 className="h-4 w-4 text-neutral-600" />
)}
</button>
</Panel>
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
>
<div className="flex items-center gap-2 text-sm text-neutral-600">
<AlertTriangle className="h-4 w-4" />
<span>No transitions found. Connect your steps to see the flow.</span>
</div>
</Panel>
)}
</ReactFlow>
</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>
);
}
@@ -0,0 +1,246 @@
import {AnimatePresence, motion} from 'framer-motion';
import {ArrowRight, Check, Code2, FileText, Mail, ShieldCheck, Users, Workflow, X, Zap} from 'lucide-react';
import {useRouter} from 'next/router';
import {useEffect, useMemo} from 'react';
import {useSWRConfig} from 'swr';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {useOnboardingComplete} from '../../lib/hooks/useOnboardingComplete';
import {type OnboardingPath, useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
import {useOnboardingStatus} from '../../lib/hooks/useOnboardingStatus';
import {useProjectSetupState} from '../../lib/hooks/useProjectSetupState';
// Keys the banner derives state from. Revalidated on every route change so
// progress reflects the latest state as soon as the user moves between pages.
const LIVE_KEY_FRAGMENTS = ['/setup-state', '/activity/stats', '/contacts?limit=1', '/campaigns?page=1&limit=1'];
const BANNER_POLL_MS = 5_000;
interface BannerStep {
id: string;
icon: React.ElementType;
title: string;
href: string;
done: boolean;
}
function buildMarketingSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
if (!setupState) return [];
return [
{
id: 'domain',
icon: ShieldCheck,
title: 'Verify your domain',
href: '/settings?tab=domains',
done: setupState.hasVerifiedDomain,
},
{
id: 'contacts',
icon: Users,
title: 'Add contacts',
href: '/contacts',
done: setupState.contactCount > 0,
},
{
id: 'campaign',
icon: Mail,
title: 'Draft your first campaign',
href: '/campaigns/create',
done: setupState.lastCampaignSentAt !== null,
},
];
}
function buildDeveloperSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
if (!setupState) return [];
return [
{
id: 'domain',
icon: ShieldCheck,
title: 'Verify your domain',
href: '/settings?tab=domains',
done: setupState.hasVerifiedDomain,
},
{
id: 'snippets',
icon: Code2,
title: 'Send your first email',
href: '/onboarding/developer',
done: false,
},
];
}
function buildWorkflowSteps(setupState: ReturnType<typeof useProjectSetupState>['setupState']): BannerStep[] {
if (!setupState) return [];
return [
{
id: 'domain',
icon: ShieldCheck,
title: 'Verify your domain',
href: '/settings?tab=domains',
done: setupState.hasVerifiedDomain,
},
{
id: 'event',
icon: Zap,
title: 'Fire your first event',
href: '/onboarding/workflows',
done: false,
},
{
id: 'template',
icon: FileText,
title: 'Create a template',
href: '/templates/create',
done: false,
},
{
id: 'workflow',
icon: Workflow,
title: 'Build a workflow',
href: '/workflows',
done: setupState.hasEnabledWorkflow,
},
];
}
function stepsForPath(
path: OnboardingPath,
setupState: ReturnType<typeof useProjectSetupState>['setupState'],
): BannerStep[] {
if (path === 'developer') return buildDeveloperSteps(setupState);
if (path === 'workflows') return buildWorkflowSteps(setupState);
return buildMarketingSteps(setupState);
}
export function OnboardingBanner() {
const router = useRouter();
const {activeProject} = useActiveProject();
const status = useOnboardingStatus();
const {path, clearPath} = useOnboardingPath(activeProject?.id);
const {markComplete} = useOnboardingComplete(activeProject?.id);
const onOnboardingRoute = router.pathname.startsWith('/onboarding');
const bannerCandidate = !onOnboardingRoute && status === 'show' && Boolean(path);
const {setupState} = useProjectSetupState(activeProject?.id);
const {mutate} = useSWRConfig();
useEffect(() => {
if (!bannerCandidate) return;
const revalidate = () => {
void mutate(
key => typeof key === 'string' && LIVE_KEY_FRAGMENTS.some(fragment => key.includes(fragment)),
);
};
const interval = window.setInterval(revalidate, BANNER_POLL_MS);
router.events.on('routeChangeComplete', revalidate);
return () => {
window.clearInterval(interval);
router.events.off('routeChangeComplete', revalidate);
};
}, [bannerCandidate, mutate, router.events]);
const steps = useMemo<BannerStep[]>(() => {
if (!path) return [];
return stepsForPath(path, setupState);
}, [path, setupState]);
const visible = bannerCandidate && steps.length > 0;
const doneCount = steps.filter(s => s.done).length;
const currentStepId = steps.find(s => !s.done)?.id;
const handleDismiss = () => {
markComplete();
clearPath();
};
const handleStepClick = (href: string) => {
void router.push(href);
};
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{opacity: 0, y: -6}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -6}}
transition={{duration: 0.25, ease: [0.22, 1, 0.36, 1]}}
className="mb-6 rounded-xl border border-neutral-200 bg-white shadow-sm"
>
<div className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6 sm:p-5">
<div className="flex items-center justify-between gap-4 sm:flex-col sm:items-start sm:justify-center">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-semibold text-neutral-900">Finish your setup</p>
<p className="text-xs text-neutral-500 tabular-nums">
{doneCount} of {steps.length} {doneCount === 1 ? 'step' : 'steps'} done
</p>
</div>
<button
type="button"
onClick={handleDismiss}
className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 transition-colors sm:hidden"
aria-label="Dismiss setup guide"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-1 flex-wrap items-center gap-2 sm:justify-center lg:justify-end">
{steps.map((step, index) => {
const Icon = step.icon;
const isCurrent = step.id === currentStepId;
const base =
'group relative inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
const variant = step.done
? 'border-green-200 bg-green-50 text-green-900 hover:border-green-300'
: isCurrent
? 'border-neutral-900 bg-neutral-900 text-white hover:bg-neutral-800'
: 'border-neutral-200 bg-white text-neutral-700 hover:border-neutral-300 hover:text-neutral-900';
return (
<button
key={step.id}
type="button"
onClick={() => handleStepClick(step.href)}
className={`${base} ${variant}`}
>
<span
className={`flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums ${
step.done
? 'bg-green-600 text-white'
: isCurrent
? 'bg-white text-neutral-900'
: 'bg-neutral-100 text-neutral-500'
}`}
>
{step.done ? <Check className="h-2.5 w-2.5" /> : index + 1}
</span>
<Icon className="h-3.5 w-3.5 opacity-80" />
<span>{step.title}</span>
{isCurrent && (
<ArrowRight className="h-3 w-3 opacity-80 transition-transform group-hover:translate-x-0.5" />
)}
</button>
);
})}
</div>
<button
type="button"
onClick={handleDismiss}
className="hidden h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-100 hover:text-neutral-700 transition-colors sm:flex"
aria-label="Dismiss setup guide"
>
<X className="h-4 w-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,83 @@
import {motion} from 'framer-motion';
import Image from 'next/image';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {type CSSProperties, type ReactNode, useCallback} from 'react';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {useOnboardingComplete} from '../../lib/hooks/useOnboardingComplete';
interface OnboardingLayoutProps {
step: 1 | 2;
totalSteps?: 1 | 2;
/** Tailwind max-width class for the content column. Defaults to max-w-2xl. */
maxWidthClass?: string;
children: ReactNode;
}
const backgroundStyle: CSSProperties = {
backgroundColor: '#fafafa',
backgroundImage: 'radial-gradient(#e5e7eb 1px, transparent 1px)',
backgroundSize: '20px 20px',
};
export function OnboardingLayout({step, totalSteps = 2, maxWidthClass = 'max-w-2xl', children}: OnboardingLayoutProps) {
const router = useRouter();
const {activeProject} = useActiveProject();
const {markComplete} = useOnboardingComplete(activeProject?.id);
const handleSkip = useCallback(() => {
markComplete();
void router.push('/');
}, [markComplete, router]);
const dots = Array.from({length: totalSteps}, (_, i) => i + 1);
return (
<div className="h-screen flex flex-col overflow-y-auto" style={backgroundStyle}>
<header className="flex items-center justify-between px-6 py-5 sm:px-10">
<Link href="/" className="flex items-center gap-2.5">
<div className="h-8 w-8 rounded-lg bg-white shadow-sm border border-neutral-200 flex items-center justify-center p-1">
<Image src="/assets/logo.svg" alt="" aria-hidden width={24} height={24} />
</div>
<span className="text-lg font-bold tracking-tight text-neutral-900">Plunk</span>
</Link>
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-neutral-500">
<span className="tabular-nums">
Step {step} of {totalSteps}
</span>
<div className="flex items-center gap-1.5 ml-1">
{dots.map(d => (
<span
key={d}
className={`h-1.5 w-1.5 rounded-full transition-colors ${
d <= step ? 'bg-neutral-900' : 'bg-neutral-300'
}`}
/>
))}
</div>
</div>
<button
type="button"
onClick={handleSkip}
className="text-sm text-neutral-500 hover:text-neutral-900 transition-colors"
>
Skip setup
</button>
</header>
<main className="flex-1 flex justify-center px-4 pb-16 pt-4 sm:pb-24 sm:pt-8">
<motion.div
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.35, ease: [0.22, 1, 0.36, 1]}}
className={`w-full ${maxWidthClass}`}
>
{children}
</motion.div>
</main>
</div>
);
}
@@ -7,6 +7,7 @@ import {useProjects} from '../hooks/useProject';
interface ActiveProjectContextValue {
activeProject: Project | null;
setActiveProject: (project: Project) => void;
updateActiveProject: (project: Project) => void;
availableProjects: Project[];
isLoading: boolean;
}
@@ -55,9 +56,16 @@ export function ActiveProjectProvider({children}: {children: ReactNode}) {
void mutate(() => true, undefined, {revalidate: true});
};
// Updates the active project's data in-place without invalidating the SWR cache.
// Use this when saving settings for the current project, not when switching projects.
const updateActiveProject = (project: Project) => {
setActiveProjectState(project);
};
const value: ActiveProjectContextValue = {
activeProject,
setActiveProject,
updateActiveProject,
availableProjects: projects ?? [],
isLoading,
};
@@ -0,0 +1,35 @@
import {useCallback, useSyncExternalStore} from 'react';
const storageKey = (projectId: string) => `plunk-onboarded-${projectId}`;
function subscribe(callback: () => void) {
if (typeof window === 'undefined') return () => undefined;
window.addEventListener('storage', callback);
window.addEventListener('plunk:onboarding-changed', callback);
return () => {
window.removeEventListener('storage', callback);
window.removeEventListener('plunk:onboarding-changed', callback);
};
}
/**
* Per-project onboarding completion flag backed by localStorage.
* `isComplete` is `null` on the server and during the first client render,
* then resolves to `true`/`false` after hydration.
*/
export function useOnboardingComplete(projectId: string | undefined) {
const getSnapshot = useCallback(() => {
if (!projectId || typeof window === 'undefined') return null;
return localStorage.getItem(storageKey(projectId)) === 'true';
}, [projectId]);
const isComplete = useSyncExternalStore(subscribe, getSnapshot, () => null);
const markComplete = useCallback(() => {
if (!projectId || typeof window === 'undefined') return;
localStorage.setItem(storageKey(projectId), 'true');
window.dispatchEvent(new Event('plunk:onboarding-changed'));
}, [projectId]);
return {isComplete, markComplete};
}
@@ -0,0 +1,28 @@
import {useRouter} from 'next/router';
import {useEffect} from 'react';
import {useActiveProject} from '../contexts/ActiveProjectProvider';
import {useOnboardingComplete} from './useOnboardingComplete';
import {useOnboardingStatus, type OnboardingStatus} from './useOnboardingStatus';
export type OnboardingGateState = OnboardingStatus;
/**
* Gate helper for the /onboarding pages. Redirects to the dashboard when the
* project is already onboarded (flag set or first email sent) and persists
* the flag so future visits short-circuit immediately.
*/
export function useOnboardingGate() {
const router = useRouter();
const {activeProject} = useActiveProject();
const {isComplete, markComplete} = useOnboardingComplete(activeProject?.id);
const state = useOnboardingStatus();
useEffect(() => {
if (state !== 'skip') return;
if (!isComplete) markComplete();
void router.replace('/');
}, [state, isComplete, markComplete, router]);
return {state, markComplete};
}
@@ -0,0 +1,51 @@
import {useCallback, useSyncExternalStore} from 'react';
export type OnboardingPath = 'developer' | 'marketing' | 'workflows';
const storageKey = (projectId: string) => `plunk-onboarding-path-${projectId}`;
const CHANGE_EVENT = 'plunk:onboarding-path-changed';
function subscribe(callback: () => void) {
if (typeof window === 'undefined') return () => undefined;
window.addEventListener('storage', callback);
window.addEventListener(CHANGE_EVENT, callback);
return () => {
window.removeEventListener('storage', callback);
window.removeEventListener(CHANGE_EVENT, callback);
};
}
function isPath(value: string | null): value is OnboardingPath {
return value === 'developer' || value === 'marketing' || value === 'workflows';
}
/**
* Per-project record of the onboarding path the user chose.
* Drives the persistent onboarding banner so navigation doesn't lose the thread.
*/
export function useOnboardingPath(projectId: string | undefined) {
const getSnapshot = useCallback(() => {
if (!projectId || typeof window === 'undefined') return null;
const value = localStorage.getItem(storageKey(projectId));
return isPath(value) ? value : null;
}, [projectId]);
const path = useSyncExternalStore(subscribe, getSnapshot, () => null);
const setPath = useCallback(
(next: OnboardingPath) => {
if (!projectId || typeof window === 'undefined') return;
localStorage.setItem(storageKey(projectId), next);
window.dispatchEvent(new Event(CHANGE_EVENT));
},
[projectId],
);
const clearPath = useCallback(() => {
if (!projectId || typeof window === 'undefined') return;
localStorage.removeItem(storageKey(projectId));
window.dispatchEvent(new Event(CHANGE_EVENT));
}, [projectId]);
return {path, setPath, clearPath};
}
@@ -0,0 +1,26 @@
import {useMemo} from 'react';
import {useActiveProject} from '../contexts/ActiveProjectProvider';
import {useDashboardStats} from './useDashboardStats';
import {useOnboardingComplete} from './useOnboardingComplete';
export type OnboardingStatus = 'loading' | 'show' | 'skip';
/**
* Read-only onboarding status. "skip" means the user is already onboarded
* either they dismissed/finished explicitly, or their project has already
* sent at least one email.
*/
export function useOnboardingStatus(): OnboardingStatus {
const {activeProject} = useActiveProject();
const {isComplete} = useOnboardingComplete(activeProject?.id);
const {totalEmailsSent, isLoading} = useDashboardStats();
return useMemo<OnboardingStatus>(() => {
if (isComplete === true) return 'skip';
if (isComplete === null) return 'loading';
if (isLoading) return 'loading';
if (totalEmailsSent > 0) return 'skip';
return 'show';
}, [isComplete, isLoading, totalEmailsSent]);
}
+52
View File
@@ -0,0 +1,52 @@
const RECENT_KEY = 'cmdPaletteRecents';
const MAX_RECENT = 5;
export interface RecentPage {
label: string;
href: string;
}
export function getRecentPages(): RecentPage[] {
if (typeof window === 'undefined') return [];
try {
return JSON.parse(localStorage.getItem(RECENT_KEY) ?? '[]') as RecentPage[];
} catch {
return [];
}
}
export function addRecentPage(page: RecentPage) {
const current = getRecentPages().filter(p => p.href !== page.href);
localStorage.setItem(RECENT_KEY, JSON.stringify([page, ...current].slice(0, MAX_RECENT)));
}
const STATIC_LABELS: Record<string, string> = {
'/': 'Dashboard',
'/contacts': 'Contacts',
'/segments': 'Segments',
'/activity': 'Activity',
'/analytics': 'Analytics',
'/templates': 'Templates',
'/workflows': 'Workflows',
'/campaigns': 'Campaigns',
'/settings': 'Settings',
'/campaigns/create': 'New Campaign',
'/templates/create': 'New Template',
'/segments/new': 'New Segment',
};
const DYNAMIC_LABELS: [RegExp, string][] = [
[/^\/contacts\/[^/]+$/, 'Contact'],
[/^\/segments\/[^/]+$/, 'Segment'],
[/^\/workflows\/[^/]+$/, 'Workflow'],
[/^\/campaigns\/[^/]+$/, 'Campaign'],
[/^\/templates\/[^/]+$/, 'Template'],
];
export function getFallbackLabel(pathname: string): string | null {
if (STATIC_LABELS[pathname]) return STATIC_LABELS[pathname];
for (const [pattern, label] of DYNAMIC_LABELS) {
if (pattern.test(pathname)) return label;
}
return null;
}
+28
View File
@@ -8,9 +8,11 @@ import {DefaultSeo} from 'next-seo';
import {NuqsAdapter} from 'nuqs/adapters/next/pages';
import {Loader} from '@plunk/ui';
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
import {CommandPalette} from '../components/CommandPalette';
import {useProjects} from '../lib/hooks/useProject';
import {useUser} from '../lib/hooks/useUser';
import {network} from '../lib/network';
import {addRecentPage, getFallbackLabel} from '../lib/recentPages';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import advancedFormat from 'dayjs/plugin/advancedFormat';
@@ -135,6 +137,31 @@ function Root(props: AppProps) {
route => router.pathname === route || router.pathname.startsWith(`${route}/`),
);
useEffect(() => {
const handleRouteChange = () => {
const pathname = window.location.pathname;
// Pass 1: record immediately with a route-pattern label so detail pages
// are always captured even before their data loads.
const fallback = getFallbackLabel(pathname);
if (fallback) {
addRecentPage({label: fallback, href: pathname});
}
// Pass 2: after data loads, replace the label with the page's own title
// (e.g. contact email set by NextSeo after the API response arrives).
setTimeout(() => {
const titleLabel = document.title.split(' | ')[0]?.trim();
if (titleLabel && titleLabel !== 'Plunk' && titleLabel !== fallback) {
addRecentPage({label: titleLabel, href: pathname});
}
}, 800);
};
router.events.on('routeChangeComplete', handleRouteChange);
return () => router.events.off('routeChangeComplete', handleRouteChange);
}, [router.events]);
return (
<>
<Toaster position={'top-right'} />
@@ -145,6 +172,7 @@ function Root(props: AppProps) {
<App {...props} />
) : (
<ProjectGuard>
<CommandPalette />
<App {...props} />
</ProjectGuard>
)}
+8 -8
View File
@@ -42,32 +42,32 @@ export default function ActivityPage() {
value: stats?.totalEvents?.toLocaleString() || '0',
icon: Zap,
description: 'Last 30 days',
color: 'text-blue-600',
bgColor: 'bg-blue-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
},
{
name: 'Emails Sent',
value: stats?.totalEmailsSent?.toLocaleString() || '0',
icon: Send,
description: 'Last 30 days',
color: 'text-green-600',
bgColor: 'bg-green-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
},
{
name: 'Open Rate',
value: stats?.openRate ? `${stats.openRate.toFixed(1)}%` : '0%',
icon: Eye,
description: `${stats?.totalEmailsOpened?.toLocaleString() || '0'} opens`,
color: 'text-purple-600',
bgColor: 'bg-purple-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
},
{
name: 'Click Rate',
value: stats?.clickRate ? `${stats.clickRate.toFixed(1)}%` : '0%',
icon: MousePointerClick,
description: `${stats?.totalEmailsClicked?.toLocaleString() || '0'} clicks`,
color: 'text-orange-600',
bgColor: 'bg-orange-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
},
];
+35 -42
View File
@@ -17,6 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from '@plunk/ui';
import {EmptyState} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {useAnalytics} from '../../lib/hooks/useAnalytics';
import useSWR from 'swr';
@@ -148,8 +149,8 @@ export default function AnalyticsPage() {
value: stats?.totalEmailsSent?.toLocaleString() || cumulativeTotals.emails.toLocaleString(),
icon: Send,
description: `Last ${days} days`,
color: 'text-blue-600',
bgColor: 'bg-blue-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
trend: stats?.totalEmailsSent ? (stats.totalEmailsSent > 0 ? 'positive' : 'neutral') : 'neutral',
},
{
@@ -157,8 +158,8 @@ export default function AnalyticsPage() {
value: stats?.openRate ? `${stats.openRate.toFixed(1)}%` : '0%',
icon: Eye,
description: `${stats?.totalEmailsOpened?.toLocaleString() || cumulativeTotals.opens.toLocaleString()} opens`,
color: 'text-green-600',
bgColor: 'bg-green-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
trend: stats?.openRate && stats.openRate > 20 ? 'positive' : 'neutral',
},
{
@@ -166,8 +167,8 @@ export default function AnalyticsPage() {
value: stats?.clickRate ? `${stats.clickRate.toFixed(1)}%` : '0%',
icon: MousePointerClick,
description: `${stats?.totalEmailsClicked?.toLocaleString() || cumulativeTotals.clicks.toLocaleString()} clicks`,
color: 'text-purple-600',
bgColor: 'bg-purple-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
trend: stats?.clickRate && stats.clickRate > 3 ? 'positive' : 'neutral',
},
{
@@ -175,8 +176,8 @@ export default function AnalyticsPage() {
value: campaignStats?.active?.toLocaleString() || '0',
icon: Megaphone,
description: `${campaignStats?.total || 0} total campaigns`,
color: 'text-indigo-600',
bgColor: 'bg-indigo-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
trend: campaignStats?.active ? 'positive' : 'neutral',
},
{
@@ -184,8 +185,8 @@ export default function AnalyticsPage() {
value: stats?.totalWorkflowsStarted?.toLocaleString() || '0',
icon: Activity,
description: 'Automations triggered',
color: 'text-cyan-600',
bgColor: 'bg-cyan-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
trend: stats?.totalWorkflowsStarted && stats.totalWorkflowsStarted > 0 ? 'positive' : 'neutral',
},
{
@@ -193,8 +194,8 @@ export default function AnalyticsPage() {
value: stats?.totalEvents?.toLocaleString() || '0',
icon: Zap,
description: 'Custom events tracked',
color: 'text-orange-600',
bgColor: 'bg-orange-100',
color: 'text-neutral-600',
bgColor: 'bg-neutral-100',
trend: stats?.totalEvents && stats.totalEvents > 0 ? 'positive' : 'neutral',
},
];
@@ -270,11 +271,12 @@ export default function AnalyticsPage() {
<CardContent>
{!hasData ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="text-center">
<Mail className="mx-auto h-8 w-8 text-neutral-300" />
<h3 className="mt-3 text-sm font-semibold text-neutral-900">No email data yet</h3>
<p className="mt-1 text-sm text-neutral-500">Send your first email to see analytics here.</p>
</div>
<EmptyState
className="py-0"
icon={Mail}
title="No email data yet"
description="Send your first email to see analytics here."
/>
</div>
) : (
<ChartContainer config={volumeChartConfig} className="h-[400px] w-full">
@@ -379,13 +381,12 @@ export default function AnalyticsPage() {
<CardContent>
{!hasData ? (
<div className="flex h-[300px] w-full items-center justify-center">
<div className="text-center">
<Eye className="mx-auto h-8 w-8 text-neutral-300" />
<h3 className="mt-3 text-sm font-semibold text-neutral-900">No engagement data</h3>
<p className="mt-1 text-sm text-neutral-500">
Engagement metrics will appear once emails are opened.
</p>
</div>
<EmptyState
className="py-0"
icon={Eye}
title="No engagement data"
description="Engagement metrics will appear once emails are opened."
/>
</div>
) : (
<ChartContainer config={engagementChartConfig} className="h-[300px] w-full">
@@ -456,8 +457,8 @@ export default function AnalyticsPage() {
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-green-100 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<div className="h-8 w-8 rounded-lg bg-neutral-100 flex items-center justify-center flex-shrink-0">
<CheckCircle2 className="h-4 w-4 text-neutral-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Open Rate</p>
@@ -469,8 +470,8 @@ export default function AnalyticsPage() {
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-blue-100 flex items-center justify-center flex-shrink-0">
<BarChart3 className="h-4 w-4 text-blue-600" />
<div className="h-8 w-8 rounded-lg bg-neutral-100 flex items-center justify-center flex-shrink-0">
<BarChart3 className="h-4 w-4 text-neutral-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Click Rate</p>
@@ -482,8 +483,8 @@ export default function AnalyticsPage() {
</div>
</div>
<div className="flex items-start gap-3">
<div className="h-8 w-8 rounded-lg bg-purple-100 flex items-center justify-center flex-shrink-0">
<Zap className="h-4 w-4 text-purple-600" />
<div className="h-8 w-8 rounded-lg bg-neutral-100 flex items-center justify-center flex-shrink-0">
<Zap className="h-4 w-4 text-neutral-600" />
</div>
<div>
<p className="text-sm font-medium text-neutral-900">Engagement</p>
@@ -508,8 +509,8 @@ export default function AnalyticsPage() {
<p className="text-2xl font-bold text-neutral-900">{stats?.totalEvents?.toLocaleString() || '0'}</p>
<p className="text-sm text-neutral-500">Total Events</p>
</div>
<div className="h-12 w-12 rounded-lg bg-orange-100 flex items-center justify-center">
<Zap className="h-6 w-6 text-orange-600" />
<div className="h-12 w-12 rounded-lg bg-neutral-100 flex items-center justify-center">
<Zap className="h-6 w-6 text-neutral-600" />
</div>
</div>
<div className="pt-4 border-t">
@@ -565,11 +566,7 @@ export default function AnalyticsPage() {
<td className="text-right py-3 px-4">
<span
className={`text-sm font-medium ${
campaign.openRate > 30
? 'text-green-600'
: campaign.openRate > 20
? 'text-blue-600'
: 'text-neutral-600'
campaign.openRate > 30 ? 'text-green-700' : 'text-neutral-700'
}`}
>
{campaign.openRate.toFixed(1)}%
@@ -578,11 +575,7 @@ export default function AnalyticsPage() {
<td className="text-right py-3 px-4">
<span
className={`text-sm font-medium ${
campaign.clickRate > 5
? 'text-green-600'
: campaign.clickRate > 3
? 'text-blue-600'
: 'text-neutral-600'
campaign.clickRate > 5 ? 'text-green-700' : 'text-neutral-700'
}`}
>
{campaign.clickRate.toFixed(1)}%
+72 -54
View File
@@ -16,6 +16,7 @@ import {
FormItem,
FormLabel,
FormMessage,
IconSpinner,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
@@ -33,16 +34,6 @@ import {useProjects} from '../../lib/hooks/useProject';
import {useUser} from '../../lib/hooks/useUser';
import {network} from '../../lib/network';
const Spinner = () => (
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
export default function Login() {
const {mutate: userMutate} = useUser();
@@ -57,6 +48,11 @@ export default function Login() {
},
});
const [lastUsed] = useState<'email' | 'google' | 'github' | null>(() => {
if (typeof window === 'undefined') return null;
const stored = localStorage.getItem('plunk_last_auth_method');
return stored === 'email' || stored === 'google' || stored === 'github' ? stored : null;
});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showReset, setShowReset] = useState(false);
const [resetEmail, setResetEmail] = useState('');
@@ -82,6 +78,7 @@ export default function Login() {
setErrorMessage('Email or password is incorrect');
} else {
setErrorMessage(null);
localStorage.setItem('plunk_last_auth_method', 'email');
await userMutate();
await projectsMutate();
@@ -157,49 +154,65 @@ export default function Login() {
<>
<div className="grid gap-2">
{oauthConfig.google && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
<div className="relative">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
localStorage.setItem('plunk_last_auth_method', 'google');
window.location.href = `${API_URI}/oauth/google/outbound`;
}}
>
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Continue with Google
</Button>
{lastUsed === 'google' && (
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-neutral-400">
Last used
</span>
)}
</div>
)}
{oauthConfig.github && (
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
<div className="relative">
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
localStorage.setItem('plunk_last_auth_method', 'github');
window.location.href = `${API_URI}/oauth/github/outbound`;
}}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
Continue with GitHub
</Button>
{lastUsed === 'github' && (
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-neutral-400">
Last used
</span>
)}
</div>
)}
</div>
<div className="relative">
@@ -219,7 +232,12 @@ export default function Login() {
name="email"
render={({field}) => (
<FormItem>
<FormLabel>Email</FormLabel>
<div className="flex items-center gap-2">
<FormLabel>Email</FormLabel>
{lastUsed === 'email' && (
<span className="text-[10px] text-neutral-400">Last used</span>
)}
</div>
<FormControl>
<Input placeholder="[email protected]" autoFocus {...field} />
</FormControl>
@@ -269,7 +287,7 @@ export default function Login() {
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<Spinner />
<IconSpinner size="sm" />
Signing in...
</>
) : (
@@ -325,7 +343,7 @@ export default function Login() {
<Button className="w-full" type="submit" disabled={resetStatus === 'loading'}>
{resetStatus === 'loading' ? (
<>
<Spinner />
<IconSpinner size="sm" />
Sending...
</>
) : (
+5 -14
View File
@@ -10,6 +10,7 @@ import {
FormItem,
FormLabel,
FormMessage,
IconSpinner,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
@@ -29,16 +30,6 @@ const dotGrid = {
backgroundSize: '20px 20px',
};
const Spinner = () => (
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
const Wordmark = () => (
<div className="flex items-center justify-center gap-2.5">
@@ -114,9 +105,9 @@ export default function ResetPassword() {
This link is invalid or has expired. Request a new one from the login page.
</p>
</div>
<Link href="/auth/login" className="mt-2">
<Button>Back to login</Button>
</Link>
<Button asChild className="mt-2">
<Link href="/auth/login">Back to login</Link>
</Button>
</div>
</CardContent>
</Card>
@@ -202,7 +193,7 @@ export default function ResetPassword() {
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<Spinner />
<IconSpinner size="sm" />
Resetting...
</>
) : (
+2 -11
View File
@@ -10,6 +10,7 @@ import {
FormItem,
FormLabel,
FormMessage,
IconSpinner,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
@@ -27,16 +28,6 @@ import {useProjects} from '../../lib/hooks/useProject';
import {useUser} from '../../lib/hooks/useUser';
import {network} from '../../lib/network';
const Spinner = () => (
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
export default function Signup() {
const {mutate: userMutate} = useUser();
@@ -226,7 +217,7 @@ export default function Signup() {
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<Spinner />
<IconSpinner size="sm" />
Creating account...
</>
) : (
+8 -22
View File
@@ -1,5 +1,5 @@
import {AuthenticationSchemas} from '@plunk/shared';
import {Button, Card, CardContent} from '@plunk/ui';
import {Button, Card, CardContent, IconSpinner} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import Image from 'next/image';
@@ -15,16 +15,6 @@ const dotGrid = {
backgroundSize: '20px 20px',
};
const Spinner = () => (
<svg className="h-6 w-6 animate-spin text-neutral-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
export default function VerifyEmail() {
const router = useRouter();
@@ -200,11 +190,9 @@ export default function VerifyEmail() {
</p>
)}
<Link href="/auth/login">
<Button variant="outline" className="w-full">
Back to login
</Button>
</Link>
<Button asChild variant="outline" className="w-full">
<Link href="/auth/login">Back to login</Link>
</Button>
</div>
</motion.div>
)}
@@ -219,7 +207,7 @@ export default function VerifyEmail() {
className="flex flex-col items-center gap-4"
>
<div className="h-12 w-12 rounded-full bg-neutral-100 flex items-center justify-center">
<Spinner />
<IconSpinner size="sm" />
</div>
<div className="flex flex-col gap-1.5">
<h1 className="text-xl font-bold tracking-tight">Verifying...</h1>
@@ -283,11 +271,9 @@ export default function VerifyEmail() {
</p>
)}
<Link href="/auth/login">
<Button variant="outline" className="w-full">
Back to login
</Button>
</Link>
<Button asChild variant="outline" className="w-full">
<Link href="/auth/login">Back to login</Link>
</Button>
</div>
</motion.div>
)}
+13 -15
View File
@@ -26,6 +26,7 @@ import {
SelectItemWithDescription,
SelectTrigger,
SelectValue,
IconSpinner,
StickySaveBar,
} from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db';
@@ -52,11 +53,13 @@ import {
Users,
XCircle,
} from 'lucide-react';
import DOMPurify from 'dompurify';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {NextSeo} from 'next-seo';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
interface CampaignStats {
@@ -307,10 +310,7 @@ export default function CampaignDetailsPage() {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="h-8 w-8 animate-spin mx-auto border-4 border-neutral-200 border-t-neutral-900 rounded-full" />
<p className="mt-2 text-sm text-neutral-500">Loading campaign...</p>
</div>
<IconSpinner />
</div>
</DashboardLayout>
);
@@ -339,15 +339,14 @@ export default function CampaignDetailsPage() {
if (isEditMode) {
return (
<DashboardLayout>
<NextSeo title={campaign.data.name} />
<form onSubmit={handleSave} className={`space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
{/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/campaigns">
<Button type="button" variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="ghost" size="sm">
<Link href="/campaigns"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 truncate">{c.name}</h1>
@@ -845,15 +844,14 @@ export default function CampaignDetailsPage() {
// Render stats view for sent/scheduled campaigns
return (
<DashboardLayout>
<NextSeo title={campaign.data.name} />
<div className="space-y-6">
{/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/campaigns">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="ghost" size="sm">
<Link href="/campaigns"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 sm:gap-3 mb-2">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 truncate">{c.name}</h1>
@@ -996,7 +994,7 @@ export default function CampaignDetailsPage() {
<p className="text-sm font-medium text-neutral-700 mb-3">Message Content</p>
<div className="border-2 border-neutral-200 rounded-lg overflow-hidden bg-white">
<div className="p-6 max-h-96 overflow-y-auto">
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{__html: c.body}} />
<div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{__html: DOMPurify.sanitize(c.body)}} />
</div>
</div>
</div>
+10 -23
View File
@@ -6,6 +6,7 @@ import {
CardDescription,
CardHeader,
CardTitle,
IconSpinner,
Input,
Label,
Select,
@@ -166,21 +167,9 @@ export default function CreateCampaignPage() {
<NextSeo title="Create Campaign" />
<DashboardLayout>
{loadingTemplate && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900 mb-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-5 flex items-center gap-3">
<IconSpinner />
<p className="text-sm text-neutral-700">Loading template...</p>
</div>
</div>
@@ -188,11 +177,9 @@ export default function CreateCampaignPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/campaigns">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="ghost" size="sm">
<Link href="/campaigns"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Campaign</h1>
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
@@ -407,9 +394,9 @@ export default function CreateCampaignPage() {
{/* Actions */}
<div className="flex justify-end gap-3">
<Link href="/campaigns">
<Button type="button" variant="outline">Cancel</Button>
</Link>
<Button asChild variant="outline">
<Link href="/campaigns">Cancel</Link>
</Button>
<Button type="submit" disabled={saving}>
{saving ? 'Creating...' : 'Create Campaign'}
</Button>
+119 -189
View File
@@ -3,9 +3,6 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
DropdownMenu,
DropdownMenuContent,
@@ -20,26 +17,13 @@ import {
import type {Campaign, Template} from '@plunk/db';
import {CampaignStatus} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {EmptyState} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {TemplateSelectionDialog} from '../../components/TemplateSelectionDialog';
import {CampaignSelectionDialog} from '../../components/CampaignSelectionDialog';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {
AlertCircle,
Calendar,
ChevronDown,
Copy,
FileText,
Info,
Mail,
MousePointerClick,
Plus,
RefreshCw,
Trash2,
Users,
} from 'lucide-react';
import {Ban, Calendar, ChevronDown, Copy, Edit, FileText, Mail, Plus, RefreshCw, Trash2} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
@@ -65,23 +49,15 @@ export default function CampaignsPage() {
);
const getStatusBadge = (status: CampaignStatus) => {
const variants: Record<
CampaignStatus,
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
> = {
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
const config: Record<CampaignStatus, {label: string; variant: 'neutral' | 'default' | 'success'}> = {
DRAFT: {label: 'Draft', variant: 'neutral'},
SCHEDULED: {label: 'Scheduled', variant: 'default'},
SENDING: {label: 'Sending', variant: 'default'},
SENT: {label: 'Sent', variant: 'success'},
CANCELLED: {label: 'Cancelled', variant: 'neutral'},
};
const config = variants[status];
return (
<Badge variant={config.variant} className={config.className}>
{config.label}
</Badge>
);
const {label, variant} = config[status];
return <Badge variant={variant} className="shrink-0">{label}</Badge>;
};
const handleCancel = async () => {
@@ -270,27 +246,21 @@ export default function CampaignsPage() {
</div>
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex gap-4">
<div className="flex-1">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
<SelectItem value="SENDING">Sending</SelectItem>
<SelectItem value="SENT">Sent</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<div className="w-56">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem>
<SelectItem value="DRAFT">Draft</SelectItem>
<SelectItem value="SCHEDULED">Scheduled</SelectItem>
<SelectItem value="SENDING">Sending</SelectItem>
<SelectItem value="SENT">Sent</SelectItem>
<SelectItem value="CANCELLED">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{/* Campaigns List */}
<div className="space-y-4">
@@ -358,12 +328,12 @@ export default function CampaignsPage() {
</DropdownMenuContent>
</DropdownMenu>
) : (
<Link href="/campaigns/create">
<Button>
<Button asChild>
<Link href="/campaigns/create">
<Plus className="h-4 w-4" />
Create Campaign
</Button>
</Link>
</Link>
</Button>
)
}
/>
@@ -374,152 +344,112 @@ export default function CampaignsPage() {
{data?.data.map(campaign => {
const openRate = campaign.sentCount > 0 ? (campaign.openedCount / campaign.sentCount) * 100 : 0;
const clickRate = campaign.sentCount > 0 ? (campaign.clickedCount / campaign.sentCount) * 100 : 0;
const deliveryProgress =
campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
const deliveryPct = campaign.totalRecipients > 0 ? (campaign.sentCount / campaign.totalRecipients) * 100 : 0;
return (
<Card key={campaign.id} className="transition-colors hover:border-neutral-300">
<CardHeader className="pb-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<Link
href={`/campaigns/${campaign.id}`}
className="hover:text-primary transition-colors flex-1 min-w-0"
>
<CardTitle className="text-xl truncate">{campaign.name}</CardTitle>
</Link>
{getStatusBadge(campaign.status)}
</div>
{campaign.description && (
<CardDescription className="line-clamp-2">{campaign.description}</CardDescription>
)}
</div>
<Card key={campaign.id} className="transition-colors hover:border-neutral-300 flex flex-col [&:has([data-card-link]:focus-visible)]:ring-2 [&:has([data-card-link]:focus-visible)]:ring-ring [&:has([data-card-link]:focus-visible)]:ring-offset-2">
<Link
href={`/campaigns/${campaign.id}`}
data-card-link=""
className="flex-1 block p-6 pb-4 hover:bg-neutral-50/50 transition-colors rounded-t-xl focus-visible:outline-none"
aria-label={`Open ${campaign.name}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="font-semibold text-neutral-900 leading-snug truncate">{campaign.name}</h3>
{getStatusBadge(campaign.status)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Recipients */}
<div className="bg-blue-50 border border-blue-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Users className="h-3.5 w-3.5 text-blue-600" />
<span className="text-xs font-medium text-blue-900">Recipients</span>
{(campaign.status === 'DRAFT' || campaign.status === 'SCHEDULED') && (
<div className="group relative">
<Info className="h-3 w-3 text-blue-600 cursor-help" />
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1">
This count will be recalculated before sending
</div>
</div>
<div className="flex items-center gap-3 text-sm flex-wrap">
{campaign.status === 'DRAFT' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">estimated recipients</span>
</span>
</>
)}
{campaign.status === 'SCHEDULED' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">recipients</span>
</span>
{campaign.scheduledFor && (
<>
<span className="h-3 w-px bg-neutral-200" />
<span className="text-xs text-neutral-500">
Sending {dayjs(campaign.scheduledFor).format('MMM D, YYYY [at] h:mm A')}
</span>
</>
)}
</div>
<p className="text-lg font-bold text-blue-900">{campaign.totalRecipients.toLocaleString()}</p>
{campaign.totalRecipients > 0 && campaign.status !== 'DRAFT' && (
<p className="text-xs text-blue-700 mt-1">{deliveryProgress.toFixed(0)}% sent</p>
)}
{campaign.status === 'DRAFT' && <p className="text-xs text-blue-600 mt-1">Estimated</p>}
</div>
{/* Open Rate */}
{campaign.sentCount > 0 && (
<div className="bg-purple-50 border border-purple-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<Mail className="h-3.5 w-3.5 text-purple-600" />
<span className="text-xs font-medium text-purple-900">Opens</span>
</div>
<p className="text-lg font-bold text-purple-900">{openRate.toFixed(1)}%</p>
<p className="text-xs text-purple-700 mt-1">{campaign.openedCount.toLocaleString()} opened</p>
</div>
</>
)}
{/* Click Rate */}
{campaign.clickedCount > 0 && (
<div className="bg-orange-50 border border-orange-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<MousePointerClick className="h-3.5 w-3.5 text-orange-600" />
<span className="text-xs font-medium text-orange-900">Clicks</span>
</div>
<p className="text-lg font-bold text-orange-900">{clickRate.toFixed(1)}%</p>
<p className="text-xs text-orange-700 mt-1">
{campaign.clickedCount.toLocaleString()} clicked
</p>
</div>
{campaign.status === 'SENDING' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{deliveryPct.toFixed(0)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">delivered</span>
</span>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{openRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">opens</span>
</span>
</>
)}
{/* Bounced Rate */}
{campaign.bouncedCount > 0 && campaign.sentCount > 0 && (
<div className="bg-red-50 border border-red-100 rounded-lg p-3">
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="h-3.5 w-3.5 text-red-600" />
<span className="text-xs font-medium text-red-900">Bounced</span>
</div>
<p className="text-lg font-bold text-red-900">
{((campaign.bouncedCount / campaign.sentCount) * 100).toFixed(1)}%
</p>
<p className="text-xs text-red-700 mt-1">{campaign.bouncedCount.toLocaleString()} bounced</p>
</div>
{campaign.status === 'SENT' && (
<>
<span>
<strong className="font-semibold text-neutral-900">{campaign.sentCount.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">sent</span>
</span>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{openRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">opens</span>
</span>
{clickRate > 0 && (
<>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{clickRate.toFixed(1)}%</strong>
<span className="text-neutral-400 ml-1 text-xs">clicks</span>
</span>
</>
)}
</>
)}
{/* Scheduled For */}
{campaign.scheduledFor && (
<div className="bg-green-50 border border-green-100 rounded-lg p-3 md:col-span-2">
<div className="flex items-center gap-2 mb-1">
<Calendar className="h-3.5 w-3.5 text-green-600" />
<span className="text-xs font-medium text-green-900">Scheduled</span>
</div>
<p className="text-sm font-semibold text-green-900">
{new Date(campaign.scheduledFor).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-xs text-green-700 mt-1">
{new Date(campaign.scheduledFor).toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
})}
</p>
</div>
{campaign.status === 'CANCELLED' && (
<span>
<strong className="font-semibold text-neutral-900">{campaign.totalRecipients.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">recipients</span>
</span>
)}
</div>
</Link>
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(campaign.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="px-6 py-3 border-t border-neutral-100 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(campaign.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
<span>Updated {formatRelativeTime(campaign.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(campaign.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2 border-t border-neutral-100">
<Link href={`/campaigns/${campaign.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
{campaign.status === 'DRAFT' ? 'Edit Campaign' : 'View Details'}
</Button>
</Link>
<Button variant="outline" size="sm" onClick={() => handleDuplicate(campaign.id)}>
<div className="flex items-center gap-1">
<Button asChild variant="ghost" size="sm" title={campaign.status === 'DRAFT' ? 'Edit campaign' : 'View campaign'}>
<Link href={`/campaigns/${campaign.id}`} aria-label={campaign.status === 'DRAFT' ? 'Edit campaign' : 'View campaign'}><Edit className="h-4 w-4" /></Link>
</Button>
<Button variant="ghost" size="sm" title="Duplicate campaign" onClick={() => handleDuplicate(campaign.id)}>
<Copy className="h-4 w-4" />
</Button>
{campaign.status === 'DRAFT' && (
<Button
variant="destructive"
variant="ghost"
size="sm"
title="Delete campaign"
onClick={() => {
setCampaignToDelete(campaign.id);
setShowDeleteDialog(true);
@@ -528,21 +458,21 @@ export default function CampaignsPage() {
<Trash2 className="h-4 w-4" />
</Button>
)}
{(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && (
<Button
variant="destructive"
variant="ghost"
size="sm"
title="Cancel campaign"
onClick={() => {
setCampaignToCancel(campaign.id);
setShowCancelDialog(true);
}}
>
Cancel
<Ban className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</div>
</Card>
);
})}
+12 -13
View File
@@ -5,13 +5,14 @@ import {
CardHeader,
CardTitle,
ConfirmDialog,
IconSpinner,
Input,
Label,
Switch,
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import {AnimatePresence, motion} from 'framer-motion';
import {ArrowLeft, Check, Copy, Database, ExternalLink, Loader2, Save, Settings, Trash2} from 'lucide-react';
import {ArrowLeft, Check, Copy, Database, ExternalLink, Save, Settings, Trash2} from 'lucide-react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {useRouter} from 'next/router';
@@ -90,7 +91,7 @@ export default function ContactDetailPage() {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-neutral-400" />
<IconSpinner />
</div>
</DashboardLayout>
);
@@ -104,12 +105,12 @@ export default function ContactDetailPage() {
<p className="text-neutral-500 mb-6">
The contact you&apos;re looking for doesn&apos;t exist or has been deleted.
</p>
<Link href="/contacts">
<Button>
<Button asChild>
<Link href="/contacts">
<ArrowLeft className="h-4 w-4" />
Back to Contacts
</Button>
</Link>
</Link>
</Button>
</div>
</DashboardLayout>
);
@@ -123,11 +124,9 @@ export default function ContactDetailPage() {
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
<Link href="/contacts">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="outline" size="sm">
<Link href="/contacts"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900 truncate">{contact.email}</h1>
<p className="mt-1">
@@ -260,7 +259,7 @@ export default function ContactDetailPage() {
<p className="text-sm font-medium text-neutral-900">Created</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.createdAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
@@ -270,7 +269,7 @@ export default function ContactDetailPage() {
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(contact.updatedAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
+51 -71
View File
@@ -12,14 +12,15 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
IconSpinner,
Input,
Label,
Switch,
} from '@plunk/ui';
import type {Contact} from '@plunk/db';
import type {CursorPaginatedResponse} from '@plunk/types';
import {EmptyState} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {KeyValueEditor} from '../../components/KeyValueEditor';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
@@ -36,6 +37,7 @@ import {
Search,
Trash2,
Upload,
X,
XCircle,
} from 'lucide-react';
import {NextSeo} from 'next-seo';
@@ -77,14 +79,17 @@ export default function ContactsPage() {
}
}, [data, cursor]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
};
useEffect(() => {
if (searchInput === search) return;
const timer = setTimeout(() => {
setSearch(searchInput);
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}, 350);
return () => clearTimeout(timer);
}, [searchInput, search]);
const handleNextPage = () => {
if (data?.cursor) {
@@ -186,40 +191,34 @@ export default function ContactsPage() {
</div>
</div>
{/* Search & Filters */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
>
Clear
</Button>
)}
</form>
</CardContent>
</Card>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search by email..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setCursor(undefined);
setCursorHistory([undefined]);
setCurrentPage(0);
setContacts([]);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Bulk Actions Toolbar */}
{selectedContacts.size > 0 && (
@@ -265,22 +264,7 @@ export default function ContactsPage() {
<CardContent>
{isLoading && contacts.length === 0 ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading contacts...</p>
</div>
<IconSpinner />
</div>
) : contacts.length === 0 ? (
<EmptyState
@@ -354,18 +338,16 @@ export default function ContactsPage() {
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-500">
<div className="group relative inline-block cursor-help">
{formatRelativeTime(contact.createdAt)}
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-1/2 transform -translate-x-1/2 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="ghost" size="sm">
<Link href={`/contacts/${contact.id}`}><Edit className="h-4 w-4" /></Link>
</Button>
<Button variant="ghost" size="sm" onClick={() => promptDelete(contact.id)}>
<Trash2 className="h-4 w-4" />
</Button>
@@ -404,16 +386,14 @@ export default function ContactsPage() {
<div className="flex items-center justify-between">
<div className="group relative inline-block cursor-help">
<span className="text-xs text-neutral-500">{formatRelativeTime(contact.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(contact.createdAt).format('Do MMMM YYYY, h:mm A')}
</div>
</div>
<div className="flex items-center gap-1">
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="ghost" size="sm">
<Link href={`/contacts/${contact.id}`}><Edit className="h-4 w-4" /></Link>
</Button>
<Button variant="ghost" size="sm" onClick={() => promptDelete(contact.id)}>
<Trash2 className="h-4 w-4" />
</Button>
+18 -31
View File
@@ -8,6 +8,7 @@ import {
CardDescription,
CardHeader,
CardTitle,
Skeleton,
} from '@plunk/ui';
import {AlertCircle, Mail, Send, TrendingUp, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
@@ -19,6 +20,8 @@ import {QuickStart} from '../components/QuickStart';
import {SecurityWarningBanner} from '../components/SecurityWarningBanner';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {useDashboardStats} from '../lib/hooks/useDashboardStats';
import {useOnboardingPath} from '../lib/hooks/useOnboardingPath';
import {useOnboardingStatus} from '../lib/hooks/useOnboardingStatus';
import {useProjectSetupState} from '../lib/hooks/useProjectSetupState';
import {useProjectSecurity} from '../lib/hooks/useProjectSecurity';
import {useConfig} from '../lib/hooks/useConfig';
@@ -32,6 +35,9 @@ export default function Index() {
const {securityMetrics} = useProjectSecurity(activeProject?.id);
const {data: config} = useConfig();
const {data: user} = useUser();
const onboardingStatus = useOnboardingStatus();
const {path: onboardingPath} = useOnboardingPath(activeProject?.id);
const bannerActive = onboardingStatus === 'show' && Boolean(onboardingPath);
const [isResending, setIsResending] = useState(false);
const [resendMessage, setResendMessage] = useState<string>('');
@@ -89,29 +95,16 @@ export default function Index() {
<AlertDescription className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="space-y-2 flex-1">
<p className="text-sm font-medium">
This project has been disabled due to security violations. All scheduled campaigns and workflows
have been cancelled. The project is now in read-only mode - you can view your data but cannot
This project has been disabled and is now in read-only mode. You can view your data but cannot
create, update, or delete anything.
</p>
{securityMetrics && securityMetrics.status.violations.length > 0 && (
<>
<p className="text-sm font-medium mt-3">Security violations that caused suspension:</p>
<ul className="list-disc list-inside space-y-1 text-sm text-red-800">
{securityMetrics.status.violations.map((violation, idx) => (
<li key={idx}>{violation}</li>
))}
</ul>
</>
)}
<p className="text-xs text-red-800 mt-2">
Please contact support to resolve this issue and get your project re-enabled.
Please contact support for more details and to get your project re-enabled.
</p>
</div>
<Link href="/settings?tab=security">
<Button size="sm" variant="outline" className="w-full sm:w-auto flex-shrink-0">
View Details
</Button>
</Link>
<Button asChild size="sm" variant="outline" className="w-full sm:w-auto flex-shrink-0">
<Link href="/settings?tab=security">View Details</Link>
</Button>
</AlertDescription>
</Alert>
)}
@@ -161,11 +154,9 @@ export default function Index() {
<span className="text-sm">
Your emails currently include Plunk branding. Upgrade to a subscription to remove it.
</span>
<Link href="/settings?tab=billing">
<Button size="sm" className="w-full sm:w-auto">
Upgrade Now
</Button>
</Link>
<Button asChild size="sm" className="w-full sm:w-auto">
<Link href="/settings?tab=billing">Upgrade Now</Link>
</Button>
</AlertDescription>
</Alert>
)}
@@ -187,11 +178,7 @@ export default function Index() {
<Icon className="h-4 w-4 text-neutral-500" />
</div>
<CardTitle className="text-2xl tabular-nums">
{isLoading ? (
<div className="h-7 w-16 bg-neutral-100 rounded animate-pulse" />
) : (
stat.value
)}
{isLoading ? <Skeleton className="h-7 w-16" /> : stat.value}
</CardTitle>
</CardHeader>
</Card>
@@ -200,9 +187,9 @@ export default function Index() {
</div>
{/* Quick Actions & API Keys */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Quick Start */}
<QuickStart setupState={setupState} isLoading={isLoadingSetupState} />
<div className={`grid grid-cols-1 gap-6 ${bannerActive ? '' : 'lg:grid-cols-2'}`}>
{/* Quick Start — hidden when the persistent onboarding banner is guiding the user */}
{!bannerActive && <QuickStart setupState={setupState} isLoading={isLoadingSetupState} />}
{/* API Keys */}
<Card>
+4 -28
View File
@@ -1,4 +1,4 @@
import {Button, Card, CardContent} from '@plunk/ui';
import {Button, Card, CardContent, IconSpinner} from '@plunk/ui';
import {createTranslator, type Translator} from '@plunk/shared';
import {AnimatePresence, motion} from 'framer-motion';
import {useRouter} from 'next/router';
@@ -82,19 +82,7 @@ export default function Manage() {
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner />
<p className="text-sm text-neutral-500">Loading...</p>
</div>
</CardContent>
@@ -111,19 +99,7 @@ export default function Manage() {
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner />
<p className="text-sm text-neutral-500">{translator.t('pages.common.loading')}</p>
</div>
</CardContent>
@@ -189,7 +165,7 @@ export default function Manage() {
<button
onClick={() => void handleToggleSubscription()}
disabled={updating}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-500 focus:ring-offset-2 ${
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
contact?.subscribed ? 'bg-neutral-900' : 'bg-neutral-200'
} ${updating ? 'opacity-50 cursor-not-allowed' : ''}`}
>
+227
View File
@@ -0,0 +1,227 @@
import {Button, Skeleton} from '@plunk/ui';
import {motion} from 'framer-motion';
import {ArrowLeft, ArrowRight, KeyRound, Mail, ShieldCheck} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useMemo} from 'react';
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
import {CodeTabs, type CodeSnippet} from '../../components/onboarding/CodeTabs';
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
import {API_URI} from '../../lib/constants';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
interface SetupStep {
id: string;
icon: React.ElementType;
title: string;
description: string;
cta?: string;
href?: string;
required?: boolean;
}
const steps: SetupStep[] = [
{
id: 'domain',
icon: ShieldCheck,
title: 'Verify your sender domain',
description: 'Sending requires a verified domain. Two DNS records and you\'re set.',
cta: 'Verify domain',
href: '/settings?tab=domains',
required: true,
},
{
id: 'key',
icon: KeyRound,
title: 'Copy your secret key',
description: 'Add it to your server environment. Never expose it in client-side code.',
},
{
id: 'send',
icon: Mail,
title: 'Send your first email',
description: 'One API call. Pick your language and drop it into your app.',
},
];
function buildSnippets(apiUrl: string, secret: string): CodeSnippet[] {
const body = {
to: '[email protected]',
subject: 'Hello from Plunk',
body: '<p>Your first email is live.</p>',
from: '[email protected]',
};
const curl = `curl -X POST ${apiUrl}/v1/send \\
-H "Authorization: Bearer ${secret}" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}'`;
const node = `await fetch("${apiUrl}/v1/send", {
method: "POST",
headers: {
Authorization: "Bearer ${secret}",
"Content-Type": "application/json",
},
body: JSON.stringify(${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}),
});`;
const python = `import requests
requests.post(
"${apiUrl}/v1/send",
headers={"Authorization": "Bearer ${secret}"},
json=${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')},
)`;
const php = `$ch = curl_init("${apiUrl}/v1/send");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer ${secret}",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')}),
]);
curl_exec($ch);`;
return [
{id: 'curl', label: 'cURL', code: curl},
{id: 'node', label: 'Node.js', code: node},
{id: 'python', label: 'Python', code: python},
{id: 'php', label: 'PHP', code: php},
];
}
export default function OnboardingDeveloper() {
const router = useRouter();
const {activeProject} = useActiveProject();
const {state} = useOnboardingGate();
const {path, setPath} = useOnboardingPath(activeProject?.id);
const snippets = useMemo(
() => buildSnippets(API_URI, activeProject?.secret ?? 'sk_your_secret_key'),
[activeProject?.secret],
);
useEffect(() => {
if (activeProject && !path) setPath('developer');
}, [activeProject, path, setPath]);
if (state !== 'show') return null;
const handleContinue = () => {
void router.push('/');
};
const handleStepClick = (href: string) => {
void router.push(href);
};
return (
<>
<NextSeo title="Send your first email" />
<OnboardingLayout step={2}>
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
Send your first email.
</h1>
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
Three steps to your first send. Start with your domain so emails actually reach the inbox.
</p>
</header>
<div className="flex flex-col gap-3">
{steps.map((step, index) => {
const Icon = step.icon;
return (
<motion.div
key={step.id}
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<div className="flex items-center gap-3">
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
{index + 1}
</span>
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
<Icon className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col gap-1 pt-0.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
{step.required && (
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
Required
</span>
)}
</div>
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
</div>
</div>
{step.cta && step.href && (
<Button
variant="outline"
size="sm"
className="sm:self-center sm:flex-shrink-0"
onClick={() => handleStepClick(step.href!)}
>
{step.cta}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
)}
</div>
{step.id === 'key' && (
<div className="border-t border-neutral-100 pt-4">
{activeProject ? (
<ApiKeyDisplay
label="Secret key"
value={activeProject.secret}
description="Keep this server-side. Treat it like a password."
isSecret
/>
) : (
<Skeleton className="h-16 rounded-lg" />
)}
</div>
)}
{step.id === 'send' && (
<div className="overflow-hidden rounded-lg border border-neutral-200">
<CodeTabs snippets={snippets} />
</div>
)}
</motion.div>
);
})}
</div>
<div className="flex items-center justify-between pt-2">
<Link
href="/onboarding"
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<Button onClick={handleContinue}>
Continue to dashboard
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</OnboardingLayout>
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
import {motion} from 'framer-motion';
import {ArrowRight, Code2, Megaphone, Workflow} from 'lucide-react';
import {NextSeo} from 'next-seo';
import {useRouter} from 'next/router';
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
import {type OnboardingPath, useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
interface PathOption {
id: OnboardingPath;
icon: React.ElementType;
title: string;
description: string;
meta: string;
}
const paths: PathOption[] = [
{
id: 'developer',
icon: Code2,
title: 'Send transactional emails',
description: 'Wire Plunk into your app for password resets, receipts, and notifications.',
meta: '~1 minute',
},
{
id: 'marketing',
icon: Megaphone,
title: 'Run email campaigns',
description: 'Import contacts, draft campaigns, and broadcast to your audience.',
meta: '~5 minutes',
},
{
id: 'workflows',
icon: Workflow,
title: 'Automate with workflows',
description: 'Trigger emails from events in your app — onboarding, retention, receipts.',
meta: '~5 minutes',
},
];
export default function OnboardingWelcome() {
const router = useRouter();
const {state} = useOnboardingGate();
const {activeProject} = useActiveProject();
const {setPath} = useOnboardingPath(activeProject?.id);
if (state !== 'show') return null;
const handleSelect = (id: PathOption['id']) => {
setPath(id);
void router.push(`/onboarding/${id}`);
};
return (
<>
<NextSeo title="Welcome to Plunk" />
<OnboardingLayout step={1} maxWidthClass="max-w-5xl">
<div className="flex flex-col gap-8">
<header className="flex flex-col gap-2">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
Welcome to Plunk.
</h1>
<p className="text-base text-neutral-600 max-w-lg leading-relaxed">
Two minutes of setup and you&#39;ll be sending. How do you plan to use Plunk?
</p>
</header>
<div className="grid gap-4 md:grid-cols-3">
{paths.map((path, index) => {
const Icon = path.icon;
return (
<motion.button
key={path.id}
type="button"
onClick={() => handleSelect(path.id)}
initial={{opacity: 0, y: 8}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.35, delay: 0.08 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
whileHover={{y: -2}}
whileTap={{scale: 0.995}}
className="group flex flex-col gap-5 rounded-xl border border-neutral-200 bg-white p-6 text-left shadow-sm transition-colors hover:border-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<div className="flex items-center justify-between">
<span className="flex h-10 w-10 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
<Icon className="h-5 w-5" />
</span>
<span className="text-xs font-medium text-neutral-400 tabular-nums">{path.meta}</span>
</div>
<div className="flex flex-col gap-1.5">
<h2 className="text-base font-semibold text-neutral-900">{path.title}</h2>
<p className="text-sm text-neutral-600 leading-relaxed">{path.description}</p>
</div>
<div className="flex items-center gap-1.5 text-sm font-medium text-neutral-900">
<span>Start here</span>
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
</div>
</motion.button>
);
})}
</div>
<p className="text-xs text-neutral-500">
Not sure? Pick the closest fit you can do all three once you&#39;re set up.
</p>
</div>
</OnboardingLayout>
</>
);
}
+152
View File
@@ -0,0 +1,152 @@
import {Button} from '@plunk/ui';
import {motion} from 'framer-motion';
import {ArrowLeft, ArrowRight, Mail, ShieldCheck, Users} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect} from 'react';
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
interface SetupStep {
id: string;
icon: React.ElementType;
title: string;
description: string;
href: string;
cta: string;
required?: boolean;
}
const steps: SetupStep[] = [
{
id: 'domain',
icon: ShieldCheck,
title: 'Verify your sender domain',
description: 'Two DNS records prove you own the address your campaigns send from.',
href: '/settings?tab=domains',
cta: 'Verify domain',
required: true,
},
{
id: 'contacts',
icon: Users,
title: 'Add your contacts',
description: 'Import a CSV, paste a list, or sync contacts from your app.',
href: '/contacts',
cta: 'Add contacts',
},
{
id: 'campaign',
icon: Mail,
title: 'Draft your first campaign',
description: 'Compose in the editor, preview, and schedule — or send when you are ready.',
href: '/campaigns/create',
cta: 'Draft campaign',
},
];
export default function OnboardingMarketing() {
const router = useRouter();
const {state} = useOnboardingGate();
const {activeProject} = useActiveProject();
const {path, setPath} = useOnboardingPath(activeProject?.id);
// Record the marketing path so the persistent banner guides the user after
// they click into a step. Only set if unset — don't overwrite a developer
// choice if the user navigated here manually.
useEffect(() => {
if (activeProject && !path) setPath('marketing');
}, [activeProject, path, setPath]);
if (state !== 'show') return null;
const handleContinue = () => {
void router.push('/');
};
const handleStepClick = (href: string) => {
void router.push(href);
};
return (
<>
<NextSeo title="Set up your campaigns" />
<OnboardingLayout step={2}>
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
Set up for campaigns.
</h1>
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
Three steps to your first broadcast. Start anywhere you can come back to finish the rest.
</p>
</header>
<div className="flex flex-col gap-3">
{steps.map((step, index) => {
const Icon = step.icon;
return (
<motion.div
key={step.id}
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex items-start gap-4">
<div className="flex items-center gap-3">
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
{index + 1}
</span>
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
<Icon className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col gap-1 pt-0.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
{step.required && (
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
Required
</span>
)}
</div>
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
</div>
</div>
<Button
variant="outline"
size="sm"
className="sm:self-center sm:flex-shrink-0"
onClick={() => handleStepClick(step.href)}
>
{step.cta}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
</motion.div>
);
})}
</div>
<div className="flex items-center justify-between pt-2">
<Link
href="/onboarding"
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<Button onClick={handleContinue}>
Continue to dashboard
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</OnboardingLayout>
</>
);
}
+233
View File
@@ -0,0 +1,233 @@
import {Button, Skeleton} from '@plunk/ui';
import {motion} from 'framer-motion';
import {ArrowLeft, ArrowRight, FileText, ShieldCheck, Workflow, Zap} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useRouter} from 'next/router';
import {useEffect, useMemo} from 'react';
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
import {CodeTabs, type CodeSnippet} from '../../components/onboarding/CodeTabs';
import {OnboardingLayout} from '../../components/onboarding/OnboardingLayout';
import {API_URI} from '../../lib/constants';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {useOnboardingGate} from '../../lib/hooks/useOnboardingGate';
import {useOnboardingPath} from '../../lib/hooks/useOnboardingPath';
interface SetupStep {
id: string;
icon: React.ElementType;
title: string;
description: string;
cta?: string;
href?: string;
required?: boolean;
}
const steps: SetupStep[] = [
{
id: 'domain',
icon: ShieldCheck,
title: 'Verify your sender domain',
description: 'Workflows send from a verified domain. Two DNS records and you\'re set.',
cta: 'Verify domain',
href: '/settings?tab=domains',
required: true,
},
{
id: 'track',
icon: Zap,
title: 'Fire events from your app',
description: 'Call /v1/track whenever something interesting happens. Workflows listen and respond.',
},
{
id: 'template',
icon: FileText,
title: 'Create an email template',
description: 'Design the email your workflow will send — drag blocks or write HTML.',
cta: 'Create template',
href: '/templates/create',
},
{
id: 'workflow',
icon: Workflow,
title: 'Build your first workflow',
description: 'Pick a trigger event, attach your template, chain delays, hit publish.',
cta: 'Open workflow builder',
href: '/workflows',
},
];
function buildTrackSnippets(apiUrl: string, publicKey: string): CodeSnippet[] {
const body = {
event: 'user.signed-up',
email: '[email protected]',
data: {plan: 'pro'},
};
const curl = `curl -X POST ${apiUrl}/v1/track \\
-H "Authorization: Bearer ${publicKey}" \\
-H "Content-Type: application/json" \\
-d '${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}'`;
const node = `await fetch("${apiUrl}/v1/track", {
method: "POST",
headers: {
Authorization: "Bearer ${publicKey}",
"Content-Type": "application/json",
},
body: JSON.stringify(${JSON.stringify(body, null, 2).replace(/\n/g, '\n ')}),
});`;
const python = `import requests
requests.post(
"${apiUrl}/v1/track",
headers={"Authorization": "Bearer ${publicKey}"},
json=${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')},
)`;
const php = `$ch = curl_init("${apiUrl}/v1/track");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer ${publicKey}",
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode(${JSON.stringify(body, null, 4).replace(/\n/g, '\n ')}),
]);
curl_exec($ch);`;
return [
{id: 'curl', label: 'cURL', code: curl},
{id: 'node', label: 'Node.js', code: node},
{id: 'python', label: 'Python', code: python},
{id: 'php', label: 'PHP', code: php},
];
}
export default function OnboardingWorkflows() {
const router = useRouter();
const {activeProject} = useActiveProject();
const {state} = useOnboardingGate();
const {path, setPath} = useOnboardingPath(activeProject?.id);
const snippets = useMemo(
() => buildTrackSnippets(API_URI, activeProject?.public ?? 'pk_your_public_key'),
[activeProject?.public],
);
useEffect(() => {
if (activeProject && !path) setPath('workflows');
}, [activeProject, path, setPath]);
if (state !== 'show') return null;
const handleContinue = () => {
void router.push('/');
};
const handleStepClick = (href: string) => {
void router.push(href);
};
return (
<>
<NextSeo title="Automate with workflows" />
<OnboardingLayout step={2}>
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight text-neutral-900">
Automate with workflows.
</h1>
<p className="text-base text-neutral-600 max-w-xl leading-relaxed">
Four steps to your first automated email. Start with your domain so emails actually reach the inbox.
</p>
</header>
<div className="flex flex-col gap-3">
{steps.map((step, index) => {
const Icon = step.icon;
return (
<motion.div
key={step.id}
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.3, delay: 0.05 + index * 0.06, ease: [0.22, 1, 0.36, 1]}}
className="flex flex-col gap-4 rounded-xl border border-neutral-200 bg-white p-5 shadow-sm"
>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-4">
<div className="flex items-center gap-3">
<span className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-neutral-200 bg-neutral-50 text-xs font-semibold tabular-nums text-neutral-900">
{index + 1}
</span>
<span className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-900">
<Icon className="h-5 w-5" />
</span>
</div>
<div className="flex flex-col gap-1 pt-0.5">
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-neutral-900">{step.title}</h2>
{step.required && (
<span className="rounded bg-neutral-900 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white">
Required
</span>
)}
</div>
<p className="text-sm text-neutral-600 leading-relaxed">{step.description}</p>
</div>
</div>
{step.cta && step.href && (
<Button
variant="outline"
size="sm"
className="sm:self-center sm:flex-shrink-0"
onClick={() => handleStepClick(step.href!)}
>
{step.cta}
<ArrowRight className="h-3.5 w-3.5" />
</Button>
)}
</div>
{step.id === 'track' && (
<div className="flex flex-col gap-3 border-t border-neutral-100 pt-4">
{activeProject ? (
<ApiKeyDisplay
label="Public key"
value={activeProject.public}
description="Use this key to track events from browsers or clients."
/>
) : (
<Skeleton className="h-16 rounded-lg" />
)}
<div className="overflow-hidden rounded-lg border border-neutral-200">
<CodeTabs snippets={snippets} />
</div>
</div>
)}
</motion.div>
);
})}
</div>
<div className="flex items-center justify-between pt-2">
<Link
href="/onboarding"
className="inline-flex items-center gap-1.5 text-sm font-medium text-neutral-500 hover:text-neutral-900 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<Button onClick={handleContinue}>
Continue to dashboard
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</OnboardingLayout>
</>
);
}
+4 -22
View File
@@ -12,6 +12,7 @@ import {
FormItem,
FormLabel,
FormMessage,
IconSpinner,
Input,
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
@@ -54,8 +55,8 @@ export default function CreateProject() {
// Set the newly created project as active
setActiveProject(newProject);
// Redirect to dashboard
await router.push('/');
// Redirect into onboarding for this project
await router.push('/onboarding');
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
}
@@ -129,26 +130,7 @@ export default function CreateProject() {
<Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<>
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner size="sm" className="mr-2" />
Creating...
</>
) : (
+47 -69
View File
@@ -1,4 +1,5 @@
import {
Badge,
Button,
Card,
CardContent,
@@ -6,6 +7,8 @@ import {
CardHeader,
CardTitle,
ConfirmDialog,
EmptyState,
IconSpinner,
Input,
Label,
} from '@plunk/ui';
@@ -19,6 +22,7 @@ import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {NextSeo} from 'next-seo';
import type {FilterCondition} from '@plunk/types';
import {SegmentSchemas} from '@plunk/shared';
import {SegmentFilterBuilder} from '../../components/SegmentFilterBuilder';
@@ -123,21 +127,19 @@ export default function SegmentDetailPage() {
}
};
const handleAddMembers = async () => {
if (pickedEmails.length === 0) {
toast.error('Select at least one contact');
return;
}
const handleAddMembers = async (emails: string[], subscribed = true) => {
setIsAddingMembers(true);
try {
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
const result = await network.fetch<{added: number; created: number; notFound: string[]}, typeof SegmentSchemas.members>(
'POST',
`/segments/${id}/members`,
{emails: pickedEmails},
{emails, createMissing: true, subscribed},
);
toast.success(`Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`);
const msg = result.created > 0
? `Added ${result.added} contact${result.added !== 1 ? 's' : ''} (${result.created} new)`
: `Added ${result.added} contact${result.added !== 1 ? 's' : ''} to segment`;
toast.success(msg);
setPickedEmails([]);
void mutate();
void mutateContacts();
@@ -178,22 +180,7 @@ export default function SegmentDetailPage() {
return (
<DashboardLayout>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading segment...</p>
</div>
<IconSpinner />
</div>
</DashboardLayout>
);
@@ -207,12 +194,12 @@ export default function SegmentDetailPage() {
<p className="text-neutral-500 mb-6">
The segment you&apos;re looking for doesn&apos;t exist or has been deleted.
</p>
<Link href="/segments">
<Button>
<Button asChild>
<Link href="/segments">
<ArrowLeft className="h-4 w-4" />
Back to Segments
</Button>
</Link>
</Link>
</Button>
</div>
</DashboardLayout>
);
@@ -222,25 +209,20 @@ export default function SegmentDetailPage() {
return (
<DashboardLayout>
<NextSeo title={segment.name} />
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link href="/segments">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="outline" size="sm">
<Link href="/segments"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold text-neutral-900">{segment.name}</h1>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
isStatic ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'
}`}
>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">{segment.name}</h1>
<Badge variant={isStatic ? 'neutral' : 'default'}>
{isStatic ? 'Static' : 'Dynamic'}
</span>
</Badge>
</div>
{segment.description && <p className="text-neutral-500 mt-1">{segment.description}</p>}
</div>
@@ -293,7 +275,7 @@ export default function SegmentDetailPage() {
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
className="mt-1 h-4 w-4 text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
@@ -334,26 +316,26 @@ export default function SegmentDetailPage() {
<Card>
<CardHeader>
<CardTitle>Add Members</CardTitle>
<CardDescription>Search and select contacts to add to this segment</CardDescription>
<CardDescription>Search and select contacts, or paste a list of emails</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<ContactPicker
selected={pickedEmails}
onChange={setPickedEmails}
onAdd={handleAddMembers}
existing={contactsData?.data.map(c => c.email) ?? []}
placeholder="Search contacts to add..."
/>
<Button
type="button"
onClick={handleAddMembers}
disabled={isAddingMembers || pickedEmails.length === 0}
>
{isAddingMembers
? 'Adding...'
: pickedEmails.length > 0
? `Add ${pickedEmails.length} Contact${pickedEmails.length !== 1 ? 's' : ''}`
: 'Add Contacts'}
</Button>
{pickedEmails.length > 0 && (
<Button
type="button"
onClick={() => void handleAddMembers(pickedEmails)}
disabled={isAddingMembers}
className="w-full"
>
{isAddingMembers ? 'Adding...' : `Add ${pickedEmails.length} Contact${pickedEmails.length !== 1 ? 's' : ''}`}
</Button>
)}
</CardContent>
</Card>
)}
@@ -382,12 +364,11 @@ export default function SegmentDetailPage() {
<p className="text-sm text-neutral-500">Loading contacts...</p>
</div>
) : contactsData?.data.length === 0 ? (
<div className="text-center py-8">
<Users className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
<p className="text-neutral-500">
{isStatic ? 'No members in this segment yet' : 'No contacts match this segment'}
</p>
</div>
<EmptyState
icon={Users}
title={isStatic ? 'No members yet' : 'No contacts match'}
description={isStatic ? 'Add contacts to this segment to get started.' : 'No contacts currently match these filter conditions.'}
/>
) : (
<>
<div className="space-y-2">
@@ -402,18 +383,15 @@ export default function SegmentDetailPage() {
<span className="text-sm font-medium">{contact.email}</span>
</div>
<div className="flex items-center gap-2">
<Link href={`/contacts/${contact.id}`}>
<Button variant="ghost" size="sm">
View
</Button>
</Link>
<Button asChild variant="ghost" size="sm">
<Link href={`/contacts/${contact.id}`}>View</Link>
</Button>
{isStatic && (
<Button
variant="ghost"
variant="destructiveGhost"
size="sm"
onClick={() => handleRemoveMember(contact.email)}
disabled={removingEmail === contact.email}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<UserMinus className="h-4 w-4" />
</Button>
@@ -512,7 +490,7 @@ export default function SegmentDetailPage() {
<p className="text-sm font-medium text-neutral-900">Created</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(segment.createdAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
@@ -522,7 +500,7 @@ export default function SegmentDetailPage() {
<p className="text-sm font-medium text-neutral-900">Last Updated</p>
<div className="group relative inline-block cursor-help">
<p className="text-sm text-neutral-500">{dayjs(segment.updatedAt).fromNow()}</p>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
+106 -116
View File
@@ -1,24 +1,24 @@
import {
Alert,
AlertDescription,
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
IconSpinner,
Input,
} from '@plunk/ui';
import type {Segment} from '@plunk/db';
import type {FilterCondition} from '@plunk/types';
import {EmptyState} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {AlertTriangle, Calendar, Edit, Filter, Plus, Trash2, Users} from 'lucide-react';
import {AlertTriangle, Calendar, Edit, Filter, Plus, Search, Trash2, X} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {useMemo, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
@@ -52,10 +52,17 @@ export default function SegmentsPage() {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [segmentToDelete, setSegmentToDelete] = useState<string | null>(null);
const [searchInput, setSearchInput] = useState('');
// Show warning if there are many segments
const showLimitWarning = segments && segments.length >= 50;
const filteredSegments = useMemo(() => {
if (!segments || !searchInput.trim()) return segments;
const q = searchInput.toLowerCase();
return segments.filter(s => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q));
}, [segments, searchInput]);
const handleDelete = async () => {
if (!segmentToDelete) return;
@@ -83,13 +90,35 @@ export default function SegmentsPage() {
Create dynamic audience groups based on contact attributes and behaviors
</p>
</div>
<Link href="/segments/new" className="w-full sm:w-auto">
<Button className="w-full sm:w-auto">
<Button asChild className="w-full sm:w-auto">
<Link href="/segments/new">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Segment</span>
<span className="sm:hidden">Create</span>
</Button>
</Link>
</Link>
</Button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search segments..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => setSearchInput('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Warning if too many segments */}
@@ -105,132 +134,93 @@ export default function SegmentsPage() {
{/* Segments Grid */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading segments...</p>
</div>
<IconSpinner />
</div>
) : segments?.length === 0 ? (
) : filteredSegments?.length === 0 ? (
<Card>
<CardContent>
<EmptyState
icon={Filter}
title="No segments yet"
description="Group contacts by attributes to target specific audiences."
title={searchInput ? 'No segments match' : 'No segments yet'}
description={searchInput ? 'Try a different search term.' : 'Group contacts by attributes to target specific audiences.'}
action={
<Link href="/segments/new">
<Button>
<Plus className="h-4 w-4" />
Create Segment
!searchInput ? (
<Button asChild>
<Link href="/segments/new">
<Plus className="h-4 w-4" />
Create Segment
</Link>
</Button>
</Link>
) : undefined
}
/>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{segments?.map(segment => (
<Card key={segment.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<CardTitle className="text-lg">{segment.name}</CardTitle>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
(segment as unknown as {type: string}).type === 'STATIC'
? 'bg-purple-100 text-purple-700'
: 'bg-blue-100 text-blue-700'
}`}
>
{(segment as unknown as {type: string}).type === 'STATIC' ? 'Static' : 'Dynamic'}
</span>
</div>
{segment.description && (
<CardDescription className="mt-1">{segment.description}</CardDescription>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredSegments?.map(segment => {
const isDynamic = (segment as unknown as {type: string}).type !== 'STATIC';
const filterCount = isDynamic ? countFiltersInCondition(segment.condition) : 0;
return (
<Card key={segment.id} className="transition-colors hover:border-neutral-300 flex flex-col [&:has([data-card-link]:focus-visible)]:ring-2 [&:has([data-card-link]:focus-visible)]:ring-ring [&:has([data-card-link]:focus-visible)]:ring-offset-2">
<Link
href={`/segments/${segment.id}`}
data-card-link=""
className="flex-1 block p-6 pb-4 hover:bg-neutral-50/50 transition-colors rounded-t-xl focus-visible:outline-none"
aria-label={`Open ${segment.name}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="font-semibold text-neutral-900 leading-snug">{segment.name}</h3>
<Badge variant={isDynamic ? 'default' : 'neutral'} className="shrink-0 mt-0.5">
{isDynamic ? 'Dynamic' : 'Static'}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm">
<span>
<strong className="font-semibold text-neutral-900">{segment.memberCount.toLocaleString()}</strong>
<span className="text-neutral-400 ml-1 text-xs">members</span>
</span>
{isDynamic && (
<>
<span className="h-3 w-px bg-neutral-200" />
<span>
<strong className="font-semibold text-neutral-900">{filterCount}</strong>
<span className="text-neutral-400 ml-1 text-xs">filters</span>
</span>
</>
)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Members</span>
</div>
<span className="text-lg font-semibold text-neutral-900">{segment.memberCount}</span>
</div>
{(segment as unknown as {type: string}).type !== 'STATIC' && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-neutral-500" />
<span className="text-sm text-neutral-600">Filters</span>
</div>
<span className="text-sm font-medium text-neutral-900">
{countFiltersInCondition(segment.condition)}
</span>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2 border-t border-neutral-200">
<Link href={`/segments/${segment.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<Edit className="h-4 w-4" />
Edit
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSegmentToDelete(segment.id);
setShowDeleteDialog(true);
}}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(segment.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
</Link>
<div className="px-6 py-3 border-t border-neutral-100 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(segment.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
<span>Updated {formatRelativeTime(segment.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(segment.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button asChild variant="ghost" size="sm" title="Edit segment">
<Link href={`/segments/${segment.id}`} aria-label="Edit segment"><Edit className="h-4 w-4" /></Link>
</Button>
<Button
variant="ghost"
size="sm"
title="Delete segment"
onClick={() => {
setSegmentToDelete(segment.id);
setShowDeleteDialog(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</Card>
);
})}
</div>
)}
</div>
+15 -17
View File
@@ -48,14 +48,15 @@ export default function NewSegmentPage() {
// For static segments with pre-selected contacts, add them now
if (segmentType === 'STATIC' && selectedContacts.length > 0) {
try {
const result = await network.fetch<{added: number; notFound: string[]}, typeof SegmentSchemas.members>(
const result = await network.fetch<{added: number; created: number; notFound: string[]}, typeof SegmentSchemas.members>(
'POST',
`/segments/${segment.id}/members`,
{emails: selectedContacts},
);
toast.success(
`Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`,
{emails: selectedContacts, createMissing: true},
);
const msg = result.created > 0
? `Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''} (${result.created} new)`
: `Segment created with ${result.added} contact${result.added !== 1 ? 's' : ''}`;
toast.success(msg);
} catch {
// Segment was created; just warn about members
toast.warning('Segment created, but some contacts could not be added');
@@ -79,13 +80,11 @@ export default function NewSegmentPage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link href="/segments">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="outline" size="sm">
<Link href="/segments"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div>
<h1 className="text-3xl font-bold text-neutral-900">Create Segment</h1>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Segment</h1>
<p className="text-neutral-500 mt-1">
{segmentType === 'DYNAMIC'
? 'Build complex audience filters with AND/OR logic'
@@ -159,7 +158,7 @@ export default function NewSegmentPage() {
type="checkbox"
checked={trackMembership}
onChange={e => setTrackMembership(e.target.checked)}
className="mt-1 h-4 w-4 text-neutral-900 focus:ring-neutral-900 border-neutral-300 rounded"
className="mt-1 h-4 w-4 text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 border-neutral-300 rounded"
/>
<div className="flex-1">
<Label htmlFor="trackMembership" className="font-medium cursor-pointer">
@@ -196,6 +195,7 @@ export default function NewSegmentPage() {
<ContactPicker
selected={selectedContacts}
onChange={setSelectedContacts}
onAdd={async (emails, _subscribed) => setSelectedContacts(prev => [...new Set([...prev, ...emails])])}
placeholder="Search and select contacts..."
/>
</CardContent>
@@ -204,11 +204,9 @@ export default function NewSegmentPage() {
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Link href="/segments">
<Button type="button" variant="outline" disabled={isSubmitting}>
Cancel
</Button>
</Link>
<Button asChild variant="outline" disabled={isSubmitting}>
<Link href="/segments">Cancel</Link>
</Button>
<Button type="submit" disabled={isSubmitting}>
<Save className="h-4 w-4 mr-2" />
{isSubmitting ? 'Creating...' : 'Create Segment'}
+33 -36
View File
@@ -86,7 +86,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta
export default function Settings() {
const router = useRouter();
const {activeProject, setActiveProject} = useActiveProject();
const {activeProject, setActiveProject, updateActiveProject} = useActiveProject();
const {mutate: projectsMutate} = useProjects();
const {data: config} = useConfig();
const {data: user} = useUser();
@@ -201,8 +201,8 @@ export default function Settings() {
values,
);
// Update the active project in context
setActiveProject(updatedProject);
// Update the active project in context without invalidating the full SWR cache
updateActiveProject(updatedProject);
// Refresh projects list
await projectsMutate();
@@ -375,7 +375,7 @@ export default function Settings() {
<div className="space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-neutral-900">Settings</h1>
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Settings</h1>
<p className="text-neutral-500 mt-2">Manage your project settings and preferences</p>
</div>
@@ -395,6 +395,7 @@ export default function Settings() {
{/* General Tab */}
<TabsContent value="general">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Project Settings</CardTitle>
@@ -491,12 +492,6 @@ export default function Settings() {
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
{/* Success/Error Messages */}
<AnimatePresence mode="wait">
{successMessage && (
@@ -520,13 +515,19 @@ export default function Settings() {
</motion.div>
)}
</AnimatePresence>
<div className="flex justify-end">
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
{/* API Keys - Separate Card */}
<Card className="mt-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
@@ -554,7 +555,7 @@ export default function Settings() {
</Card>
{/* Danger Zone - Separate Card */}
<Card className="border-red-200 mt-6">
<Card className="border-red-200">
<CardHeader className="border-b border-red-100 bg-red-50">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-lg shadow-sm border border-red-200">
@@ -568,13 +569,13 @@ export default function Settings() {
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Reset Project */}
<div className="group">
<div className="flex items-start justify-between gap-4 p-5 rounded-lg border border-neutral-200 bg-white transition-all">
<CardContent className="p-0">
<div className="divide-y divide-neutral-100">
{/* Reset Project */}
<div className="flex items-start justify-between gap-4 px-6 py-5">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Database className="h-4 w-4 text-orange-600" />
<div className="flex items-center gap-2 mb-1.5">
<Database className="h-4 w-4 text-amber-700" />
<h4 className="font-semibold text-neutral-900">Reset Project Data</h4>
</div>
<p className="text-sm text-neutral-600 mb-3">
@@ -586,22 +587,15 @@ export default function Settings() {
<span>API keys, domains, billing information</span>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => setShowResetDialog(true)}
className="shrink-0 border-orange-300 text-orange-700 hover:bg-orange-50 hover:border-orange-400"
>
<Button type="button" variant="outline" onClick={() => setShowResetDialog(true)} className="shrink-0">
Reset Data
</Button>
</div>
</div>
{/* Delete Project */}
<div className="group">
<div className="flex items-start justify-between gap-4 p-5 rounded-lg border-2 border-red-200 bg-red-50/50 transition-all">
{/* Delete Project */}
<div className="flex items-start justify-between gap-4 px-6 py-5 bg-red-50/40">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center gap-2 mb-1.5">
<AlertTriangle className="h-4 w-4 text-red-600" />
<h4 className="font-semibold text-red-900">Delete Project Permanently</h4>
</div>
@@ -629,6 +623,7 @@ export default function Settings() {
</div>
</CardContent>
</Card>
</div>
</TabsContent>
{/* Billing Tab */}
@@ -823,7 +818,7 @@ export default function Settings() {
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-500" />
<AlertTriangle className="h-5 w-5 text-amber-600" />
Regenerate API Keys
</DialogTitle>
<DialogDescription className="space-y-3">
@@ -841,7 +836,7 @@ export default function Settings() {
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
Cancel
</Button>
<Button variant="default" onClick={handleRegenerateKeys} className="bg-orange-600 hover:bg-orange-700">
<Button variant="destructive" onClick={handleRegenerateKeys}>
Regenerate Keys
</Button>
</DialogFooter>
@@ -852,8 +847,8 @@ export default function Settings() {
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="space-y-3">
<div className="mx-auto w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
<Database className="h-6 w-6 text-orange-600" />
<div className="mx-auto w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center">
<Database className="h-6 w-6 text-amber-700" />
</div>
<DialogTitle className="text-center text-xl">Reset Project Data?</DialogTitle>
<DialogDescription className="text-center text-base">
@@ -865,7 +860,7 @@ export default function Settings() {
<div className="py-4">
<label className="text-sm font-medium text-neutral-700 block mb-2 text-center">
Type{' '}
<span className="font-mono font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded">RESET</span>{' '}
<span className="font-mono font-semibold text-amber-700 bg-amber-50 px-2 py-0.5 rounded">RESET</span>{' '}
to confirm
</label>
<Input
@@ -889,9 +884,10 @@ export default function Settings() {
Cancel
</Button>
<Button
variant="destructive"
onClick={handleResetProject}
disabled={resetConfirmText !== 'RESET'}
className="w-full bg-orange-600 hover:bg-orange-700 disabled:opacity-50"
className="w-full"
>
Reset Data
</Button>
@@ -946,9 +942,10 @@ export default function Settings() {
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteProject}
disabled={deleteConfirmText !== 'DELETE'}
className="w-full bg-red-600 hover:bg-red-700 disabled:opacity-50"
className="w-full"
>
Delete Forever
</Button>
+4 -40
View File
@@ -1,4 +1,4 @@
import {Button, Card, CardContent} from '@plunk/ui';
import {Button, Card, CardContent, IconSpinner} from '@plunk/ui';
import {createTranslator, type Translator} from '@plunk/shared';
import {AnimatePresence, motion} from 'framer-motion';
import {useRouter} from 'next/router';
@@ -72,19 +72,7 @@ export default function Subscribe() {
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner />
<p className="text-sm text-neutral-500">Loading...</p>
</div>
</CardContent>
@@ -101,19 +89,7 @@ export default function Subscribe() {
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner />
<p className="text-sm text-neutral-500">{translator.t('pages.common.loading')}</p>
</div>
</CardContent>
@@ -219,19 +195,7 @@ export default function Subscribe() {
<Button onClick={() => void handleSubscribe()} className="w-full" disabled={subscribing}>
{subscribing ? (
<div className="flex items-center gap-2">
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner size="sm" />
<span>{translator.t('pages.subscribe.buttonLoading')}</span>
</div>
) : (
+7 -21
View File
@@ -6,6 +6,7 @@ import {
CardHeader,
CardTitle,
ConfirmDialog,
IconSpinner,
Input,
Label,
StickySaveBar,
@@ -18,6 +19,7 @@ import {network} from '../../lib/network';
import {useChangeTracking} from '../../lib/hooks/useChangeTracking';
import {ArrowLeft, Save, Trash2, TriangleAlert} from 'lucide-react';
import Link from 'next/link';
import {NextSeo} from 'next-seo';
import {useRouter} from 'next/router';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
@@ -117,22 +119,7 @@ export default function TemplateEditorPage() {
return (
<DashboardLayout>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading template...</p>
</div>
<IconSpinner />
</div>
</DashboardLayout>
);
@@ -140,15 +127,14 @@ export default function TemplateEditorPage() {
return (
<DashboardLayout>
<NextSeo title={template.name} />
<form onSubmit={handleSave} className={`max-w-5xl mx-auto space-y-6 ${hasChanges ? 'pb-32' : ''}`}>
{/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/templates">
<Button type="button" variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="ghost" size="sm">
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Edit Template</h1>
<p className="text-neutral-500 mt-1 text-sm sm:text-base">Make changes to your email template</p>
+6 -8
View File
@@ -73,11 +73,9 @@ export default function CreateTemplatePage() {
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/templates">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<Button asChild variant="ghost" size="sm">
<Link href="/templates"><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Template</h1>
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
@@ -217,9 +215,9 @@ export default function CreateTemplatePage() {
{/* Actions */}
<div className="flex justify-end gap-3">
<Link href="/templates">
<Button type="button" variant="outline">Cancel</Button>
</Link>
<Button asChild variant="outline">
<Link href="/templates">Cancel</Link>
</Button>
<Button type="submit" disabled={saving}>
{saving ? 'Creating...' : 'Create Template'}
</Button>
+99 -166
View File
@@ -3,22 +3,20 @@ import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
ConfirmDialog,
IconSpinner,
Input,
} from '@plunk/ui';
import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {EmptyState} from '@plunk/ui';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmptyState} from '../../components/EmptyState';
import {network} from '../../lib/network';
import {formatRelativeTime} from '../../lib/dateUtils';
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2} from 'lucide-react';
import {Calendar, Copy, Edit, FileText, Plus, Search, Trash2, X} from 'lucide-react';
import {NextSeo} from 'next-seo';
import Link from 'next/link';
import {useState} from 'react';
import {useEffect, useState} from 'react';
import {toast} from 'sonner';
import useSWR from 'swr';
import dayjs from 'dayjs';
@@ -36,11 +34,13 @@ export default function TemplatesPage() {
{revalidateOnFocus: false},
);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setSearch(searchInput);
setPage(1);
};
useEffect(() => {
const timer = setTimeout(() => {
setSearch(searchInput);
setPage(1);
}, 350);
return () => clearTimeout(timer);
}, [searchInput]);
const handleDelete = async () => {
if (!templateToDelete) return;
@@ -80,107 +80,63 @@ export default function TemplatesPage() {
{data?.total ? `${data.total} total templates` : ''}
</p>
</div>
<Link href="/templates/create" className="w-full sm:w-auto">
<Button className="w-full sm:w-auto">
<Button asChild className="w-full sm:w-auto">
<Link href="/templates/create">
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Create Template</span>
<span className="sm:hidden">Create</span>
</Button>
</Link>
</Link>
</Button>
</div>
{/* Search & Filters */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="space-y-4">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-500" />
<Input
type="text"
placeholder="Search templates..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10"
/>
</div>
<Button type="submit">Search</Button>
{search && (
<Button
type="button"
variant="outline"
onClick={() => {
setSearch('');
setSearchInput('');
setPage(1);
}}
>
Clear
</Button>
)}
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder="Search templates..."
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
className="pl-10 pr-10"
/>
{searchInput && (
<button
type="button"
aria-label="Clear search"
onClick={() => {
setSearchInput('');
setSearch('');
setPage(1);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 transition-colors"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="flex gap-1.5 shrink-0">
{(['ALL', 'MARKETING', 'TRANSACTIONAL', 'HEADLESS'] as const).map(type => (
<Button
key={type}
type="button"
onClick={() => { setTypeFilter(type); setPage(1); }}
variant={typeFilter === type ? 'default' : 'secondary'}
size="sm"
>
{type === 'ALL' ? 'All' : type.charAt(0) + type.slice(1).toLowerCase()}
</Button>
))}
</div>
</div>
{/* Type Filter */}
<div className="flex gap-2">
<Button
type="button"
onClick={() => setTypeFilter('ALL')}
variant={typeFilter === 'ALL' ? 'default' : 'secondary'}
size="sm"
>
All Templates
</Button>
<Button
type="button"
onClick={() => setTypeFilter('MARKETING')}
variant={typeFilter === 'MARKETING' ? 'default' : 'secondary'}
size="sm"
>
Marketing
</Button>
<Button
type="button"
onClick={() => setTypeFilter('TRANSACTIONAL')}
variant={typeFilter === 'TRANSACTIONAL' ? 'default' : 'secondary'}
size="sm"
>
Transactional
</Button>
<Button
type="button"
onClick={() => setTypeFilter('HEADLESS')}
variant={typeFilter === 'HEADLESS' ? 'default' : 'secondary'}
size="sm"
>
Headless
</Button>
</div>
</form>
</CardContent>
</Card>
{/* Templates Grid */}
<div className="grid gap-4">
{/* Templates */}
<div>
{isLoading ? (
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-center py-12">
<div className="text-center">
<svg
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<p className="mt-2 text-sm text-neutral-500">Loading templates...</p>
</div>
<IconSpinner />
</div>
</CardContent>
</Card>
@@ -193,12 +149,12 @@ export default function TemplatesPage() {
description={search ? 'Try a different search term.' : 'Create reusable email designs for campaigns.'}
action={
!search ? (
<Link href="/templates/create">
<Button>
<Button asChild>
<Link href="/templates/create">
<Plus className="h-4 w-4" />
Create Template
</Button>
</Link>
</Link>
</Button>
) : undefined
}
/>
@@ -206,36 +162,47 @@ export default function TemplatesPage() {
</Card>
) : (
<>
{data?.data.map(template => (
<Card key={template.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<CardTitle>{template.name}</CardTitle>
<Badge
className={'capitalize'}
variant={template.type === 'MARKETING' ? 'info' : template.type === 'HEADLESS' ? 'warning' : 'success'}
>
{template.type.toLowerCase()}
</Badge>
</div>
{template.description && (
<CardDescription className="mt-2">{template.description}</CardDescription>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data?.data.map(template => (
<Card key={template.id} className="transition-colors hover:border-neutral-300 flex flex-col [&:has([data-card-link]:focus-visible)]:ring-2 [&:has([data-card-link]:focus-visible)]:ring-ring [&:has([data-card-link]:focus-visible)]:ring-offset-2">
<Link
href={`/templates/${template.id}`}
data-card-link=""
className="flex-1 block p-6 pb-4 hover:bg-neutral-50/50 transition-colors rounded-t-xl focus-visible:outline-none"
aria-label={`Edit ${template.name}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<h3 className="font-semibold text-neutral-900 leading-snug">{template.name}</h3>
<Badge
className="capitalize shrink-0 mt-0.5"
variant="neutral"
>
{template.type.toLowerCase()}
</Badge>
</div>
<div className="flex items-center gap-2 ml-4">
<Link href={`/templates/${template.id}`}>
<Button variant="ghost" size="sm">
<Edit className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={() => handleDuplicate(template.id)}>
<p className="text-sm font-medium text-neutral-700 truncate">{template.subject}</p>
</Link>
<div className="px-6 py-3 border-t border-neutral-100 flex items-center justify-between">
<div className="flex items-center gap-1.5 text-xs text-neutral-400">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Updated {formatRelativeTime(template.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-md bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button asChild variant="ghost" size="sm" title="Edit template">
<Link href={`/templates/${template.id}`} aria-label="Edit template"><Edit className="h-4 w-4" /></Link>
</Button>
<Button variant="ghost" size="sm" title="Duplicate template" onClick={() => handleDuplicate(template.id)}>
<Copy className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
title="Delete template"
onClick={() => {
setTemplateToDelete(template.id);
setShowDeleteDialog(true);
@@ -245,43 +212,9 @@ export default function TemplatesPage() {
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div>
<p className="text-xs text-neutral-500 mb-1">Subject</p>
<p className="text-sm font-medium text-neutral-900">{template.subject}</p>
</div>
<div>
<p className="text-xs text-neutral-500 mb-1">From</p>
<p className="text-sm text-neutral-700">{template.from}</p>
</div>
<div className="flex items-center gap-4 text-xs text-neutral-500 pt-3 border-t border-neutral-100">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3" />
<div className="group relative inline-block cursor-help">
<span>Created {formatRelativeTime(template.createdAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.createdAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
</div>
<div className="group relative inline-block cursor-help">
<span> Updated {formatRelativeTime(template.updatedAt)}</span>
<div className="hidden group-hover:block absolute z-10 w-48 p-2 bg-neutral-900 text-white text-xs rounded shadow-lg bottom-full left-0 mb-1 whitespace-nowrap">
{dayjs(template.updatedAt).format('DD MMMM YYYY, hh:mm')}
</div>
</div>
{template.replyTo && (
<div>
Reply to: <span className="text-neutral-700">{template.replyTo}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
))}
</Card>
))}
</div>
{/* Pagination */}
{data && data.totalPages > 1 && (
+4 -40
View File
@@ -1,4 +1,4 @@
import {Button, Card, CardContent} from '@plunk/ui';
import {Button, Card, CardContent, IconSpinner} from '@plunk/ui';
import {createTranslator, type Translator} from '@plunk/shared';
import {AnimatePresence, motion} from 'framer-motion';
import {useRouter} from 'next/router';
@@ -72,19 +72,7 @@ export default function Unsubscribe() {
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner />
<p className="text-sm text-neutral-500">Loading...</p>
</div>
</CardContent>
@@ -101,19 +89,7 @@ export default function Unsubscribe() {
<Card>
<CardContent className="p-8">
<div className="flex flex-col items-center gap-4">
<svg
className="h-8 w-8 animate-spin text-neutral-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner />
<p className="text-sm text-neutral-500">{translator.t('pages.common.loading')}</p>
</div>
</CardContent>
@@ -236,19 +212,7 @@ export default function Unsubscribe() {
>
{unsubscribing ? (
<div className="flex items-center gap-2">
<svg
className="h-4 w-4 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<IconSpinner size="sm" />
<span>{translator.t('pages.unsubscribe.buttonLoading')}</span>
</div>
) : (

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