Compare commits

...
Author SHA1 Message Date
Marc Seitz 3d052a66bd chore: update dependencies 2025-08-21 18:32:45 +02:00
Marc Seitz a856420483 feat: add trigger helper 2025-08-21 18:27:35 +02:00
Marc Seitz b797cb982e fix: ref warning on buttontooltip 2025-08-21 18:27:25 +02:00
Marc Seitz 37f0a0c260 feat: improve thumbnail request errors 2025-08-21 18:20:11 +02:00
Marc Seitz 35b66ffcbf fix: remove errors on thumbnail requests 2025-08-21 12:01:14 +02:00
Marc SeitzandGitHub 297a81326e Merge pull request #1823 from mfts/fix/notion
feat: add separate function to fix internal notion api change
2025-08-20 12:20:00 +02:00
Marc Seitz 8c08d6c99c feat: add separate function to fix internal notion api change 2025-08-20 12:12:21 +02:00
Marc SeitzandGitHub 5c80c410df Merge pull request #1821 from mfts/feat/dataroom-bulk-download
feat: add toggle to disallow bulk download
2025-08-19 09:52:53 +02:00
Marc Seitz 9d8f0e5998 feat: add toggle to disallow bulk download 2025-08-19 09:20:29 +02:00
Marc SeitzandGitHub bebe96dd5c Merge pull request #1820 from mfts/chore/depss
feat: update fallback plan
2025-08-18 21:25:08 +02:00
Marc Seitz efb7ba9e4f feat: fix plan null 2025-08-18 21:07:06 +02:00
Marc Seitz 0590bab122 feat: don't default to free plan 2025-08-18 19:31:46 +02:00
Marc Seitz d014bdfefa chore: update deps 2025-08-18 19:31:28 +02:00
Marc SeitzandGitHub faf99e9ef3 Merge pull request #1817 from mfts/fix/pause
fix: update pause behavior
2025-08-16 16:27:39 +02:00
Marc Seitz 2a791d0ca3 fix: improvement 2025-08-16 00:52:44 +02:00
Marc Seitz 18bfebb11e fix: update pause behavior 2025-08-16 00:46:03 +02:00
Marc SeitzandGitHub 294efb89dd Merge pull request #1815 from mfts/feat/add-create-dataroom
feat: dynamically load code imports
2025-08-16 00:06:00 +02:00
Marc Seitz d926083097 feat: dynamically load code imports 2025-08-15 23:57:18 +02:00
Marc SeitzandGitHub fbee53aeb2 Merge pull request #1814 from mfts/mfts-patch-1
Create SECURITY.md
2025-08-15 22:43:17 +02:00
Marc SeitzandGitHub d18cb16461 Create SECURITY.md 2025-08-15 22:35:46 +02:00
Marc SeitzandGitHub 05cda54338 Merge pull request #1813 from mfts/feat/bulk
feat: allow download doc-less folders
2025-08-14 22:31:27 +02:00
Marc Seitz ac74bd2be0 feat: bump duration 2025-08-14 22:14:49 +02:00
Marc Seitz d5df006e64 fix 2 2025-08-14 22:11:19 +02:00
Marc SeitzandGitHub 4637d71bb2 Merge pull request #1812 from mfts/feat/bulk 2025-08-14 20:47:33 +02:00
Marc Seitz 7fa983977b fix 2025-08-14 20:41:26 +02:00
Marc Seitz 15dd0eff53 feat: extend 2025-08-14 19:34:39 +02:00
Marc SeitzandGitHub c6f08be03d Merge pull request #1811 from mfts/fix/invocations
feat: introduce ratelimiting to endpoint
2025-08-12 21:28:46 +02:00
Marc Seitz 8be2fa5366 feat: introduce ratelimiting to endpoint 2025-08-12 21:13:56 +02:00
Marc SeitzandGitHub ffc99b7e67 Merge pull request #1810 from mfts/fix/printscreen
feat: improve screenshot protection
2025-08-11 00:25:02 +02:00
Marc Seitz 42d05ed647 fix typo 2025-08-11 00:07:49 +02:00
Marc Seitz 2852de5572 feat: improve screenshot protection 2025-08-10 23:55:32 +02:00
Marc SeitzandGitHub e9bbaac5a0 Merge pull request #1808 from mfts/fix/quantity
fix: minimum quantity error
2025-08-09 14:35:19 +02:00
Marc SeitzGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
c1f49e855e Potential fix for code scanning alert no. 66: Use of externally-controlled format string
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-09 14:28:24 +02:00
Marc Seitz 9dcd9b68af fix: minimum quantity error 2025-08-09 14:22:45 +02:00
Marc SeitzandGitHub daefbde9bf Merge pull request #1805 from AshishViradiya153/427/500Error
attempt fix: user unable to log in
2025-08-09 13:56:57 +02:00
Ashish Viradiya 6f92160d57 attempt fix: user unable to log in 2025-08-07 20:46:09 +05:30
Marc SeitzandGitHub 78554ded87 Merge pull request #1804 from mfts/cursor/export-custom-fields-as-label-value-pairs-79e5
feat: export custom fields as label value pairs 79e5
2025-08-07 09:39:36 +02:00
Marc Seitz d335c7f1f7 feat: improve dataroom view lookup 2025-08-07 09:27:27 +02:00
Marc Seitz 67550162f2 fix: improve exports with nan 2025-08-07 09:11:54 +02:00
Marc Seitz e7e01dc6f0 Merge branch 'main' into cursor/export-custom-fields-as-label-value-pairs-79e5 2025-08-06 20:06:37 +02:00
Marc SeitzandGitHub 6a604d7a13 Merge pull request #1803 from mfts/cursor/add-no-datarooms-included-to-pro-plan-12af
feat: add no datarooms included to pro plan 12af
2025-08-06 20:06:10 +02:00
Marc Seitz 615ac0220f Merge branch 'main' into cursor/add-no-datarooms-included-to-pro-plan-12af 2025-08-06 19:57:56 +02:00
Marc SeitzandGitHub 029d765f5d Merge pull request #1797 from mfts/feat/cancel-flow
feat: v1 cancel
2025-08-06 19:36:48 +02:00
Marc Seitz 0cdc74258f refactor: change plan function 2025-08-06 19:30:08 +02:00
Marc Seitz c2182a6042 feat: improve 2025-08-06 19:29:51 +02:00
Marc Seitz e219c53739 feat: improve roles 2025-08-06 19:29:32 +02:00
Marc Seitz 1bb7e79a37 chore: cleanup 2025-08-06 19:29:22 +02:00
Marc Seitz bf7c9ff5d4 fix 2025-08-06 19:04:56 +02:00
Marc Seitz c652ad607b feat(ee): add native billing flow 2025-08-06 18:55:13 +02:00
Cursor Agentandmarcftone 141def2c54 Refactor custom fields export to support dynamic and consistent column generation
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-06 07:11:28 +00:00
Marc Seitz a923ce6dfb refactor: change price id retrieval function 2025-08-04 21:09:09 +02:00
Cursor Agentandmarcftone 7de964e4f0 Add feature flag for not included features in plan comparison
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-04 09:43:41 +00:00
Marc SeitzandGitHub f386f275e6 Merge pull request #1800 from mfts/fix/watermar
feat: keep original name for watermarked document
2025-08-04 11:18:57 +02:00
Marc Seitz 46d8922f59 feat: keep original name for watermarked document 2025-08-04 10:58:04 +02:00
Marc SeitzandGitHub 44292347f1 Merge pull request #1799 from mfts/cursor/add-phone-number-field-to-custom-fields-58b7
feat: add phone number field to custom fields 58b7
2025-08-04 10:39:09 +02:00
Marc Seitz c6fc0b824b fix: component height 2025-08-04 10:26:00 +02:00
Marc Seitz eb70302dd2 feat: upgrade phone input component 2025-08-04 10:23:42 +02:00
Marc SeitzandGitHub 0f3aeb5ea7 Merge pull request #1798 from mfts/cursor/track-teamid-in-link-viewed-event-112e
feat: track teamid in link viewed event 112e
2025-08-03 20:17:50 +02:00
Marc Seitz 0dfdd2565e feat: add teamid to link viewed events 2025-08-03 20:11:51 +02:00
Marc Seitz a8560359fd feat: v1 cancel 2025-08-03 19:27:23 +02:00
Cursor Agentandmarcftone a4cb2e4e72 Pass teamId to tracking context in document and dataroom views
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-03 16:38:17 +00:00
Cursor Agentandmarcftone 9c8841620a Add phone number support for custom fields in access form
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-03 09:21:09 +00:00
Marc SeitzandGitHub 0df6b96d68 Merge pull request #1795 from mfts/cursor/add-document-and-page-number-context-48ff
feat(ee): add document and page number context 48ff
2025-08-02 20:54:08 +02:00
Marc Seitz 9226ba5a70 feat(ee): add conversation context 2025-08-02 20:40:30 +02:00
Marc Seitz 927796c0cd Merge branch 'main' into cursor/add-document-and-page-number-context-48ff 2025-08-02 18:34:59 +02:00
Marc SeitzandGitHub 3988e6af49 Merge pull request #1794 from mfts/cursor/notify-team-members-of-new-conversation-messages-311d
feat(ee): notify team members of new conversation messages 311d
2025-08-02 18:30:17 +02:00
Marc Seitz f962b97729 fix 2025-08-02 18:23:12 +02:00
Marc Seitz 2b918234d8 fix: validation 2025-08-02 18:20:34 +02:00
Marc Seitz 07c2821d4e feat(ee): update conversation notifications for team members 2025-08-02 16:18:53 +02:00
Cursor Agentandmarcftone 6fe27092d4 Add team member notifications for conversations in datarooms
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-02 10:55:02 +00:00
Marc SeitzandGitHub 09e1d58d07 Merge pull request #1793 from mfts/feat/rtf-support
feat: add rtf / txt file support
2025-08-02 12:50:13 +02:00
Cursor Agentandmarcftone b66dd7f3d1 Add document context and improve conversation title display
Co-authored-by: marcftone <marcftone@gmail.com>
2025-08-02 09:32:41 +00:00
Marc Seitz 179a93c31b feat: add rtf / txt file support 2025-08-02 11:23:33 +02:00
Marc SeitzandGitHub 9d50baec54 Merge pull request #1792 from mfts/cursor/implement-multipart-file-uploads-with-pre-signed-urls-3ae0
feat: implement multipart file uploads with pre signed urls 3ae0
2025-07-31 22:02:03 +02:00
Marc Seitz afc210d9cb feat: add abort multipart 2025-07-31 21:43:59 +02:00
Marc Seitz cb05dd0656 feat: add validation 2025-07-31 21:42:47 +02:00
Marc Seitz ea59fdbdc5 fix 2025-07-31 13:37:49 +02:00
Marc Seitz 116a903489 feat: add multi part upload 2025-07-31 13:37:30 +02:00
Marc Seitz 73798ce172 Merge branch 'main' into cursor/implement-multipart-file-uploads-with-pre-signed-urls-3ae0 2025-07-30 20:55:44 +02:00
Marc SeitzandGitHub be8d482efc Merge pull request #1791 from mfts/feat/changeint
fix: change fileSize to bigint
2025-07-30 14:46:05 +02:00
Marc Seitz 1a9b75a1a4 feat: add serialization for bigint from database to client
cast bigint to number because js number is basically bigint
2025-07-30 14:37:15 +02:00
Marc Seitz e1803c1f01 fix: change fileSize to bigint 2025-07-30 13:39:16 +02:00
Cursor Agentandmarcftone ff3ee45c14 Implement multipart S3 upload with parallel processing for large files
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-30 09:09:58 +00:00
Marc SeitzandGitHub 3f3e8c8377 Merge pull request #1790 from mfts/feat/add-gtm
fix: passkeys
2025-07-29 18:46:56 +02:00
Marc Seitz be52b37f3d feat: don't send event if already sent 2025-07-29 18:39:55 +02:00
Marc Seitz 1afc0832fc fix: passkey username required 2025-07-29 18:39:27 +02:00
Marc SeitzandGitHub fcf5be17e6 Merge pull request #1789 from mfts/feat/add-gtm
feat: add gtm
2025-07-29 17:51:23 +02:00
Marc Seitz 9d81120017 chore: update dependencies 2025-07-29 17:45:01 +02:00
Marc Seitz 30e8c6e8cf feat: add gtm 2025-07-29 17:41:14 +02:00
Marc SeitzandGitHub 47b91488cb Merge pull request #1788 from mfts/cursor/add-cancel-export-button-to-stop-trigger-run-7af9
feat: add cancel export button and recent exports
2025-07-29 13:00:14 +02:00
Marc Seitz d28a2a257c feat: show recent exports 2025-07-29 12:46:43 +02:00
Marc Seitz 428c0fbd2d chore: cleanup cancel button 2025-07-29 11:18:58 +02:00
Cursor Agentandmarcftone fb5e4ef47f Refactor: Use ExportJobCleanupItem interface in RedisJobStore
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-29 08:50:53 +00:00
Cursor Agentandmarcftone e404919c45 Handle Redis client auto-parsing of job store items
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-29 08:35:13 +00:00
Cursor Agentandmarcftone 589902deb1 Add export job cancellation support for background tasks
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-29 08:26:48 +00:00
Marc SeitzandGitHub 31d44dac27 Merge pull request #1787 from mfts/fix/passkeys
feat: list passkeys
2025-07-28 21:07:43 +02:00
Marc Seitz 15e8d1c7d7 fix 2025-07-28 20:46:22 +02:00
Marc Seitz 20f423c3c1 fix: improve passkey requests 2025-07-28 20:41:23 +02:00
Marc SeitzandGitHub 7b8bd28ca5 Merge pull request #1786 from mfts/cursor/add-notion-public-access-checker-and-url-management-b997
feat: add notion access checker
2025-07-28 20:29:41 +02:00
Marc Seitz 13266da720 fix 2025-07-28 19:15:09 +02:00
Marc Seitz bfe0cdc963 feat: list passkeys 2025-07-28 19:12:46 +02:00
Marc Seitz bc1671f063 fix 2025-07-28 18:39:44 +02:00
Marc Seitz e99accc2ec feat: add notion indicator 2025-07-28 18:10:11 +02:00
Cursor Agentandmarcftone 59f27b3afd Add Notion document accessibility indicator and URL management
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-28 12:42:16 +00:00
Marc SeitzandGitHub bf246ff863 Merge pull request #1784 from mfts/feat/breakpoint-improve
feat: improve useBreakpoint hook performance and accuracy
2025-07-26 11:44:23 +02:00
Marc Seitz 2a1d1465cc fix: 2025-07-26 11:29:19 +02:00
Marc Seitz b8cbf94b4d feat: improve useBreakpoint hook performance and accuracy
Refactored useBreakpoint to use matchMedia for more efficient breakpoint detection and added a debounced resize listener for edge cases. The hook now initializes state as undefined and ensures state updates only when the value changes, returning a boolean for consistent usage.
2025-07-26 11:17:26 +02:00
Marc SeitzandGitHub adc450c899 Merge pull request #1748 from AshishViradiya153/smallScreenUi
improvement: small screen shows only icon in btn with tooltip
2025-07-25 20:48:10 +02:00
Marc SeitzandGitHub 0d55dba60d Merge pull request #1768 from AshishViradiya153/407/customeLabel
added ability to change 'Your action is requested'
2025-07-25 20:38:28 +02:00
Marc Seitz f0d08fe2a4 feat: make add documents always big 2025-07-25 20:37:23 +02:00
Marc Seitz b0fbddbd60 refactor: move responsive button to ui directory 2025-07-25 20:37:13 +02:00
Marc Seitz 3d722e18ec feat: update welcome message input w/ sanitzation 2025-07-25 20:25:58 +02:00
Marc Seitz 4c6d90ac5d chore: delete unused page 2025-07-25 20:25:41 +02:00
Marc Seitz e4601bbd41 chore: update migration 2025-07-25 20:25:34 +02:00
Marc Seitz b437a4ba04 Merge branch 'main' into 407/customeLabel 2025-07-25 19:50:58 +02:00
Marc SeitzandGitHub 95c60ae564 Merge pull request #1733 from AshishViradiya153/380/upgradeBtn
Improvement: Unify upgrade buttons everywhere
2025-07-25 19:50:37 +02:00
Marc Seitz c0da96f123 Merge branch 'main' into 407/customeLabel 2025-07-25 19:49:51 +02:00
Marc Seitz 7198b4c205 chore: update plan text on dr notifications 2025-07-25 19:38:12 +02:00
Marc Seitz a13c7d9a77 docs: update trial banner 2025-07-25 19:36:37 +02:00
Marc SeitzandGitHub 680fd69f4d Merge pull request #1776 from AshishViradiya153/414/emailNotification
improvement: notify team if blocked email tried to access a link
2025-07-25 19:31:46 +02:00
Marc Seitz f4d21c64e4 Merge branch 'main' into 380/upgradeBtn 2025-07-25 19:25:31 +02:00
Marc SeitzandGitHub 7144f46152 Merge pull request #1779 from AshishViradiya153/planModal/highlight
improvement: Added highlights when click on adding folder
2025-07-25 19:24:56 +02:00
Marc Seitz 2da5c31de7 fix: improvements 2025-07-25 19:24:20 +02:00
Marc Seitz 3844d87d6e Merge branch 'main' into 380/upgradeBtn 2025-07-25 19:21:21 +02:00
Marc Seitz 373a15672c chore: unused import 2025-07-25 19:11:42 +02:00
Marc Seitz d023c95272 feat(ee): send blocked access email notifications 2025-07-25 19:09:57 +02:00
Marc Seitz e6c8b4b80f chore: undo email notifications 2025-07-25 19:09:39 +02:00
Marc Seitz 6e6595b709 Merge branch 'main' into 414/emailNotification 2025-07-25 18:11:59 +02:00
Marc SeitzandGitHub 132c9815fa Merge pull request #1782 from mfts/fix/watermark
fix: add missing needsWatermark on dataroom folder
2025-07-24 23:45:50 +02:00
Marc Seitz 48f443bb97 fix: add missing needsWatermark on dataroom folder 2025-07-24 23:36:27 +02:00
Marc SeitzandGitHub 4d77cfe342 Merge pull request #1781 from mfts/fix/watermark
fix: watermark on partial folder downloads
2025-07-24 23:15:28 +02:00
Marc Seitz c64a83400b fix: watermark on partial folder downloads 2025-07-24 23:07:43 +02:00
Marc SeitzandGitHub 3e54f057a0 Merge pull request #1780 from mfts/fix/codeql
fix: codeql
2025-07-24 22:45:08 +02:00
Marc Seitz ef1a00d712 fix 2025-07-24 14:53:03 +02:00
Marc Seitz a65049fe8d fix 2025-07-24 14:49:40 +02:00
Marc Seitz 29236ae6b6 fix: codeql 2025-07-24 14:44:00 +02:00
Ashish Viradiya 603b9bd3e7 improvement: Added highlights when click on adding folder 2025-07-24 18:00:52 +05:30
Marc Seitz 1eb0e56f67 chore: dependency updates 2025-07-24 14:30:12 +02:00
Marc Seitz 9c29192245 refactor: remove react-syntax-highlighter for shiki 2025-07-24 14:08:29 +02:00
Marc SeitzandGitHub d7440bfe5e Merge pull request #1778 from mfts/fix/codeql
fix: improve code coverage
2025-07-24 13:58:05 +02:00
Marc Seitz aad1e31d17 feat: protect endpoints 2025-07-24 13:49:25 +02:00
Marc Seitz 1545332b8b feat: add zod validations for fetch url inputs 2025-07-24 13:40:49 +02:00
Marc SeitzandGitHub 03f79d3f57 Merge pull request #1777 from mfts/fix/codeql
fix: codeql
2025-07-24 08:42:40 +02:00
Marc Seitz 3c4a4a254d feat: add shared folder path validation schema
Introduced a Zod schema for validating folder path parameters to prevent type confusion and path traversal attacks. Updated all relevant API routes to use this schema for input validation, improving security and consistency across folder-related endpoints.
2025-07-24 08:30:36 +02:00
Marc Seitz ab73dbc66f fix: remove handlebars and validate separately 2025-07-24 08:18:32 +02:00
Marc SeitzandGitHub daf2d9b4a2 Merge pull request #1772 from AshishViradiya153/411/wordbreak
Fix: prevent long document names from overflowing modal dialog
2025-07-23 21:11:07 +02:00
Marc Seitz bdd67017b8 Merge branch 'main' into 411/wordbreak 2025-07-23 21:05:19 +02:00
Marc Seitz 0e4d45a28a fix: add overflow truncation to move document to folder modal 2025-07-23 21:04:51 +02:00
Marc SeitzandGitHub e1e7831513 Merge pull request #1726 from AshishViradiya153/382/popup
Added data room trial button on each popup
2025-07-23 20:48:10 +02:00
Marc Seitz d761f78873 Merge branch 'main' into 411/wordbreak 2025-07-23 20:47:42 +02:00
Marc Seitz 13f3de029d chore: update deps 2025-07-23 20:41:12 +02:00
Marc Seitz b57487b9a3 Merge branch 'main' into 382/popup 2025-07-23 20:39:19 +02:00
Ashish Viradiya 84e206e484 define type 2025-07-21 19:39:13 +05:30
Ashish Viradiya 2705c580f3 improvement: notify team if blocked email tried to access a link 2025-07-21 18:54:34 +05:30
Ashish Viradiya e5a1c11a80 fix option selection text overflow 2025-07-21 09:03:16 +05:30
Ashish Viradiya f0d79474e0 Fix: prevent long document names from overflowing modal dialog 2025-07-21 08:24:19 +05:30
Ashish Viradiya 950f7e12e3 added ability to change 'Your action is requested' 2025-07-17 04:26:31 +05:30
Ashish Viradiya 0503d904ac improvement 2025-07-08 23:03:46 +05:30
Ashish Viradiya 185d699cad improvement: small screen shows only icon in btn with tooltip 2025-07-08 21:23:09 +05:30
Ashish Viradiya 7385393342 update 2025-07-02 02:56:37 +05:30
Ashish Viradiya f70b1c06e0 Improvement: Unify upgrade buttons everywhere 2025-07-02 00:32:38 +05:30
Ashish Viradiya e569677358 Added data room trial button on each popup 2025-06-30 18:11:25 +05:30
212 changed files with 10656 additions and 3088 deletions
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
All versions of Papermark are currently being supported with security updates.
## Reporting a Vulnerability
To report a vulnerability, send an email to security@papermark.com.
We will respond within 48 hours acknowledging your report with details about next steps and potential rewards/compensation for responsible disclosure.
+4 -7
View File
@@ -1,6 +1,5 @@
"use client";
import { Metadata } from "next";
import Link from "next/link";
import { useParams } from "next/navigation";
@@ -145,9 +144,7 @@ export default function Login() {
signIn("google", {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
if (res?.status) {
setClickedMethod(undefined);
}
setClickedMethod(undefined);
});
}}
loading={clickedMethod === "google"}
@@ -169,9 +166,7 @@ export default function Login() {
signIn("linkedin", {
...(next && next.length > 0 ? { callbackUrl: next } : {}),
}).then((res) => {
if (res?.status) {
setClickedMethod(undefined);
}
setClickedMethod(undefined);
});
}}
loading={clickedMethod === "linkedin"}
@@ -192,6 +187,8 @@ export default function Login() {
setClickedMethod("passkey");
signInWithPasskey({
tenantId: process.env.NEXT_PUBLIC_HANKO_TENANT_ID as string,
}).then(() => {
setClickedMethod(undefined);
});
}}
variant="outline"
+8 -1
View File
@@ -1,5 +1,7 @@
import { Metadata } from "next";
import { GTMComponent } from "@/components/gtm-component";
import LoginClient from "./page-client";
const data = {
@@ -37,5 +39,10 @@ export const metadata: Metadata = {
};
export default function LoginPage() {
return <LoginClient />;
return (
<>
<GTMComponent />
<LoginClient />
</>
);
}
+10
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { ItemType, LinkAudienceType } from "@prisma/client";
@@ -98,6 +99,8 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
dataroomId: true,
emailProtected: true,
@@ -286,6 +289,8 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -298,6 +303,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -314,6 +321,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is denied
if (isDenied) {
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -356,6 +365,7 @@ export async function POST(request: NextRequest) {
: false;
if (!isMember && !hasDomainAccess) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
+11 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
// Import authOptions directly from the source
import { authOptions } from "@/pages/api/auth/[...nextauth]";
@@ -19,7 +20,7 @@ import { parseSheet } from "@/lib/sheet";
import { recordLinkView } from "@/lib/tracking/record-link-view";
import { CustomUser, WatermarkConfigSchema } from "@/lib/types";
import { checkPassword, decryptEncrpytedPassword, log } from "@/lib/utils";
import { extractEmailDomain, isEmailMatched } from "@/lib/utils/email-domain";
import { isEmailMatched } from "@/lib/utils/email-domain";
import { generateOTP } from "@/lib/utils/generate-otp";
import { LOCALHOST_IP } from "@/lib/utils/geo";
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
@@ -84,6 +85,9 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
emailProtected: true,
enableNotification: true,
emailAuthenticated: true,
@@ -221,6 +225,8 @@ export async function POST(request: NextRequest) {
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
@@ -233,6 +239,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -249,6 +257,8 @@ export async function POST(request: NextRequest) {
// Deny access if the email is denied
if (isDenied) {
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
+9 -14
View File
@@ -39,8 +39,8 @@ import { DataTablePagination } from "@/components/visitors/data-table-pagination
import { usePlan } from "@/lib/swr/use-billing";
import { fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradeButton } from "../ui/upgrade-button";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
interface Document {
id: string;
@@ -221,20 +221,15 @@ export default function DocumentsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_documents_export"
open={open}
setOpen={setOpen}
/>
</>
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_documents_export"
variant="outline"
size="sm"
/>
);
} else {
return (
@@ -251,7 +246,7 @@ export default function DocumentsTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="rounded-xl border overflow-x-auto">
<div className="overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+9 -14
View File
@@ -43,7 +43,7 @@ import { cn, timeAgo } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
interface Link {
id: string;
@@ -285,20 +285,15 @@ export default function LinksTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_links_export"
open={open}
setOpen={setOpen}
/>
</>
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_links_export"
variant="outline"
size="sm"
/>
);
} else {
return (
@@ -315,7 +310,7 @@ export default function LinksTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="rounded-xl border overflow-x-auto">
<div className="overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+9 -27
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
@@ -24,8 +24,6 @@ import {
Download,
DownloadCloudIcon,
FileBadgeIcon,
FileDigitIcon,
MoreHorizontalIcon,
ServerIcon,
ThumbsDownIcon,
ThumbsUpIcon,
@@ -38,11 +36,6 @@ import { cn, durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Gauge } from "@/components/ui/gauge";
import {
Table,
@@ -55,13 +48,7 @@ import {
import { BadgeTooltip } from "@/components/ui/tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import VisitorChart from "@/components/visitors/visitor-chart";
import VisitorClicks from "@/components/visitors/visitor-clicks";
import VisitorCustomFields from "@/components/visitors/visitor-custom-fields";
import VisitorUserAgent from "@/components/visitors/visitor-useragent";
import VisitorVideoChart from "@/components/visitors/visitor-video-chart";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
interface View {
id: string;
@@ -341,20 +328,15 @@ export default function ViewsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_views_export"
open={open}
setOpen={setOpen}
/>
</>
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_views_export"
variant="outline"
size="sm"
/>
);
} else {
return (
+9 -16
View File
@@ -26,7 +26,6 @@ import { toast } from "sonner";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import { Gauge } from "@/components/ui/gauge";
import {
Table,
TableBody,
@@ -42,8 +41,7 @@ import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import { usePlan } from "@/lib/swr/use-billing";
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
interface Visitor {
email: string;
@@ -269,20 +267,15 @@ export default function VisitorsTable({
};
const UpgradeOrExportButton = () => {
const [open, setOpen] = useState(false);
if (isFree && !isTrial) {
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
Upgrade to Export
</Button>
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger="dashboard_visitors_export"
open={open}
setOpen={setOpen}
/>
</>
<UpgradeButton
text="Export"
clickedPlan={PlanEnum.Pro}
trigger="dashboard_visitors_export"
variant="outline"
size="sm"
/>
);
} else {
return (
@@ -299,7 +292,7 @@ export default function VisitorsTable({
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
<div className="rounded-xl border overflow-x-auto">
<div className="overflow-x-auto rounded-xl border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
+6 -5
View File
@@ -37,17 +37,18 @@ export function AddSeatModal({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const analytics = useAnalytics();
const { plan: userPlan, planName, isAnnualPlan } = usePlan();
const { plan: userPlan, planName, isAnnualPlan, isOldAccount } = usePlan();
const { limits } = useLimits();
const [quantity, setQuantity] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(false);
// Get the minimum quantity for the current plan
const priceId = getPriceIdFromPlan(
planName,
isAnnualPlan ? "yearly" : "monthly",
);
const priceId = getPriceIdFromPlan({
planSlug: userPlan,
isOld: isOldAccount,
period: isAnnualPlan ? "yearly" : "monthly",
});
const minQuantity = getQuantityFromPriceId(priceId);
// Set initial quantity to 1 (adding one seat)
+7 -3
View File
@@ -8,11 +8,11 @@ import Cookies from "js-cookie";
import { usePlausible } from "next-plausible";
import { toast } from "sonner";
import { usePlan } from "@/lib/swr/use-billing";
import X from "@/components/shared/icons/x";
import { Button } from "@/components/ui/button";
import { usePlan } from "@/lib/swr/use-billing";
export default function ProAnnualBanner({
setShowProAnnualBanner,
}: {
@@ -63,7 +63,11 @@ export default function ProAnnualBanner({
"Content-Type": "application/json",
},
body: JSON.stringify({
priceId: getPriceIdFromPlan("Pro", "yearly"),
priceId: getPriceIdFromPlan({
planSlug: "pro",
isOld: isOldAccount,
period: "yearly",
}),
upgradePlan: true,
proAnnualBanner: true,
}),
+304 -42
View File
@@ -1,9 +1,16 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { CancellationModal } from "@/ee/features/billing/cancellation/components";
import { PlanEnum } from "@/ee/stripe/constants";
import { CreditCardIcon, MoreVertical, ReceiptTextIcon } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import {
@@ -14,27 +21,55 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { usePlan } from "@/lib/swr/use-billing";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { UpgradeButton } from "@/components/ui/upgrade-button";
export default function UpgradePlanContainer() {
const router = useRouter();
const [loading, setLoading] = useState<boolean>(false);
const teamInfo = useTeam();
const { plan, isFree, isDataroomsPlus } = usePlan();
const [unpauseLoading, setUnpauseLoading] = useState<boolean>(false);
const [cancellationModalOpen, setCancellationModalOpen] =
useState<boolean>(false);
const { currentTeamId } = useTeam();
const {
plan,
isFree,
isDataroomsPlus,
isPaused,
isCancelled,
isCustomer,
startsAt,
endsAt,
discount,
mutate: mutatePlan,
} = usePlan({ withDiscount: true });
const analytics = useAnalytics();
const manageSubscription = async () => {
if (!teamInfo?.currentTeam?.id) {
return;
}
const teamId = teamInfo?.currentTeam?.id;
const manageSubscription = async ({
type,
}: {
type:
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update";
}) => {
if (!currentTeamId) return;
setLoading(true);
try {
fetch(`/api/teams/${teamId}/billing/manage`, {
fetch(`/api/teams/${currentTeamId}/billing/manage`, {
method: "POST",
body: JSON.stringify({ type }),
headers: {
"Content-Type": "application/json",
},
})
.then(async (res) => {
const url = await res.json();
@@ -53,48 +88,275 @@ export default function UpgradePlanContainer() {
}
};
const handleUnpauseSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/unpause`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to unpause subscription");
}
// Track the unpause event for analytics
analytics.capture("Subscription Unpaused", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription unpaused successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const handleReactivateSubscription = async () => {
if (!currentTeamId) return;
setUnpauseLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/reactivate`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to reactivate subscription");
}
// Track the reactivation event for analytics
analytics.capture("Subscription Reactivated", {
teamId: currentTeamId,
plan: plan,
});
toast.success("Subscription reactivated successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} catch (error) {
console.error(error);
} finally {
setUnpauseLoading(false);
}
};
const isBillingCycleCurrent = () => {
if (!startsAt || !endsAt) return false;
const currentDate = new Date();
return currentDate >= new Date(startsAt) && currentDate <= new Date(endsAt);
};
const getDiscountText = () => {
if (!discount || !discount.valid) return null;
let discountText = "";
if (discount.percentOff) {
discountText = `${discount.percentOff}% off`;
} else if (discount.amountOff) {
discountText = `$${(discount.amountOff / 100).toFixed(2)} off`;
}
if (discount.duration === "repeating" && discount.durationInMonths) {
discountText += ` for ${discount.durationInMonths} month${discount.durationInMonths > 1 ? "s" : ""}`;
} else if (discount.duration === "once") {
discountText += " (one-time)";
}
return discountText;
};
const ButtonList = () => {
if (isFree) {
return (
<Link href="/settings/upgrade">
<Button>Upgrade Plan</Button>
</Link>
<div className="flex items-center gap-3">
<UpgradeButton
text=""
customText="Upgrade"
clickedPlan={PlanEnum.Business}
trigger="upgrade_plan"
useModal={false}
onClick={() => router.push("/settings/upgrade")}
/>
{isCustomer && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "invoices" })}
>
<ReceiptTextIcon className="h-4 w-4" />
View invoices
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
} else if (isCancelled) {
return (
<Button onClick={handleReactivateSubscription} loading={unpauseLoading}>
Reactivate subscription
</Button>
);
} else {
return (
<Button onClick={manageSubscription} loading={loading}>
Manage Subscription
</Button>
<div className="flex items-center gap-3">
{isPaused ? (
<Button
onClick={handleUnpauseSubscription}
loading={unpauseLoading}
>
Unpause subscription
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setCancellationModalOpen(true)}
>
Cancel subscription
</Button>
<Button
variant="outline"
onClick={() =>
manageSubscription({ type: "subscription_update" })
}
loading={loading}
>
Change plan
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "manage" })}
>
<CreditCardIcon className="h-4 w-4" />
Change billing information
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => manageSubscription({ type: "invoices" })}
>
<ReceiptTextIcon className="h-4 w-4" />
View invoices
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
);
}
};
return (
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>Billing</CardTitle>
<CardDescription>
You are currently on the{" "}
<span className="mx-0.5 rounded-full bg-background px-2.5 py-1 text-xs font-bold tracking-normal text-foreground ring-1 ring-gray-800 dark:ring-gray-400">
<>
<div className="rounded-lg">
<Card className="bg-transparent">
<CardHeader>
<CardTitle>
{isDataroomsPlus
? "Datarooms+"
: plan.charAt(0).toUpperCase() + plan.slice(1)}
</span>{" "}
plan.{" "}
<Link
href="/settings/upgrade"
className="text-sm underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
</CardDescription>
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">
<div className="shrink-0">{ButtonList()}</div>
</CardFooter>
</Card>
</div>
: plan.charAt(0).toUpperCase() + plan.slice(1)}{" "}
Plan
</CardTitle>
{!isCancelled && startsAt && endsAt && isBillingCycleCurrent() && (
<CardDescription>
<span className="font-medium text-foreground">
Current billing cycle:{" "}
</span>
<span className="text-foreground">
{new Date(startsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
{" - "}
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isPaused && endsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription will pause on:{" "}
</span>
<span className="text-foreground">
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{isCancelled && endsAt && (
<CardDescription>
<span className="font-medium text-foreground">
Subscription cancels on:{" "}
</span>
<span className="text-foreground">
{new Date(endsAt).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</span>
</CardDescription>
)}
{discount && discount.valid && getDiscountText() && (
<CardDescription>
<div className="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20 dark:bg-green-400/10 dark:text-green-400 dark:ring-green-400/30">
🎉 {getDiscountText()} applied
</div>
</CardDescription>
)}
</CardHeader>
<CardContent></CardContent>
<CardFooter className="flex items-center justify-end rounded-b-lg border-t px-6 py-3">
<div className="shrink-0">{ButtonList()}</div>
</CardFooter>
</Card>
</div>
<CancellationModal
open={cancellationModalOpen}
onOpenChange={setCancellationModalOpen}
/>
</>
);
}
+19 -11
View File
@@ -9,8 +9,7 @@ import { getStripe } from "@/ee/stripe/client";
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
import { PLANS } from "@/ee/stripe/utils";
import { CheckIcon, Users2Icon, CircleHelpIcon } from "lucide-react";
import { toast } from "sonner";
import { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
@@ -20,11 +19,11 @@ import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import {
BadgeTooltip,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
BadgeTooltip,
} from "@/components/ui/tooltip";
// Start Data Room Trial Button Component
@@ -32,7 +31,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
const router = useRouter();
const handleStartTrial = () => {
router.push('/welcome?type=dataroom-trial');
router.push("/welcome?type=dataroom-trial");
};
return (
@@ -53,7 +52,11 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
return (
<div className={cn("justify-between gap-x-8", baseClasses)}>
<div className="flex items-center gap-x-3">
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
{feature.isNotIncluded ? (
<XIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<span>{feature.text}</span>
</div>
{feature.tooltip && (
@@ -76,7 +79,11 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
return (
<div className={cn("text-sm", baseClasses)}>
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
{feature.isNotIncluded ? (
<XIcon className="mr-3 h-5 w-5 flex-shrink-0 text-gray-500" />
) : (
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
)}
<div className="flex items-center gap-2">
<span>{feature.text}</span>
{feature.tooltip && (
@@ -313,10 +320,11 @@ export function UpgradePlanModal({
loading={selectedPlan === planOption}
disabled={selectedPlan !== null}
onClick={() => {
const priceId = getPriceIdFromPlan(
displayPlanName,
const priceId = getPriceIdFromPlan({
planName: displayPlanName,
period,
);
isOld: isOldAccount,
});
setSelectedPlan(planOption);
if (isCustomer && teamPlan !== "free") {
@@ -382,8 +390,8 @@ export function UpgradePlanModal({
>
See all plans
</Link>
{trigger === "sidebar_datarooms" &&
((teamPlan === "free" && !isTrial) || (teamPlan === "pro" && !isTrial)) && (
{((teamPlan === "free" && !isTrial) ||
(teamPlan === "pro" && !isTrial)) && (
<>
<span>|</span>
<StartDataRoomTrialButton teamId={teamId} />
+77 -38
View File
@@ -7,30 +7,39 @@ import { getColorForVersion, timeFormatter } from "./utils";
const CustomTooltip = ({
payload,
active,
documentId: propDocumentId,
}: {
payload: any;
active: boolean | undefined;
documentId?: string;
}) => {
const router = useRouter(); // Call useRouter at the top level
const documentId = router.query.id as string;
// Use prop documentId if provided, otherwise fall back to router
const documentId = propDocumentId || (router.query.id as string);
// Default pageNumber to 0 or a sensible default if payload is not available
const pageNumber =
payload && payload.length > 0 ? parseInt(payload[0].payload.pageNumber) : 0;
// Safely extract pageNumber and versionNumber with validation
const pageNumberStr =
payload && payload.length > 0 ? payload[0]?.payload?.pageNumber : null;
// Default versionNumber to 0 or a sensible default if payload is not available
const versionNumber =
payload && payload.length > 0
? parseInt(payload[0].payload.versionNumber)
: 1;
const { data, error } = useDocumentThumbnail(
pageNumber,
documentId,
versionNumber,
const pageNumber = pageNumberStr ? parseInt(pageNumberStr, 10) : 0;
// For thumbnails, don't specify version number - let the API use the primary/latest version
const versionNumber = undefined;
// Only fetch thumbnail if we have valid documentId and pageNumber
const shouldFetchThumbnail =
documentId && pageNumber > 0 && !isNaN(pageNumber);
// Always call the hook - this is required by React rules of hooks
const { data, error, loading } = useDocumentThumbnail(
shouldFetchThumbnail ? pageNumber : 0, // Pass 0 to disable the hook if invalid
documentId || "",
versionNumber, // undefined means use primary/latest version
);
const imageUrl = data && !error ? data.imageUrl : null; // Always called, regardless of `active` or `payload`
const imageUrl =
shouldFetchThumbnail && data && !error ? data.imageUrl : null;
// Early return after hooks are called
if (!active || !payload || payload.length === 0) return null;
return (
@@ -38,34 +47,64 @@ const CustomTooltip = ({
<div className="w-52 rounded-md border border-tremor-border bg-tremor-background text-sm leading-6 dark:border-dark-tremor-border dark:bg-dark-tremor-background">
<div className="rounded-t-md border-b border-tremor-border bg-tremor-background px-2.5 py-2 dark:border-dark-tremor-border dark:bg-dark-tremor-background">
<p className="font-medium text-tremor-content dark:text-dark-tremor-content">
Page {payload[0].payload.pageNumber}
Page {pageNumber || "Unknown"}
</p>
{imageUrl ? (
<img
src={imageUrl}
alt={`Page ${payload[0].payload.pageNumber} Thumbnail`}
/>
) : null}
{shouldFetchThumbnail && (
<>
{loading && (
<div className="mt-2 flex items-center justify-center p-4">
<div className="text-xs text-tremor-content-subtle dark:text-dark-tremor-content-subtle">
Loading thumbnail...
</div>
</div>
)}
{imageUrl && !loading && (
<img
src={imageUrl}
alt={`Page ${pageNumber} Thumbnail`}
className="mt-2 max-w-full"
onError={(e) => {
// Hide image if it fails to load
e.currentTarget.style.display = "none";
}}
/>
)}
{error && !loading && (
<div className="mt-2 text-xs text-tremor-content-subtle dark:text-dark-tremor-content-subtle">
Thumbnail unavailable
</div>
)}
</>
)}
</div>
{payload.map((item: any, idx: number) => (
<div
className="flex w-full items-center justify-between space-x-4 px-2.5 py-2"
key={idx}
>
<div className="text-overflow-ellipsis flex items-center space-x-2 overflow-hidden whitespace-nowrap">
<span
className={`bg-${getColorForVersion(item.dataKey)}-500 h-2.5 w-2.5 flex-shrink-0 rounded-full`}
aria-hidden="true"
></span>
<p className="text-overflow-ellipsis overflow-hidden whitespace-nowrap text-tremor-content dark:text-dark-tremor-content">
{item.dataKey}
{payload
.filter(
(item: any) =>
// Only show items that are not metadata fields
item.dataKey !== "versionNumber" &&
item.dataKey !== "pageNumber" &&
typeof item.value === "number" &&
item.value > 0,
)
.map((item: any, idx: number) => (
<div
className="flex w-full items-center justify-between space-x-4 px-2.5 py-2"
key={idx}
>
<div className="text-overflow-ellipsis flex items-center space-x-2 overflow-hidden whitespace-nowrap">
<span
className={`bg-${getColorForVersion(item.dataKey)}-500 h-2.5 w-2.5 flex-shrink-0 rounded-full`}
aria-hidden="true"
></span>
<p className="text-overflow-ellipsis overflow-hidden whitespace-nowrap text-tremor-content dark:text-dark-tremor-content">
{item.dataKey}
</p>
</div>
<p className="font-medium text-tremor-content-emphasis dark:text-dark-tremor-content-emphasis">
{timeFormatter(item.value)}
</p>
</div>
<p className="font-medium text-tremor-content-emphasis dark:text-dark-tremor-content-emphasis">
{timeFormatter(item.value)}
</p>
</div>
))}
))}
</div>
</>
);
+9 -2
View File
@@ -64,14 +64,21 @@ export default function BarChartComponent({
isSum = false,
isDummy = false,
versionNumber,
documentId,
}: {
data: any;
isSum?: boolean;
isDummy?: boolean;
versionNumber?: number;
documentId?: string;
}) {
const [, setValue] = useState<any>(null);
// Create a tooltip component that has access to documentId
const TooltipWithDocumentId = (props: any) => (
<CustomTooltip {...props} documentId={documentId} />
);
if (isSum) {
const renamedData = renameSumDurationKey(data, versionNumber);
@@ -86,7 +93,7 @@ export default function BarChartComponent({
yAxisWidth={50}
showGridLines={false}
onValueChange={(v) => setValue(v)}
customTooltip={isDummy ? undefined : CustomTooltip}
customTooltip={TooltipWithDocumentId}
/>
);
}
@@ -112,7 +119,7 @@ export default function BarChartComponent({
yAxisWidth={50}
showGridLines={false}
onValueChange={(v) => setValue(v)}
customTooltip={isDummy ? undefined : CustomTooltip}
customTooltip={CustomTooltip}
/>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/conversation-list-item";
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/conversation-message";
import { ConversationListItem as ConversationListItemEE } from "@/ee/features/conversations/components/dashboard/conversation-list-item";
import { ConversationMessage as ConversationMessageEE } from "@/ee/features/conversations/components/shared/conversation-message";
export function ConversationListItem(props: any) {
return <ConversationListItemEE {...props} />;
@@ -3,8 +3,7 @@ import { useState } from "react";
import { DownloadIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ButtonTooltip } from "@/components/ui/tooltip";
import { ResponsiveButton } from "@/components/ui/responsive-button";
export default function DownloadDataroomButton({
teamId,
@@ -57,16 +56,13 @@ export default function DownloadDataroomButton({
}
};
return (
<ButtonTooltip content="Download Dataroom" sideOffset={4}>
<Button
onClick={downloadDataroom}
variant="outline"
className=""
size="sm"
loading={isLoading}
>
<DownloadIcon />
</Button>
</ButtonTooltip>
<ResponsiveButton
icon={<DownloadIcon className="h-4 w-4" />}
text="Download"
onClick={downloadDataroom}
variant="outline"
size="sm"
loading={isLoading}
/>
);
}
@@ -25,6 +25,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveButton } from "@/components/ui/responsive-button";
import {
Select,
SelectContent,
@@ -135,10 +136,13 @@ export default function GenerateIndexDialog({
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" disabled={disabled}>
<FileSlidersIcon />
Generate Index File
</Button>
<ResponsiveButton
icon={<FileSlidersIcon className="h-4 w-4" />}
text="Generate Index"
variant="outline"
size="sm"
disabled={disabled}
/>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
+241 -48
View File
@@ -3,6 +3,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { ExportJob } from "@/lib/redis-job-store";
import { Button } from "../ui/button";
interface ExportStatus {
status: string;
progress?: string;
@@ -31,6 +35,9 @@ export function ExportVisitsModal({
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
const [showModal, setShowModal] = useState(false);
const [viewCount, setViewCount] = useState<number | null>(null);
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
const [showNewExport, setShowNewExport] = useState(false);
const [loading, setLoading] = useState(true);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const exportStartedRef = useRef<boolean>(false);
@@ -43,18 +50,45 @@ export function ExportVisitsModal({
};
}, []);
const startExport = useCallback(async () => {
// Fetch existing exports when modal opens
useEffect(() => {
const fetchExistingExports = async () => {
try {
setLoading(true);
setShowModal(true);
const endpoint = groupId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
const response = await fetch(endpoint, { method: "GET" });
if (response.ok) {
const exports = await response.json();
setExistingExports(exports);
} else {
console.error("Failed to fetch existing exports");
}
} catch (error) {
console.error("Error fetching existing exports:", error);
} finally {
setLoading(false);
}
};
fetchExistingExports();
}, [teamId, dataroomId, groupId]);
const startNewExport = useCallback(async () => {
// Prevent double triggering
if (exportStartedRef.current) {
console.warn("Export already started, skipping duplicate request");
return;
}
exportStartedRef.current = true;
setShowNewExport(true);
try {
// Show modal immediately
setShowModal(true);
// Get view count first
try {
const viewCountResponse = await fetch(
@@ -72,10 +106,11 @@ export function ExportVisitsModal({
}
// Trigger the background export job
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : ""}/export-visits`,
{ method: "POST" },
);
const endpoint = groupId
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
const response = await fetch(endpoint, { method: "POST" });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
@@ -174,11 +209,6 @@ export function ExportVisitsModal({
}
}, [teamId, dataroomId, groupId, dataroomName, groupName]);
// Start export immediately when component mounts
useEffect(() => {
startExport();
}, [startExport]);
// Send export via email
const sendExportEmail = async () => {
if (!exportStatus?.exportId || !session?.user?.email) return;
@@ -201,6 +231,72 @@ export function ExportVisitsModal({
}
};
// Cancel export
const cancelExport = async () => {
if (!exportStatus?.exportId) return;
try {
const response = await fetch(
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,
{ method: "PATCH" },
);
if (response.ok) {
toast.success("Export cancelled successfully");
handleClose();
} else {
const errorData = await response.json();
toast.error(errorData.error || "Failed to cancel export");
}
} catch (error) {
console.error("Error cancelling export:", error);
toast.error("Failed to cancel export");
}
};
// Download existing export
const downloadExport = async (exportId: string, resourceName: string) => {
try {
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
const link = window.document.createElement("a");
link.href = downloadUrl;
link.setAttribute(
"download",
`${resourceName || dataroomName}_${groupName ? `${groupName}_` : ""}visits_${new Date().toISOString().split("T")[0]}.csv`,
);
link.rel = "noopener noreferrer";
link.style.display = "none";
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download export");
}
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case "COMPLETED":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "PROCESSING":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "PENDING":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "FAILED":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
// Handle close - cleanup and call parent onClose
const handleClose = () => {
if (pollIntervalRef.current) {
@@ -209,6 +305,10 @@ export function ExportVisitsModal({
}
setShowModal(false);
setExportStatus(null);
setShowNewExport(false);
setExistingExports([]); // Reset existing exports
setViewCount(null); // Reset view count
setLoading(true); // Reset loading state
exportStartedRef.current = false; // Reset for potential reuse
onClose();
};
@@ -224,7 +324,7 @@ export function ExportVisitsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Export Visits</h3>
<button
@@ -247,48 +347,141 @@ export function ExportVisitsModal({
</button>
</div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
<div className="text-sm text-gray-600">
Exporting visits for: {displayName}
</div>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
) : showNewExport ? (
// Show export progress
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} visits)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<div className="text-sm text-gray-600">
Exporting visits for: {displayName}
</div>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} visits)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
{/* Cancel button - only show if export is in progress */}
{exportStatus?.exportId && (
<div className="flex gap-2 px-3">
<Button
onClick={cancelExport}
variant="outline"
size="sm"
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
>
Cancel Export
</Button>
</div>
)}
</div>
) : (
// Show existing exports and new export option
<div className="space-y-4">
<div className="text-sm text-gray-600">
Exports for: {displayName}
</div>
{existingExports.length > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Recent Exports
</h4>
<div className="max-h-48 space-y-2 overflow-y-auto">
{existingExports.map((exportJob) => (
<div
key={exportJob.id}
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
>
{exportJob.status}
</span>
<span className="text-xs text-gray-500">
{formatDate(exportJob.createdAt)}
</span>
</div>
{exportJob.groupId && (
<div className="mt-1 text-xs text-gray-500">
Group: {exportJob.groupId}
</div>
)}
{exportJob.error && (
<p className="mt-1 text-xs text-red-600">
{exportJob.error}
</p>
)}
</div>
{exportJob.status === "COMPLETED" && exportJob.result && (
<button
onClick={() =>
downloadExport(
exportJob.id,
exportJob.resourceName || dataroomName,
)
}
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
>
Download
</button>
)}
</div>
))}
</div>
</div>
) : (
<div className="text-center text-sm text-gray-500">
No previous exports found
</div>
)}
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
<button
onClick={sendExportEmail}
onClick={startNewExport}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
Start New Export
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
</div>
</div>
)}
</div>
</div>
);
@@ -5,6 +5,9 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { SidebarFolderTreeSelection } from "@/components/datarooms/folders";
import { Button } from "@/components/ui/button";
import {
@@ -16,9 +19,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { TSelectedFolder } from "../documents/move-folder-modal";
export function MoveToDataroomFolderModal({
@@ -110,7 +110,7 @@ export function MoveToDataroomFolderModal({
<DialogDescription>Move your item to a folder.</DialogDescription>
</DialogHeader>
<form>
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
<SidebarFolderTreeSelection
dataroomId={dataroomId}
selectedFolder={selectedFolder}
@@ -133,7 +133,9 @@ export function MoveToDataroomFolderModal({
) : (
<>
Move to{" "}
<span className="font-medium">{selectedFolder.name}</span>
<span className="max-w-[200px] truncate font-medium">
{selectedFolder.name}
</span>
</>
)}
</Button>
@@ -0,0 +1,104 @@
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface BulkDownloadSettingsProps {
dataroomId: string;
}
export default function BulkDownloadSettings({
dataroomId,
}: BulkDownloadSettingsProps) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { data: dataroomData, mutate: mutateDataroom } = useSWR<{
id: string;
name: string;
pId: string;
allowBulkDownload: boolean;
}>(
dataroomId ? `/api/teams/${teamId}/datarooms/${dataroomId}` : null,
fetcher,
);
const [isUpdating, setIsUpdating] = useState(false);
const handleBulkDownloadToggle = async (checked: boolean) => {
if (!dataroomId || !teamId || isUpdating) return;
setIsUpdating(true);
toast.promise(
fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
allowBulkDownload: checked,
}),
}).then(async (res) => {
if (!res.ok) {
throw new Error("Failed to update bulk download settings");
}
await mutateDataroom();
}),
{
loading: "Updating bulk download settings...",
success: "Bulk download settings updated successfully",
error: "Failed to update bulk download settings",
},
);
setIsUpdating(false);
};
return (
<Card className="bg-transparent">
<CardHeader>
<CardTitle className="flex items-center">Dataroom Download</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label
htmlFor="bulk-download-toggle"
className="text-sm font-medium"
>
Allow bulk download of entire dataroom
</Label>
</div>
<Switch
id="bulk-download-toggle"
checked={dataroomData?.allowBulkDownload ?? true}
onCheckedChange={handleBulkDownloadToggle}
disabled={isUpdating}
/>
</div>
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-6">
<p className="text-sm text-muted-foreground transition-colors">
When enabled, visitors can download all dataroom contents as a single
ZIP file. Individual document and folder downloads will still work
regardless of this setting.
</p>
</CardFooter>
</Card>
);
}
@@ -83,7 +83,7 @@ export default function NotificationSettings({
<CardTitle>
Notifications{" "}
{!isDataroomsPlus && !features?.roomChangeNotifications ? (
<PlanBadge plan="datarooms-plus" />
<PlanBadge plan="data rooms plus" />
) : null}
</CardTitle>
<CardDescription>
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { BellIcon, CogIcon, ShieldIcon } from "lucide-react";
import { BellIcon, CogIcon, DownloadIcon, ShieldIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -39,6 +39,18 @@ export default function SettingsTabs({ dataroomId }: SettingsTabsProps) {
<BellIcon className="h-4 w-4" />
Notifications
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/downloads`}
className={cn(
"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted",
{
"bg-muted font-medium": router.pathname.includes("downloads"),
},
)}
>
<DownloadIcon className="h-4 w-4" />
Downloads
</Link>
<Link
href={`/datarooms/${dataroomId}/settings/file-permissions`}
className={cn(
+8 -45
View File
@@ -7,7 +7,11 @@ import { useTheme } from "next-themes";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "@/lib/constants";
import {
FREE_PLAN_ACCEPTED_FILE_TYPES,
FULL_PLAN_ACCEPTED_FILE_TYPES,
SUPPORTED_DOCUMENT_MIME_TYPES,
} from "@/lib/constants";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { bytesToSize } from "@/lib/utils";
@@ -46,49 +50,8 @@ export default function DocumentUpload({
const { getRootProps, getInputProps } = useDropzone({
accept:
isFree && !isTrial
? {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[], // ".xlsx"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
}
: {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
[], // ".xlsx"
"application/vnd.ms-excel.sheet.macroEnabled.12": [".xlsm"], // ".xlsm"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"application/vnd.ms-powerpoint": [], // ".ppt"
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
[], // ".pptx"
"application/vnd.oasis.opendocument.presentation": [], // ".odp"
"application/msword": [], // ".doc"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[], // ".docx"
"application/vnd.oasis.opendocument.text": [], // ".odt"
"image/vnd.dwg": [".dwg"], // ".dwg"
"image/vnd.dxf": [".dxf"], // ".dxf"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
"application/zip": [], // ".zip"
"application/x-zip-compressed": [], // ".zip"
"video/mp4": [], // ".mp4"
"video/quicktime": [], // ".mov"
"video/x-msvideo": [], // ".avi"
"video/webm": [], // ".webm"
"video/ogg": [], // ".ogg"
"application/vnd.google-earth.kml+xml": [".kml"], // ".kml"
"application/vnd.google-earth.kmz": [".kmz"], // ".kmz"
"application/vnd.ms-outlook": [".msg"], // ".msg"
},
? FREE_PLAN_ACCEPTED_FILE_TYPES
: FULL_PLAN_ACCEPTED_FILE_TYPES,
multiple: false,
onDropAccepted: (acceptedFiles) => {
if (acceptedFiles.length === 0) {
@@ -229,7 +192,7 @@ export default function DocumentUpload({
? "Replace file?"
: isFree && !isTrial
? `Only *.pdf, *.xls, *.xlsx, *.csv, *.ods, *.png, *.jpeg, *.jpg`
: `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
: `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.rtf, *txt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
</p>
</div>
</div>
+26 -6
View File
@@ -9,6 +9,7 @@ import { DefaultPermissionStrategy } from "@prisma/client";
import { parsePageId } from "notion-utils";
import { toast } from "sonner";
import { mutate } from "swr";
import { z } from "zod";
import { useAnalytics } from "@/lib/analytics";
import {
@@ -18,6 +19,7 @@ import {
} from "@/lib/documents/create-document";
import { putFile } from "@/lib/files/put-file";
import { useDataroomPermissions } from "@/lib/hooks/use-dataroom-permissions";
import { getNotionPageIdFromSlug } from "@/lib/notion/utils";
import { usePlan } from "@/lib/swr/use-billing";
import { useDataroom } from "@/lib/swr/use-dataroom";
import useLimits from "@/lib/swr/use-limits";
@@ -387,17 +389,35 @@ export function AddDocumentModal({
return;
}
const validateNotionPageURL = parsePageId(notionLink);
// Check if it's a valid URL or not by Regx
const isValidURL =
/^(https?:\/\/)?([a-zA-Z0-9-]+\.){1,}[a-zA-Z]{2,}([a-zA-Z0-9-._~:/?#[\]@!$&'()*+,;=]+)?$/;
// Check if the field is empty or not
if (!notionLink) {
toast.error("Please enter a Notion link to proceed.");
return; // prevent form from submitting
}
if (validateNotionPageURL === null || !isValidURL.test(notionLink)) {
// Validate URL format with Zod
const urlSchema = z.string().url();
const urlValidation = urlSchema.safeParse(notionLink);
if (!urlValidation.success) {
toast.error("Please enter a valid URL format.");
return;
}
// Try to validate the Notion page URL
let validateNotionPageURL = parsePageId(notionLink);
// If parsePageId fails, try to get page ID from slug
if (validateNotionPageURL === null) {
try {
validateNotionPageURL = await getNotionPageIdFromSlug(notionLink);
} catch (slugError) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
}
if (!validateNotionPageURL) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
@@ -88,7 +88,7 @@ export function AddToDataroomModal({
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogContent className="max-w-[90vw] sm:max-w-[425px]">
<DialogHeader className="text-start">
<DialogTitle>
<span className="font-bold">{documentName}</span>
@@ -98,18 +98,21 @@ export function AddToDataroomModal({
</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedDataroom(value)}>
<SelectTrigger className="min-w-fit">
<SelectTrigger className="w-[380px] max-w-full [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:truncate [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<SelectValue placeholder="Select a dataroom" />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-[380px] max-w-[90vw]">
{datarooms?.map((dataroom) => (
<SelectItem
key={dataroom.id}
value={dataroom.id}
disabled={dataroom.id === dataroomId}
className="break-words"
>
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
<span className="line-clamp-1 break-words">
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</span>
</SelectItem>
))}
</SelectContent>
@@ -127,15 +130,15 @@ export function AddToDataroomModal({
{!selectedDataroom ? (
"Select a dataroom"
) : (
<>
Add to{" "}
<span className="font-medium">
<span className="flex w-full max-w-[350px] items-center justify-center truncate">
Add to
<span className="ml-1 line-clamp-1 truncate font-medium">
{
datarooms?.filter((d) => d.id === selectedDataroom)[0]
.name
}
</span>
</>
</span>
)}
</Button>
</DialogFooter>
@@ -114,18 +114,21 @@ export function AddFolderToDataroomModal({
<DialogDescription>Add your folder to a dataroom.</DialogDescription>
</DialogHeader>
<Select onValueChange={(value) => setSelectedDataroom(value)}>
<SelectTrigger className="min-w-fit">
<SelectTrigger className="w-[380px] max-w-full [&>span]:truncate [&>span]:max-w-full [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap">
<SelectValue placeholder="Select a dataroom" />
</SelectTrigger>
<SelectContent>
<SelectContent className="w-[380px] max-w-[90vw]">
{datarooms?.map((dataroom) => (
<SelectItem
key={dataroom.id}
value={dataroom.id}
disabled={dataroom.id === dataroomId}
className="break-words"
>
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
<span className="break-words line-clamp-1">
{dataroom.name}
{dataroom.id === dataroomId ? " (current)" : ""}
</span>
</SelectItem>
))}
</SelectContent>
@@ -143,15 +146,15 @@ export function AddFolderToDataroomModal({
{!selectedDataroom ? (
"Select a dataroom"
) : (
<>
Add to{" "}
<span className="font-medium">
<span className="flex items-center justify-center w-full max-w-[350px] truncate">
Add to
<span className="font-medium truncate line-clamp-1 ml-1">
{
datarooms?.filter((d) => d.id === selectedDataroom)[0]
.name
}
</span>
</>
</span>
)}
</Button>
</DialogFooter>
+1 -1
View File
@@ -608,7 +608,7 @@ export default function DocumentHeader({
{actionRows.map((row, i) => (
<ul
key={i.toString()}
className="flex flex-wrap items-center justify-end gap-x-4 md:flex-nowrap md:gap-x-2"
className="flex flex-wrap items-center justify-end gap-x-2 md:flex-nowrap md:gap-x-1"
>
{row.map((action, i) => (
<li key={i}>{action}</li>
+227 -41
View File
@@ -4,6 +4,10 @@ import { Document } from "@prisma/client";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import { ExportJob } from "@/lib/redis-job-store";
import { Button } from "../ui/button";
interface ExportStatus {
status: string;
progress?: string;
@@ -26,6 +30,9 @@ export function ExportVisitsModal({
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
const [showModal, setShowModal] = useState(false);
const [viewCount, setViewCount] = useState<number | null>(null);
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
const [showNewExport, setShowNewExport] = useState(false);
const [loading, setLoading] = useState(true);
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const exportStartedRef = useRef<boolean>(false);
@@ -38,18 +45,44 @@ export function ExportVisitsModal({
};
}, []);
const startExport = useCallback(async () => {
// Fetch existing exports when modal opens
useEffect(() => {
const fetchExistingExports = async () => {
try {
setLoading(true);
setShowModal(true);
const response = await fetch(
`/api/teams/${teamId}/documents/${document.id}/export-visits`,
{ method: "GET" },
);
if (response.ok) {
const exports = await response.json();
setExistingExports(exports);
} else {
console.error("Failed to fetch existing exports");
}
} catch (error) {
console.error("Error fetching existing exports:", error);
} finally {
setLoading(false);
}
};
fetchExistingExports();
}, [teamId, document.id]);
const startNewExport = useCallback(async () => {
// Prevent double triggering
if (exportStartedRef.current) {
console.warn("Export already started, skipping duplicate request");
return;
}
exportStartedRef.current = true;
setShowNewExport(true);
try {
// Show modal immediately
setShowModal(true);
// Get view count first
try {
const viewCountResponse = await fetch(
@@ -167,12 +200,7 @@ export function ExportVisitsModal({
);
handleClose();
}
}, [document.id, teamId]);
// Start export immediately when component mounts
useEffect(() => {
startExport();
}, [startExport]);
}, [document.id, teamId, document.name]);
// Send export via email
const sendExportEmail = async () => {
@@ -196,6 +224,72 @@ export function ExportVisitsModal({
}
};
// Cancel export
const cancelExport = async () => {
if (!exportStatus?.exportId) return;
try {
const response = await fetch(
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,
{ method: "PATCH" },
);
if (response.ok) {
toast.success("Export cancelled successfully");
handleClose();
} else {
const errorData = await response.json();
toast.error(errorData.error || "Failed to cancel export");
}
} catch (error) {
console.error("Error cancelling export:", error);
toast.error("Failed to cancel export");
}
};
// Download existing export
const downloadExport = async (exportId: string, resourceName: string) => {
try {
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
const link = window.document.createElement("a");
link.href = downloadUrl;
link.setAttribute(
"download",
`${resourceName || document.name}_visits_${new Date().toISOString().split("T")[0]}.csv`,
);
link.rel = "noopener noreferrer";
link.style.display = "none";
window.document.body.appendChild(link);
link.click();
window.document.body.removeChild(link);
} catch (error) {
console.error("Download failed:", error);
toast.error("Failed to download export");
}
};
// Format date for display
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
// Get status badge color
const getStatusColor = (status: string) => {
switch (status) {
case "COMPLETED":
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
case "PROCESSING":
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
case "PENDING":
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
case "FAILED":
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
default:
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
}
};
// Handle close - cleanup and call parent onClose
const handleClose = () => {
if (pollIntervalRef.current) {
@@ -204,6 +298,10 @@ export function ExportVisitsModal({
}
setShowModal(false);
setExportStatus(null);
setShowNewExport(false);
setExistingExports([]); // Reset existing exports
setViewCount(null); // Reset view count
setLoading(true); // Reset loading state
exportStartedRef.current = false; // Reset for potential reuse
onClose();
};
@@ -215,7 +313,7 @@ export function ExportVisitsModal({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Export Visits</h3>
<button
@@ -238,44 +336,132 @@ export function ExportVisitsModal({
</button>
</div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
{loading ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} view{viewCount !== 1 ? "s" : ""} to export
) : showNewExport ? (
// Show export progress
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
<span className="text-sm text-gray-600">
{exportStatus?.progress || "Processing export..."}
</span>
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} views)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
{viewCount !== null && (
<div className="text-sm text-gray-600">
Found {viewCount} view{viewCount !== 1 ? "s" : ""} to export
</div>
)}
{viewCount !== null && viewCount > 10 && session?.user?.email && (
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
<p className="mb-2 font-medium text-muted-foreground">
Large export detected ({viewCount} views)
</p>
<p className="mb-3 text-muted-foreground">
This export may take several minutes. We recommend getting it
emailed to you when ready.
</p>
<button
onClick={sendExportEmail}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
{/* Cancel button - only show if export is in progress */}
{exportStatus?.exportId && (
<div className="flex gap-2 px-3">
<Button
onClick={cancelExport}
variant="outline"
size="sm"
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
>
Cancel Export
</Button>
</div>
)}
</div>
) : (
// Show existing exports and new export option
<div className="space-y-4">
<div className="text-sm text-gray-600">
Exports for: {document.name}
</div>
{existingExports.length > 0 ? (
<div className="space-y-3">
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
Recent Exports
</h4>
<div className="max-h-48 space-y-2 overflow-y-auto">
{existingExports.map((exportJob) => (
<div
key={exportJob.id}
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
>
{exportJob.status}
</span>
<span className="text-xs text-gray-500">
{formatDate(exportJob.createdAt)}
</span>
</div>
{exportJob.error && (
<p className="mt-1 text-xs text-red-600">
{exportJob.error}
</p>
)}
</div>
{exportJob.status === "COMPLETED" && exportJob.result && (
<button
onClick={() =>
downloadExport(
exportJob.id,
exportJob.resourceName || document.name,
)
}
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
>
Download
</button>
)}
</div>
))}
</div>
</div>
) : (
<div className="text-center text-sm text-gray-500">
No previous exports found
</div>
)}
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
<button
onClick={sendExportEmail}
onClick={startNewExport}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Email to {session.user.email}
Start New Export
</button>
</div>
)}
{(!viewCount || viewCount <= 10) && (
<div className="text-sm text-gray-500">
Your export will be ready shortly...
</div>
)}
</div>
</div>
)}
</div>
</div>
);
+8 -6
View File
@@ -5,6 +5,9 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { moveDocumentToFolder } from "@/lib/documents/move-documents";
import { moveFolderToFolder } from "@/lib/documents/move-folder";
import { SidebarFolderTreeSelection } from "@/components/sidebar-folders";
import { Button } from "@/components/ui/button";
import {
@@ -16,9 +19,6 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { moveDocumentToFolder } from "@/lib/documents/move-documents";
import { moveFolderToFolder } from "@/lib/documents/move-folder";
export type TSelectedFolder = {
id: string | null;
name: string;
@@ -104,14 +104,14 @@ export function MoveToFolderModal({
<DialogHeader className="text-start">
<DialogTitle>
Move
<div className="w-[376px] truncate font-bold">
<div className="truncate font-bold">
{`${(documentIds?.length ?? 0) + (folderIds?.length ?? 0)} items`}
</div>
</DialogTitle>
<DialogDescription>Move your item to a folder.</DialogDescription>
</DialogHeader>
<form>
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
<SidebarFolderTreeSelection
selectedFolder={selectedFolder}
setSelectedFolder={setSelectedFolder}
@@ -133,7 +133,9 @@ export function MoveToFolderModal({
) : (
<>
Move to{" "}
<span className="font-medium">{selectedFolder.name}</span>
<span className="max-w-[200px] truncate font-medium">
{selectedFolder.name}
</span>
</>
)}
</Button>
@@ -0,0 +1,305 @@
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { DocumentVersion } from "@prisma/client";
import {
CircleCheckIcon,
CircleXIcon,
Edit,
ExternalLink,
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { ButtonTooltip } from "@/components/ui/tooltip";
interface NotionAccessibilityIndicatorProps {
documentId: string;
primaryVersion: DocumentVersion;
onUrlUpdate?: () => void;
}
interface AccessibilityStatus {
isAccessible: boolean;
url: string;
statusCode?: number;
lastChecked: string;
error?: string;
}
export default function NotionAccessibilityIndicator({
documentId,
primaryVersion,
onUrlUpdate,
}: NotionAccessibilityIndicatorProps) {
const teamInfo = useTeam();
const [status, setStatus] = useState<AccessibilityStatus | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isMainDialogOpen, setIsMainDialogOpen] = useState(false);
const [isUrlDialogOpen, setIsUrlDialogOpen] = useState(false);
const [newUrl, setNewUrl] = useState("");
const [isUpdating, setIsUpdating] = useState(false);
const checkAccessibility = async () => {
if (!teamInfo?.currentTeam?.id) return;
setIsLoading(true);
try {
const response = await fetch(
`/api/teams/${teamInfo.currentTeam.id}/documents/${documentId}/check-notion-accessibility`,
);
const data = await response.json();
if (response.ok) {
setStatus(data);
} else {
toast.error("Failed to check accessibility");
}
} catch (error) {
console.error("Error checking accessibility:", error);
toast.error("Failed to check accessibility");
} finally {
setIsLoading(false);
}
};
const updateNotionUrl = async () => {
if (!teamInfo?.currentTeam?.id || !newUrl.trim()) return;
setIsUpdating(true);
try {
const response = await fetch(
`/api/teams/${teamInfo.currentTeam.id}/documents/${documentId}/update-notion-url`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ notionUrl: newUrl.trim() }),
},
);
const data = await response.json();
if (response.ok) {
toast.success("Notion URL updated successfully");
setIsUrlDialogOpen(false);
setNewUrl("");
// Refresh accessibility status
await checkAccessibility();
// Trigger parent component update if provided
if (onUrlUpdate) {
onUrlUpdate();
}
} else {
toast.error(data.message || "Failed to update URL");
}
} catch (error) {
console.error("Error updating URL:", error);
toast.error("Failed to update URL");
} finally {
setIsUpdating(false);
}
};
const openNotionPage = () => {
if (status?.url) {
window.open(status.url, "_blank");
}
};
// Check accessibility on component mount
useEffect(() => {
if (primaryVersion.type === "notion") {
checkAccessibility();
}
}, []);
// Only render for Notion documents
if (primaryVersion.type !== "notion") {
return null;
}
const getStatusIcon = () => {
if (isLoading) {
return <LoadingSpinner className="h-4 w-4" />;
}
if (status?.isAccessible) {
return (
<CircleCheckIcon className="h-4 w-4 rounded-full bg-green-500 text-white" />
);
} else {
return (
<CircleXIcon className="h-4 w-4 rounded-full bg-red-500 text-white" />
);
}
};
const getTooltipText = () => {
if (isLoading) {
return "Checking accessibility...";
}
if (status?.isAccessible) {
return "Notion page is publicly accessible";
} else if (status?.error) {
return "Unable to check accessibility";
} else {
return "Notion page is not publicly accessible";
}
};
return (
<>
{/* Compact Icon Trigger */}
<Dialog open={isMainDialogOpen} onOpenChange={setIsMainDialogOpen}>
<ButtonTooltip content={getTooltipText()}>
<DialogTrigger asChild>
<Button
variant="outline"
size="default"
className="h-8 w-8 p-0 lg:h-9 lg:w-9"
>
{getStatusIcon()}
</Button>
</DialogTrigger>
</ButtonTooltip>
{/* Main Status Dialog */}
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Notion Page Status</DialogTitle>
<DialogDescription>
Check and manage your Notion page accessibility.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Status Display */}
<div className="flex items-center gap-3 rounded-lg border p-3">
{getStatusIcon()}
<div className="flex-1">
<div className="font-medium">
{isLoading
? "Checking..."
: status?.isAccessible
? "Publicly accessible"
: status?.error
? "Unable to check"
: "Not publicly accessible"}
</div>
{status?.lastChecked && (
<div className="text-sm text-gray-500">
Last checked:{" "}
{new Date(status.lastChecked).toLocaleTimeString()}
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={checkAccessibility}
disabled={isLoading}
className="flex-1"
>
<RefreshCw
className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
{status?.url && (
<Button
variant="outline"
size="sm"
onClick={openNotionPage}
className="flex-1"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open Page
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setNewUrl(status?.url || "");
setIsUrlDialogOpen(true);
}}
className="flex-1"
>
<Edit className="mr-2 h-4 w-4" />
Edit URL
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* URL Edit Dialog */}
<Dialog open={isUrlDialogOpen} onOpenChange={setIsUrlDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Update Notion URL</DialogTitle>
<DialogDescription>
Change the URL of the Notion page for this document.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="notion-url">Notion URL</Label>
<Input
id="notion-url"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://www.notion.so/your-page-url"
className="w-full"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsUrlDialogOpen(false)}
disabled={isUpdating}
>
Cancel
</Button>
<Button
onClick={updateNotionUrl}
disabled={isUpdating || !newUrl.trim()}
>
{isUpdating ? (
<>
<LoadingSpinner className="mr-2 h-4 w-4" />
Updating...
</>
) : (
"Update URL"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,23 +0,0 @@
import BarChartComponent from "../charts/bar-chart";
export default function StatsChartDummy({
totalPagesMax = 0,
}: {
totalPagesMax?: number;
}) {
let durationData = Array.from({ length: totalPagesMax }, (_, i) => ({
pageNumber: (i + 1).toString(),
data: [
{
versionNumber: 1,
avg_duration: 16000 / (i + 1),
},
],
}));
return (
<div className="rounded-bl-lg border-b border-l pb-0.5 pl-0.5 md:pb-1 md:pl-1">
<BarChartComponent data={durationData} isDummy />
</div>
);
}
+23 -6
View File
@@ -3,7 +3,6 @@ import ErrorPage from "next/error";
import { TStatsData } from "@/lib/swr/use-stats";
import BarChartComponent from "../charts/bar-chart";
import StatsChartDummy from "./stats-chart-dummy";
import StatsChartSkeleton from "./stats-chart-skeleton";
export default function StatsChart({
@@ -36,8 +35,9 @@ export default function StatsChart({
}));
const swrData = stats?.duration;
const hasRealData = stats && stats.views.length > 0;
if (swrData) {
if (swrData && hasRealData) {
swrData.data.forEach((dataItem) => {
const pageIndex = durationData.findIndex(
(item) => item.pageNumber === dataItem.pageNumber,
@@ -81,11 +81,28 @@ export default function StatsChart({
);
}
return stats && stats.views.length > 0 ? (
// If no real data, generate dummy data with some sample durations
if (!hasRealData) {
durationData = Array.from({ length: totalPagesMax }, (_, i) => ({
pageNumber: (i + 1).toString(),
data: [
{
versionNumber: 1,
avg_duration: 16000 / (i + 1), // Decreasing duration like the original dummy
},
],
}));
}
const isDummy = !hasRealData;
return (
<div className="rounded-bl-lg border-b border-l pb-0.5 pl-0.5 md:pb-1 md:pl-1">
<BarChartComponent data={durationData} />
<BarChartComponent
data={durationData}
documentId={documentId}
isDummy={isDummy}
/>
</div>
) : (
<StatsChartDummy totalPagesMax={totalPagesMax} />
);
}
+5 -5
View File
@@ -23,6 +23,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
export function AddDomainModal({
open,
@@ -116,17 +117,16 @@ export function AddDomainModal({
) {
if (children) {
return (
<UpgradePlanModal
<UpgradeButton
text="Add Domain"
clickedPlan={
linkType === "DATAROOM_LINK"
? PlanEnum.DataRooms
: PlanEnum.Business
}
trigger={"add_domain_overview"}
highlightItem={["custom-domain"]}
>
<Button>Upgrade to Add Domain</Button>
</UpgradePlanModal>
trigger="add_domain_overview"
/>
);
} else {
return (
+1
View File
@@ -142,6 +142,7 @@ export function AddFolderModal({
<UpgradePlanModal
clickedPlan={PlanEnum.Pro}
trigger={"add_folder_button"}
highlightItem={["folder", "multi-file"]}
>
{children}
</UpgradePlanModal>
+11
View File
@@ -0,0 +1,11 @@
import { GoogleTagManager } from "@next/third-parties/google";
const GTM_ID = process.env.NEXT_PUBLIC_GTM_ID;
export function GTMComponent() {
if (!GTM_ID) {
return null;
}
return <GoogleTagManager gtmId={GTM_ID} />;
}
+3 -3
View File
@@ -75,8 +75,8 @@ function TrialBannerComponent({
</AlertTitle>
<AlertDescription>
You are on the <span className="font-bold">Data Rooms</span> plan
trial, you have access to advanced access controls, group permissions,
and data room. <br />
trial, you have access to advanced access controls, granular file
permissions, and data room. <br />
<UpgradePlanModal
clickedPlan={PlanEnum.DataRooms}
trigger={"trial_navbar"}
@@ -94,4 +94,4 @@ function TrialBannerComponent({
</Alert>
</div>
);
}
}
@@ -1,3 +1,3 @@
import ConversationSection from "@/ee/features/conversations/components/link-option-conversation-section";
import ConversationSection from "@/ee/features/conversations/components/dashboard/link-option-conversation-section";
export default ConversationSection;
@@ -100,7 +100,7 @@ export default memo(function CustomField({
<SelectItem value="SHORT_TEXT">Short Text</SelectItem>
<SelectItem value="LONG_TEXT">Long Text</SelectItem>
<SelectItem value="NUMBER">Number</SelectItem>
{/* <SelectItem value="PHONE_NUMBER">Phone</SelectItem> */}
<SelectItem value="PHONE_NUMBER">Phone</SelectItem>
<SelectItem value="URL">URL</SelectItem>
</SelectContent>
</Select>
@@ -24,7 +24,7 @@ export type CustomFieldData = Omit<
> & {
type: Omit<
CustomFieldType,
"PHONE_NUMBER" | "CHECKBOX" | "SELECT" | "MULTI_SELECT"
"CHECKBOX" | "SELECT" | "MULTI_SELECT"
>;
};
@@ -89,7 +89,7 @@ function FolderSelectionModal({
</DialogDescription>
</DialogHeader>
<form>
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
{dataroomId && dataroomId !== "all_documents" ? (
<DataroomFolderTree
dataroomId={dataroomId}
@@ -115,7 +115,9 @@ function FolderSelectionModal({
) : (
<>
Select{" "}
<span className="font-medium">{selectedFolder.name}</span>
<span className="max-w-[200px] truncate font-medium">
{selectedFolder.name}
</span>
</>
)}
</Button>
+34 -17
View File
@@ -18,6 +18,7 @@ import {
import { useQueryState } from "nuqs";
import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import z from "zod";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
@@ -105,7 +106,7 @@ export default function LinksTable({
const now = Date.now();
const router = useRouter();
const { isFree, isTrial } = usePlan();
const teamInfo = useTeam();
const { currentTeamId } = useTeam();
const { id: targetId, groupId } = router.query as {
id: string;
groupId?: string;
@@ -267,7 +268,7 @@ export default function LinksTable({
"Content-Type": "application/json",
},
body: JSON.stringify({
teamId: teamInfo?.currentTeam?.id,
teamId: currentTeamId,
}),
});
@@ -280,7 +281,7 @@ export default function LinksTable({
// Update the duplicated link in the list of links
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
link.documentId ?? link.dataroomId ?? "",
)}/links`,
(links || []).concat(duplicatedLink),
@@ -292,7 +293,7 @@ export default function LinksTable({
const groupLinks =
links?.filter((link) => link.groupId === groupId) || [];
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
duplicatedLink.documentId ?? duplicatedLink.dataroomId ?? "",
)}/groups/${duplicatedLink.groupId}/links`,
groupLinks.concat(duplicatedLink),
@@ -316,8 +317,15 @@ export default function LinksTable({
if (permissions === null && editPermissionLink.permissionGroupId) {
// Delete the permission group - database will set permissionGroupId to null automatically
try {
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const permissionGroupIdParsed = z
.string()
.cuid()
.parse(editPermissionLink.permissionGroupId);
const deleteResponse = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
{
method: "DELETE",
},
@@ -331,8 +339,8 @@ export default function LinksTable({
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
targetId,
`/api/teams/${teamIdParsed}/${endpointTargetType}/${encodeURIComponent(
targetIdParsed,
)}/links`,
(currentLinks: LinkWithViews[] | undefined) =>
(currentLinks || []).map((link: LinkWithViews) =>
@@ -345,7 +353,7 @@ export default function LinksTable({
// Invalidate the permission group cache
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
);
setShowPermissionsSheet(false);
@@ -361,8 +369,10 @@ export default function LinksTable({
if (!editPermissionLink.permissionGroupId) {
setIsLoading(true);
try {
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const response = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups`,
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups`,
{
method: "POST",
headers: {
@@ -386,7 +396,7 @@ export default function LinksTable({
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
);
@@ -394,7 +404,7 @@ export default function LinksTable({
// Cache the new permission group data
if (newPermissionGroup?.id) {
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${newPermissionGroup.id}`,
newPermissionGroup,
false,
);
@@ -412,8 +422,15 @@ export default function LinksTable({
} else {
try {
// Update the permissions for the existing link
const teamIdParsed = z.string().cuid().parse(currentTeamId);
const targetIdParsed = z.string().cuid().parse(targetId);
const permissionGroupIdParsed = z
.string()
.cuid()
.parse(editPermissionLink.permissionGroupId);
const res = await fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
`/api/teams/${teamIdParsed}/datarooms/${targetIdParsed}/permission-groups/${permissionGroupIdParsed}`,
{
method: "PUT",
headers: {
@@ -434,7 +451,7 @@ export default function LinksTable({
// Refresh the links cache
const endpointTargetType = `${targetType.toLowerCase()}s`;
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
);
@@ -442,7 +459,7 @@ export default function LinksTable({
// Invalidate the permission group cache
if (editPermissionLink.permissionGroupId) {
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
`/api/teams/${currentTeamId}/datarooms/${targetId}/permission-groups/${editPermissionLink.permissionGroupId}`,
);
}
@@ -502,7 +519,7 @@ export default function LinksTable({
// Update the archived link in the list of links
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
targetId,
)}/links`,
(links || []).map((link) => (link.id === linkId ? archivedLink : link)),
@@ -514,7 +531,7 @@ export default function LinksTable({
const groupLinks =
links?.filter((link) => link.groupId === groupId) || [];
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/${endpointTargetType}/${encodeURIComponent(
`/api/teams/${currentTeamId}/${endpointTargetType}/${encodeURIComponent(
archivedLink.documentId ?? archivedLink.dataroomId ?? "",
)}/groups/${groupId}/links`,
groupLinks.map((link) => (link.id === linkId ? archivedLink : link)),
@@ -969,4 +986,4 @@ export default function LinksTable({
</div>
</>
);
}
}
+9 -8
View File
@@ -5,8 +5,6 @@ import { LockIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -14,6 +12,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { UpgradeButton } from "@/components/ui/upgrade-button";
interface FeaturePreviewProps {
/**
@@ -68,7 +67,7 @@ export function FeaturePreview({
trigger,
children,
className,
upgradeButtonText = "Upgrade to unlock",
upgradeButtonText = "Unlock",
}: FeaturePreviewProps) {
return (
<div className={cn("relative", className)}>
@@ -91,11 +90,13 @@ export function FeaturePreview({
</CardDescription>
</CardHeader>
<CardContent className="text-center">
<UpgradePlanModal clickedPlan={requiredPlan} trigger={trigger}>
<Button size="lg" className="w-full">
{upgradeButtonText}
</Button>
</UpgradePlanModal>
<UpgradeButton
text={upgradeButtonText}
clickedPlan={requiredPlan}
trigger={trigger}
size="lg"
className="w-full"
/>
</CardContent>
</Card>
</div>
+14 -4
View File
@@ -120,7 +120,7 @@ const Folder = memo<FolderProps>(
<div
title={name}
className={cn(
"inline-flex w-full cursor-pointer items-center",
"inline-flex w-full cursor-pointer items-center overflow-hidden",
"rounded-md text-foreground duration-100 hover:bg-gray-100 hover:dark:bg-muted",
"px-3 py-1.5 leading-6",
active && "bg-gray-100 font-semibold dark:bg-muted",
@@ -147,7 +147,10 @@ const Folder = memo<FolderProps>(
<FolderIcon className="h-5 w-5 shrink-0" aria-hidden="true" />
)}
<span
className="ml-2 w-fit truncate"
className="ml-2 truncate whitespace-nowrap"
style={{
maxWidth: `${Math.max(150, 300 - indent * 30)}px`,
}}
title={(label ?? name) as string}
>
{label ?? name}
@@ -165,6 +168,7 @@ const Folder = memo<FolderProps>(
Folder.displayName = "Folder";
const File = memo<FileProps>(({ label, name, active, onToggle }) => {
const indent = useIndent();
const toggle = useCallback(() => {
onToggle?.(!active);
}, [onToggle]);
@@ -179,12 +183,18 @@ const File = memo<FileProps>(({ label, name, active, onToggle }) => {
)}
>
<span
className="ml-5 inline-flex cursor-default items-center"
className="ml-5 inline-flex w-full cursor-default items-center overflow-hidden"
onClick={toggle}
>
<Ident />
<FileIcon className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="ml-2 w-fit truncate" title={(label ?? name) as string}>
<span
className="ml-2 truncate whitespace-nowrap"
style={{
maxWidth: `${Math.max(150, 280 - indent * 30)}px`,
}}
title={(label ?? name) as string}
>
{label ?? name}
</span>
</span>
+61 -87
View File
@@ -1,29 +1,13 @@
import * as React from "react";
import { E164Number } from "libphonenumber-js";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { ChevronDownIcon, PhoneIcon } from "lucide-react";
import * as RPNInput from "react-phone-number-input";
import flags from "react-phone-number-input/flags";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input, InputProps } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { ScrollArea } from "./scroll-area";
import { Input, InputProps } from "@/components/ui/input";
type PhoneInputProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
@@ -39,7 +23,8 @@ const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
return (
<RPNInput.default
ref={ref}
className={cn("flex", className)}
className={cn("shadow-xs flex rounded-md", className)}
international
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
@@ -63,7 +48,17 @@ PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<HTMLInputElement, InputProps>(
({ className, ...props }, ref) => (
<Input
className={cn("rounded-e-lg rounded-s-none", className)}
data-slot="phone-input"
className={cn(
"-ms-px rounded-s-none border-0 bg-transparent py-1.5 shadow-sm ring-1 ring-inset ring-gray-600 focus:ring-2 focus:ring-inset focus:ring-gray-300 focus-visible:z-10",
className,
)}
style={
{
backgroundColor: "var(--phone-input-bg)",
color: "var(--phone-input-color)",
} as React.CSSProperties
}
{...props}
ref={ref}
/>
@@ -71,13 +66,11 @@ const InputComponent = React.forwardRef<HTMLInputElement, InputProps>(
);
InputComponent.displayName = "InputComponent";
type CountrySelectOption = { label: string; value: RPNInput.Country };
type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
onChange: (value: RPNInput.Country) => void;
options: CountrySelectOption[];
options: { label: string; value: RPNInput.Country | undefined }[];
};
const CountrySelect = ({
@@ -86,70 +79,47 @@ const CountrySelect = ({
onChange,
options,
}: CountrySelectProps) => {
const handleSelect = React.useCallback(
(country: RPNInput.Country) => {
onChange(country);
},
[onChange],
);
const handleSelect = (event: React.ChangeEvent<HTMLSelectElement>) => {
onChange(event.target.value as RPNInput.Country);
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant={"outline"}
className={cn("flex gap-1 rounded-e-none rounded-s-lg px-3")}
disabled={disabled}
>
<FlagComponent country={value} countryName={value} />
<ChevronsUpDown
className={cn(
"-mr-2 h-4 w-4 opacity-50",
disabled ? "hidden" : "opacity-100",
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandList>
<ScrollArea className="h-72">
<CommandInput placeholder="Search country..." />
<CommandEmpty>No country found.</CommandEmpty>
<CommandGroup>
{options
.filter((x) => x.value)
.map((option) => (
<CommandItem
className="gap-2"
key={option.value}
onSelect={() => handleSelect(option.value)}
>
<FlagComponent
country={option.value}
countryName={option.label}
/>
<span className="flex-1 text-sm">{option.label}</span>
{option.value && (
<span className="text-sm text-foreground/50">
{`+${RPNInput.getCountryCallingCode(option.value)}`}
</span>
)}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
option.value === value ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<div
className="has-aria-invalid:border-destructive/60 has-aria-invalid:ring-destructive/20 dark:has-aria-invalid:ring-destructive/40 has-disabled:pointer-events-none has-disabled:opacity-50 relative inline-flex h-9 items-center self-stretch rounded-s-md border-0 bg-transparent py-2 pe-2 ps-3 text-muted-foreground shadow-sm outline-none ring-1 ring-inset ring-gray-600 transition-[color,box-shadow] focus-within:z-10 focus-within:ring-2 focus-within:ring-inset focus-within:ring-gray-300 hover:text-foreground"
style={
{
backgroundColor: "var(--phone-input-bg)",
color: "var(--phone-input-color)",
} as React.CSSProperties
}
>
<div className="inline-flex items-center gap-1" aria-hidden="true">
<FlagComponent country={value} countryName={value} aria-hidden="true" />
<span className="text-muted-foreground/80">
<ChevronDownIcon size={16} aria-hidden="true" />
</span>
</div>
<select
disabled={disabled}
value={value}
onChange={handleSelect}
className="absolute inset-0 text-sm opacity-0"
aria-label="Select country"
>
<option key="default" value="">
Select a country
</option>
{options
.filter((x) => x.value)
.map((option, i) => (
<option key={option.value ?? `empty-${i}`} value={option.value}>
{option.label}{" "}
{option.value &&
`+${RPNInput.getCountryCallingCode(option.value)}`}
</option>
))}
</select>
</div>
);
};
@@ -157,8 +127,12 @@ const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country];
return (
<span className="flex h-4 w-6 overflow-hidden rounded-sm bg-foreground/20">
{Flag && <Flag title={countryName} />}
<span className="w-5 overflow-hidden rounded-sm">
{Flag ? (
<Flag title={countryName} />
) : (
<PhoneIcon size={16} aria-hidden="true" />
)}
</span>
);
};
+55
View File
@@ -0,0 +1,55 @@
import React, { cloneElement } from "react";
import { useBreakpoint } from "@/lib/hooks/use-breakpoint";
import { Button, ButtonProps } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface ResponsiveButtonProps extends ButtonProps {
icon: React.ReactElement;
text: string;
breakpoint?: number;
}
export const ResponsiveButton = React.forwardRef<
HTMLButtonElement,
ResponsiveButtonProps
>(({ icon, text, breakpoint = 1024, ...props }, ref) => {
const isSmaller = useBreakpoint(breakpoint);
if (isSmaller) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button ref={ref} {...props}>
{icon}
<span className="sr-only">{text}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{text}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
const iconWithAriaHidden = cloneElement(icon, {
"aria-hidden": "true",
});
return (
<Button ref={ref} {...props}>
{iconWithAriaHidden}
{text}
</Button>
);
});
ResponsiveButton.displayName = "ResponsiveButton";
+16 -15
View File
@@ -106,22 +106,21 @@ export const BadgeTooltip = ({
);
};
export const ButtonTooltip = ({
content,
sideOffset = 0,
className,
children,
link,
}: {
content: string;
sideOffset?: number;
className?: string;
children: React.ReactNode;
link?: string;
}) => {
export const ButtonTooltip = React.forwardRef<
HTMLDivElement,
{
content: string;
sideOffset?: number;
className?: string;
children: React.ReactNode;
link?: string;
}
>(({ content, sideOffset = 0, className, children, link }, ref) => {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipTrigger asChild ref={ref}>
{children}
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
sideOffset={sideOffset}
@@ -149,7 +148,9 @@ export const ButtonTooltip = ({
</TooltipPortal>
</Tooltip>
);
};
});
ButtonTooltip.displayName = "ButtonTooltip";
export {
Tooltip,
+99
View File
@@ -0,0 +1,99 @@
import React, { ButtonHTMLAttributes, useState } from "react";
import { PlanEnum } from "@/ee/stripe/constants";
import { CrownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { Button } from "@/components/ui/button";
interface UpgradeButtonProps {
text: string;
clickedPlan: PlanEnum;
trigger: string;
variant?: "default" | "outline" | "ghost";
size?: "default" | "sm" | "lg" | "icon";
className?: string;
highlightItem?: string[];
onClick?: () => void;
useModal?: boolean;
customText?: string;
type?: ButtonHTMLAttributes<HTMLButtonElement>["type"];
disabled?: boolean;
}
export function UpgradeButton({
text,
clickedPlan,
trigger,
variant = "default",
size = "default",
className,
highlightItem,
onClick,
useModal = true,
customText,
type = "button",
disabled,
}: UpgradeButtonProps) {
const [open, setOpen] = useState(false);
const buttonContent = (
<Button
disabled={disabled}
type={type}
variant={variant}
size={size}
className={cn("gap-1.5", className)}
onClick={onClick ? onClick : useModal ? () => setOpen(true) : undefined}
>
{customText ? (
<>
<CrownIcon className="h-4 w-4" /> {customText}
</>
) : (
<>
<CrownIcon className="h-4 w-4" />
Upgrade to {text}
</>
)}
</Button>
);
if (!useModal || onClick || disabled) {
return buttonContent;
}
return (
<>
{buttonContent}
<UpgradePlanModal
clickedPlan={clickedPlan}
trigger={trigger}
open={open}
setOpen={setOpen}
highlightItem={highlightItem}
/>
</>
);
}
export function createUpgradeButton(
text: string,
clickedPlan: PlanEnum,
trigger: string,
options?: Partial<UpgradeButtonProps>,
) {
return function UpgradeButtonComponent(props?: Partial<UpgradeButtonProps>) {
return (
<UpgradeButton
text={text}
clickedPlan={clickedPlan}
trigger={trigger}
{...options}
{...props}
/>
);
};
}
+10 -45
View File
@@ -1,6 +1,6 @@
import { useRouter } from "next/router";
import { useCallback, useMemo, useRef, useState } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useTeam } from "@/context/team-context";
import { DocumentStorageType } from "@prisma/client";
@@ -10,7 +10,11 @@ import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "@/lib/constants";
import {
FREE_PLAN_ACCEPTED_FILE_TYPES,
FULL_PLAN_ACCEPTED_FILE_TYPES,
SUPPORTED_DOCUMENT_MIME_TYPES,
} from "@/lib/constants";
import { DocumentData, createDocument } from "@/lib/documents/create-document";
import { resumableUpload } from "@/lib/files/tus-upload";
import {
@@ -33,46 +37,9 @@ import { getPagesCount } from "@/lib/utils/get-page-number-count";
// Originally these mime values were directly used in the dropzone hook.
// There was a solid reason to take them out of the scope, primarily to solve a browser compatibility issue to determine the file type when user dropped a folder.
// you will figure out how this change helped to fix the compatibility issue once you have went through reading of `getFilesFromDropEvent` and `traverseFolder`
const acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrail = {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [], // ".xlsx"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
};
const allAcceptableDropZoneMimeTypes = {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [], // ".xlsx"
"application/vnd.ms-excel.sheet.macroEnabled.12": [".xlsm"], // ".xlsm"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": [], // ".docx"
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
[], // ".pptx"
"application/vnd.ms-powerpoint": [], // ".ppt"
"application/msword": [], // ".doc"
"application/vnd.oasis.opendocument.text": [], // ".odt"
"application/vnd.oasis.opendocument.presentation": [], // ".odp"
"image/vnd.dwg": [".dwg"], // ".dwg"
"image/vnd.dxf": [".dxf"], // ".dxf"
"image/png": [], // ".png"
"image/jpeg": [], // ".jpeg"
"image/jpg": [], // ".jpg"
"application/zip": [], // ".zip"
"application/x-zip-compressed": [], // ".zip"
"video/mp4": [], // ".mp4"
"video/webm": [], // ".webm"
"video/quicktime": [], // ".mov"
"video/x-msvideo": [], // ".avi"
"video/ogg": [], // ".ogg"
"application/vnd.google-earth.kml+xml": [".kml"], // ".kml"
"application/vnd.google-earth.kmz": [".kmz"], // ".kmz"
"application/vnd.ms-outlook": [".msg"], // ".msg"
};
const acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrial =
FREE_PLAN_ACCEPTED_FILE_TYPES;
const allAcceptableDropZoneMimeTypes = FULL_PLAN_ACCEPTED_FILE_TYPES;
interface FileWithPaths extends File {
path?: string;
@@ -134,8 +101,6 @@ export default function UploadZone({
? limits?.documents - limits?.usage?.documents
: 0;
const [progress, setProgress] = useState<number>(0);
const [showProgress, setShowProgress] = useState(false);
const uploadProgress = useRef<number[]>([]);
const fileSizeLimits = useMemo(
@@ -150,7 +115,7 @@ export default function UploadZone({
const acceptableDropZoneFileTypes =
isFree && !isTrial
? acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrail
? acceptableDropZoneMimeTypesWhenIsFreePlanAndNotTrial
: allAcceptableDropZoneMimeTypes;
// this var will help to determine the correct api endpoint to request folder creation (If needed).
+69 -3
View File
@@ -8,11 +8,77 @@ import { Button } from "@/components/ui/button";
export const ScreenProtector = () => {
const [blockScreen, setBlockScreen] = useState<boolean>(false);
// Key combinations for screenshot in Windows, Linux, and macOS
useHotkeys("meta+shift, alt, printscreen", (event) => {
// Comprehensive screenshot prevention for all major platforms
const handleScreenshotAttempt = (event: KeyboardEvent) => {
setBlockScreen(true);
event.preventDefault();
});
event.stopPropagation();
};
// Windows screenshot shortcuts
useHotkeys(
[
"printscreen", // PrintScreen key
"alt+printscreen", // Alt + PrintScreen (active window)
"meta+printscreen", // Win + PrintScreen (save to file)
"meta+shift+s", // Win + Shift + S (Snipping Tool)
"meta+g", // Win + G (Game Bar)
],
handleScreenshotAttempt,
{
preventDefault: true,
enableOnFormTags: true,
enableOnContentEditable: true,
},
);
// macOS screenshot shortcuts
useHotkeys(
[
"meta+shift",
"meta+shift+3", // Cmd + Shift + 3 (full screen)
"meta+shift+4", // Cmd + Shift + 4 (selection)
"meta+shift+5", // Cmd + Shift + 5 (screenshot utility)
"meta+shift+4+space", // Cmd + Shift + 4 + Space (window)
],
handleScreenshotAttempt,
{
preventDefault: true,
enableOnFormTags: true,
enableOnContentEditable: true,
},
);
// Linux screenshot shortcuts
useHotkeys(
[
"shift+printscreen", // Shift + PrintScreen (selection in some Linux distros)
"ctrl+alt+printscreen", // Ctrl + Alt + PrintScreen (some Linux distros)
],
handleScreenshotAttempt,
{
preventDefault: true,
enableOnFormTags: true,
enableOnContentEditable: true,
},
);
// Developer tools that could be used for screenshots
useHotkeys(
[
"f12", // F12 (Developer Tools)
"ctrl+shift+i", // Ctrl + Shift + I (Developer Tools)
"meta+alt+i", // Cmd + Option + I (macOS Developer Tools)
"ctrl+shift+c", // Ctrl + Shift + C (Inspect Element)
"meta+alt+c", // Cmd + Option + C (macOS Inspect Element)
],
handleScreenshotAttempt,
{
preventDefault: true,
enableOnFormTags: true,
enableOnContentEditable: true,
},
);
if (blockScreen) {
return (
@@ -1,11 +1,13 @@
import { Brand, CustomField, DataroomBrand } from "@prisma/client";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { E164Number } from "libphonenumber-js";
import { cn } from "@/lib/utils";
import { determineTextColor } from "@/lib/utils/determine-text-color";
import { Input } from "@/components/ui/input";
import { PhoneInput } from "@/components/ui/phone-input";
import { Textarea } from "@/components/ui/textarea";
export default function CustomFieldsSection({
fields,
data,
@@ -24,6 +26,10 @@ export default function CustomFieldsSection({
setData({ ...data, [identifier]: e.target.value });
};
const handlePhoneChange = (value: E164Number | null, identifier: string) => {
setData({ ...data, [identifier]: value || "" });
};
if (!fields?.length) return null;
return (
@@ -33,7 +39,7 @@ export default function CustomFieldsSection({
const value = data[field.identifier] || "";
const isLongText = field.type === "LONG_TEXT";
const InputComponent = isLongText ? Textarea : Input;
const isPhoneNumber = field.type === "PHONE_NUMBER";
return (
<div key={field.identifier} className="relative space-y-2">
@@ -46,42 +52,74 @@ export default function CustomFieldsSection({
>
{field.label}
</label>
<InputComponent
name={field.identifier || ""}
id={field.id || ""}
type={
field.type === "NUMBER"
? "number"
: field.type === "URL"
? "url"
: "text"
}
pattern={field.type === "URL" ? "https://.*" : undefined}
onInvalid={(e) => {
if (field.type === "URL") {
e.currentTarget.setCustomValidity(
"Please enter a valid URL starting with https://",
);
{isPhoneNumber ? (
<PhoneInput
id={field.identifier}
value={value as E164Number}
onChange={(phoneValue) =>
handlePhoneChange(phoneValue, field.identifier!)
}
}}
onInput={(e) => e.currentTarget.setCustomValidity("")}
autoComplete="off"
data-1p-ignore
required={field.required}
disabled={field.disabled}
className={cn(
"flex w-full rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6",
isLongText && "min-h-[100px] resize-none",
)}
style={{
backgroundColor:
brand && brand.accentColor ? brand.accentColor : "black",
color: determineTextColor(brand?.accentColor),
}}
value={value}
placeholder={field.placeholder || ""}
onChange={(e) => handleInputChange(e, field.identifier!)}
/>
placeholder={field.placeholder || "+1 123 456 7890"}
defaultCountry="US"
disabled={field.disabled}
className={cn(
"flex w-full rounded-md border-0 bg-black text-gray-500 placeholder:text-gray-400 sm:text-sm sm:leading-6",
)}
style={
{
"--phone-input-bg":
brand && brand.accentColor ? brand.accentColor : "black",
"--phone-input-color": determineTextColor(
brand?.accentColor,
),
} as React.CSSProperties
}
/>
) : (
(() => {
const InputComponent = isLongText ? Textarea : Input;
return (
<InputComponent
name={field.identifier || ""}
id={field.id || ""}
type={
field.type === "NUMBER"
? "number"
: field.type === "URL"
? "url"
: "text"
}
pattern={field.type === "URL" ? "https://.*" : undefined}
onInvalid={(e) => {
if (field.type === "URL") {
e.currentTarget.setCustomValidity(
"Please enter a valid URL starting with https://",
);
}
}}
onInput={(e) => e.currentTarget.setCustomValidity("")}
autoComplete="off"
data-1p-ignore
required={field.required}
disabled={field.disabled}
className={cn(
"flex w-full rounded-md border-0 bg-black py-1.5 text-gray-500 shadow-sm ring-1 ring-inset ring-gray-600 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6",
isLongText && "min-h-[100px] resize-none",
)}
style={{
backgroundColor:
brand && brand.accentColor
? brand.accentColor
: "black",
color: determineTextColor(brand?.accentColor),
}}
value={value}
placeholder={field.placeholder || ""}
onChange={(e) => handleInputChange(e, field.identifier!)}
/>
);
})()
)}
</div>
);
})}
+3 -1
View File
@@ -132,7 +132,9 @@ export default function AccessForm({
color: determineTextColor(brand?.accentColor),
}}
>
Your action is requested to continue
{brand && "welcomeMessage" in brand && brand.welcomeMessage
? brand.welcomeMessage
: "Your action is requested to continue"}
</h1>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
import {
ConversationSidebarProps,
ConversationViewSidebar as ConversationViewSidebarEE,
} from "@/ee/features/conversations/components/conversation-view-sidebar";
} from "@/ee/features/conversations/components/viewer/conversation-view-sidebar";
export function ConversationSidebar(props: ConversationSidebarProps) {
return <ConversationViewSidebarEE {...props} />;
@@ -9,6 +9,7 @@ import { toast } from "sonner";
import { useAnalytics } from "@/lib/analytics";
import { SUPPORTED_DOCUMENT_SIMPLE_TYPES } from "@/lib/constants";
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
import { LinkWithDataroomDocument, NotionTheme } from "@/lib/types";
import LoadingSpinner from "@/components/ui/loading-spinner";
@@ -16,7 +17,6 @@ import AccessForm, {
DEFAULT_ACCESS_FORM_DATA,
DEFAULT_ACCESS_FORM_TYPE,
} from "@/components/view/access-form";
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
import EmailVerificationMessage from "../access-form/email-verification-form";
import ViewData, { TViewDocumentData } from "../view-data";
@@ -193,6 +193,7 @@ export default function DataroomDocumentView({
viewerEmail: viewerEmail ?? data.email ?? verifiedEmail ?? userEmail,
isEmbedded,
isTeamMember,
teamId: link.teamId,
});
// set the verification token to the cookie
+2 -1
View File
@@ -8,8 +8,8 @@ import { toast } from "sonner";
import { useAnalytics } from "@/lib/analytics";
import { SUPPORTED_DOCUMENT_SIMPLE_TYPES } from "@/lib/constants";
import { LinkWithDataroom } from "@/lib/types";
import { useDisablePrint } from "@/lib/hooks/use-disable-print";
import { LinkWithDataroom } from "@/lib/types";
import LoadingSpinner from "@/components/ui/loading-spinner";
import AccessForm, {
@@ -157,6 +157,7 @@ export default function DataroomView({
viewerEmail: viewerEmail ?? data.email ?? verifiedEmail ?? userEmail,
isEmbedded,
isTeamMember,
teamId: link.teamId,
});
// set the verification token to the cookie
+3 -1
View File
@@ -23,6 +23,7 @@ const DEFAULT_BANNER_IMAGE = "/_static/papermark-banner.png";
export default function DataroomNav({
allowDownload,
allowBulkDownload,
brand,
viewId,
linkId,
@@ -34,6 +35,7 @@ export default function DataroomNav({
isTeamMember,
}: {
allowDownload?: boolean;
allowBulkDownload?: boolean;
brand?: Partial<DataroomBrand>;
viewId?: string;
linkId?: string;
@@ -187,7 +189,7 @@ export default function DataroomNav({
</Tooltip>
</TooltipProvider>
)}
{allowDownload ? (
{allowDownload && allowBulkDownload ? (
<ButtonTooltip content="Download Dataroom">
<Button
onClick={downloadDataroom}
+1
View File
@@ -171,6 +171,7 @@ export default function DocumentView({
viewerEmail: data.email ?? verifiedEmail ?? userEmail,
isEmbedded,
isTeamMember,
teamId: link.teamId,
});
// set the verification token to the cookie
+16 -1
View File
@@ -135,10 +135,24 @@ export default function Nav({
const pdfBlob = await response.blob();
const blobUrl = window.URL.createObjectURL(pdfBlob);
// Extract filename from Content-Disposition header
const contentDisposition = response.headers.get("content-disposition");
let filename = "document.pdf";
if (contentDisposition) {
const filenameMatch = contentDisposition.match(
/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/,
);
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(
filenameMatch[1].replace(/['"]/g, ""),
);
}
}
const link = document.createElement("a");
link.href = blobUrl;
link.rel = "noopener noreferrer";
link.download = "watermarked_document.pdf";
link.download = filename;
document.body.appendChild(link);
link.click();
@@ -422,6 +436,7 @@ export default function Nav({
<ConversationSidebar
dataroomId={dataroomId}
documentId={documentId}
pageNumber={pageNumber}
viewId={viewId || ""}
viewerId={viewerId}
linkId={linkId!}
+4 -2
View File
@@ -113,9 +113,10 @@ export default function DataroomViewer({
isEmbedded?: boolean;
viewerEmail?: string;
}) {
const { documents, folders } = dataroom as {
const { documents, folders, allowBulkDownload } = dataroom as {
documents: DataroomDocument[];
folders: DataroomFolder[];
allowBulkDownload: boolean;
};
const searchParams = useSearchParams();
@@ -242,7 +243,7 @@ export default function DataroomViewer({
new Date(folder.updatedAt);
const allDocumentsCanDownload =
folderDocuments.length > 0 &&
folderDocuments.length === 0 || // Allow download for empty folders
folderDocuments.every((doc) => {
const accessControl = accessControls.find(
(access) => access.itemId === doc.dataroomDocumentId,
@@ -329,6 +330,7 @@ export default function DataroomViewer({
viewId={viewId}
dataroom={dataroom}
allowDownload={allDocumentsCanDownload}
allowBulkDownload={allowBulkDownload}
isPreview={isPreview}
dataroomId={dataroom?.id}
viewerId={viewerId}
+6 -1
View File
@@ -37,6 +37,10 @@ const Collection = dynamic(() =>
),
);
const Code = dynamic(() =>
import("react-notion-x/build/third-party/code").then((m) => m.Code),
);
export const NotionPage = ({
recordMap,
versionNumber,
@@ -432,6 +436,7 @@ export const NotionPage = ({
disableHeader={true}
components={{
Collection,
Code,
PageLink: (props: {
className: string;
href: any;
@@ -467,4 +472,4 @@ export const NotionPage = ({
/>
</div>
);
};
};
+2 -4
View File
@@ -1,8 +1,7 @@
import React, { useMemo } from "react";
import Handlebars from "handlebars";
import { WatermarkConfig } from "@/lib/types";
import { safeTemplateReplace } from "@/lib/utils";
export const SVGWatermark = ({
config,
@@ -16,8 +15,7 @@ export const SVGWatermark = ({
pageIndex: number;
}) => {
const watermarkText = useMemo(() => {
const template = Handlebars.compile(config.text);
return template(viewerData);
return safeTemplateReplace(config.text, viewerData);
}, [config.text, viewerData]);
const { width, height } = documentDimensions;
+2 -10
View File
@@ -4,6 +4,7 @@ import { DocumentStorageType } from "@prisma/client";
import { FileRejection, useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { VIEWER_ACCEPTED_FILE_TYPES } from "@/lib/constants";
import { DocumentData } from "@/lib/documents/create-document";
import { viewerUpload } from "@/lib/files/viewer-tus-upload";
import { cn } from "@/lib/utils";
@@ -11,16 +12,7 @@ import { getSupportedContentType } from "@/lib/utils/get-content-type";
import { getPagesCount } from "@/lib/utils/get-page-number-count";
// File types allowed for viewer uploads
const acceptableViewerFileTypes = {
"application/pdf": [], // ".pdf"
"application/vnd.ms-excel": [], // ".xls"
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [], // ".xlsx"
"text/csv": [], // ".csv"
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
"image/jpeg": [], // ".jpg"
"image/png": [], // ".png"
"image/jpg": [], // ".jpg"
};
const acceptableViewerFileTypes = VIEWER_ACCEPTED_FILE_TYPES;
export default function ViewerUploadZone({
children,
+86 -31
View File
@@ -1,14 +1,10 @@
"use client";
import { PropsWithChildren, useState } from "react";
import { PropsWithChildren, useState, useEffect } from "react";
import { CircleCheck, CircleXIcon, CopyIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
materialDark,
materialLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { createHighlighter, type Highlighter } from "shiki";
import { useCopyToClipboard } from "@/lib/utils/use-copy-to-clipboard";
import { useMediaQuery } from "@/lib/utils/use-media-query";
@@ -28,21 +24,88 @@ export type EventListProps = PropsWithChildren<{
events: any[];
}>;
// Shiki Code Highlighter Component
const CodeHighlighter = ({
code,
language = "json",
isDark = false
}: {
code: string;
language?: string;
isDark?: boolean;
}) => {
const [highlighter, setHighlighter] = useState<Highlighter | null>(null);
const [highlightedCode, setHighlightedCode] = useState<string>("");
useEffect(() => {
const initHighlighter = async () => {
const shiki = await createHighlighter({
themes: ['material-theme-lighter', 'material-theme-darker'],
langs: ['json']
});
setHighlighter(shiki);
};
initHighlighter();
}, []);
useEffect(() => {
if (highlighter && code) {
const theme = isDark ? 'material-theme-darker' : 'material-theme-lighter';
const html = highlighter.codeToHtml(code, {
lang: language,
theme: theme,
transformers: [
{
pre(node) {
// Add custom styling to the pre element
node.properties.style = [
node.properties.style,
'margin: 0',
'padding: 0.5rem',
'font-size: 0.875rem',
'border-radius: 0.375rem',
'overflow-x: auto'
].filter(Boolean).join('; ');
},
code(node) {
// Ensure proper styling for the code element
node.properties.style = [
node.properties.style,
'display: block',
'line-height: 1.5'
].filter(Boolean).join('; ');
}
}
]
});
setHighlightedCode(html);
}
}, [highlighter, code, language, isDark]);
if (!highlighter || !highlightedCode) {
return (
<pre className="rounded-md bg-gray-100 p-2 text-sm dark:bg-gray-800">
<code>{code}</code>
</pre>
);
}
return (
<div
className="overflow-x-auto rounded-md"
dangerouslySetInnerHTML={{ __html: highlightedCode }}
/>
);
};
const WebhookEvent = ({ event }: { event: any }) => {
const { copyToClipboard, isCopied } = useCopyToClipboard({ timeout: 2000 });
const isSuccess = event.http_status >= 200 && event.http_status < 300;
const { isMobile } = useMediaQuery();
const [isOpen, setIsOpen] = useState(false);
const { theme, systemTheme } = useTheme();
const isLight =
theme === "light" || (theme === "system" && systemTheme === "light");
// Custom style overrides for react-syntax-highlighter
const customStyle = {
margin: 0,
padding: "0.5rem",
fontSize: "0.875rem",
};
const isDark = theme === "dark" || (theme === "system" && systemTheme === "dark");
return (
<>
@@ -112,29 +175,21 @@ const WebhookEvent = ({ event }: { event: any }) => {
<p className="text-sm text-gray-700">{event.http_status}</p>
</div>
<div className="overflow-y-scroll">
<SyntaxHighlighter
<CodeHighlighter
code={JSON.stringify(event.response_body, null, 2)}
language="json"
showLineNumbers
customStyle={customStyle}
style={isLight ? materialLight : materialDark}
lineNumberStyle={{ textAlign: "right", minWidth: "3.25em" }}
>
{JSON.stringify(event.response_body, null, 2)}
</SyntaxHighlighter>
isDark={isDark}
/>
</div>
</div>
<div className="grid gap-4 border-t border-gray-200 bg-transparent py-4">
<h4 className="font-semibold">Request</h4>
<div className="overflow-y-scroll">
<SyntaxHighlighter
<CodeHighlighter
code={JSON.stringify(event.request_body, null, 2)}
language="json"
showLineNumbers
style={isLight ? materialLight : materialDark}
customStyle={customStyle}
lineNumberStyle={{ textAlign: "right", minWidth: "3.25em" }}
>
{JSON.stringify(event.request_body, null, 2)}
</SyntaxHighlighter>
isDark={isDark}
/>
</div>
</div>
</ScrollArea>
@@ -5,6 +5,7 @@ import { Dispatch, SetStateAction, useState } from "react";
import { useTeam } from "@/context/team-context";
import { motion } from "motion/react";
import { toast } from "sonner";
import z from "zod";
import { useAnalytics } from "@/lib/analytics";
import { STAGGER_CHILD_VARIANTS } from "@/lib/constants";
@@ -34,8 +35,7 @@ export function UploadContainer({
const analytics = useAnalytics();
const [uploading, setUploading] = useState<boolean>(false);
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id as string;
const { currentTeamId: teamId } = useTeam();
const handleBrowserUpload = async (event: any) => {
event.preventDefault();
@@ -62,7 +62,7 @@ export function UploadContainer({
const { type, data, numPages, fileSize } = await putFile({
file: currentFile,
teamId,
teamId: teamId as string,
});
setCurrentFile(null);
@@ -79,7 +79,7 @@ export function UploadContainer({
// create a document in the database
const response = await createDocument({
documentData,
teamId,
teamId: teamId as string,
numPages,
createLink: dataroomId ? false : true, // don't create a link if the document is being added to a dataroom
});
@@ -89,55 +89,64 @@ export function UploadContainer({
if (dataroomId) {
// add document to dataroom
await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/documents`,
{
try {
const dataroomIdParsed = z.string().cuid().parse(dataroomId);
await fetch(
`/api/teams/${teamId}/datarooms/${dataroomIdParsed}/documents`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ documentId: document.id }),
},
);
analytics.capture("Document Added to Dataroom", {
documentId: document.id,
name: document.name,
numPages: document.numPages,
path: router.asPath,
type: document.type,
teamId: teamId,
dataroomId: dataroomId,
});
// create link to dataroom
const newLinkResponse = await fetch(`/api/links`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ documentId: document.id }),
},
);
analytics.capture("Document Added to Dataroom", {
documentId: document.id,
name: document.name,
numPages: document.numPages,
path: router.asPath,
type: document.type,
teamId: teamInfo?.currentTeam?.id,
dataroomId: dataroomId,
});
// create link to dataroom
const newLinkResponse = await fetch(`/api/links`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
targetId: dataroomId,
linkType: "DATAROOM_LINK",
teamId,
}),
});
if (newLinkResponse.ok) {
const link = await newLinkResponse.json();
setCurrentLinkId(link.id);
analytics.capture("Link Added", {
linkId: link.id,
dataroomId: dataroomId,
customDomain: null,
teamId: teamInfo?.currentTeam?.id,
body: JSON.stringify({
targetId: dataroomId,
linkType: "DATAROOM_LINK",
teamId,
}),
});
}
setTimeout(() => {
if (newLinkResponse.ok) {
const link = await newLinkResponse.json();
setCurrentLinkId(link.id);
analytics.capture("Link Added", {
linkId: link.id,
dataroomId: dataroomId,
customDomain: null,
teamId: teamId,
});
}
setTimeout(() => {
setUploading(false);
}, 2000);
} catch (error) {
console.error("Error adding document to dataroom:", error);
toast.error("Failed to add document to dataroom");
setUploading(false);
}, 2000);
return;
}
} else {
const linkId = document.links[0].id;
@@ -149,13 +158,13 @@ export function UploadContainer({
path: router.asPath,
type: document.type,
contentType: document.contentType,
teamId: teamInfo?.currentTeam?.id,
teamId: teamId,
});
analytics.capture("Link Added", {
linkId: linkId,
documentId: document.id,
customDomain: null,
teamId: teamInfo?.currentTeam?.id,
teamId: teamId,
});
setTimeout(() => {
+141
View File
@@ -0,0 +1,141 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface PauseResumeReminderEmailProps {
teamName?: string;
userName?: string;
resumeDate?: string;
plan?: string;
userRole?: string;
}
const baseUrl =
process.env.NEXT_PUBLIC_MARKETING_URL || "https://www.papermark.com";
export default function PauseResumeReminderEmail({
teamName = "Your Team",
userName = "Team Member",
resumeDate = "March 15, 2024",
plan = "Pro",
userRole = "Admin",
}: PauseResumeReminderEmailProps) {
const previewText = `Your ${teamName} subscription will resume billing in 3 days`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-[40px] w-[465px] rounded border border-solid border-[#eaeaea] p-[20px]">
<Section className="mt-[32px]">
<Img
src={`${baseUrl}/_static/papermark-logo.png`}
width="160"
height="48"
alt="Papermark"
className="mx-auto my-0"
/>
</Section>
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black">
Subscription Resume Reminder
</Heading>
<Text className="text-[14px] leading-[24px] text-black">
Hello {userName},
</Text>
<Text className="text-[14px] leading-[24px] text-black">
This is a friendly reminder that your <strong>{teamName}</strong>{" "}
team's paused subscription will automatically resume billing in{" "}
<strong>3 days</strong>.
</Text>
<Section className="my-[32px] rounded-lg border border-solid border-[#e5e7eb] bg-[#f9fafb] p-[24px]">
<Text className="m-0 text-[14px] font-semibold leading-[24px] text-black">
📅 Resume Details:
</Text>
<Text className="mb-0 mt-[12px] text-[14px] leading-[20px] text-[#6b7280]">
<strong>Team:</strong> {teamName}
<br />
<strong>Plan:</strong> {plan}
<br />
<strong>Resume Date:</strong> {resumeDate}
<br />
<strong>Your Role:</strong> {userRole}
</Text>
</Section>
<Text className="text-[14px] leading-[24px] text-black">
<strong>What happens next?</strong>
</Text>
<Text className="text-[14px] leading-[20px] text-black">
Your subscription will automatically resume on{" "}
<strong>{resumeDate}</strong>
<br />
Billing will restart at your regular plan rate
<br />
All features will be fully restored
<br /> Your existing data and links remain unchanged
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<strong>Need to make changes?</strong>
</Text>
<Text className="text-[14px] leading-[24px] text-black">
If you'd like to cancel your subscription instead of resuming, or
need to update your billing information, you can manage your
subscription in your account settings.
</Text>
<Section className="my-[32px] text-center">
<Link
className="rounded bg-[#000000] px-5 py-3 text-center text-[12px] font-semibold text-white no-underline"
href={`${baseUrl}/settings/billing`}
>
Manage Subscription
</Link>
</Section>
<Text className="text-[14px] leading-[24px] text-black">
If you have any questions or concerns, please don't hesitate to
reach out to our support team.
</Text>
<Text className="text-[14px] leading-[24px] text-black">
Best regards,
<br />
The Papermark Team
</Text>
<Section className="mt-[32px] border-t border-solid border-[#eaeaea] pt-[20px]">
<Text className="text-[12px] leading-[16px] text-[#666]">
This email was sent to you as an admin/manager of the {teamName}{" "}
team on Papermark. If you believe this was sent in error, please
contact our support team.
</Text>
<Text className="text-[12px] leading-[16px] text-[#666]">
Papermark - The secure document sharing platform
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
@@ -0,0 +1,101 @@
import React from "react";
import {
Body,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export default function BlockedEmailAttempt({
blockedEmail,
linkName,
resourceName,
resourceType = "document",
timestamp,
locationString,
accessType,
}: {
blockedEmail: string;
linkName: string;
resourceName: string;
resourceType?: "document" | "dataroom";
timestamp?: string;
locationString?: string;
accessType?: "global" | "allow" | "deny";
}) {
const accessTypeTexts = {
global:
"This email is on your global block list and was denied access. No further action is required.",
allow:
"This email is not on your link's allow list and was denied access. No further action is required.",
deny: "This email is on your link's block list and was denied access. No further action is required.",
default: "This email was denied access. No further action is required.",
};
const accessTypeText = accessType
? accessTypeTexts[accessType]
: accessTypeTexts.default;
return (
<Html>
<Head />
<Preview>Blocked email attempted to access your {resourceType}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
Blocked Email Attempted Access
</Text>
<Text className="text-sm leading-6 text-black">
A blocked email attempted to access your {resourceType}:
</Text>
<Text className="break-all text-sm leading-6 text-black">
<ul>
<li className="text-sm leading-6 text-black">
<span className="font-semibold">Email address:</span>{" "}
{blockedEmail}
</li>
<li className="text-sm leading-6 text-black">
<span className="font-semibold">Time:</span>{" "}
{timestamp || new Date().toLocaleString()}
</li>
<li className="text-sm leading-6 text-black">
<span className="font-semibold">
{resourceType === "dataroom" ? "Dataroom" : "Document"}{" "}
name:
</span>{" "}
{resourceName}
</li>
<li className="text-sm leading-6 text-black">
<span className="font-semibold">Link name:</span> {linkName}
</li>
</ul>
</Text>
<Text className="mt-4 text-sm leading-6 text-black">
{accessTypeText}
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc.
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
@@ -0,0 +1 @@
export { reportDeniedAccessAttempt } from "./lib/report-denied-access-attempt";
@@ -0,0 +1,84 @@
import { Link } from "@prisma/client";
import prisma from "@/lib/prisma";
import { sendBlockedEmailAttemptNotification } from "./send-blocked-email-attempt";
export async function reportDeniedAccessAttempt(
link: Partial<Link>,
email: string,
accessType: "global" | "allow" | "deny" = "global",
) {
if (!link || !link.teamId) return;
// Get all admin and manager emails
const users = await prisma.userTeam.findMany({
where: {
role: { in: ["ADMIN", "MANAGER"] },
status: "ACTIVE",
teamId: link.teamId,
},
select: {
user: { select: { email: true } },
},
});
const adminManagerEmails = users
.map((u) => u.user?.email)
.filter((e): e is string => !!e);
// Get resource info and owner email
let resourceType: "dataroom" | "document" = "dataroom";
let resourceName = "Dataroom";
let ownerEmail: string | undefined;
if (link.documentId) {
resourceType = "document";
const document = await prisma.document.findUnique({
where: { id: link.documentId },
select: { name: true, ownerId: true },
});
resourceName = document?.name || "Document";
if (document?.ownerId) {
const owner = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: document.ownerId,
teamId: link.teamId,
},
status: "ACTIVE",
},
select: { user: { select: { email: true } } },
});
ownerEmail = owner?.user?.email || undefined;
}
} else if (link.dataroomId) {
const dataroom = await prisma.dataroom.findUnique({
where: { id: link.dataroomId },
select: { name: true },
});
resourceName = dataroom?.name || "Dataroom";
}
// Combine all recipients and remove duplicates
const allRecipients = [...adminManagerEmails];
if (ownerEmail && !allRecipients.includes(ownerEmail)) {
allRecipients.push(ownerEmail);
}
// Send email to all recipients
if (allRecipients.length > 0) {
const [to, ...cc] = allRecipients;
await sendBlockedEmailAttemptNotification({
to,
cc: cc.length > 0 ? cc : undefined,
blockedEmail: email,
linkName: link.name || `Link #${link.id?.slice(-5)}`,
resourceName,
resourceType,
timestamp: new Date().toLocaleString(),
accessType,
});
}
}
@@ -0,0 +1,48 @@
import { sendEmail } from "@/lib/resend";
import BlockedEmailAttempt from "../components/blocked-email-attempt";
export const sendBlockedEmailAttemptNotification = async ({
to,
cc,
blockedEmail,
linkName,
resourceName,
resourceType = "document",
timestamp,
locationString,
accessType,
}: {
to: string;
cc?: string[];
blockedEmail: string;
linkName: string;
resourceName: string;
resourceType?: "document" | "dataroom";
timestamp?: string;
locationString?: string;
accessType: "global" | "allow" | "deny";
}) => {
const emailTemplate = BlockedEmailAttempt({
blockedEmail,
linkName,
resourceName,
resourceType,
timestamp,
locationString,
accessType,
});
try {
await sendEmail({
to,
cc,
subject: `Blocked access attempt to ${resourceType}: ${resourceName}`,
react: emailTemplate,
system: true,
test: process.env.NODE_ENV === "development",
});
return { success: true };
} catch (error) {
return { success: false, error };
}
};
@@ -0,0 +1,92 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { isOldAccount } from "@/ee/stripe/utils";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/cancel cancel a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
role: {
in: ["ADMIN", "MANAGER"],
},
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const stripe = stripeInstance(isOldAccount(team.plan));
waitUntil(
Promise.all([
stripe.subscriptions.update(team.subscriptionId, {
cancel_at_period_end: true,
}),
stripe.subscriptions.deleteDiscount(team.subscriptionId),
prisma.team.update({
where: { id: teamId },
data: {
cancelledAt: new Date(),
},
}),
log({
message: `Team ${teamId} cancelled their subscription.`,
type: "info",
}),
]),
);
return res.status(200).json({ success: true });
} catch (error) {
console.error("Error cancelling subscription:", error);
await log({
message: `Error cancelling subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to cancel subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
@@ -0,0 +1,114 @@
import { NextApiRequest, NextApiResponse } from "next";
import { CancellationReason } from "@/ee/features/billing/cancellation/lib/constants";
import { stripeInstance } from "@/ee/stripe";
import { isOldAccount } from "@/ee/stripe/utils";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/cancellation-feedback submit cancellation feedback
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
const { reason, feedback } = req.body as {
reason: CancellationReason;
feedback: string;
};
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
role: {
in: ["ADMIN", "MANAGER"],
},
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
const reasonLabels: Record<CancellationReason, string> = {
too_expensive: "Too expensive",
unused: "Not used enough",
missing_features: "Missing features",
switched_service: "Switched to another service",
other: "Other reason",
};
// Prepare feedback data for logging and analytics
const feedbackData = {
reason,
reasonLabel: reasonLabels[reason],
feedback: feedback || "",
timestamp: new Date().toISOString(),
};
// Update the subscription cancellation details
if (team.stripeId && team.subscriptionId) {
const stripe = stripeInstance(isOldAccount(team.plan));
waitUntil(
stripe.subscriptions.update(team.subscriptionId, {
cancellation_details: {
feedback: reason,
comment: feedback || "",
},
}),
);
}
// Log to Slack
waitUntil(
log({
message: `💔 **Cancellation Feedback Received**\n\n**Team:** ${teamId} (${team.plan})\n**Reason:** ${reasonLabels[reason]}\n**Feedback:** ${feedback || "No additional feedback provided"}\n\nTime: ${new Date().toLocaleString()}`,
type: "info",
}),
);
return res.status(200).json({
success: true,
feedbackData, // Return this for PostHog tracking in the frontend
});
} catch (error) {
console.error("Error submitting cancellation feedback:", error);
await log({
message: `Error submitting cancellation feedback for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to submit feedback" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
@@ -0,0 +1,130 @@
import { NextApiRequest, NextApiResponse } from "next";
import { sendPauseResumeNotificationTask } from "@/ee/features/billing/cancellation/lib/trigger/pause-resume-notification";
import { stripeInstance } from "@/ee/stripe";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/pause pause a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
endsAt: true,
plan: true,
limits: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const isOldAccount = team.plan.includes("+old");
const stripe = stripeInstance(isOldAccount);
const pauseStartsAt = team.endsAt ? new Date(team.endsAt) : new Date();
const pauseEndsAt = new Date(pauseStartsAt);
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
const reminderAt = new Date(pauseEndsAt);
reminderAt.setDate(pauseEndsAt.getDate() - 3);
// Pause the subscription in Stripe
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: {
behavior: "mark_uncollectible",
resumes_at: Math.floor(pauseEndsAt.getTime() / 1000), // seconds (int)
},
metadata: {
pause_starts_at: pauseStartsAt.toISOString(),
pause_ends_at: pauseEndsAt.toISOString(),
paused_reason: "user_request",
original_plan: team.plan,
},
});
await prisma.team.update({
where: { id: teamId },
data: {
pausedAt: new Date(),
pauseStartsAt,
pauseEndsAt,
},
});
waitUntil(
Promise.all([
// Schedule the pause resume notifications
sendPauseResumeNotificationTask.trigger(
{ teamId },
{
delay: reminderAt, // 3 days before pause ends
tags: [`team_${teamId}`],
idempotencyKey: `pause-resume-${teamId}-${new Date().getTime()}`,
},
),
// Remove the existing discounts from the subscription
stripe.subscriptions.deleteDiscount(team.subscriptionId),
log({
message: `Team ${teamId} (${team.plan}) paused their subscription for 3 months.`,
type: "info",
}),
]),
);
res.status(200).json({
success: true,
message: "Subscription paused successfully",
});
} catch (error) {
console.error("Error pausing subscription:", error);
await log({
message: `Error pausing subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to pause subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
@@ -0,0 +1,95 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { isOldAccount } from "@/ee/stripe/utils";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/reactivate reactivate a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
role: {
in: ["ADMIN", "MANAGER"],
},
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
const stripe = stripeInstance(isOldAccount(team.plan));
const subscription = await stripe.subscriptions.update(
team.subscriptionId,
{
cancel_at_period_end: false,
},
);
await prisma.team.update({
where: { id: teamId },
data: {
cancelledAt: null,
pauseStartsAt: null,
},
});
waitUntil(
log({
message: `Team ${teamId} reactivated their subscription.`,
type: "info",
}),
);
return res.status(200).json({ success: true });
} catch (error) {
console.error("Error reactivating subscription:", error);
await log({
message: `Error reactivating subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to reactivate subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
@@ -0,0 +1,106 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { getCouponFromPlan } from "@/ee/stripe/functions/get-coupon-from-plan";
import { isOldAccount } from "@/ee/stripe/utils";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/retention-offer apply retention offer
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
role: {
in: ["ADMIN", "MANAGER"],
},
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
plan: true,
pausedAt: true,
startsAt: true,
endsAt: true,
limits: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
let isAnnualPlan = false;
if (team?.startsAt && team?.endsAt) {
const durationInDays = Math.round(
(team.endsAt.getTime() - team.startsAt.getTime()) /
(1000 * 60 * 60 * 24),
);
// If duration is more than 31 days, consider it yearly
isAnnualPlan = durationInDays > 31;
}
const stripe = stripeInstance(isOldAccount(team.plan));
const couponId = getCouponFromPlan(team.plan, isAnnualPlan);
await stripe.subscriptions.update(team.subscriptionId, {
discounts: [
{
coupon: couponId,
},
],
});
waitUntil(
log({
message: `Retention offer applied to team ${teamId}: 50% off for ${
isAnnualPlan ? "12 months" : "3 months"
}`,
type: "info",
}),
);
return res.status(200).json({ success: true });
} catch (error) {
console.error("Error applying retention offer:", error);
await log({
message: `Error applying retention offer for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to apply retention offer" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
@@ -0,0 +1,129 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getDisplayNameFromPlan } from "@/ee/stripe/functions/get-display-name-from-plan";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { log } from "@/lib/utils";
import { sendEmailPauseResumeReminder } from "../emails/lib/send-email-pause-resume-reminder";
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
// We only allow POST requests
if (req.method !== "POST") {
res.status(405).json({ message: "Method Not Allowed" });
return;
}
// Extract the API Key from the Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.split(" ")[1]; // Assuming the format is "Bearer [token]"
// Check if the API Key matches
if (token !== process.env.INTERNAL_API_KEY) {
res.status(401).json({ message: "Unauthorized" });
return;
}
// Define validation schema
const requestSchema = z.object({
teamId: z
.string()
.min(1, "teamId is required and must be a non-empty string"),
});
// Validate request body
const validationResult = requestSchema.safeParse(req.body);
if (!validationResult.success) {
const firstError = validationResult.error.errors[0];
res.status(400).json({
message: firstError.message,
field: firstError.path[0],
});
return;
}
const { teamId } = validationResult.data;
try {
// Get all team members (ADMIN/MANAGER) for this team
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
name: true,
plan: true,
pauseEndsAt: true,
users: {
where: {
role: {
in: ["ADMIN", "MANAGER"],
},
blockedAt: null, // Only active team members (not blocked)
},
select: {
user: {
select: {
id: true,
email: true,
},
},
},
},
},
});
const teamMembers = team?.users;
if (!teamMembers || teamMembers.length === 0) {
res.status(200).json({
message: "No team members found for notifications",
notified: 0,
});
return;
}
if (!team.pauseEndsAt) {
res.status(200).json({
message: "Team is not paused",
notified: 0,
});
return;
}
// Get all team member emails
const teamMemberEmails = teamMembers
.map((tm) => tm.user.email)
.filter((email): email is string => !!email);
// Send email to all team members at once
await sendEmailPauseResumeReminder({
teamName: team.name,
plan: getDisplayNameFromPlan(team.plan),
resumeDate: team.pauseEndsAt.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}),
teamMemberEmails,
});
res.status(200).json({
message: "Successfully sent pause resume reminder to team members",
notified: teamMemberEmails.length,
});
return;
} catch (error) {
log({
message: `Failed to send pause resume reminder for team ${teamId} to team members. \n\n Error: ${error} \n\n*Metadata*: \`{teamId: ${teamId}}\``,
type: "error",
mention: true,
});
return res
.status(500)
.json({ message: "Failed to send pause resume reminder" });
}
}
@@ -0,0 +1,119 @@
import { NextApiRequest, NextApiResponse } from "next";
import { stripeInstance } from "@/ee/stripe";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { runs } from "@trigger.dev/sdk/v3";
import { waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth/next";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { log } from "@/lib/utils";
export const config = {
// in order to enable `waitUntil` function
supportsResponseStreaming: true,
};
export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") {
// POST /api/teams/:teamId/billing/unpause unpause a user's subscription
const session = await getServerSession(req, res, authOptions);
if (!session) {
res.status(401).end("Unauthorized");
return;
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
},
},
select: {
id: true,
stripeId: true,
subscriptionId: true,
endsAt: true,
plan: true,
pauseStartsAt: true,
pauseEndsAt: true,
},
});
if (!team) {
return res.status(400).json({ error: "Team does not exist" });
}
if (!team.stripeId) {
return res.status(400).json({ error: "No Stripe customer ID" });
}
if (!team.subscriptionId) {
return res.status(400).json({ error: "No subscription ID" });
}
if (!team.pauseStartsAt || !team.pauseEndsAt) {
return res.status(400).json({ error: "Subscription is not paused" });
}
const isOldAccount = team.plan.includes("+old");
const stripe = stripeInstance(isOldAccount);
// Unpause the subscription in Stripe
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: "",
});
await prisma.team.update({
where: { id: teamId },
data: {
pausedAt: null,
pauseStartsAt: null,
pauseEndsAt: null,
},
});
// Get all delayed and queued runs for this dataroom
const allRuns = await runs.list({
taskIdentifier: ["send-pause-resume-notification"],
tag: [`team_${teamId}`],
status: ["DELAYED", "QUEUED"],
period: "90d",
});
// Cancel any existing unsent notification runs for this dataroom
waitUntil(
Promise.all([
allRuns.data.map((run) => runs.cancel(run.id)),
log({
message: `Team ${teamId} (${team.plan}) unpaused their subscription. Next billing date: ${team.endsAt}`,
type: "info",
}),
]),
);
res.status(200).json({
success: true,
message: "Subscription unpaused successfully",
});
} catch (error) {
console.error("Error unpausing subscription:", error);
await log({
message: `Error unpausing subscription for team ${teamId}: ${error}`,
type: "error",
});
res.status(500).json({ error: "Failed to unpause subscription" });
}
} else {
res.setHeader("Allow", ["POST"]);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
@@ -0,0 +1,294 @@
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { type CancellationReason } from "../lib/constants";
import { ConfirmCancellationModal } from "./confirm-cancellation-modal";
import { FeedbackModal } from "./feedback-modal";
import { PauseSubscriptionModal } from "./pause-subscription-modal";
import { CancellationBaseModal } from "./reason-base-modal";
import { RetentionOfferModal } from "./retention-offer-modal";
import { ScheduleCallModal } from "./schedule-call-modal";
type CancellationStep =
| "reason"
| "retention-offer"
| "pause-offer"
| "feedback"
| "confirm"
| "schedule-call"
| "final-feedback";
interface CancellationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CancellationModal({
open,
onOpenChange,
}: CancellationModalProps) {
// Start with pause offer - this should be the first thing users see
const [currentStep, setCurrentStep] =
useState<CancellationStep>("pause-offer");
const [selectedReason, setSelectedReason] =
useState<CancellationReason | null>(null);
const [feedback, setFeedback] = useState("");
const [loading, setLoading] = useState(false);
const { currentTeamId } = useTeam();
const analytics = useAnalytics();
const reasons: { value: CancellationReason; label: string }[] = [
{ value: "too_expensive", label: "It's too expensive" },
{ value: "unused", label: "I don't use the service enough" },
{ value: "missing_features", label: "Some features are missing" },
{
value: "switched_service",
label: "I'm switching to a different service",
},
{ value: "other", label: "Other reason" },
];
// Reset state when modal closes
useEffect(() => {
if (!open) {
const timeoutId = setTimeout(() => {
setCurrentStep("pause-offer");
setSelectedReason(null);
setFeedback("");
setLoading(false);
}, 150);
return () => clearTimeout(timeoutId);
}
}, [open]);
const handleReasonClick = (reason: CancellationReason) => {
setSelectedReason(reason);
// Route based on reason - only "other" goes to feedback first
switch (reason) {
case "too_expensive":
setCurrentStep("retention-offer");
break;
case "unused":
setCurrentStep("confirm"); // Go directly to cancellation flow for unused
break;
case "missing_features":
setCurrentStep("schedule-call");
break;
case "switched_service":
setCurrentStep("retention-offer");
break;
case "other":
setCurrentStep("feedback"); // Only "other" goes to feedback first
break;
default:
setCurrentStep("confirm");
}
};
const handleBack = () => {
switch (currentStep) {
case "reason":
// From reason selection, go back to pause offer
setCurrentStep("pause-offer");
break;
case "retention-offer":
case "schedule-call":
setCurrentStep("reason");
break;
case "feedback":
// From "other" reason feedback, go back to reason selection
setCurrentStep("reason");
break;
case "confirm":
// Go back to the appropriate step based on reason
if (selectedReason === "missing_features") {
setCurrentStep("schedule-call");
} else if (selectedReason === "unused") {
setCurrentStep("reason"); // Go back to reason selection for unused
} else if (selectedReason === "other") {
setCurrentStep("feedback"); // Go back to "other" feedback
} else {
setCurrentStep("retention-offer");
}
break;
case "final-feedback":
// From final feedback, close modal
onOpenChange(false);
break;
case "pause-offer":
default:
onOpenChange(false);
}
};
const handleClose = () => {
onOpenChange(false);
};
const handleFinalFeedbackSubmit = async () => {
if (!currentTeamId || !selectedReason) return;
setLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/cancellation-feedback`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
reason: selectedReason,
feedback: feedback,
}),
},
);
if (response.ok) {
const data = await response.json();
// Track in PostHog
analytics.capture("Cancellation Feedback Submitted", {
teamId: currentTeamId,
reason: selectedReason,
reasonLabel: data.feedbackData?.reasonLabel || selectedReason,
feedback: feedback || "",
hasCustomFeedback: !!feedback,
});
toast.success("Thank you for your feedback!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
onOpenChange(false);
} else {
throw new Error("Failed to submit feedback");
}
} catch (error) {
console.error("Error submitting feedback:", error);
toast.error("Sorry, we couldn't submit your feedback. Please try again.");
} finally {
setLoading(false);
}
};
if (!open) {
return null;
}
if (currentStep === "pause-offer") {
return (
<PauseSubscriptionModal
open={open}
onOpenChange={onOpenChange}
onBack={handleBack}
onDecline={() => setCurrentStep("reason")} // Declining pause leads to reason selection
onClose={handleClose}
/>
);
}
if (currentStep === "reason" || !selectedReason) {
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title="Why do you want to cancel?"
description="Help us understand what we could improve"
showKeepButton={true}
onBack={handleBack}
>
<div className="space-y-3">
{reasons.map((reason) => (
<button
key={reason.value}
onClick={() => handleReasonClick(reason.value)}
className="w-full rounded-lg border border-gray-200 bg-white p-4 text-left transition-colors hover:ring-1 hover:ring-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:hover:ring-gray-200"
>
<div className="text-sm font-medium">{reason.label}</div>
</button>
))}
</div>
</CancellationBaseModal>
);
}
if (currentStep === "retention-offer") {
return (
<RetentionOfferModal
open={open}
onOpenChange={onOpenChange}
reason={selectedReason}
onBack={handleBack}
onDecline={() => setCurrentStep("confirm")}
onClose={handleClose}
/>
);
}
if (currentStep === "schedule-call") {
return (
<ScheduleCallModal
open={open}
onOpenChange={onOpenChange}
reason={selectedReason}
onDecline={() => setCurrentStep("confirm")}
/>
);
}
if (currentStep === "feedback") {
return (
<FeedbackModal
open={open}
onOpenChange={onOpenChange}
reason={selectedReason}
feedback={feedback}
onFeedbackChange={setFeedback}
onBack={handleBack}
onContinue={() => {
// For "other" reason, continue to confirmation
setCurrentStep("confirm");
}}
isFinalStep={false} // This is the "other" reason feedback, not final
/>
);
}
if (currentStep === "confirm") {
return (
<ConfirmCancellationModal
open={open}
onOpenChange={onOpenChange}
onConfirmCancellation={() => {
// After cancellation is confirmed, show feedback modal
setCurrentStep("final-feedback");
}}
/>
);
}
if (currentStep === "final-feedback") {
return (
<FeedbackModal
open={open}
onOpenChange={onOpenChange}
reason={selectedReason}
feedback={feedback}
onFeedbackChange={setFeedback}
onBack={handleBack}
onContinue={handleFinalFeedbackSubmit}
isFinalStep={true} // This is the final feedback
loading={loading}
/>
);
}
return null;
}
@@ -0,0 +1,123 @@
"use client";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { Button } from "@/components/ui/button";
import { CancellationBaseModal } from "./reason-base-modal";
interface ConfirmCancellationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirmCancellation: () => void;
}
export function ConfirmCancellationModal({
open,
onOpenChange,
onConfirmCancellation,
}: ConfirmCancellationModalProps) {
const [loading, setLoading] = useState(false);
const { currentTeamId } = useTeam();
const { plan: currentPlan, endsAt } = usePlan();
const analytics = useAnalytics();
const handleConfirmCancellation = async () => {
if (!currentTeamId) return;
setLoading(true);
// If we have a callback, call it instead of redirecting
try {
// Still make the API call to get the cancellation URL for future use
const response = await fetch(
`/api/teams/${currentTeamId}/billing/cancel`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (response.ok) {
// track the event
analytics.capture("Subscription Cancelled", {
teamId: currentTeamId,
plan: currentPlan,
endsAt: endsAt,
});
// Call the callback instead of redirecting
onConfirmCancellation();
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
} else {
toast.error("Something went wrong");
}
} catch (err) {
toast.error("Something went wrong");
} finally {
setLoading(false);
}
};
const getDescription = `Your subscription will end on ${new Date(
endsAt!,
).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}`;
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title="Confirm cancellation"
description={getDescription}
showKeepButton={true}
confirmButton={
<Button
variant="destructive"
onClick={handleConfirmCancellation}
loading={loading}
>
Confirm cancellation
</Button>
}
>
<div>
<h3 className="mb-4 text-lg font-semibold">
After cancellation, you'll lose access to:
</h3>
<ul className="space-y-2 text-muted-foreground">
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
All premium features
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Priority customer support
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Advanced analytics
</li>
<li className="flex items-center">
<span className="mr-3 h-2 w-2 rounded-full bg-muted-foreground"></span>
Team collaboration tools
</li>
</ul>
</div>
</CancellationBaseModal>
);
}
@@ -0,0 +1,144 @@
"use client";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { CancellationReason } from "../lib/constants";
import { CancellationBaseModal } from "./reason-base-modal";
interface FeedbackModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
feedback: string;
onFeedbackChange: (feedback: string) => void;
onBack?: () => void;
onContinue: () => void;
isFinalStep?: boolean;
loading?: boolean;
}
export function FeedbackModal({
open,
onOpenChange,
reason,
feedback,
onFeedbackChange,
onBack,
onContinue,
isFinalStep = false,
loading = false,
}: FeedbackModalProps) {
const getTitle = () => {
if (isFinalStep) {
return "Sorry to see you go!";
}
switch (reason) {
case "too_expensive":
return "Tell us about pricing";
case "unused":
return "Help us understand your usage";
case "missing_features":
return "What features do you need?";
case "switched_service":
return "What's driving your decision?";
case "other":
return "Tell us more";
default:
return "Help us understand what we could improve";
}
};
const getSubtitle = () => {
if (isFinalStep) {
return "Please share your feedback to help us improve";
}
switch (reason) {
case "too_expensive":
return "What would make the pricing work better for your budget?";
case "unused":
return "Help us understand how we can make Papermark more valuable for your workflow.";
case "missing_features":
return "What features are you missing? This helps us prioritize our roadmap.";
case "switched_service":
return "What does the competitor offer that we don't? This helps us improve.";
case "other":
return "We'd love to hear your specific feedback.";
default:
return "Help us understand what we could improve";
}
};
const getPlaceholder = () => {
if (isFinalStep) {
return "Help us understand what we could improve...";
}
switch (reason) {
case "too_expensive":
return "Tell us about your budget constraints or what pricing would work...";
case "unused":
return "Share how you use Papermark and what would make it more valuable...";
case "missing_features":
return "Tell us about the features you need...";
case "switched_service":
return "Tell us about the competitor and what attracted you to them...";
case "other":
return "Share your thoughts...";
default:
return "Help us understand what we could improve...";
}
};
const cancelButton = (isFinalStep: boolean) => {
if (isFinalStep) {
return null;
} else {
return (
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="flex items-center gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</Button>
);
}
};
const proceedButton = (isFinalStep: boolean) => {
if (isFinalStep) {
return (
<Button className="ml-auto" onClick={onContinue} loading={loading}>
Submit
</Button>
);
} else {
return <Button onClick={onContinue}>Submit</Button>;
}
};
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title={getTitle()}
description={getSubtitle()}
cancelButton={cancelButton(isFinalStep)}
proceedButton={proceedButton(isFinalStep)}
>
<Textarea
placeholder={getPlaceholder()}
value={feedback}
onChange={(e) => onFeedbackChange(e.target.value)}
rows={6}
className="resize-none bg-white dark:bg-gray-800"
/>
</CancellationBaseModal>
);
}
@@ -0,0 +1,6 @@
export { PauseSubscriptionModal } from "./pause-subscription-modal";
export { RetentionOfferModal } from "./retention-offer-modal";
export { FeedbackModal } from "./feedback-modal";
export { ConfirmCancellationModal } from "./confirm-cancellation-modal";
export { ScheduleCallModal } from "./schedule-call-modal";
export { CancellationModal } from "./cancellation-modal";
@@ -0,0 +1,203 @@
"use client";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { ArrowLeft, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import { timeIn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Modal } from "@/components/ui/modal";
interface PauseSubscriptionModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onBack: () => void;
onDecline: () => void;
onClose: () => void;
}
export function PauseSubscriptionModal({
open,
onOpenChange,
onBack,
onDecline,
onClose,
}: PauseSubscriptionModalProps) {
const [loading, setLoading] = useState(false);
const { currentTeamId } = useTeam();
const { endsAt, plan } = usePlan();
const analytics = useAnalytics();
const handlePauseSubscription = async () => {
if (!currentTeamId) return;
setLoading(true);
try {
const response = await fetch(
`/api/teams/${currentTeamId}/billing/pause`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
throw new Error("Failed to pause subscription");
}
const pauseStartsAt = endsAt ? new Date(endsAt) : new Date();
const pauseEndsAt = new Date(pauseStartsAt);
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
// Track the pause event for analytics
analytics.capture("Subscription Paused", {
teamId: currentTeamId,
plan: plan,
pauseStartsAt: pauseStartsAt.toISOString(),
pauseEndsAt: pauseEndsAt.toISOString(),
pauseDurationDays: 90,
});
toast.success("Subscription paused successfully!");
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
onClose();
} catch (error) {
console.error("Error pausing subscription:", error);
toast.error("Failed to pause subscription. Please try again.");
} finally {
setLoading(false);
}
};
const pauseStartsAt = endsAt ? new Date(endsAt) : new Date();
const pauseEndsAt = new Date(pauseStartsAt);
pauseEndsAt.setDate(pauseStartsAt.getDate() + 90);
const pauseStartsAtString = new Date(pauseStartsAt).toLocaleDateString(
"en-US",
{
month: "long",
day: "numeric",
year:
new Date(pauseStartsAt).getFullYear() === new Date().getFullYear()
? undefined
: "numeric",
},
);
const pauseEndsAtString = new Date(pauseEndsAt).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year:
new Date(pauseEndsAt).getFullYear() === new Date().getFullYear()
? undefined
: "numeric",
});
return (
<Modal
showModal={open}
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
if (typeof show === "function") {
onOpenChange(show(open));
} else {
onOpenChange(show);
}
}}
className="max-w-lg"
>
<div className="flex flex-col items-center justify-center space-y-3 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
<DialogTitle className="text-2xl font-semibold">
Pause your subscription
</DialogTitle>
<DialogDescription className="text-center text-base text-muted-foreground">
Take a break for 3 months and resume when you're ready
</DialogDescription>
</div>
<div className="px-4 py-4 dark:bg-gray-900 sm:px-8">
<div className="space-y-6">
<div className="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-900/10">
<div className="mb-4 text-base font-medium text-green-800 dark:text-green-700">
What pausing means for you?
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-500" />
<span className="text-sm font-medium text-green-800 dark:text-green-700">
You pay $0 for 3 months
</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-500" />
<span className="text-sm font-medium text-green-800 dark:text-green-700">
All your links continue to work
</span>
</div>
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-500" />
<span className="text-sm font-medium text-green-800 dark:text-green-700">
All your datarooms are accessible
</span>
</div>
</div>
</div>
<Button
onClick={handlePauseSubscription}
className="w-full"
size="lg"
loading={loading}
>
Pause for 3 months
</Button>
{/* Timeline information */}
<div className="space-y-3 border-t pt-4">
<div className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Pause starts:</span>
<span className="font-medium">
{pauseStartsAtString}{" "}
<span className="italic">({timeIn(pauseStartsAt)})</span>
</span>
</div>
<div className="flex items-center justify-between">
<span>Reminder email:</span>
<span className="font-medium">3 days before resume</span>
</div>
<div className="flex items-center justify-between">
<span>Auto-resume date:</span>
<span className="font-medium">{pauseEndsAtString}</span>
</div>
</div>
</div>
<div className="flex items-center justify-between border-t pt-4">
<Button
variant="ghost"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button variant="outline" onClick={onDecline}>
Continue to cancellation
</Button>
</div>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,86 @@
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Modal } from "@/components/ui/modal";
export function CancellationBaseModal({
open,
onOpenChange,
children,
title,
description,
onBack,
onDecline,
onConfirm,
confirmButton,
showKeepButton,
cancelButton,
proceedButton,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
title: string;
description: string;
onBack?: () => void;
onDecline?: () => void;
onConfirm?: () => void;
confirmButton?: React.ReactNode;
showKeepButton?: boolean;
cancelButton?: React.ReactNode;
proceedButton?: React.ReactNode;
}) {
return (
<Modal
showModal={open}
setShowModal={(show: boolean | ((prev: boolean) => boolean)) => {
if (typeof show === "function") {
onOpenChange(show(open));
} else {
onOpenChange(show);
}
}}
className="max-w-lg"
>
<div className="flex flex-col items-center justify-center space-y-2 border-b border-border bg-white px-4 py-4 pt-8 dark:border-gray-900 dark:bg-gray-900 sm:px-8">
<DialogTitle className="text-2xl font-semibold">{title}</DialogTitle>
<DialogDescription className="max-w-md text-center text-base text-muted-foreground">
{description}
</DialogDescription>
</div>
<div className="space-y-6 bg-white px-4 py-6 dark:bg-gray-900 sm:px-8">
<div className="space-y-6">{children}</div>
<div className="flex items-center justify-between border-t pt-4">
{cancelButton}
{proceedButton}
{onBack && (
<Button
variant="ghost"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</Button>
)}
{showKeepButton && (
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex items-center gap-2"
>
Stay on Papermark
</Button>
)}
{onDecline && (
<Button variant="outline" onClick={onDecline}>
Decline offer
</Button>
)}
{confirmButton}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,167 @@
"use client";
import { useRouter } from "next/router";
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { PLANS } from "@/ee/stripe/utils";
import { toast } from "sonner";
import { mutate } from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CancellationReason } from "../lib/constants";
import { CancellationBaseModal } from "./reason-base-modal";
interface RetentionOfferModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
onBack: () => void;
onDecline: () => void;
onClose: () => void;
}
export function RetentionOfferModal({
open,
onOpenChange,
reason,
onBack,
onDecline,
onClose,
}: RetentionOfferModalProps) {
const [loading, setLoading] = useState(false);
const { currentTeamId } = useTeam();
const router = useRouter();
const { plan: userPlan, isAnnualPlan } = usePlan();
const { limits } = useLimits();
const currentQuantity = limits?.users ?? 1;
const calculateSavings = () => {
// Find current plan pricing
const currentPlan = PLANS.find((p) => p.slug === userPlan);
if (!currentPlan) return { savings: "€0" };
const monthlyPrice = currentPlan.price.monthly.unitPrice;
const yearlyPrice = currentPlan.price.yearly.unitPrice;
// Simple logic: 50% discount for 3 months (monthly) or 12 months (annual)
const discountPercent = 0.5;
const durationMonths = isAnnualPlan ? 12 : 3;
const basePrice = isAnnualPlan ? yearlyPrice : monthlyPrice;
// Calculate savings
const totalSavings = Math.round(
(basePrice * durationMonths * discountPercent * currentQuantity) / 100,
);
return { savings: `${totalSavings}` };
};
const getOfferDetails = () => {
const { savings } = calculateSavings();
if (isAnnualPlan) {
return {
title: "Special offer just for you",
subtitle: "Let us make this work for your budget",
discount: "50% off your next year",
savings,
duration: "12 months",
};
} else {
return {
title: "Special offer just for you",
subtitle: "Let us make this work for your budget",
discount: "50% off for the next 3 months",
savings,
duration: "3 months",
};
}
};
const offerDetails = getOfferDetails();
const handleAcceptOffer = async () => {
if (!currentTeamId) return;
setLoading(true);
await fetch(`/api/teams/${currentTeamId}/billing/retention-offer`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
isAnnualPlan,
}),
})
.then(async (res) => {
const data = await res.json();
if (data.success) {
mutate(`/api/teams/${currentTeamId}/billing/plan`);
mutate(`/api/teams/${currentTeamId}/billing/plan?withDiscount=true`);
onClose();
toast.success(
`50% discount applied for ${isAnnualPlan ? "12 months" : "3 months"}!`,
);
}
})
.catch((err) => {
toast.error("Something went wrong");
})
.finally(() => {
setLoading(false);
});
};
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title={offerDetails.title}
description={offerDetails.subtitle}
showKeepButton={true}
// onBack={onBack}
onDecline={onDecline}
>
<div className="flex items-center justify-between">
<Badge
variant="secondary"
className="border-blue-200 bg-blue-50 text-blue-700"
>
Special offer
</Badge>
<span className="text-sm text-muted-foreground">Limited time</span>
</div>
<div className="text-center">
<h3 className="mb-2 text-xl font-semibold">{offerDetails.discount}</h3>
</div>
<div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<div className="mb-2 text-2xl font-bold text-green-800">
You save {offerDetails.savings}
</div>
<div className="text-sm font-medium text-green-700">
Over {offerDetails.duration}
</div>
</div>
<Button
onClick={handleAcceptOffer}
className="w-full"
size="lg"
loading={loading}
>
Accept this offer
</Button>
</CancellationBaseModal>
);
}
@@ -0,0 +1,106 @@
"use client";
import { useEffect, useState } from "react";
import Cal, { getCalApi } from "@calcom/embed-react";
import { useSession } from "next-auth/react";
import { CancellationReason } from "../lib/constants";
import { CancellationBaseModal } from "./reason-base-modal";
interface ScheduleCallModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
reason: CancellationReason;
onDecline: () => void;
}
export function ScheduleCallModal({
open,
onOpenChange,
reason,
onDecline,
}: ScheduleCallModalProps) {
const [calLoaded, setCalLoaded] = useState(false);
const { data: session } = useSession();
useEffect(() => {
if (open) {
(async function () {
try {
const cal = await getCalApi({ namespace: "papermark-support" });
cal("ui", { hideEventTypeDetails: true, layout: "month_view" });
setCalLoaded(true);
} catch (error) {
console.error("Error loading Cal.com:", error);
setCalLoaded(true); // Set to true anyway to show fallback
}
})();
}
}, [open]);
const getTitle = () => {
switch (reason) {
case "missing_features":
return "Let's talk about what you need";
default:
return "Schedule a consultation call";
}
};
const getSubtitle = () => {
switch (reason) {
case "missing_features":
return "Our team will reach out to understand your requirements";
default:
return "We'd love to understand how we can better serve you";
}
};
return (
<CancellationBaseModal
open={open}
onOpenChange={onOpenChange}
title={getTitle()}
description={getSubtitle()}
showKeepButton={true}
onDecline={onDecline}
>
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
<div className="mb-2 text-lg font-semibold text-green-800">
Free consultation
</div>
<div className="text-sm text-green-700">
30-minute call within 24 hours
</div>
</div>
{/* Cal.com embed container */}
<div className="max-h-[540px] rounded-lg border bg-white dark:bg-gray-800">
{calLoaded ? (
<Cal
namespace="papermark-support"
calLink={`marcseitz/papermark-support?email=${session?.user?.email || ""}&name=${session?.user?.name || ""}`}
style={{
width: "100%",
height: "540px",
overflow: "scroll",
}}
config={{ layout: "month_view" }}
/>
) : (
<div className="flex h-[500px] items-center justify-center">
<div className="text-center">
<div className="mb-2 text-lg font-semibold">
Loading calendar...
</div>
<div className="text-sm text-muted-foreground">
Please wait while we load the booking calendar
</div>
</div>
</div>
)}
</div>
</CancellationBaseModal>
);
}
@@ -0,0 +1,111 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface PauseResumeReminderEmailProps {
teamName?: string;
userName?: string;
resumeDate?: string;
plan?: string;
userRole?: string;
}
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || "https://app.papermark.com";
export default function PauseResumeReminderEmail({
teamName = "Your Team",
resumeDate = "March 15, 2024",
plan = "Pro",
}: PauseResumeReminderEmailProps) {
const previewText = `${teamName}'s paused subscription will resume billing soon`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-xl font-semibold">
Subscription Resume Reminder
</Text>
<Text className="text-sm leading-6 text-black">
This is a friendly reminder that your{" "}
<span className="font-semibold">{teamName}</span> team's paused
subscription will automatically resume billing in{" "}
<span className="font-semibold">3 days</span>.
</Text>
<Text className="text-sm font-semibold leading-6 text-black">
What happens next?
</Text>
<Text className="break-all text-sm leading-6 text-black">
<ul>
<li className="text-sm leading-6 text-black">
Your subscription will resume on{" "}
<span className="font-semibold">{resumeDate}</span>
</li>
<li className="text-sm leading-6 text-black">
Billing will restart at your{" "}
<span className="font-semibold">{plan}</span> plan rate
</li>
<li className="text-sm leading-6 text-black">
All features will be fully restored
</li>
<li className="text-sm leading-6 text-black">
Your existing data and links remain unchanged
</li>
</ul>
</Text>
<Text className="text-sm font-semibold leading-6 text-black">
Need to make changes?
</Text>
<Text className="text-sm leading-6 text-black">
If you'd like to cancel your subscription or need to update your
billing information, you can manage your subscription in your{" "}
<span className="font-semibold">account settings</span>.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${baseUrl}/settings/billing`}
style={{ padding: "12px 20px" }}
>
Manage Subscription
</Button>
</Section>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc.
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
@@ -0,0 +1,37 @@
import { sendEmail } from "@/lib/resend";
import PauseResumeReminderEmail from "../components/pause-resume-reminder";
export const sendEmailPauseResumeReminder = async ({
teamName,
plan,
resumeDate,
teamMemberEmails,
}: {
teamName: string;
plan: string;
resumeDate: string;
teamMemberEmails: string[];
}) => {
try {
if (!teamMemberEmails || teamMemberEmails.length === 0) {
console.log("No team member emails provided");
return;
}
await sendEmail({
to: teamMemberEmails[0], // Send to first team member
cc: teamMemberEmails.slice(1).join(","), // Send to all other team members
subject: "Your Papermark subscription will resume in 3 days",
react: PauseResumeReminderEmail({
teamName,
plan,
resumeDate,
}),
test: process.env.NODE_ENV === "development",
system: true,
});
} catch (e) {
console.error("Failed to send team member notification:", e);
}
};
@@ -0,0 +1,6 @@
export type CancellationReason =
| "too_expensive"
| "unused"
| "missing_features"
| "switched_service"
| "other";
@@ -0,0 +1,109 @@
import { logger, task } from "@trigger.dev/sdk/v3";
import prisma from "@/lib/prisma";
type PauseResumeNotificationPayload = {
teamId: string;
};
export const sendPauseResumeNotificationTask = task({
id: "send-pause-resume-notification",
retry: { maxAttempts: 3 },
run: async (payload: PauseResumeNotificationPayload) => {
logger.info("Starting pause resume notification", {
teamId: payload.teamId,
});
// Verify the team is still paused and get team details
const team = await prisma.team.findUnique({
where: {
id: payload.teamId,
},
select: {
id: true,
name: true,
plan: true,
pauseEndsAt: true,
pausedAt: true,
users: {
where: {
role: {
in: ["ADMIN", "MANAGER"],
},
// Only active team members (not blocked)
blockedAt: null,
},
select: {
userId: true,
role: true,
},
},
},
});
if (!team) {
logger.error("Team not found for pause resume notification", {
teamId: payload.teamId,
});
return;
}
// Check if team is still paused
if (!team.pausedAt || !team.pauseEndsAt) {
logger.info("Team is no longer paused, skipping notification", {
teamId: payload.teamId,
pausedAt: team.pausedAt,
pauseEndsAt: team.pauseEndsAt,
});
return;
}
if (team.users.length === 0) {
logger.info("No admin/manager users found for team", {
teamId: payload.teamId,
teamName: team.name,
});
return;
}
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/jobs/send-pause-resume-notification`,
{
method: "POST",
body: JSON.stringify({
teamId: payload.teamId,
}),
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.INTERNAL_API_KEY}`,
},
},
);
if (!response.ok) {
logger.error("Failed to send pause resume notification", {
teamId: payload.teamId,
error: await response.text(),
});
}
const { message } = (await response.json()) as { message: string };
logger.info("Pause resume notification sent successfully", {
teamId: payload.teamId,
message,
});
} catch (error) {
logger.error("Error sending pause resume notification", {
teamId: payload.teamId,
error,
});
}
logger.info("Completed pause resume notifications", {
teamId: payload.teamId,
});
return;
},
});
@@ -1,5 +1,8 @@
import { NextApiRequest, NextApiResponse } from "next";
import { runs } from "@trigger.dev/sdk/v3";
import { waitUntil } from "@vercel/functions";
import prisma from "@/lib/prisma";
import {
@@ -8,6 +11,7 @@ import {
} from "../lib/api/conversations";
import { messageService } from "../lib/api/messages";
import { notificationService } from "../lib/api/notifications";
import { sendConversationTeamMemberNotificationTask } from "../lib/trigger/conversation-message-notification";
// Route mapping object to handle different paths
const routeHandlers = {
@@ -39,7 +43,11 @@ const routeHandlers = {
},
},
dataroom: true,
dataroomDocument: true,
dataroomDocument: {
include: {
document: true,
},
},
participants: {
where: {
viewerId,
@@ -67,11 +75,13 @@ const routeHandlers = {
// POST /api/conversations
"POST /": async (req: NextApiRequest, res: NextApiResponse) => {
const { dataroomId, viewId, viewerId, ...data } =
const { dataroomId, viewId, viewerId, documentId, pageNumber, ...data } =
req.body as CreateConversationInput & {
dataroomId: string;
viewId: string;
viewerId?: string;
documentId?: string;
pageNumber?: number;
};
// Check if conversations are allowed
@@ -108,15 +118,83 @@ const routeHandlers = {
return res.status(400).json({ error: "Team not found" });
}
// Map documentId to dataroomDocumentId and get version info if provided
let enhancedData = { ...data };
if (documentId) {
const dataroomDocument = await prisma.dataroomDocument.findFirst({
where: {
dataroomId,
documentId,
},
include: {
document: {
include: {
versions: {
where: { isPrimary: true },
select: { versionNumber: true },
},
},
},
},
});
if (dataroomDocument) {
enhancedData.dataroomDocumentId = dataroomDocument.id;
// Set page number if provided
if (pageNumber) {
enhancedData.documentPageNumber = pageNumber;
}
// Set document version number from the primary version
if (dataroomDocument.document.versions[0]?.versionNumber) {
enhancedData.documentVersionNumber =
dataroomDocument.document.versions[0].versionNumber;
}
}
}
// Create the conversation
const conversation = await conversationService.createConversation({
dataroomId,
viewerId,
viewId,
teamId: team.id,
data,
data: enhancedData,
});
// Get all delayed and queued runs for this dataroom
const allRuns = await runs.list({
taskIdentifier: ["send-conversation-team-member-notification"],
tag: [`conversation_${conversation.id}`],
status: ["DELAYED", "QUEUED"],
period: "5m",
});
// Cancel any existing unsent notification runs for this dataroom
await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));
waitUntil(
sendConversationTeamMemberNotificationTask.trigger(
{
dataroomId,
messageId: conversation.messages[0].id,
conversationId: conversation.id,
senderUserId: viewerId,
teamId: team.id,
},
{
idempotencyKey: `conversation-notification-${team.id}-${dataroomId}-${conversation.id}-${conversation.messages[0].id}`,
tags: [
`team_${team.id}`,
`dataroom_${dataroomId}`,
`conversation_${conversation.id}`,
],
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
},
),
);
return res.status(201).json(conversation);
},
@@ -125,7 +203,7 @@ const routeHandlers = {
const { content, viewId, viewerId, conversationId } = req.body as {
content: string;
viewId: string;
viewerId?: string;
viewerId: string;
conversationId: string;
};
@@ -141,6 +219,38 @@ const routeHandlers = {
viewerId,
});
// Get all delayed and queued runs for this dataroom
const allRuns = await runs.list({
taskIdentifier: ["send-conversation-team-member-notification"],
tag: [`conversation_${message.conversationId}`],
status: ["DELAYED", "QUEUED"],
period: "5m",
});
// Cancel any existing unsent notification runs for this dataroom
await Promise.all(allRuns.data.map((run) => runs.cancel(run.id)));
waitUntil(
sendConversationTeamMemberNotificationTask.trigger(
{
dataroomId: message.conversation.dataroomId,
messageId: message.id,
conversationId: message.conversationId,
senderUserId: viewerId,
teamId: message.conversation.teamId,
},
{
idempotencyKey: `conversation-notification-${message.conversation.teamId}-${message.conversation.dataroomId}-${message.conversationId}-${message.id}`,
tags: [
`team_${message.conversation.teamId}`,
`dataroom_${message.conversation.dataroomId}`,
`conversation_${message.conversationId}`,
],
delay: new Date(Date.now() + 5 * 60 * 1000), // 5 minute delay
},
),
);
return res.status(201).json(message);
},
@@ -0,0 +1,184 @@
import { NextApiRequest, NextApiResponse } from "next";
import { sendConversationTeamNotification } from "@/ee/features/conversations/emails/lib/send-conversation-team-notification";
import { z } from "zod";
import prisma from "@/lib/prisma";
import { log } from "@/lib/utils";
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
// We only allow POST requests
if (req.method !== "POST") {
res.status(405).json({ message: "Method Not Allowed" });
return;
}
// Extract the API Key from the Authorization header
const authHeader = req.headers.authorization;
const token = authHeader?.split(" ")[1]; // Assuming the format is "Bearer [token]"
// Check if the API Key matches
if (token !== process.env.INTERNAL_API_KEY) {
res.status(401).json({ message: "Unauthorized" });
return;
}
// Define validation schema
const requestSchema = z.object({
conversationId: z
.string()
.min(1, "conversationId is required and must be a non-empty string"),
dataroomId: z
.string()
.min(1, "dataroomId is required and must be a non-empty string"),
senderUserId: z
.string()
.min(1, "senderUserId is required and must be a non-empty string"),
teamId: z
.string()
.min(1, "teamId is required and must be a non-empty string"),
});
// Validate request body
const validationResult = requestSchema.safeParse(req.body);
if (!validationResult.success) {
const firstError = validationResult.error.errors[0];
res.status(400).json({
message: firstError.message,
field: firstError.path[0],
});
return;
}
const { conversationId, dataroomId, senderUserId, teamId } =
validationResult.data;
try {
// Get all team members (ADMIN/MANAGER) for this team
const teamMembers = await prisma.userTeam.findMany({
where: {
teamId,
role: {
in: ["ADMIN", "MANAGER"],
},
// Only active team members (not blocked)
blockedAt: null,
},
select: {
userId: true,
notificationPreferences: true,
user: {
select: {
id: true,
email: true,
},
},
},
});
if (!teamMembers || teamMembers.length === 0) {
res.status(200).json({
message: "No team members found for notifications",
notified: 0,
});
return;
}
// Filter out team members who have disabled conversation notifications
const eligibleTeamMembers = teamMembers.filter((teamMember) => {
if (teamMember.notificationPreferences) {
try {
const preferences = teamMember.notificationPreferences as {
conversations?: boolean;
};
if (preferences.conversations === false) {
return false;
}
} catch (error) {
// If preferences parsing fails, include the team member
}
}
return teamMember.user.email; // Only include if they have an email
});
if (eligibleTeamMembers.length === 0) {
res.status(200).json({
message: "No eligible team members for notifications",
notified: 0,
});
return;
}
// Fetch the conversation and dataroom information
const conversation = await prisma.conversation.findUnique({
where: {
id: conversationId,
dataroomId: dataroomId,
teamId: teamId,
},
select: {
title: true,
dataroom: {
select: {
name: true,
},
},
},
});
if (!conversation) {
res.status(404).json({ message: "Conversation not found." });
return;
}
// First try to find as a user
const userSender = await prisma.viewer.findUnique({
where: { id: senderUserId },
select: { email: true },
});
let senderEmail = "";
if (userSender) {
senderEmail = userSender.email;
} else {
senderEmail = "Unknown sender";
}
// Generate the URL for team members to access the conversation
const conversationUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/datarooms/${dataroomId}/conversations/${conversationId}`;
// For team members, provide a generic unsubscribe URL to team settings
// Get all team member emails
const teamMemberEmails = eligibleTeamMembers
.map((tm) => tm.user.email)
.filter((email): email is string => !!email);
// Send email to all team members at once
await sendConversationTeamNotification({
conversationTitle: conversation.title || "Untitled Conversation",
dataroomName: conversation.dataroom?.name || "Untitled Dataroom",
senderEmail,
teamMemberEmails,
url: conversationUrl,
});
res.status(200).json({
message: "Successfully sent conversation notification to team members",
notified: teamMemberEmails.length,
teamMemberEmails,
});
return;
} catch (error) {
log({
message: `Failed to send conversation notification for dataroom ${dataroomId} to team members. \n\n Error: ${error} \n\n*Metadata*: \`{dataroomId: ${dataroomId}, teamId: ${teamId}}\``,
type: "error",
mention: true,
});
return res.status(500).json({ message: (error as Error).message });
}
}
@@ -65,6 +65,11 @@ const routeHandlers = {
createdAt: true,
},
},
dataroomDocument: {
include: {
document: true,
},
},
_count: {
select: {
messages: {
@@ -117,6 +122,15 @@ const routeHandlers = {
viewerEmail: viewer?.email,
unreadCount: conversation._count.messages,
lastMessage: conversation.messages[0] || null,
dataroomDocumentName: conversation.dataroomDocumentId
? conversation.dataroomDocument.document.name
: undefined,
documentPageNumber: conversation.dataroomDocumentId
? conversation.documentPageNumber
: undefined,
documentVersionNumber: conversation.dataroomDocumentId
? conversation.documentVersionNumber
: undefined,
};
});
@@ -373,6 +387,11 @@ const routeHandlers = {
viewer: true,
},
},
dataroomDocument: {
include: {
document: true,
},
},
},
});
@@ -0,0 +1,57 @@
import { BookOpenIcon, FileTextIcon } from "lucide-react";
interface ConversationDocumentContextProps {
dataroomDocument?: {
document: {
name: string;
type?: string;
};
};
documentPageNumber?: number | null;
documentVersionNumber?: number | null;
className?: string;
showVersionNumber?: boolean; // Controls whether to show version info
}
export function ConversationDocumentContext({
dataroomDocument,
documentPageNumber,
documentVersionNumber,
className = "",
showVersionNumber = false,
}: ConversationDocumentContextProps) {
if (!dataroomDocument) return null;
const hasPageOrVersion =
documentPageNumber || (showVersionNumber && documentVersionNumber);
return (
<div
className={`flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm ${className}`}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<FileTextIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">
{dataroomDocument.document.name}
</span>
</div>
{hasPageOrVersion && (
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{documentPageNumber && (
<span className="flex items-center gap-1">
<BookOpenIcon className="h-3 w-3" />
Page {documentPageNumber}
</span>
)}
{documentPageNumber && showVersionNumber && documentVersionNumber && (
<span></span>
)}
{showVersionNumber && documentVersionNumber && (
<span>v{documentVersionNumber}</span>
)}
</div>
)}
</div>
);
}
@@ -8,11 +8,33 @@ export function ConversationListItem({
navigateToConversation,
conversation,
isActive,
showVersionNumber = false,
}: {
navigateToConversation: (id: string) => void;
conversation: any;
isActive: boolean;
showVersionNumber?: boolean;
}) {
// Helper function to format document reference
const formatDocumentReference = () => {
if (!conversation.dataroomDocumentName) return "Untitled conversation";
const documentName = conversation.dataroomDocumentName;
const parts = [];
if (conversation.documentPageNumber) {
parts.push(`Page ${conversation.documentPageNumber}`);
}
// Only show version number if showVersionNumber is true (admin/team member view)
if (showVersionNumber && conversation.documentVersionNumber) {
parts.push(`v${conversation.documentVersionNumber}`);
}
const reference = parts.length > 0 ? ` (${parts.join(", ")})` : "";
return `${documentName}${reference}`;
};
return (
<button
className={cn(
@@ -40,14 +62,7 @@ export function ConversationListItem({
</div>
</div>
<div className="text-xs font-medium">
{conversation.title ||
(conversation.dataroomDocument
? `${conversation.dataroomDocument.document.name}${
conversation.documentPageNumber
? ` (Page ${conversation.documentPageNumber})`
: ""
}`
: "Untitled conversation")}
{formatDocumentReference() || conversation.title}
</div>
</div>
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { toast } from "sonner";
import z from "zod";
import { Button } from "@/components/ui/button";
import {
@@ -74,8 +75,11 @@ export function ConversationsNotEnabledBanner({
const handleToggleConversations = async (newValue: boolean) => {
setIsProcessing(true);
try {
const dataroomIdParsed = z.string().cuid().parse(dataroomId);
const teamIdParsed = z.string().cuid().parse(teamId);
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/conversations/toggle-conversations`,
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/toggle-conversations`,
{
method: "POST",
headers: {
@@ -0,0 +1,57 @@
import { BookOpenIcon, FileTextIcon } from "lucide-react";
interface ConversationDocumentContextProps {
dataroomDocument?: {
document: {
name: string;
type?: string;
};
};
documentPageNumber?: number | null;
documentVersionNumber?: number | null;
className?: string;
showVersionNumber?: boolean; // Controls whether to show version info
}
export function ConversationDocumentContext({
dataroomDocument,
documentPageNumber,
documentVersionNumber,
className = "",
showVersionNumber = false,
}: ConversationDocumentContextProps) {
if (!dataroomDocument) return null;
const hasPageOrVersion =
documentPageNumber || (showVersionNumber && documentVersionNumber);
return (
<div
className={`flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm ${className}`}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<FileTextIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">
{dataroomDocument.document.name}
</span>
</div>
{hasPageOrVersion && (
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
{documentPageNumber && (
<span className="flex items-center gap-1">
<BookOpenIcon className="h-3 w-3" />
Page {documentPageNumber}
</span>
)}
{documentPageNumber && showVersionNumber && documentVersionNumber && (
<span></span>
)}
{showVersionNumber && documentVersionNumber && (
<span>v{documentVersionNumber}</span>
)}
</div>
)}
</div>
);
}
@@ -2,23 +2,19 @@
import { useState } from "react";
import { ConversationMessage } from "@/ee/features/conversations/components/conversation-message";
import { ConversationDocumentContext } from "@/ee/features/conversations/components/shared/conversation-document-context";
import { ConversationMessage } from "@/ee/features/conversations/components/shared/conversation-message";
import { format } from "date-fns";
import { ArrowLeftIcon, BellIcon, BellOffIcon, Plus } from "lucide-react";
import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import { cn, fetcher } from "@/lib/utils";
import { fetcher } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Sheet, SheetContent } from "@/components/ui/sheet";
// Type definitions
interface Message {
@@ -40,6 +36,7 @@ interface Conversation {
userId: string | null;
viewerId: string | null;
documentPageNumber: number | null;
documentVersionNumber: number | null;
dataroomDocument?: {
document: {
name: string;
@@ -223,19 +220,20 @@ export function ConversationViewSidebar({
{/* Conversation Header */}
<div className="border-b p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className="flex min-w-0 flex-1 items-center">
<Button
variant="ghost"
size="icon"
onClick={() => setActiveConversation(null)}
className="-ml-2 mr-2"
className="-ml-2 mr-2 shrink-0"
>
<ArrowLeftIcon className="h-5 w-5" />
</Button>
<h3 className="truncate font-medium">
{activeConversation.title ||
"Untitled conversation"}
</h3>
<div className="min-w-0">
<h3 className="truncate font-medium">
{activeConversation.title || "Conversation"}
</h3>
</div>
</div>
<Button
variant={
@@ -302,6 +300,18 @@ export function ConversationViewSidebar({
{/* Messages */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-2 p-4">
{/* Document Context */}
<ConversationDocumentContext
dataroomDocument={activeConversation.dataroomDocument}
documentPageNumber={
activeConversation.documentPageNumber
}
documentVersionNumber={
activeConversation.documentVersionNumber
}
showVersionNumber={false} // Viewers see simplified context
className="mb-2"
/>
{activeConversation.messages?.map((message) => (
<ConversationMessage
key={message.id}
@@ -62,14 +62,7 @@ export default function ConversationNotification({
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
© {new Date().getFullYear()} Papermark, Inc.
</Text>
<Text className="text-xs">
You received this email from{" "}
@@ -0,0 +1,80 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export default function ConversationTeamNotification({
conversationTitle,
dataroomName,
senderEmail,
url,
}: {
conversationTitle: string;
dataroomName: string;
senderEmail: string;
url: string;
}) {
return (
<Html>
<Head />
<Preview>New message from visitor in your dataroom</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-xl font-semibold">
{`New message in ${dataroomName}`}
</Text>
<Text className="text-sm leading-6 text-black">
A visitor (<span className="font-semibold">{senderEmail}</span>)
has sent a new message in the conversation{" "}
<span className="font-semibold">{conversationTitle}</span> in your
dataroom <span className="font-semibold">{dataroomName}</span> on
Papermark.
</Text>
<Text className="text-sm leading-6 text-black">
As a manager, you&apos;re receiving this notification to stay
informed about visitor interactions in your dataroom.
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${url}`}
style={{ padding: "12px 20px" }}
>
View the conversation
</Button>
</Section>
<Text className="text-sm text-black">
or copy and paste this URL into your browser: <br />
{`${url}`}
</Text>
<Text className="text-sm text-gray-400">Papermark</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc.
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}

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