Compare commits

..
301 Commits
Author SHA1 Message Date
Marc Seitz 8b4dac1c83 fix: update types 2025-09-13 18:27:46 +02:00
Marc Seitz 8abf39357b chore: logs 2025-09-13 18:02:10 +02:00
Marc Seitz 73fbef9f51 fix: slack session auth 2025-09-13 17:34:13 +02:00
Marc SeitzandGitHub dc36c24cd5 Merge pull request #1852 from AshishViradiya153/slack/reform
Slack/reform
2025-09-13 17:14:59 +02:00
Marc Seitz 6fadbfa212 chore: remove nodemailer 2025-09-13 16:57:26 +02:00
Marc Seitz daf8987141 feat: add featureflag for slack 2025-09-13 16:54:35 +02:00
Marc Seitz f9266fc611 refactor: redirect 2025-09-13 16:54:27 +02:00
Marc Seitz b8a4265edf feat: slack integration improvements and simplification 2025-09-13 16:49:39 +02:00
Marc Seitz 4e86e2a8d9 Merge branch 'main' into slack/reform 2025-09-12 19:13:09 +02:00
Marc SeitzandGitHub 1f5366e152 Merge pull request #1864 from mfts/feat/bulk
feat: extend max duration
2025-09-11 23:27:51 +02:00
Marc Seitz cca9d12eca feat: extend max duration 2025-09-11 22:28:13 +02:00
Marc SeitzandGitHub 4698748803 Merge pull request #1801 from mfts/emails
feat: emails updates and new emails
2025-09-10 23:41:35 +02:00
Marc Seitz 9b99839e51 chore: revert 2025-09-10 23:37:12 +02:00
Marc Seitz 2eba40a4c5 chore: fix 2025-09-10 22:10:34 +02:00
Marc Seitz 103a11e04f fix: lint error 2025-09-10 21:46:21 +02:00
Marc Seitz 4d8856a92e feat(ee): increase limits for max files 2025-09-10 21:46:09 +02:00
Marc Seitz ed336579e8 chore: update dependencies 2025-09-10 21:39:20 +02:00
Marc Seitz b0a4fbe17b fix: lint error 2025-09-10 21:36:44 +02:00
Marc Seitz 2489d0c05e feat: remove trial end reminder cron route 2025-09-10 21:14:44 +02:00
Marc Seitz 3ab376b522 chore: update dependencies 2025-09-10 20:47:39 +02:00
Marc Seitz 6cd1820ce7 feat: cleanup email templates 2025-09-10 20:35:30 +02:00
Marc Seitz 010aa4a4d1 feat: improve welcome email 2025-09-10 20:35:13 +02:00
Marc Seitz 4e856d351f feat: add personal upgrade welcome email 2025-09-10 20:15:34 +02:00
Marc Seitz 0831a047e2 chore: cleanup 2025-09-10 20:15:21 +02:00
Marc Seitz 0999c6bd41 feat: add one month check in trigger 2025-09-10 20:05:28 +02:00
Marc Seitz 81c3a38148 feat: update the upgrade email notification 2025-09-10 20:04:52 +02:00
Marc Seitz 46574bfdd6 feat: remove unused trial end reminder 2025-09-10 20:04:00 +02:00
Marc Seitz a0f70fb05e feat: add footer to team invitation 2025-09-10 19:23:06 +02:00
Marc Seitz bc84b2b84e feat: add dataroom trial 24h end email 2025-09-10 19:18:40 +02:00
Marc Seitz 7e0647f9f7 feat: add datarooms info email 2025-09-10 19:11:30 +02:00
Marc Seitz b71592aafa Merge branch 'main' into emails 2025-09-10 18:54:22 +02:00
Marc SeitzandGitHub 33899e693f Merge pull request #1859 from mfts/fix/dataroom-index
feat: server-side detection if dataroom index enabled
2025-09-07 12:35:59 +02:00
Marc SeitzandGitHub 6170b20205 Merge pull request #1850 from mfts/cursor/update-link-agreement-to-show-compliance-text-7d7a
Update link agreement to show compliance text
2025-09-07 12:28:42 +02:00
Marc Seitz 96414bca5a feat: server-side detection if dataroom index enabled 2025-09-07 12:26:46 +02:00
Marc Seitz 3027d7e579 fix: search in domains 2025-09-07 11:29:30 +02:00
Marc Seitz 2060cca3a4 feat: validate and sanitze content 2025-09-07 11:29:22 +02:00
Marc Seitz 992216bb20 Merge branch 'main' into cursor/update-link-agreement-to-show-compliance-text-7d7a 2025-09-07 10:32:16 +02:00
Marc SeitzandGitHub f8bf8aa6f6 Merge pull request #1857 from mfts/mfts-patch-1
Correct grammar in security policy document
2025-09-07 10:30:39 +02:00
Marc Seitz 914b7dd4c5 feat: remove style on agreement 2025-09-07 10:29:42 +02:00
Marc SeitzandGitHub a6a52c4844 Fix grammatical error in SECURITY.md 2025-09-07 10:25:11 +02:00
Marc SeitzandGitHub 66d66ee515 Merge pull request #1858 from mfts/fix/custom-domai
fix: improve custom domains loading
2025-09-07 10:24:23 +02:00
Marc Seitz cf9c3909ff chore: downgrade to prisma 6.5 2025-09-07 10:23:08 +02:00
Marc Seitz 5dc02cf6a5 refactor: prisma schema 2025-09-07 10:23:01 +02:00
Marc Seitz 6142c5746e Merge branch 'main' into cursor/update-link-agreement-to-show-compliance-text-7d7a 2025-09-07 09:49:45 +02:00
Marc Seitz 7c9c7bcaf7 chore: update dependencies 2025-09-07 09:43:21 +02:00
Marc Seitz 82ef46848e fix: custom domain redirected 2025-09-07 09:39:34 +02:00
Marc Seitz 0b37bb9e27 fix: teamId cannot be undefined 2025-09-07 09:39:24 +02:00
Marc Seitz 9c02ad2223 chore: cleanup 2025-09-07 09:38:50 +02:00
Marc SeitzandGitHub ca9dc9b2b1 Correct grammar in security policy document 2025-09-07 07:59:17 +02:00
Marc SeitzandGitHub 7eed8deb32 Merge pull request #1855 from mfts/feat/dataroom-index
feat: add automatic hierarchical index for dataroom items
2025-09-06 14:59:06 +02:00
Marc Seitz 6d38f304b8 feat: improve transaction 2025-09-06 14:54:08 +02:00
Marc Seitz 8c002304d8 chore: cleanup 2025-09-06 14:53:59 +02:00
Marc Seitz fd05a86f50 feat: improve index calculation by using a single transaction with batches 2025-09-06 14:27:16 +02:00
Marc Seitz 2ccb23ddf6 feat: update index generator 2025-09-06 14:14:11 +02:00
Marc Seitz 0c618998b6 chore: update missing types 2025-09-06 14:14:03 +02:00
Marc Seitz 87c649f7f0 fix 2025-09-05 19:13:26 +02:00
Marc Seitz 4fd7369dba feat: add automatic hierarchical index for dataroom items 2025-09-05 19:09:32 +02:00
Marc SeitzandGitHub 8b87f23225 Merge pull request #1853 from mfts/cursor/fix-email-validation-for-university-domains-3a86
Fix email validation for university domains
2025-09-04 17:14:17 +02:00
Cursor Agentandyul.schnay fa2257e564 Improve email regex to support more complex domain structures
Co-authored-by: yul.schnay <yul.schnay@gmail.com>
2025-09-04 13:02:10 +00:00
Ashish Viradiya 16398bbe32 improvement 2025-09-04 11:39:17 +05:30
Ashish Viradiya e842a56dae improvement 2025-09-04 10:42:33 +05:30
Ashish Viradiya 3c6fae3e53 slack integration 2025-09-04 10:14:25 +05:30
Ashish Viradiya 635d39a5ea schema update 2025-09-04 09:59:42 +05:30
Ashish Viradiya 19de3594bc chore: remove date-fns-tz 2025-09-04 09:57:48 +05:30
Ashish Viradiya 0f977e2fb2 schema rebase 2025-09-04 08:40:11 +05:30
Ashish ViradiyaandGitHub 37f0b15ae9 Merge branch 'main' into 421-slack 2025-09-04 08:00:21 +05:30
Marc Seitz e55e40d5fe feat: add agreement statement to access screen 2025-09-03 23:56:21 +02:00
Cursor Agentandmarcftone 2bfc0238e2 Add support for text-based agreements alongside link-based agreements
Co-authored-by: marcftone <marcftone@gmail.com>
2025-09-03 13:03:11 +00:00
Marc SeitzandGitHub 4dd874c198 Merge pull request #1849 from mfts/feat/delete-convo
feat: delete convo endpoint
2025-09-02 23:26:52 +02:00
Marc Seitz 32661529e7 fix: remove check 2025-09-02 23:19:55 +02:00
Marc Seitz 63062819eb feat: delete convo endpoint 2025-09-02 23:08:05 +02:00
Marc SeitzandGitHub ace2ed5a4a Merge pull request #1848 from mfts/feat/domain-improv
refactor: improve domain configuration experience
2025-09-02 15:39:11 +02:00
Marc Seitz 952da2c258 fix 2025-09-02 13:20:27 +02:00
Marc Seitz 890d69b2a5 refactor: improve domain configuration experience 2025-09-02 11:43:37 +02:00
Marc Seitz b42cc06989 fix 2025-09-01 13:56:10 +02:00
Marc SeitzandGitHub 9a908d9965 Merge pull request #1847 from mfts/feat/conversations
feat(ee): add publishable q&a conversations
2025-08-30 20:24:52 +02:00
Marc Seitz b22234a28f fix: validation 2025-08-30 20:18:54 +02:00
Marc Seitz dbb6d9d520 feat: improve zod parser 2025-08-30 19:49:36 +02:00
Marc Seitz a4913820c1 feat: upgrade packages 2025-08-30 19:49:25 +02:00
Marc Seitz 070ab6608b refactor: create common zod schema for faqs 2025-08-30 19:28:23 +02:00
Marc Seitz 8311543423 fix 2025-08-30 18:59:00 +02:00
Marc Seitz 1be79bbbfe feat: several improvements to conversations 2025-08-30 18:54:20 +02:00
Marc Seitz 4ac1e36788 feat(ee): add database entry for conversations 2025-08-30 12:25:23 +02:00
Marc Seitz 610257a320 feat(ee): add viewer side conversations faq 2025-08-30 12:21:31 +02:00
Marc Seitz 6e6122861a feat(ee): add dashboard faq conversations 2025-08-30 12:21:00 +02:00
Marc Seitz 7e7f9359ef chore: remove duplicate context component 2025-08-30 12:20:01 +02:00
Marc Seitz 2cec85ad35 chore: remove unused route 2025-08-30 12:19:27 +02:00
Marc Seitz 4c02f435b7 feat: update q&a tab 2025-08-30 12:19:17 +02:00
Marc Seitz c000d99710 refactor(ee): move visitor upload route 2025-08-30 12:19:05 +02:00
Marc Seitz ad5c166938 feat: add new breadcrumb text 2025-08-30 12:18:38 +02:00
Marc Seitz 0445962742 feat: change conversations button in dataroom navbar 2025-08-30 12:15:39 +02:00
Marc Seitz a6e9e50fcc feat(ui): add new notification badge style 2025-08-30 12:15:20 +02:00
Marc SeitzandGitHub b5cc6e15e1 Merge pull request #1845 from mfts/feat/increase-time
feat: increase maxduration
2025-08-28 13:36:35 +02:00
Marc Seitz d8c6660fa1 feat: increase maxduration 2025-08-28 13:26:58 +02:00
Marc SeitzandGitHub 558835261f Merge pull request #1843 from mfts/fix/proxy
refactor: update client proxy
2025-08-27 21:59:35 +02:00
Marc Seitz 9eb480059e refactor: add clientside protection 2025-08-27 21:45:39 +02:00
Marc SeitzandGitHub 8236ae1e89 Merge pull request #1842 from mfts/feat/cleanup
feat: remove internal response headers
2025-08-27 10:03:52 +02:00
Marc Seitz 635e42a4f8 fix 2025-08-27 09:59:32 +02:00
Marc Seitz ddda4ef454 feat: remove internal response headers 2025-08-27 09:45:32 +02:00
Marc SeitzandGitHub f93b083d92 Merge pull request #1841 from mfts/feat/dub
fix: either allow discount or promo codes
2025-08-26 20:42:55 +02:00
Marc Seitz db3d07369f fix: either allow discount or promo codes 2025-08-26 20:19:11 +02:00
Marc SeitzandGitHub 7702eb3ca6 Merge pull request #1838 from mfts/feat/dub
feat: update notion parsing for custom domain
2025-08-26 17:31:18 +02:00
Marc Seitz e6349f9db9 feat: add empty array 2025-08-26 17:07:26 +02:00
Marc Seitz 8679378e88 feat: improve dub discount 2025-08-26 16:53:25 +02:00
Marc Seitz 9428bb2fd2 refactor: improve notion parsing 2025-08-26 16:53:18 +02:00
Marc Seitz 33f60b143a fix: notion parse 2025-08-26 16:37:46 +02:00
Marc Seitz e6d6dcdbaa feat: add dub customer coupon 2025-08-26 16:36:06 +02:00
Marc Seitz f6290e6ab2 feat: update notion parsing for custom domain 2025-08-26 16:24:49 +02:00
Marc SeitzandGitHub 61b96fe360 Merge pull request #1836 from mfts/feat/dub
fix: dataroom without documents show timestamp 0
2025-08-26 13:12:04 +02:00
Marc Seitz 0c77418265 fix: dataroom without documents show timestamp 0 2025-08-26 13:04:11 +02:00
Marc SeitzandGitHub d50d31a31b Merge pull request #1834 from mfts/feat/dub
feat: add dub integration
2025-08-26 11:10:03 +02:00
Marc Seitz 50bef1e894 feat(auth): improve dub auth handler 2025-08-26 11:02:51 +02:00
Marc Seitz e26609769c feat: add dub integration 2025-08-25 12:49:15 +02:00
Marc SeitzandGitHub 4bdf311cc0 Merge pull request #1833 from mfts/feat/revalidation
feat: revalidate every 60s
2025-08-25 11:14:55 +02:00
Marc Seitz f5c2403c57 feat: revalidate every 60s 2025-08-25 11:07:49 +02:00
Marc SeitzandGitHub 22a1d1a171 Merge pull request #1832 from mfts/fix/webhook 2025-08-24 19:12:27 +02:00
Marc Seitz 3e087e6bb1 feat: improve extension validation 2025-08-24 19:05:08 +02:00
Marc Seitz a4d96145b6 feat: fix missing extension 2025-08-24 18:49:49 +02:00
Marc SeitzandGitHub 1a44cb7e71 Merge pull request #1831 from mfts/fix/issue
fix
2025-08-23 16:58:59 +02:00
Marc Seitz c7450cae35 fix 2025-08-23 16:53:38 +02:00
Marc SeitzandGitHub ead8f8a195 Merge pull request #1830 from mfts/fix/issue
fix: url validation for notion
2025-08-23 16:42:47 +02:00
Marc Seitz f3107d172d fix 2025-08-23 16:37:43 +02:00
Marc Seitz 8b534abef9 fix: validation 2025-08-23 16:33:58 +02:00
Marc Seitz ae07c72f20 fix: validation for notion 2025-08-23 16:25:50 +02:00
Marc SeitzandGitHub e536082051 Merge pull request #1829 from mfts/fix/issue
feat: add url validation
2025-08-23 15:58:37 +02:00
Marc Seitz 9c8d7caa45 feat: improve validation 2025-08-23 15:54:05 +02:00
Marc Seitz 72fc18d1b0 feat: add url validation 2025-08-23 15:34:34 +02:00
Marc SeitzandGitHub ef3b1a23e4 Merge pull request #1826 from mfts/marc/pm-431-bug-link-presets-sets-a-group-links-allow-list-and-therefore
fix: ignore allowlist/denylist from group links via presets
2025-08-22 09:13:30 +02:00
Marc Seitz 3d2500b109 fix 2025-08-22 09:04:41 +02:00
Marc Seitz b3c8b11295 fix: ignore allowlist/denylist from group links via presets 2025-08-21 22:49:44 +02:00
Marc SeitzandGitHub 5cf3b33d96 Merge pull request #1824 from mfts/feat/error-fixes
fix: conversion scaling for long documents
2025-08-21 15:28:24 +02:00
Marc Seitz e954014f7f fix: conversion scaling for long documents 2025-08-21 14:59:18 +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 Seitz bd45eddadb feat: update email text 2025-08-20 11:49:03 +02:00
Marc Seitz ec31082790 chore: remove unused email 2025-08-20 11:48:54 +02:00
Marc Seitz a6d0553cf5 feat: upgrade react-email packages 2025-08-20 11:48:40 +02:00
Marc Seitz 0774f29438 Merge branch 'main' into emails 2025-08-19 10:01:44 +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
Ashish ViradiyaandGitHub 8ec18ea0c3 Merge branch 'main' into 421-slack 2025-08-07 19:16:19 +05:30
Ashish Viradiya 95c548f144 slack integration improvement 2025-08-07 19:02:26 +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
Ashish Viradiya 31e221f175 dark mode fix 2025-08-06 01:39:49 +05:30
Ashish Viradiya ad04e8859f improvement 2025-08-06 01:32:29 +05:30
Ashish Viradiya 90aba22628 chore: package-lock file 2025-08-05 22:56:43 +05:30
Ashish ViradiyaandGitHub cf704906b4 Merge branch 'main' into 421-slack 2025-08-05 22:52:56 +05:30
Ashish Viradiya fdcc36d961 Feat: Slack native integration 2025-08-05 22:45:50 +05:30
Marc Seitz a923ce6dfb refactor: change price id retrieval function 2025-08-04 21:09:09 +02:00
Iuliia Shnai 0e92372907 feat: emails updates and new emails. 2025-08-04 20:11:51 +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
357 changed files with 23847 additions and 6117 deletions
+11
View File
@@ -0,0 +1,11 @@
# Security Policy
## Supported Versions
The latest version of Papermark is 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 />
</>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
import prisma from "@/lib/prisma";
// Validation schema for query parameters
const visitorFAQParamsSchema = z.object({
linkId: z.string().cuid("Invalid link ID format"),
dataroomId: z.string().cuid("Invalid dataroom ID format"),
documentId: z.string().cuid("Invalid document ID format").nullish(), // This is actually dataroomDocumentId
});
export interface VisitorFAQResponse {
id: string;
editedQuestion: string;
answer: string;
documentPageNumber?: number;
documentVersionNumber?: number;
createdAt: string;
document?: {
name: string;
};
}
// GET /api/faqs?linkId=xxx&dataroomId=xxx - List published FAQs for visitors
export async function GET(req: NextRequest) {
try {
const searchParams = req.nextUrl.searchParams;
// Validate query parameters
const paramValidation = visitorFAQParamsSchema.safeParse({
linkId: searchParams.get("linkId"),
dataroomId: searchParams.get("dataroomId"),
documentId: searchParams.get("documentId"), // This is actually dataroomDocumentId
});
if (!paramValidation.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: paramValidation.error.errors[0]?.message,
},
{ status: 400 },
);
}
const { linkId, dataroomId, documentId } = paramValidation.data;
// Verify dataroom session
const session = await verifyDataroomSession(req, linkId, dataroomId);
if (!session) {
return NextResponse.json(
{ error: "Unauthorized - invalid or expired session" },
{ status: 401 },
);
}
// Build where clause based on visibility filters
const whereClause: any = {
dataroomId,
status: "PUBLISHED",
};
// Apply visibility filters
const visibilityFilters: any[] = [
{ visibilityMode: "PUBLIC_DATAROOM" }, // Always include dataroom-wide FAQs
];
if (linkId) {
visibilityFilters.push({
visibilityMode: "PUBLIC_LINK",
linkId: linkId,
});
}
if (documentId) {
visibilityFilters.push({
visibilityMode: "PUBLIC_DOCUMENT",
dataroomDocumentId: documentId,
});
}
whereClause.OR = visibilityFilters;
// Fetch published FAQs
const faqs = await prisma.dataroomFaqItem.findMany({
where: whereClause,
select: {
id: true,
editedQuestion: true,
answer: true,
documentPageNumber: true,
documentVersionNumber: true,
createdAt: true,
dataroomDocument: {
select: {
document: {
select: {
name: true,
},
},
},
},
},
orderBy: { createdAt: "desc" },
});
// Format response
const response: VisitorFAQResponse[] = faqs.map((faq: any) => ({
id: faq.id,
editedQuestion: faq.editedQuestion,
answer: faq.answer,
documentPageNumber: faq.documentPageNumber || undefined,
documentVersionNumber: faq.documentVersionNumber || undefined,
createdAt: faq.createdAt.toISOString(),
document: faq.dataroomDocument?.document
? {
name: faq.dataroomDocument.document.name,
}
: undefined,
}));
return NextResponse.json(response);
} catch (error) {
console.error("Error fetching visitor FAQs:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+1 -2
View File
@@ -37,9 +37,8 @@ export async function POST(req: Request) {
const domains = await prisma.domain.findMany({
where: {
slug: {
// exclude domains that belong to us
not: {
contains: "papermark.io",
in: ["papermark.io", "papermark.com"],
},
},
},
-158
View File
@@ -1,158 +0,0 @@
import { NextResponse } from "next/server";
import { limiter, receiver } from "@/lib/cron";
import { sendTrialEndFinalReminderEmail } from "@/lib/emails/send-trial-end-final-reminder";
import { sendTrialEndReminderEmail } from "@/lib/emails/send-trial-end-reminder";
import prisma from "@/lib/prisma";
import { calculateDaysLeft, log } from "@/lib/utils";
/**
* Cron to check if trial has expired.
* If a user is not on pro plan and has 5 days left on trial, we send a reminder email to upgrade plan.
* If a user is not on pro plan and has 1 day left on trial, we send a final reminder email to upgrade plan.
**/
// Runs once per day at 12pm (0 12 * * *)
export const maxDuration = 300; // 5 minutes in seconds
export async function POST(req: Request) {
const body = await req.json();
if (process.env.VERCEL === "1") {
const isValid = await receiver.verify({
signature: req.headers.get("Upstash-Signature") || "",
body: JSON.stringify(body),
});
if (!isValid) {
return new Response("Unauthorized", { status: 401 });
}
}
try {
const teams = await prisma.team.findMany({
where: {
plan: {
// exclude users who are on pro or free plan
in: ["trial"],
},
},
select: {
id: true,
createdAt: true,
users: {
where: { role: "ADMIN" },
select: {
user: {
select: { email: true, name: true },
},
},
},
sentEmails: {
where: {
type: {
in: [
"FIRST_TRIAL_END_REMINDER_EMAIL",
"FINAL_TRIAL_END_REMINDER_EMAIL",
],
},
},
select: { type: true },
},
},
});
const results = await Promise.allSettled(
teams.map(async (team) => {
const { id, users, createdAt } = team as {
id: string;
createdAt: Date;
users: {
user: { email: string; name: string | null };
}[];
};
const sentEmails = team.sentEmails.map((email) => email.type);
const userEmail = users[0].user.email;
const userName = users[0].user.name;
const teamCreatedAt = createdAt;
let teamDaysLeft = calculateDaysLeft(new Date(teamCreatedAt));
// send first reminder email if team has 5 days left on trial
if (teamDaysLeft == 5) {
const sentFirstTrialEndReminderEmail = sentEmails.includes(
"FIRST_TRIAL_END_REMINDER_EMAIL",
);
if (!sentFirstTrialEndReminderEmail) {
return await Promise.allSettled([
log({
message: `Trial End Reminder for team: *${id}* is expiring in ${teamDaysLeft} days, email sent.`,
type: "cron",
}),
limiter.schedule(() =>
sendTrialEndReminderEmail(userEmail, userName),
),
prisma.sentEmail.create({
data: {
type: "FIRST_TRIAL_END_REMINDER_EMAIL",
teamId: id,
recipient: userEmail,
},
}),
]);
}
}
// send final reminder email if team has 1 day left on trial
if (teamDaysLeft <= 1) {
const sentFinalTrialEndReminderEmail = sentEmails.includes(
"FINAL_TRIAL_END_REMINDER_EMAIL",
);
if (!sentFinalTrialEndReminderEmail) {
return await Promise.allSettled([
log({
message: `Final Trial End Reminder for team: *${id}* is expiring in ${teamDaysLeft} days, email sent.`,
type: "cron",
}),
limiter.schedule(() =>
sendTrialEndFinalReminderEmail(userEmail, userName),
),
prisma.sentEmail.create({
data: {
type: "FINAL_TRIAL_END_REMINDER_EMAIL",
teamId: id,
recipient: userEmail,
},
}),
]);
}
}
// downgrade the user to free if team has 0 day left on trial
if (teamDaysLeft <= 0) {
return await Promise.allSettled([
log({
message: `Downgrade to free for user: *${id}* is expiring in ${teamDaysLeft} days, downgraded.`,
type: "cron",
}),
prisma.user.update({
where: { email: userEmail },
data: { plan: "free" },
}),
prisma.team.update({
where: { id },
data: { plan: "free" },
}),
]);
}
}),
);
return NextResponse.json(results);
} catch (error) {
await log({
message: `Trial end reminder cron failed. Error: " + ${(error as Error).message}`,
type: "cron",
mention: true,
});
return NextResponse.json({ error: (error as Error).message });
}
}
@@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";
import { z } from "zod";
import { getSlackInstallationUrl } from "@/lib/integrations/slack/install";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
import { getSearchParams } from "@/lib/utils/get-search-params";
const oAuthAuthorizeSchema = z.object({
teamId: z.string().cuid(),
});
export async function GET(req: Request) {
try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { teamId } = oAuthAuthorizeSchema.parse(getSearchParams(req.url));
console.log("teamId", teamId);
const userId = (session.user as CustomUser).id;
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Access denied" }, { status: 403 });
}
const oauthUrl = await getSlackInstallationUrl(teamId);
console.log("oauthUrl", oauthUrl);
return NextResponse.json({
oauthUrl,
});
} catch (error) {
console.error("Slack OAuth authorization error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,96 @@
import { redirect } from "next/navigation";
import { NextResponse } from "next/server";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { Team } from "@prisma/client";
import { getServerSession } from "next-auth";
import z from "zod";
import { installIntegration } from "@/lib/integrations/install";
import { getSlackEnv } from "@/lib/integrations/slack/env";
import { SlackCredential } from "@/lib/integrations/slack/types";
import { encryptSlackToken } from "@/lib/integrations/slack/utils";
import prisma from "@/lib/prisma";
import { redis } from "@/lib/redis";
import { CustomUser } from "@/lib/types";
import { getSearchParams } from "@/lib/utils/get-search-params";
export const dynamic = "force-dynamic";
const oAuthCallbackSchema = z.object({
code: z.string(),
state: z.string(),
});
export const GET = async (req: Request) => {
const env = getSlackEnv();
let workspace: Pick<Team, "id" | "plan"> | null = null;
try {
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");
}
const userId = (session.user as CustomUser).id;
const { code, state } = oAuthCallbackSchema.parse(getSearchParams(req.url));
// Find workspace that initiated the Stripe app install
const teamId = await redis.get<string>(`slack:install:state:${state}`);
if (!teamId) {
throw new Error("Unknown state");
}
workspace = await prisma.team.findUniqueOrThrow({
where: {
id: teamId,
},
select: {
id: true,
plan: true,
},
});
const formData = new FormData();
formData.append("code", code);
formData.append("client_id", env.SLACK_CLIENT_ID);
formData.append("client_secret", env.SLACK_CLIENT_SECRET);
formData.append(
"redirect_uri",
`${process.env.NEXT_PUBLIC_BASE_URL}/api/integrations/slack/oauth/callback`,
);
const response = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
body: formData,
});
const data = await response.json();
console.log("data", data);
const credentials: SlackCredential = {
appId: data.app_id,
botUserId: data.bot_user_id,
scope: data.scope,
accessToken: encryptSlackToken(data.access_token),
tokenType: data.token_type,
authUser: data.authed_user,
team: data.team,
};
await installIntegration({
integrationId: env.SLACK_INTEGRATION_ID,
userId,
teamId,
credentials,
});
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: 500 });
}
redirect(`/settings/slack`);
};
+51
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";
@@ -16,6 +17,10 @@ import { PreviewSession, verifyPreviewSession } from "@/lib/auth/preview-auth";
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
import { getFile } from "@/lib/files/get-file";
import { newId } from "@/lib/id-helper";
import {
notifyDataroomAccess,
notifyDocumentView,
} from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
import { parseSheet } from "@/lib/sheet";
@@ -98,6 +103,8 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
dataroomId: true,
emailProtected: true,
@@ -286,6 +293,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 +307,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 +325,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 +369,7 @@ export async function POST(request: NextRequest) {
: false;
if (!isMember && !hasDomainAccess) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -647,6 +661,24 @@ export async function POST(request: NextRequest) {
enableNotification: link.enableNotification,
}),
);
if (link.teamId && !isPreview) {
waitUntil(
(async () => {
try {
await notifyDataroomAccess({
teamId: link.teamId!,
dataroomId,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
});
} catch (error) {
console.error("Error sending Slack notification:", error);
}
})(),
);
}
}
const dataroomViewId =
@@ -758,6 +790,25 @@ export async function POST(request: NextRequest) {
select: { id: true },
});
console.timeEnd("create-view");
// Only send Slack notifications for non-preview views
if (link.teamId && !isPreview) {
waitUntil(
(async () => {
try {
await notifyDocumentView({
teamId: link.teamId!,
documentId,
dataroomId,
linkId,
viewerEmail: verifiedEmail ?? email,
viewerId: viewer?.id,
});
} catch (error) {
console.error("Error sending Slack notification:", error);
}
})(),
);
}
}
// if document version has pages, then return pages
+25 -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]";
@@ -13,13 +14,14 @@ import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verificati
import { getFeatureFlags } from "@/lib/featureFlags";
import { getFile } from "@/lib/files/get-file";
import { newId } from "@/lib/id-helper";
import { notifyDocumentView } from "@/lib/integrations/slack/events";
import prisma from "@/lib/prisma";
import { ratelimit } from "@/lib/redis";
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 +86,9 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
emailProtected: true,
enableNotification: true,
emailAuthenticated: true,
@@ -221,6 +226,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 +240,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 +258,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 },
@@ -626,6 +637,19 @@ export async function POST(request: NextRequest) {
enableNotification: link.enableNotification,
}),
);
if (!isPreview) {
waitUntil(
notifyDocumentView({
teamId: link.teamId!,
documentId,
linkId,
viewerEmail: email ?? undefined,
viewerId: viewer?.id ?? undefined,
}).catch((error) => {
console.error("Error sending Slack notification:", error);
}),
);
}
}
const returnObject = {
+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} />
+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>
+89 -34
View File
@@ -7,6 +7,10 @@ import {
useDataroom,
useDataroomFolderWithParents,
} from "@/lib/swr/use-dataroom";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import {
Breadcrumb,
@@ -25,6 +29,70 @@ import {
} from "@/components/ui/dropdown-menu";
import { TruncatedBreadcrumbLink } from "../layouts/breadcrumb";
const BreadcrumbFolderItem = ({
folder,
dataroomId,
isLast,
}: {
folder: any;
dataroomId: string;
isLast: boolean;
}) => {
const displayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
if (isLast) {
return (
<BreadcrumbPage
className="max-w-[200px] truncate"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</BreadcrumbPage>
);
}
return (
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="max-w-[200px] truncate"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</Link>
</BreadcrumbLink>
);
};
const BreadcrumbDropdownItem = ({
folder,
dataroomId,
}: {
folder: any;
dataroomId: string;
}) => {
const displayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
return (
<DropdownMenuItem>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="w-full"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</Link>
</DropdownMenuItem>
);
};
function BreadcrumbComponentBase({
name,
dataroomId,
@@ -69,54 +137,41 @@ function BreadcrumbComponentBase({
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{folders.slice(0, -2).map((folder, index) => (
<DropdownMenuItem key={index}>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="w-full"
>
{folder.name}
</Link>
</DropdownMenuItem>
<BreadcrumbDropdownItem
key={index}
folder={folder}
dataroomId={dataroomId}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folders[folders.length - 2].path}`}
className="max-w-[200px] truncate"
>
{folders[folders.length - 2].name}
</Link>
</BreadcrumbLink>
<BreadcrumbFolderItem
folder={folders[folders.length - 2]}
dataroomId={dataroomId}
isLast={false}
/>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="max-w-[200px] truncate">
{folders[folders.length - 1].name}
</BreadcrumbPage>
<BreadcrumbFolderItem
folder={folders[folders.length - 1]}
dataroomId={dataroomId}
isLast={true}
/>
</BreadcrumbItem>
</>
) : (
folders?.map((folder, index) => (
<React.Fragment key={index}>
<BreadcrumbItem>
{index === folders.length - 1 ? (
<BreadcrumbPage className="max-w-[200px] truncate">
{folder.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link
href={`/datarooms/${dataroomId}/documents${folder.path}`}
className="max-w-[200px] truncate"
>
{folder.name}
</Link>
</BreadcrumbLink>
)}
<BreadcrumbFolderItem
folder={folder}
dataroomId={dataroomId}
isLast={index === folders.length - 1}
/>
</BreadcrumbItem>
{index < folders.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
@@ -141,4 +196,4 @@ const BreadcrumbComponent = () => {
return MemoizedBreadcrumbComponent;
};
export { BreadcrumbComponent };
export { BreadcrumbComponent };
@@ -20,6 +20,10 @@ import { type DataroomFolderDocument } from "@/lib/swr/use-dataroom";
import { type DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
import { cn, nFormatter, timeAgo } from "@/lib/utils";
import { fileIcon } from "@/lib/utils/get-file-icon";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import BarChart from "@/components/shared/icons/bar-chart";
import { Button } from "@/components/ui/button";
@@ -61,6 +65,12 @@ export default function DataroomDocumentCard({
theme === "light" || (theme === "system" && systemTheme === "light");
const router = useRouter();
// Get hierarchical display name
const displayName = useHierarchicalDisplayName(
dataroomDocument.document.name,
dataroomDocument.hierarchicalIndex,
);
const [isFirstClick, setIsFirstClick] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);
@@ -205,8 +215,11 @@ export default function DataroomDocumentCard({
<div className="flex-col">
<div className="flex items-center">
<h2 className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md">
{dataroomDocument.document.name}
<h2
className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</h2>
</div>
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
+1 -1
View File
@@ -26,7 +26,7 @@ export const DataroomNavigation = ({ dataroomId }: { dataroomId?: string }) => {
segment: "analytics",
},
{
label: "Q&A Conversations",
label: "Q&A",
href: `/datarooms/${dataroomId}/conversations`,
segment: "conversations",
limited: !limits?.conversationsInDataroom,
+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>
);
+37 -6
View File
@@ -9,6 +9,10 @@ import {
useDataroomFoldersTree,
} from "@/lib/swr/use-dataroom";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { sortByIndexThenName } from "@/lib/utils/sort-items-by-index-name";
import { FileTree } from "@/components/ui/nextra-filetree";
@@ -19,6 +23,31 @@ type MixedItem =
| (DataroomFolderWithDocuments & { itemType: "folder" })
| (DataroomFolderWithDocuments["documents"][0] & { itemType: "document" });
const DocumentFileItem = memo(
({
document,
router,
}: {
document: DataroomFolderWithDocuments["documents"][0] & {
itemType: "document";
};
router: any;
}) => {
const documentDisplayName = useHierarchicalDisplayName(
document.document.name,
document.hierarchicalIndex,
);
return (
<FileTree.File
name={documentDisplayName}
onToggle={() => router.push(`/documents/${document.document.id}`)}
/>
);
},
);
DocumentFileItem.displayName = "DocumentFileItem";
const FolderComponent = memo(
({
dataroomId,
@@ -29,6 +58,12 @@ const FolderComponent = memo(
}) => {
const router = useRouter();
// Get hierarchical display names
const folderDisplayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
const mixedItems = useMemo(() => {
const allItems: MixedItem[] = [
...(folder.childFolders || []).map((f) => ({
@@ -57,11 +92,7 @@ const FolderComponent = memo(
);
} else {
return (
<FileTree.File
key={item.id}
name={item.document.name}
onToggle={() => router.push(`/documents/${item.document.id}`)}
/>
<DocumentFileItem key={item.id} document={item} router={router} />
);
}
}),
@@ -87,7 +118,7 @@ const FolderComponent = memo(
return (
<FileTree.Folder
name={folder.name}
name={folderDisplayName}
key={folder.id}
active={isActive}
childActive={isChildActive}
+2
View File
@@ -61,6 +61,7 @@ type DataroomDocumentWithVersion = {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
versions: {
id: string;
versionNumber: number;
@@ -75,6 +76,7 @@ type DataroomFolderWithDocumentsNew = DataroomFolder & {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
}[];
};
+62 -6
View File
@@ -4,17 +4,47 @@ import { memo, useMemo } from "react";
import { DataroomFolder } from "@prisma/client";
import { HomeIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { FileTree } from "@/components/ui/nextra-filetree";
import { buildNestedFolderStructureWithDocs } from "./utils";
const ViewerDocumentFileItem = memo(
({
document,
dataroomIndexEnabled,
}: {
document: DataroomDocumentWithVersion;
dataroomIndexEnabled?: boolean;
}) => {
const documentDisplayName = getHierarchicalDisplayName(
document.name,
document.hierarchicalIndex,
dataroomIndexEnabled || false,
);
return (
<FileTree.File
name={documentDisplayName}
// onToggle={() => router.push(`/documents/${document.id}`)}
/>
);
},
);
ViewerDocumentFileItem.displayName = "ViewerDocumentFileItem";
type DataroomDocumentWithVersion = {
dataroomDocumentId: string;
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
versions: {
id: string;
versionNumber: number;
@@ -29,6 +59,7 @@ type DataroomFolderWithDocuments = DataroomFolder & {
folderId: string | null;
id: string;
name: string;
hierarchicalIndex: string | null;
}[];
};
@@ -59,25 +90,37 @@ const FolderComponent = memo(
folderId,
setFolderId,
folderPath,
dataroomIndexEnabled,
}: {
folder: DataroomFolderWithDocuments;
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
folderPath: Set<string> | null;
dataroomIndexEnabled?: boolean;
}) => {
const router = useRouter();
// Get hierarchical display name for the folder
const folderDisplayName = getHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
dataroomIndexEnabled || false,
);
// Memoize the rendering of the current folder's documents
const documents = useMemo(
() =>
folder.documents.map((doc) => (
<FileTree.File
<ViewerDocumentFileItem
key={doc.id}
name={doc.name}
// onToggle={() => router.push(`/documents/${doc.id}`)}
document={{
...doc,
versions: [], // Not needed for display
}}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
)),
[folder.documents, router.query.name],
[folder.documents, dataroomIndexEnabled],
);
// Recursively render child folders if they exist
@@ -90,9 +133,16 @@ const FolderComponent = memo(
folderId={folderId}
setFolderId={setFolderId}
folderPath={folderPath}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
)),
[folder.childFolders, folderId, setFolderId],
[
folder.childFolders,
folderId,
setFolderId,
folderPath,
dataroomIndexEnabled,
],
);
const isActive = folder.id === folderId;
@@ -109,7 +159,7 @@ const FolderComponent = memo(
}}
>
<FileTree.Folder
name={folder.name}
name={folderDisplayName}
key={folder.id}
active={isActive}
childActive={isChildActive}
@@ -165,11 +215,13 @@ const SidebarFolders = ({
documents,
folderId,
setFolderId,
dataroomIndexEnabled,
}: {
folders: DataroomFolder[];
documents: DataroomDocumentWithVersion[];
folderId: string | null;
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
dataroomIndexEnabled?: boolean;
}) => {
const nestedFolders = useMemo(() => {
if (folders) {
@@ -203,6 +255,7 @@ const SidebarFolders = ({
folderId={folderId}
setFolderId={setFolderId}
folderPath={folderPath}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
))}
</FileTree>
@@ -214,11 +267,13 @@ export function ViewFolderTree({
documents,
setFolderId,
folderId,
dataroomIndexEnabled,
}: {
folders: DataroomFolder[];
documents: DataroomDocumentWithVersion[];
setFolderId: React.Dispatch<React.SetStateAction<string | null>>;
folderId: string | null;
dataroomIndexEnabled?: boolean;
}) {
if (!folders) return null;
@@ -228,6 +283,7 @@ export function ViewFolderTree({
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
);
}
@@ -23,8 +23,13 @@ import {
import { toast } from "sonner";
import { useDebounce } from "use-debounce";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useDataroomFoldersTree } from "@/lib/swr/use-dataroom";
import { cn } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
getHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import CloudDownloadOff from "@/components/shared/icons/cloud-download-off";
import { Button } from "@/components/ui/button";
@@ -38,10 +43,35 @@ import {
} from "@/components/ui/table";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const PermissionItemName = ({ item }: { item: FileOrFolder }) => {
const { isFeatureEnabled } = useFeatureFlags();
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
const displayName = getHierarchicalDisplayName(
item.name,
item.hierarchicalIndex,
isDataroomIndexEnabled,
);
return (
<div className="flex items-center text-foreground">
{item.itemType === ItemType.DATAROOM_FOLDER ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate" style={HIERARCHICAL_DISPLAY_STYLE}>
{displayName}
</span>
</div>
);
};
// Update the FileOrFolder type to include permissions
type FileOrFolder = {
id: string;
name: string;
hierarchicalIndex?: string | null;
subItems?: FileOrFolder[];
permissions: {
view: boolean;
@@ -85,16 +115,7 @@ const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<div className="flex items-center text-foreground">
{row.original.itemType === ItemType.DATAROOM_FOLDER ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate">{row.original.name}</span>
</div>
),
cell: ({ row }) => <PermissionItemName item={row.original} />,
},
{
id: "actions",
@@ -189,6 +210,7 @@ const buildTree = (
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
hierarchicalIndex: doc.hierarchicalIndex,
permissions: getPermissions(doc.id),
itemType: ItemType.DATAROOM_DOCUMENT,
}));
@@ -230,6 +252,7 @@ const buildTree = (
result.push({
id: folder.id,
name: folder.name,
hierarchicalIndex: folder.hierarchicalIndex,
subItems: allSubItems,
permissions: folderPermissions,
itemType: ItemType.DATAROOM_FOLDER,
@@ -248,6 +271,7 @@ const buildTree = (
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
hierarchicalIndex: doc.hierarchicalIndex,
permissions: getPermissions(doc.id),
itemType: ItemType.DATAROOM_DOCUMENT,
});
@@ -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,135 @@
import { useState } from "react";
import { Hash, ListOrderedIcon, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { ResponsiveButton } from "@/components/ui/responsive-button";
interface RebuildIndexButtonProps {
teamId: string;
dataroomId: string;
disabled?: boolean;
}
export default function RebuildIndexButton({
teamId,
dataroomId,
disabled = false,
}: RebuildIndexButtonProps) {
const { isFeatureEnabled } = useFeatureFlags();
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
// Don't render if feature is not enabled
if (!isDataroomIndexEnabled) {
return null;
}
const handleRebuildIndex = async () => {
try {
setIsLoading(true);
const response = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/calculate-indexes`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || "Failed to rebuild indexes");
}
const result = await response.json();
toast.success(
`Hierarchical indexes rebuilt successfully! Updated ${result.totalUpdated} items (${result.foldersUpdated} folders, ${result.documentsUpdated} documents).`,
);
setIsOpen(false);
// Trigger a page refresh to show updated indexes
window.location.reload();
} catch (error) {
console.error("Error rebuilding indexes:", error);
toast.error(
error instanceof Error ? error.message : "Failed to rebuild indexes",
);
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<ResponsiveButton
icon={<ListOrderedIcon className="h-4 w-4" />}
text="Rebuild Index"
variant="outline"
size="sm"
disabled={disabled}
/>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center">
Rebuild Hierarchical Index
</DialogTitle>
<DialogDescription>
Recalculate the hierarchical numbering based on the dataroom
items&apos; current order.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="rounded-lg bg-muted p-4">
<div className="flex items-start gap-3">
<div className="text-sm text-muted-foreground">
<p className="mb-1 font-medium">What this does:</p>
<ul className="list-inside list-disc space-y-1">
<li>
Analyzes the current folder structure and document order
</li>
<li>
Assigns hierarchical numbers (1, 1.1, 1.1.1, 2, 2.1, etc.)
</li>
<li>
Updates the display to show these numbers alongside names
</li>
<li>Maintains the existing order and hierarchy</li>
</ul>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button onClick={handleRebuildIndex} loading={isLoading}>
<ListOrderedIcon className="h-4 w-4" />
Rebuild Index
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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(
@@ -155,10 +155,10 @@ export function DataroomSortableList({
mutate(`${baseKey}/folders`);
mutate(`${baseKey}/folders?include_documents=true`);
setIsReordering(false);
toast.success("Index saved successfully");
toast.success("Folder order saved successfully");
} catch (error) {
console.error("Failed to save new order:", error);
toast.error("Failed to save index");
toast.error("Failed to save order");
// Optionally, show an error message to the user
} finally {
setIsReordering(false);
@@ -247,7 +247,7 @@ export function DataroomSortableList({
className="gap-x-1"
>
<CheckIcon className="size-4" />
Save index
Save order
</Button>
</Portal>
<DeleteFolderModal />
+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>
+27 -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,36 @@ 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 validateNotionPageId = parsePageId(notionLink);
// If parsePageId fails, try to get page ID from slug
if (validateNotionPageId === null) {
try {
const pageId = await getNotionPageIdFromSlug(notionLink);
validateNotionPageId = pageId || undefined;
} catch (slugError) {
toast.error("Please enter a valid Notion link to proceed.");
return;
}
}
if (!validateNotionPageId) {
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>
);
+17 -4
View File
@@ -20,6 +20,10 @@ import { mutate } from "swr";
import { DataroomFolderWithCount } from "@/lib/swr/use-dataroom";
import { FolderWithCount } from "@/lib/swr/use-documents";
import { timeAgo } from "@/lib/utils";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import { Button } from "@/components/ui/button";
import {
@@ -66,6 +70,14 @@ export default function FolderCard({
const [addDataroomOpen, setAddDataroomOpen] = useState<boolean>(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
// Get hierarchical display name for dataroom folders
const displayName = useHierarchicalDisplayName(
folder.name,
isDataroom && "hierarchicalIndex" in folder
? folder.hierarchicalIndex
: undefined,
);
const folderPath =
isDataroom && dataroomId
? `/datarooms/${dataroomId}/documents${folder.path}`
@@ -84,8 +96,6 @@ export default function FolderCard({
}
}, [openFolder, addDataroomOpen]);
const handleCreateDataroom = (e: any, folderId: string) => {
e.stopPropagation();
e.preventDefault();
@@ -160,8 +170,11 @@ export default function FolderCard({
<div className="flex-col">
<div className="flex items-center">
<h2 className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md">
{folder.name}
<h2
className="min-w-0 max-w-[150px] truncate text-sm font-semibold leading-6 text-foreground sm:max-w-md"
style={HIERARCHICAL_DISPLAY_STYLE}
>
{displayName}
</h2>
</div>
<div className="mt-1 flex items-center space-x-1 text-xs leading-5 text-muted-foreground">
+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>
</>
);
}
+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 (
+4 -12
View File
@@ -14,6 +14,8 @@ import {
import { motion } from "motion/react";
import { mutate } from "swr";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -24,8 +26,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/ui/status-badge";
import { cn } from "@/lib/utils";
import { useDeleteDomainModal } from "./delete-domain-modal";
import DomainConfiguration from "./domain-configuration";
import { useDomainStatus } from "./use-domain-status";
@@ -99,16 +99,8 @@ export default function DomainCard({
</div>
</div>
<div className="overflow-hidden">
<div className="flex items-center gap-1.5 sm:gap-2.5">
<a
href={`http://${domain}`}
target="_blank"
rel="noreferrer"
className="truncate text-sm font-medium"
title={domain}
>
{domain}
</a>
<div className="flex items-center gap-1.5 truncate text-sm font-medium sm:gap-2.5">
{domain}
{isDefault ? (
<span className="xs:px-3 xs:py-1 flex items-center gap-1 rounded-full bg-sky-400/[.15] px-1.5 py-0.5 text-xs font-medium text-sky-600">
+38 -32
View File
@@ -6,6 +6,7 @@ import { getSubdomain } from "@/lib/domains";
import { DomainVerificationStatusProps } from "@/lib/types";
import { cn } from "@/lib/utils";
import { CopyButton } from "../ui/copy-button";
import { TabSelect } from "../ui/tab-select";
export default function DomainConfiguration({
@@ -19,30 +20,6 @@ export default function DomainConfiguration({
const subdomain = getSubdomain(domainJson.name, domainJson.apexName);
const [recordType, setRecordType] = useState(!!subdomain ? "CNAME" : "A");
if (status === "Pending Verification") {
const txtVerification = domainJson.verification.find(
(x: any) => x.type === "TXT",
);
return (
<div>
<DnsRecord
instructions={`Please set the following TXT record on <code>${domainJson.apexName}</code> to prove ownership of <code>${domainJson.name}</code>:`}
records={[
{
type: txtVerification.type,
name: txtVerification.domain.slice(
0,
txtVerification.domain.length - domainJson.apexName.length - 1,
),
value: txtVerification.value,
},
]}
warning="Warning: if you are using this domain for another site, setting this TXT record will transfer domain ownership away from that site and break it. Please exercise caution when setting this record; make sure that the domain that is shown in the TXT verification value is actually the <b><i>domain you want to use on Papermark.io</i></b> <b><i>not your production site</i></b>."
/>
</div>
);
}
if (status === "Conflicting DNS Records") {
return (
<div className="pt-5">
@@ -95,6 +72,11 @@ export default function DomainConfiguration({
);
}
const txtVerification =
status === "Pending Verification"
? domainJson.verification.find((x: any) => x.type === "TXT")
: undefined;
return (
<div className="pt-2">
<div className="-ml-1.5 border-b border-gray-200 dark:border-gray-400">
@@ -116,15 +98,39 @@ export default function DomainConfiguration({
recordType === "A" ? "apex domain" : "subdomain"
} <code>${
recordType === "A" ? domainJson.apexName : domainJson.name
}</code>, set the following ${recordType} record on your DNS provider:`}
}</code>, set the following ${txtVerification ? "records" : `${recordType} record`} on your DNS provider:`}
records={[
{
type: recordType,
name: recordType === "A" ? "@" : (subdomain ?? "www"),
value: recordType === "A" ? `76.76.21.21` : `cname.vercel-dns.com`,
value:
recordType === "A"
? (configJson?.recommendedIPv4?.[0]?.value?.[0] ??
"76.76.21.21")
: (configJson?.recommendedCNAME?.[0]?.value ??
"cname.vercel-dns.com"),
ttl: "86400",
},
...(txtVerification
? [
{
type: txtVerification.type,
name: txtVerification.domain.slice(
0,
txtVerification.domain.length -
domainJson.apexName.length -
1,
),
value: txtVerification.value,
},
]
: []),
]}
warning={
txtVerification
? "Warning: if you are using this domain for another site, setting this TXT record will transfer domain ownership away from that site and break it. Please exercise caution when setting this record; make sure that the domain that is shown in the TXT verification value is actually the <b><i>domain you want to use on Papermark</i></b> <b><i>not your production site</i></b>."
: undefined
}
/>
</div>
);
@@ -151,16 +157,16 @@ const DnsRecord = ({
const hasTtl = records.some((x) => x.ttl);
return (
<div className="mt-3 text-left text-gray-600">
<div className="my-5 text-gray-600 dark:text-gray-400">
<div className="mt-3 text-left text-gray-600 dark:text-gray-400">
<div className="my-5">
<MarkdownText text={instructions} />
</div>
<div
className={cn(
"grid items-end gap-x-10 gap-y-1 overflow-x-auto rounded-lg bg-gray-100/80 p-4 text-sm scrollbar-hide",
hasTtl
? "grid-cols-[repeat(4,min-content)]"
: "grid-cols-[repeat(3,min-content)]",
? "grid-cols-[repeat(4,max-content)]"
: "grid-cols-[repeat(3,max-content)]",
)}
>
{["Type", "Name", "Value"].concat(hasTtl ? "TTL" : []).map((s) => (
@@ -179,11 +185,11 @@ const DnsRecord = ({
</p>
<p key={record.value} className="flex items-end gap-1 font-mono">
{record.value}{" "}
{/* <CopyButton
<CopyButton
variant="neutral"
className="-mb-0.5"
value={record.value}
/> */}
/>
</p>
{hasTtl && (
<p key={record.ttl} className="font-mono">
+143
View File
@@ -0,0 +1,143 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function CustomDomainSetup({
name = "there",
currentPlan = "Free",
hasAccess = false,
}: {
name?: string;
currentPlan?: string;
hasAccess?: boolean;
}) {
const getPlanInfo = () => {
if (hasAccess) {
return {
title: "Your custom domain is ready to set up! 🎉",
subtitle: `Great news! Your ${currentPlan} plan includes custom domain access.`,
};
} else {
return {
title: "Interested in custom domains? 🚀",
subtitle:
"Learn how custom domains can enhance your document sharing experience.",
};
}
};
const { title, subtitle } = getPlanInfo();
return (
<Html>
<Head />
<Preview>Your Papermark custom domain set up</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">
{title}
</Text>
<Text className="text-sm leading-6 text-black">Hi {name},</Text>
<Text className="text-sm leading-6 text-black">{subtitle}</Text>
{!hasAccess && (
<>
<Text className="text-sm leading-6 text-black">
<strong>Custom domains are available: </strong>
</Text>
<ul className="list-inside list-disc text-sm">
<li>
<strong>Business:</strong> Custom domain for documents
</li>
<li>
<strong>Data Rooms:</strong> Custom domain for data rooms
</li>
<li>
<strong>Data Rooms Plus:</strong> Unlimited custom domains
for data rooms and documents
</li>
</ul>
</>
)}
<Text className="text-sm leading-6 text-black">
<strong>Setting up your custom domain:</strong>
</Text>
<ol className="list-inside list-decimal text-sm">
<li>Choose your subdomain (e.g. docs.yourcompany.com)</li>
<li>Add a CNAME record pointing to papermark.com</li>
<li>Configure the domain in your Papermark settings</li>
<li>Start sharing with your branded domain!</li>
</ol>
<Section className="my-8 text-center">
{hasAccess ? (
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/domains`}
style={{ padding: "12px 20px" }}
>
Set up your custom domain
</Button>
) : (
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/upgrade`}
style={{ padding: "12px 20px" }}
>
Upgrade to use custom domains
</Button>
)}
</Section>
<Text className="text-sm leading-6 text-black">
{hasAccess ? (
<>
Need help? Check out our{" "}
<Link
href="https://docs.papermark.com/custom-domains"
className="font-medium text-blue-600 no-underline"
>
custom domain documentation
</Link>{" "}
or reply to this email - we&apos;re here to help!
</>
) : (
<>
Want to learn more about our plans?{" "}
<Link
href="https://app.papermark.com/settings/upgrade"
className="font-medium text-blue-600 no-underline"
>
View pricing
</Link>{" "}
or reply to this email if you have any questions!
</>
)}
</Text>
<Footer footerText="If you have any questions about setting up your custom domain, simply reply to this email. We'd love to help you get started!" />
</Container>
</Body>
</Tailwind>
</Html>
);
}
@@ -0,0 +1,87 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
const DataRoomsInformationEmail = () => {
const previewText = `The document sharing infrastructure for the modern web`;
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-2xl font-normal">
Virtual Data Rooms
</Text>
<Text className="text-sm">Unlimited branded data rooms!</Text>
<Text className="text-sm">
With Papermark Data Rooms plan you can:
</Text>
<ul className="list-inside list-disc text-sm">
<li>Share data rooms with one link</li>
<li>Upload unlimited files</li>
<li>Create unlimited folders and subfolders</li>
<li>
Connect your <strong>custom domain 💫</strong>{" "}
</li>
<li>Create fully branded experience </li>
<li>Use advanced link settings</li>
</ul>
<Text className="text-sm">
All about Papermark{" "}
<a
href="https://www.papermark.com/data-room"
className="text-blue-500 underline"
>
Data Rooms
</a>{" "}
features and plans
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/datarooms?utm_source=dataroom-info&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
style={{ padding: "12px 20px" }}
>
Create new data room
</Button>
</Section>
<Text className="text-sm">
If you require a fully customizable experience,{" "}
<a
href="https://cal.com/marcseitz/papermark"
className="text-blue-500 underline"
>
book a call
</a>{" "}
with us.
</Text>
<Footer
withAddress={false}
footerText="This is a last onboarding email. If you have any feedback or questions about this email, simply reply to it. I'd love to hear from you!"
/>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default DataRoomsInformationEmail;
+13 -18
View File
@@ -14,11 +14,11 @@ import {
} from "@react-email/components";
export default function DataroomNotification({
dataroomName,
documentName,
senderEmail,
url,
unsubscribeUrl,
dataroomName = "Example Data Room",
documentName = "Example Document",
senderEmail = "example@example.com",
url = "https://app.papermark.com/datarooms/123",
unsubscribeUrl = "https://app.papermark.com/datarooms/123/unsubscribe",
}: {
dataroomName: string;
documentName: string | undefined;
@@ -33,10 +33,10 @@ export default function DataroomNotification({
<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">
<Text className="mb-8 mt-4 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
<Text className="font-seminbold mb-8 mt-4 text-center text-xl">
{`New document available for ${dataroomName}`}
</Text>
<Text className="text-sm leading-6 text-black">
@@ -45,7 +45,7 @@ export default function DataroomNotification({
added to <span className="font-semibold">{dataroomName}</span>{" "}
dataroom on Papermark.
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${url}`}
@@ -59,17 +59,12 @@ export default function DataroomNotification({
{`${url}`}
</Text>
<Text className="text-sm text-gray-400">Papermark</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Section className="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. All rights
reserved.
</Text>
<Text className="text-xs">
You received this email from{" "}
@@ -81,7 +76,7 @@ export default function DataroomNotification({
this dataroom,{" "}
<a
href={unsubscribeUrl}
className="text-gray-400 underline underline-offset-2 hover:text-gray-400"
className="text-gray-400 underline underline-offset-2"
>
click here
</a>
+92
View File
@@ -0,0 +1,92 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface TrialEndReminderEmail {
name: string | null | undefined;
}
const DataroomTrial24hReminderEmail = ({ name }: TrialEndReminderEmail) => {
return (
<Html>
<Head />
<Preview>Upgrade to Papermark Data Rooms Plan</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="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Your Data Room plan trial expires in 24 hours
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm leading-6 text-black">
Your Papermark Data Room plan trial expires in 24 hours.
Don&apos;t lose access to these features -{" "}
<Link href={`https://app.papermark.com/settings/billing`}>
upgrade today
</Link>
:
</Text>
<ul className="list-inside list-disc text-sm">
<li>
Build unlimited <strong>data rooms</strong>
</li>
<li>
Upload files of any <strong>size</strong>
</li>
<li>
Collaborate with your <strong>team</strong>
</li>
<li>
Set up <strong>secure link permissions</strong> and controls
</li>
<li>
Brand everything with your <strong>custom domain</strong>
</li>
<li>
Track detailed <strong>analytics</strong> and user activity
</li>
</ul>
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/billing`}
style={{ padding: "12px 20px" }}
>
Upgrade now
</Button>
</Section>
<Text className="text-sm font-semibold">
<span className="text-red-500"></span> Dataroom links and links
with advanced access controls will be{" "}
<span className="text-red-500 underline">disabled</span> in 24
hours.
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default DataroomTrial24hReminderEmail;
+31 -42
View File
@@ -14,53 +14,58 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface DataroomTrialEnd {
name: string | null | undefined;
}
const DataroomTrialEnd = ({ name }: DataroomTrialEnd) => {
const previewText = `Upgrade to continue using data rooms`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Preview>Upgrade to continue using data rooms</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">
<Text className="mb-8 mt-4 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Your dataroom trial has expired
<Text className="font-seminbold mb-8 mt-4 text-center text-xl">
Your Data Room plan trial has expired
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm leading-6 text-black">
Your Papermark dataroom trial has expired.{" "}
<Link href={`https://app.papermark.com/settings/billing`}>
Your Papermark Data Room trial has expired.
<br />
<Link
href={`https://app.papermark.com/settings/billing`}
className="underline"
>
Upgrade now
</Link>{" "}
to:
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Create new datarooms
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Share datarooms with your{" "}
<span className="font-bold">custom domain</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Invite your <span className="font-bold">team members</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Share <span className="font-bold">unlimited documents</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Upload <span className="font-bold">large</span> files
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<ul className="list-inside list-disc text-sm">
<li>Create new datarooms</li>
<li>
Upload <strong>large</strong> files
</li>
<li>
Invite your <strong>team members</strong>
</li>
<li>
Protect documents with <strong>advanced access controls</strong>
</li>
<li>
Share documents and data rooms with{" "}
<strong>custom domain</strong>
</li>
<li>Access advanced analytics and audit logs</li>
</ul>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/settings/billing`}
@@ -74,23 +79,7 @@ const DataroomTrialEnd = ({ name }: DataroomTrialEnd) => {
with advanced access controls have been{" "}
<span className="underline">disabled</span>.
</Text>
<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>
</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>
<Footer />
</Container>
</Body>
</Tailwind>
+1 -1
View File
@@ -12,7 +12,7 @@ const DataroomTrialWelcomeEmail = ({ name }: WelcomeEmailProps) => {
<Head />
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi {name},</Text>
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I am Marc, founder of Papermark. Thanks for creating a trial. Do you
need any help with Data Rooms setup?
@@ -13,10 +13,12 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function DataroomViewerInvitation({
dataroomName,
senderEmail,
url,
dataroomName = "Example Data Room",
senderEmail = "sender@example.com",
url = "https://app.papermark.com/datarooms/123",
}: {
dataroomName: string;
senderEmail: string;
@@ -58,23 +60,10 @@ export default function DataroomViewerInvitation({
{`${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()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it.
</Text>
</Section>
<Footer
footerText="If you have any feedback or questions about this email, simply
reply to it."
/>
</Container>
</Body>
</Tailwind>
+3 -17
View File
@@ -13,6 +13,8 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function DomainDeleted({
domain = "papermark.com",
}: {
@@ -53,23 +55,7 @@ export default function DomainDeleted({
If you did not want to keep using this domain on Papermark anyway,
you can simply ignore this email.
</Text>
<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>
</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>
<Footer />
</Container>
</Body>
</Tailwind>
+5 -15
View File
@@ -11,9 +11,11 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export function EmailUpdated({
oldEmail = "name@example.com",
newEmail = "name@example.com",
oldEmail = "old@example.com",
newEmail = "new@example.com",
}: {
oldEmail: string;
newEmail: string;
@@ -47,19 +49,7 @@ export function EmailUpdated({
<Text className="text-sm leading-6 text-black">
This message is being sent to your old email address only.
</Text>
<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>
</Text>
</Section>
<Footer />
</Container>
</Body>
</Tailwind>
-81
View File
@@ -1,81 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export default function EmailVerification({
verificationURL = "papermark.com",
email = "test@test.com",
isDataroom = false,
}: {
verificationURL: string;
email: string;
isDataroom: boolean;
}) {
return (
<Html>
<Head />
<Preview>Verify your email to view the document</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">
Please verify your email
</Text>
<Text className="text-sm leading-6 text-black">
Please click the verification link below to view the{" "}
{isDataroom ? "dataroom" : "document"}.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={verificationURL}
style={{ padding: "12px 20px" }}
>
Verify Email
</Button>
</Section>
<Text className="text-sm leading-6 text-black">
or copy and paste this URL into your browser:
</Text>
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
{verificationURL.replace(/^https?:\/\//, "")}
</Text>
<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>
</Text>
<Text className="text-xs">
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have any
feedback or questions about this email, simply reply to it.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
+20 -28
View File
@@ -11,10 +11,12 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ExportReady({
resourceName = "Export",
downloadUrl,
email,
downloadUrl = "https://app.papermark.com/datarooms/123",
email = "email@example.com",
}: {
resourceName?: string;
downloadUrl: string;
@@ -48,37 +50,27 @@ export default function ExportReady({
<Text className="text-sm leading-6 text-black">
Export details:
</Text>
<Text className="break-all text-sm leading-6 text-black">
<ul>
<li className="text-sm leading-6 text-black">
Export type: {resourceName}
</li>
</ul>
</Text>
<ul className="break-all text-sm leading-6 text-black">
<li className="text-sm leading-6 text-black">
Export type: {resourceName}
</li>
</ul>
<Text className="text-sm leading-6 text-black">
Best,
<br />
The Papermark Team
</Text>
<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, Inc.
</a>
</Text>
<Text className="text-xs">
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have any
feedback or questions about this email, simply reply to it.
</Text>
</Section>
<Footer
footerText={
<>
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have
any feedback or questions about this email, simply reply to
it.
</>
}
/>
</Container>
</Body>
</Tailwind>
@@ -0,0 +1,49 @@
import {
Body,
Head,
Html,
Link,
Tailwind,
Text,
} from "@react-email/components";
interface HundredViewsCongratsEmailProps {
name: string | null | undefined;
}
const HundredViewsCongratsEmail = ({
name,
}: HundredViewsCongratsEmailProps) => {
return (
<Html>
<Head />
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I&apos;m Marc, founder of Papermark. Congrats on 100 views on your
documents.
</Text>
<Text>Would you help others discover us too?</Text>
<Text>
<Link
href="https://www.g2.com/products/papermark/reviews"
target="_blank"
className="text-blue-500 underline"
>
Leave a review on G2
</Link>
</Text>
<Text>Small gift from us inside.</Text>
<Text>
Thanks so much,
<br />
Marc
</Text>
</Body>
</Tailwind>
</Html>
);
};
export default HundredViewsCongratsEmail;
@@ -0,0 +1,84 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function SlackIntegrationNotification({
email = "panic@thedis.co",
team = {
name: "Acme, Inc",
},
integration = {
name: "Slack",
slug: "slack",
},
}: {
email: string;
team: {
name: string;
};
integration: {
name: string;
slug: string;
};
}) {
return (
<Html>
<Head />
<Preview>An integration has been added to your team</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">
An integration has been added to your team
</Text>
<Text className="text-sm leading-6 text-black">
The <strong>{integration.name}</strong> integration has been added
to your team {team.name} on Papermark.
</Text>
<Text className="text-sm leading-6 text-black">
You can now receive notifications about document views, dataroom
access and downloads directly in your Slack channels.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/settings/integrations/${integration.slug}`}
style={{ padding: "12px 20px" }}
>
View installed integration
</Button>
</Section>
<Footer
footerText={
<>
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have
any feedback or questions about this email, simply reply to
it.
</>
}
/>
</Container>
</Body>
</Tailwind>
</Html>
);
}
+3 -17
View File
@@ -14,6 +14,8 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function InvalidDomain({
domain = "papermark.com",
invalidDays = 14,
@@ -77,23 +79,7 @@ export default function InvalidDomain({
}`
: ""}
</Text>
<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>
</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>
<Footer />
</Container>
</Body>
</Tailwind>
+14 -20
View File
@@ -36,21 +36,21 @@ const Onboarding1Email = () => {
With Papermark you can upload different kind of documents and turn
them into shareable links:
</Text>
<ul className="list-inside list-disc text-sm">
<li>PDFs</li>
<li>Microsoft Office files</li>
<li>Excel, CSV files</li>
<li>Notion via link</li>
</ul>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>PDFs</li>
<li>Excel, CSV files</li>
<li>Notion via link</li>
</ul>
<Text className="text-sm">
(All Notion changes are instantly reflected in shared documents)
</Text>
(All Notion changes are instantly reflected in shared documents)
</Text>
<Text className="text-sm">
You can also use <strong>Bulk upload 💫</strong> Just drop many
documents at once
You can also use{" "}
<span className="font-semibold">Bulk upload 💫</span> Just drop
multiple documents at once
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
@@ -63,16 +63,10 @@ const Onboarding1Email = () => {
After sharing start tracking document activity on each page
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Section className="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. All rights
reserved.
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
+15 -26
View File
@@ -14,12 +14,10 @@ import {
} from "@react-email/components";
const Onboarding2Email = () => {
const previewText = `The document sharing infrastructure for the modern web`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Preview>The document sharing infrastructure for the modern web</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
@@ -36,19 +34,16 @@ const Onboarding2Email = () => {
With Papermark you can use different link settings for shared
documents and data rooms:
</Text>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>Require email to view</li>
<li>Expiration date</li>
<li>Allow & block list 🌟</li>
<li>Email verification</li>
<li>Password protection</li>
<li>NDA and other agreements</li>
<li>Screenshot protection</li>
</ul>
</Text>
{/* <Text className="text-sm">You can also use Bulk upload</Text> */}
<Section className="mb-[32px] mt-[32px] text-center">
<ul className="list-inside list-disc text-sm">
<li>Require email to view</li>
<li>Expiration date</li>
<li>Allow & block list 🌟</li>
<li>Email verification</li>
<li>Password protection</li>
<li>NDA and other agreements</li>
<li>Screenshot protection</li>
</ul>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
@@ -62,20 +57,14 @@ const Onboarding2Email = () => {
feedback, and cta settings
</Text>
<Hr />
<Section className="mt-8 text-gray-400">
<Section className="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. All rights
reserved.
</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!{" "}
reply to it. I&apos;d love to hear from you!
</Text>
<Text className="text-xs">Stop this onboarding sequence</Text>
+14 -27
View File
@@ -14,12 +14,10 @@ import {
} from "@react-email/components";
const Onboarding3Email = () => {
const previewText = `The document sharing infrastructure for the modern web`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Preview>The document sharing infrastructure for the modern web</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
@@ -36,22 +34,17 @@ const Onboarding3Email = () => {
With Papermark you can track progress on each page of your
document and other analytics:
</Text>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>
Track time on <strong>each page 💫</strong>{" "}
</li>
<li>See who viewed your documents</li>
<li>Capture email </li>
<li>Receive feedback</li>
<li>Ask questions and get answers</li>
</ul>
{/* <Text className="text-sm">
(All Notion changes are instantly reflected in shared documents)
</Text> */}
</Text>
{/* <Text className="text-sm">You can also use Bulk upload</Text> */}
<Section className="mb-[32px] mt-[32px] text-center">
<ul className="list-inside list-disc text-sm">
<li>
Track time on{" "}
<span className="font-semibold">each page 💫</span>
</li>
<li>See who viewed your documents</li>
<li>Capture email </li>
<li>Receive feedback</li>
<li>Ask questions and get answers</li>
</ul>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`https://app.papermark.com/documents?utm_source=onboarding&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
@@ -66,14 +59,8 @@ const Onboarding3Email = () => {
<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. All rights
reserved.
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
+13 -16
View File
@@ -33,22 +33,19 @@ const Onboarding4Email = () => {
Look professional with custom branding!
</Text>
<Text className="text-sm">With Papermark you can:</Text>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>
Share documnets with your{" "}
<strong>custom domain💫</strong>{" "}
</li>
<ul className="list-inside list-disc text-sm">
<li>
Share documnets with your <strong>custom domain💫</strong>{" "}
</li>
<li>Remove &quot;powered by Papermark&quot;</li>
<li>Add logo and custom colors</li>
<li>Share data room with custom domain</li>
<li>Add banner and custom brand to data rooms</li>
</ul>
<Text className="text-sm">
(Customization for data rooms is seaprate and available in each
data room you create)
</Text>
<li>Remove &quot;powered by Papermark&quot;</li>
<li>Add logo and custom colors</li>
<li>Share data room with custom domain</li>
<li>Add banner and custom brand to data rooms</li>
</ul>
<Text className="text-sm">
(Customization for data rooms is seaprate and available in each
data room you create)
</Text>
{/* <Text className="text-sm">You can also use Bulk upload</Text> */}
<Section className="mb-[32px] mt-[32px] text-center">
@@ -76,7 +73,7 @@ const Onboarding4Email = () => {
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
className="text-gray-400 no-underline"
target="_blank"
>
papermark.com
-106
View File
@@ -1,106 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
const Onboarding3Email = () => {
const previewText = `The document sharing infrastructure for the modern web`;
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-2xl font-normal">
Virtual Data Rooms
</Text>
<Text className="text-sm">Unlimited branded data rooms!</Text>
<Text className="text-sm">With Papermark Data Rooms you can:</Text>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>Share data rooms with one link</li>
<li>Upload unlimited files</li>
<li>Create unlimited folders and subfolders</li>
<li>
Connect your <strong>custom domain 💫</strong>{" "}
</li>
<li>Create fully branded experience </li>
<li>Use advanced link settings</li>
<li>Create full whitelabeling</li>
<li>Build self-hosted expereince</li>
</ul>
<Text className="text-sm">
All about Papermark{" "}
<a
href="https://www.papermark.com/data-room"
className="text-blue-500 underline"
>
Data Rooms
</a>{" "}
features and plans
</Text>
</Text>
{/* <Text className="text-sm">You can also use Bulk upload</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={`https://app.papermark.com/datarooms?utm_source=dataroom-info&utm_medium=email&utm_campaign=20240723&utm_content=upload_documents`}
style={{ padding: "12px 20px" }}
>
Create new data room
</Button>
</Section>
<Text className="text-sm">
If you want to self-host Papermark, and build fully customizable
experience{" "}
<a
href="https://cal.com/marcseitz/papermark"
className="text-blue-500 underline"
>
book a call
</a>{" "}
with us.
</Text>
<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>
</Text>
<Text className="text-xs">
This is a last onboarding email. If you have any feedback or
questions about this email, simply reply to it. I&apos;d love to
hear from you!{" "}
</Text>
{/* <Text className="text-xs">Stop this onboarding sequence. </Text> */}
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default Onboarding3Email;
+27
View File
@@ -0,0 +1,27 @@
import { Hr, Section, Text } from "@react-email/components";
export const Footer = ({
withAddress = false,
footerText = "If you have any feedback or questions about this email, simply reply to it. I&apos;d love to hear from you!",
}: {
withAddress?: boolean;
footerText?: string | React.ReactNode;
}) => {
return (
<>
<Hr />
<Section className="text-gray-400">
<Text className="text-xs">
© {new Date().getFullYear()} Papermark, Inc. All rights reserved.{" "}
{withAddress && (
<>
<br />
1111B S Governors Ave #28117, Dover, DE 19904
</>
)}
</Text>
<Text className="text-xs">{footerText}</Text>
</Section>
</>
);
};
+84
View File
@@ -0,0 +1,84 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface SlackIntegrationEmailProps {
name: string | null | undefined;
}
const SlackIntegrationEmail = ({ name }: SlackIntegrationEmailProps) => {
const previewText = `See who viewed your documents in slack in 2 clicks`;
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-2xl font-normal">
Connect Slack in 2 clicks
</Text>
<Text className="text-sm">Hi{name && ` ${name}`}!</Text>
<Text className="text-sm">
We offer direct integration to Slack, and it&apos;s free for all
users for 30 days.
</Text>
<Text className="text-sm">
With our Slack integration, you can get real-time notifications
about document and data roomviews directly in your Slack channels
!
</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={`${process.env.NEXT_PUBLIC_BASE_URL}/settings/integrations`}
style={{ padding: "12px 20px" }}
>
See who viewed your documents in Slack
</Button>
</Section>
<Text className="text-sm">
If you have any questions or need help setting it up, just respond
to this email. I&apos;m always happy to help!
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
<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"
target="_blank"
>
papermark.com
</a>
</Text>
<Text className="text-xs">
Feel free to always reach out to me or our support team.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default SlackIntegrationEmail;
+4 -18
View File
@@ -13,11 +13,13 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function TeamInvitation({
senderName,
senderEmail,
teamName,
url,
url = "https://app.papermark.com",
}: {
senderName: string;
senderEmail: string;
@@ -59,23 +61,7 @@ export default function TeamInvitation({
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
{url.replace(/^https?:\/\//, "")}
</Text>
<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>
</Text>
<Text className="text-xs">
If you have any feedback or questions about this email, simply
reply to it.
</Text>
</Section>
<Footer />
</Container>
</Body>
</Tailwind>
@@ -0,0 +1,54 @@
import React from "react";
import {
Body,
Head,
Html,
Link,
Preview,
Tailwind,
Text,
} from "@react-email/components";
interface ThousandViewsCongratsEmailProps {
name: string | null | undefined;
}
const ThousandViewsCongratsEmail = ({
name,
}: ThousandViewsCongratsEmailProps) => {
return (
<Html>
<Head />
<Preview>1000 views on Papermark.</Preview>
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I&apos;m Marc, founder of Papermark. Congrats on 1000 views on your
documents.
</Text>
<Text>How is your experience so far?</Text>
<Text>
Thanks so much,
<br />
Marc
</Text>
<Text>
<Link
href="https://www.g2.com/products/papermark/reviews"
target="_blank"
className="text-blue-500 underline"
rel="noopener noreferrer"
>
Leave us a G2 review
</Link>
</Text>
</Body>
</Tailwind>
</Html>
);
};
export default ThousandViewsCongratsEmail;
@@ -1,98 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface TrialEndFinalReminderEmail {
name: string | null | undefined;
}
const TrialEndFinalReminderEmail = ({ name }: TrialEndFinalReminderEmail) => {
const previewText = `Upgrade to Papermark Pro`;
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="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Your pro trial expires in 24 hours
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm leading-6 text-black">
Your Papermark Pro trial expires in 24 hours.{" "}
<Link href={`https://app.papermark.com/settings/billing`}>
Upgrade now
</Link>{" "}
to:
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Send documents with your{" "}
<span className="font-bold">custom domain</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Invite your <span className="font-bold">team members</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Share <span className="font-bold">unlimited documents</span>
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Upload <span className="font-bold">large</span> files
</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={`https://app.papermark.com/settings/billing`}
style={{ padding: "12px 20px" }}
>
Upgrade now
</Button>
</Section>
<Text className="text-sm font-semibold">
<span className="text-red-500"></span> Links with custom domains
will be <span className="underline">disabled</span> after your
trial.
</Text>
<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>
</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>
);
};
export default TrialEndFinalReminderEmail;
-97
View File
@@ -1,97 +0,0 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
interface TrialEndReminderEmail {
name: string | null | undefined;
}
const TrialEndReminderEmail = ({ name }: TrialEndReminderEmail) => {
const previewText = `Upgrade to Papermark Pro`;
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="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Your pro trial is almost over
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm leading-6 text-black">
Your Papermark Pro trial is almost over. If you want to continue
enjoying the Pro features, please consider upgrading your plan.
</Text>
<Text className="text-sm leading-6 text-black">
On the Pro plan, you have access to:
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Custom domains
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Team members
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Unlimited documents
</Text>
<Text className="ml-1 text-sm leading-4 text-black">
Large file uploads
</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={`https://app.papermark.com/settings/billing`}
style={{ padding: "12px 20px" }}
>
Upgrade your plan
</Button>
</Section>
<Text className="text-sm font-semibold">
<span className="text-red-500"></span> Links with custom domains
will be <span className="text-red-500 underline">disabled</span>{" "}
after your trial.
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
<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>
</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>
);
};
export default TrialEndReminderEmail;
@@ -0,0 +1,39 @@
import React from "react";
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
} from "@react-email/components";
interface UpgradeCheckinEmailProps {
name: string | null | undefined;
}
const UpgradeCheckinEmail = ({ name }: UpgradeCheckinEmailProps) => {
return (
<Html>
<Head />
<Preview>Check-in from Marc</Preview>
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
It is Marc here. How has your experience been so far? Are you
getting the value you expected from the advanced features?
</Text>
<Text>
Any feedback - what&apos;s working well and what could we improve?
</Text>
<Text>Marc</Text>
</Body>
</Tailwind>
</Html>
);
};
export default UpgradeCheckinEmail;
@@ -0,0 +1,42 @@
import React from "react";
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
} from "@react-email/components";
interface UpgradePersonalEmailProps {
name: string | null | undefined;
planName?: string;
}
const UpgradePersonalEmail = ({
name,
planName = "Pro",
}: UpgradePersonalEmailProps) => {
return (
<Html>
<Head />
<Preview>Welcome to {planName}</Preview>
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi{name && ` ${name}`},</Text>
<Text>
I&apos;m Iuliia, co-founder of Papermark. Thanks for upgrading!
I&apos;m thrilled to have you on our {planName} plan.
</Text>
<Text>
You now have access to advanced features. Any questions so far??
</Text>
<Text>Iuliia</Text>
</Body>
</Tailwind>
</Html>
);
};
export default UpgradePersonalEmail;
+19 -40
View File
@@ -14,6 +14,8 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface UpgradePlanEmailProps {
name: string | null | undefined;
planType: string;
@@ -21,10 +23,18 @@ interface UpgradePlanEmailProps {
const UpgradePlanEmail = ({
name,
planType = "pro",
planType = "datarooms-plus",
}: UpgradePlanEmailProps) => {
const previewText = `The document sharing infrastructure for the modern web`;
const PLAN_TYPE_MAP = {
pro: "Pro",
business: "Business",
datarooms: "Data Rooms",
"datarooms-plus": "Data Rooms Plus",
};
const planTypeText = PLAN_TYPE_MAP[planType as keyof typeof PLAN_TYPE_MAP];
const features: any = {
pro: [
"Custom branding",
@@ -52,8 +62,7 @@ const UpgradePlanEmail = ({
],
};
const planTypeText = planType.toLowerCase();
const planFeatures = features[planTypeText];
const planFeatures = features[planType];
return (
<Html>
@@ -66,30 +75,18 @@ const UpgradePlanEmail = ({
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="font-seminbold mx-0 mb-8 mt-4 p-0 text-center text-xl">
Thanks for upgrading to Papermark {planType}!
Thanks for upgrading to Papermark {planTypeText}!
</Text>
<Text className="text-sm leading-6 text-black">
Hey{name && ` ${name}`}!
</Text>
<Text className="text-sm">
My name is Marc, and I&apos;m the founder of Papermark. I wanted
to personally reach out to thank you for upgrading to Papermark{" "}
{planType}!
Marc is here. I wanted to personally reach out to thank you for
upgrading to Papermark {planTypeText}!
</Text>
<Text className="text-sm leading-6 text-black">
As you might already know, we are a bootstrapped and{" "}
<Link
href="https://github.com/mfts/papermark"
target="_blank"
className="font-medium text-emerald-500 no-underline"
>
open-source
</Link>{" "}
business. Your support means the world to us and helps us continue
to build and improve Papermark.
</Text>
<Text className="text-sm leading-6 text-black">
On the {planType} plan, you now have access to:
On the {planTypeText} plan, you now have access to:
</Text>
{planFeatures?.map(
(feature: string, index: number) => (
@@ -108,9 +105,7 @@ const UpgradePlanEmail = ({
style={{ padding: "12px 20px" }}
>
Share your{" "}
{planTypeText.includes("datarooms")
? "data rooms"
: "documents"}
{planType.includes("datarooms") ? "data rooms" : "documents"}
</Button>
</Section>
<Section>
@@ -120,23 +115,7 @@ const UpgradePlanEmail = ({
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
</Section>
<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>
</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>
<Footer />
</Container>
</Body>
</Tailwind>
@@ -0,0 +1,41 @@
import React from "react";
import {
Body,
Head,
Html,
Preview,
Tailwind,
Text,
} from "@react-email/components";
interface SixMonthMilestoneEmailProps {
name: string | null | undefined;
planName?: string;
}
const SixMonthMilestoneEmail = ({
name,
planName = "Pro",
}: SixMonthMilestoneEmailProps) => {
return (
<Html>
<Head />
<Preview>6 months with Papermark</Preview>
<Tailwind>
<Body className="font-sans text-sm">
<Text>Hi {name},</Text>
<Text>What&apos;s been your biggest win using Papermark?</Text>
<Text>
Marc here. It&apos;s been 6 months since you using advanced
Papermark features! Excited to hear your story and feedback for us.
</Text>
<Text>Marc</Text>
</Body>
</Tailwind>
</Html>
);
};
export default SixMonthMilestoneEmail;
+15 -22
View File
@@ -3,7 +3,6 @@ import {
Container,
Head,
Heading,
Hr,
Html,
Link,
Preview,
@@ -12,6 +11,8 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface ConfirmEmailChangeProps {
email: string;
newEmail: string;
@@ -19,8 +20,8 @@ interface ConfirmEmailChangeProps {
}
export function ConfirmEmailChange({
email,
newEmail,
email = "email@example.com",
newEmail = "new@example.com",
confirmUrl = "https://www.papermark.com",
}: ConfirmEmailChangeProps) {
return (
@@ -60,25 +61,17 @@ export function ConfirmEmailChange({
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
{confirmUrl.replace(/^https?:\/\//, "")}
</Text>
<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>
</Text>
<Text className="text-xs">
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have any
feedback or questions about this email, simply reply to it.
</Text>
</Section>
<Footer
footerText={
<>
This email was intended for{" "}
<span className="text-black">{email}</span>. If you were not
expecting this email, you can ignore this email. If you have
any feedback or questions about this email, simply reply to
it.
</>
}
/>
</Container>
</Body>
</Tailwind>
+3 -18
View File
@@ -5,7 +5,6 @@ import {
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
@@ -13,6 +12,8 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
const VerificationLinkEmail = ({
url = "https://www.papermark.com",
}: {
@@ -50,23 +51,7 @@ const VerificationLinkEmail = ({
<Text className="max-w-sm flex-wrap break-words font-medium text-purple-600 no-underline">
{url.replace(/^https?:\/\//, "")}
</Text>
<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>
</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>
<Footer />
</Container>
</Body>
</Tailwind>
+15 -25
View File
@@ -5,7 +5,6 @@ import {
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
@@ -13,6 +12,8 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ViewedDataroom({
dataroomId = "123",
dataroomName = "Example Dataroom",
@@ -67,30 +68,19 @@ export default function ViewedDataroom({
See my dataroom insights
</Button>
</Section>
<Text className="text-sm leading-6 text-black">
Stay informed, stay ahead with Papermark.
</Text>
<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>
</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>
<Text className="text-xs">
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</Text>
</Section>
<Footer
footerText={
<>
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
<br />
<br />
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</>
}
/>
</Container>
</Body>
</Tailwind>
+14 -25
View File
@@ -5,7 +5,6 @@ import {
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
@@ -13,6 +12,8 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ViewedDocument({
documentId = "123",
documentName = "Pitchdeck",
@@ -67,30 +68,18 @@ export default function ViewedDocument({
See my document insights
</Button>
</Section>
<Text className="text-sm leading-6 text-black">
Stay informed, stay ahead with Papermark.
</Text>
<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>
</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>
<Text className="text-xs">
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</Text>
</Section>
<Footer
footerText={
<>
If you have any feedback or questions about this email, simply
reply to it. I&apos;d love to hear from you!
<br />
<br />
To stop email notifications for this link, edit the link and
uncheck &quot;Receive email notification&quot;.
</>
}
/>
</Container>
</Body>
</Tailwind>
+52 -65
View File
@@ -14,6 +14,8 @@ import {
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
interface WelcomeEmailProps {
name: string | null | undefined;
}
@@ -36,25 +38,23 @@ const WelcomeEmail = ({ name }: WelcomeEmailProps) => {
Thanks for signing up{name && `, ${name}`}!
</Text>
<Text className="text-sm">
My name is Marc, and I&apos;m the creator of Papermark the
open-source DocSend alternative! I&apos;m excited to have you on
board!
My name is Marc, and I&apos;m the founder of Papermark the
secure way to share documents and data rooms. I&apos;m excited to
have you on board!
</Text>
<Text className="text-sm">
Here are a few things you can do to get started:
</Text>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>Upload a document</li>
<li>Create a virtual data room</li>
<li>
Share a link{" "}
<span className="italic">(with your custom domain)</span>
</li>
<li>Watch the views come in real-time</li>
</ul>
</Text>
<Section className="mb-[32px] mt-[32px] text-center">
<ul className="list-inside list-disc text-sm">
<li>Turn your documents into shareable links</li>
<li>Create secure virtual data rooms</li>
<li>
Share your documents{" "}
<span className="italic">(with custom domain)</span>
</li>
<li>Watch the page-by-page insights in real-time</li>
</ul>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/welcome`}
@@ -63,62 +63,49 @@ const WelcomeEmail = ({ name }: WelcomeEmailProps) => {
Get Started
</Button>
</Section>
<Section>
<Text className="text-sm">
If you would like to keep up to date, you can:
</Text>
<Text className="text-sm">
<ul className="list-inside list-disc text-sm">
<li>
Star the repo on{" "}
<Link
href="https://github.com/mfts/papermark"
target="_blank"
>
GitHub
</Link>
</li>
<li>
Follow the journey on{" "}
<Link href="https://x.com/papermarkio" target="_blank">
Twitter
</Link>
</li>
<li>
Have a call to talk enterprise{" "}
<Link
href="https://cal.com/marcseitz/papermark"
target="_blank"
>
Book
</Link>
</li>
</ul>
</Text>
</Section>
<Section className="mt-4">
<Text className="text-sm">
If you have any questions or feedback just respond to this
email. I&apos;m always happy to help!
email.{" "}
<Link
href="https://cal.link/papermark"
target="_blank"
rel="noopener noreferrer"
>
Book a call
</Link>{" "}
to discuss your enterprise needs. I&apos;m always happy to help!
</Text>
<Text className="text-sm text-gray-400">Marc from Papermark</Text>
</Section>
<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>
</Text>
<Text className="text-xs">
You will shortly receive the intro to Papermark. Stay tuned.
</Text>
</Section>
<Footer />
<Text className="flex gap-x-1 text-xs">
<Link
href="https://www.papermark.com/customers"
target="_blank"
className="text-xs text-gray-400"
>
Customer stories
</Link>
<Link
href="https://x.com/papermarkio"
target="_blank"
className="text-xs text-gray-400"
rel="noopener noreferrer"
>
· X/Twitter
</Link>
<Link
href="https://www.linkedin.com/company/papermarkio"
target="_blank"
className="text-xs text-gray-400"
rel="noopener noreferrer"
>
· LinkedIn
</Link>
</Text>
</Container>
</Body>
</Tailwind>
@@ -266,7 +266,7 @@ export default function PapermarkYearInReviewEmail({
© {new Date().getFullYear()}{" "}
<a
href="https://www.papermark.com"
className="text-gray-400 no-underline hover:text-gray-400"
className="text-gray-400 no-underline"
target="_blank"
>
papermark.com
@@ -279,7 +279,7 @@ export default function PapermarkYearInReviewEmail({
from future Year in Review emails,{" "}
<a
href={unsubscribeUrl}
className="text-gray-400 underline underline-offset-2 hover:text-gray-400"
className="text-gray-400 underline underline-offset-2"
>
click here
</a>
+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} />;
}
+5
View File
@@ -165,7 +165,10 @@ const SingleDataroomBreadcrumb = ({ path }: { path: string }) => {
return "Permissions";
case "/datarooms/[id]/analytics":
return "Analytics";
case "/datarooms/[id]/conversations/faqs":
return "FAQ";
case "/datarooms/[id]/conversations":
case "/datarooms/[id]/conversations/[conversationId]":
return "Conversations";
case "/datarooms/[id]/settings/notifications":
return "Notifications";
@@ -220,6 +223,8 @@ const SettingsBreadcrumb = () => {
return "API Tokens";
case "/settings/webhooks":
return "Webhooks";
case "/settings/slack":
return "Slack";
case "/settings/incoming-webhooks":
return "Incoming Webhooks";
case "/settings/branding":
+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>
);
}
}
@@ -31,6 +31,8 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import LinkItem from "../link-item";
@@ -50,7 +52,7 @@ export default function AgreementSheet({
isOnlyView = false,
onClose,
}: {
defaultData?: { name: string; link: string; requireName: boolean } | null;
defaultData?: { name: string; link: string; requireName: boolean; contentType?: string; textContent?: string } | null;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
isOnlyView?: boolean;
@@ -58,7 +60,13 @@ export default function AgreementSheet({
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const [data, setData] = useState({ name: "", link: "", requireName: true });
const [data, setData] = useState({
name: "",
link: "",
textContent: "",
contentType: "LINK",
requireName: true
});
const [isLoading, setIsLoading] = useState<boolean>(false);
const [currentFile, setCurrentFile] = useState<File | null>(null);
@@ -71,6 +79,8 @@ export default function AgreementSheet({
setData({
name: defaultData?.name || "",
link: defaultData?.link || "",
textContent: defaultData?.textContent || "",
contentType: defaultData?.contentType || "LINK",
requireName: defaultData?.requireName || true,
});
}
@@ -78,7 +88,13 @@ export default function AgreementSheet({
const handleClose = (open: boolean) => {
setIsOpen(open);
setData({ name: "", link: "", requireName: true });
setData({
name: "",
link: "",
textContent: "",
contentType: "LINK",
requireName: true
});
setCurrentFile(null);
setIsLoading(false);
if (onClose) {
@@ -184,13 +200,22 @@ export default function AgreementSheet({
return;
}
// Validate URL before submitting
try {
agreementUrlSchema.parse(data.link);
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
toast.error(firstError?.message || "Please enter a valid URL");
// Validate based on content type
if (data.contentType === "LINK") {
// Validate URL
try {
agreementUrlSchema.parse(data.link);
} catch (error) {
if (error instanceof z.ZodError) {
const firstError = error.errors[0];
toast.error(firstError?.message || "Please enter a valid URL");
return;
}
}
} else if (data.contentType === "TEXT") {
// Validate text content
if (!data.textContent.trim()) {
toast.error("Please enter agreement text content");
return;
}
}
@@ -198,14 +223,19 @@ export default function AgreementSheet({
setIsLoading(true);
try {
const submitData = {
name: data.name,
contentType: data.contentType,
content: data.contentType === "LINK" ? data.link : data.textContent,
requireName: data.requireName,
};
const response = await fetch(`/api/teams/${teamId}/agreements`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
}),
body: JSON.stringify(submitData),
});
if (!response.ok) {
@@ -220,7 +250,13 @@ export default function AgreementSheet({
} finally {
setIsLoading(false);
setIsOpen(false);
setData({ name: "", link: "", requireName: true });
setData({
name: "",
link: "",
textContent: "",
contentType: "LINK",
requireName: true
});
}
};
@@ -281,56 +317,102 @@ export default function AgreementSheet({
</div>
<div className="space-y-4">
{/* Content Type Selection */}
<div className="w-full space-y-2">
<Label htmlFor="link">Link to an agreement</Label>
<Input
className={`flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset placeholder:text-muted-foreground focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 ${
!isUrlValid
? "ring-red-500 focus:ring-red-500"
: "ring-input focus:ring-gray-400"
}`}
id="link"
type="text" // Changed from "url" to avoid browser validation conflicts
name="link"
required
autoComplete="off"
data-1p-ignore
placeholder="https://www.papermark.com/nda"
value={data.link || ""}
onChange={(e) => {
const newValue = e.target.value;
setData({
...data,
link: newValue,
});
// Validate on change with debouncing
validateUrl(newValue);
}}
onBlur={(e) => {
// Validate on blur for immediate feedback
validateUrl(e.target.value);
}}
<Label>Agreement Content Type</Label>
<RadioGroup
value={data.contentType}
onValueChange={(value) => setData({...data, contentType: value})}
disabled={isOnlyView}
/>
{/* Display validation error */}
{urlError && (
<p className="mt-1 text-sm text-red-500">{urlError}</p>
)}
className="flex flex-col space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="LINK" id="link-type" />
<Label htmlFor="link-type">Link to agreement document</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="TEXT" id="text-type" />
<Label htmlFor="text-type">Text content</Label>
</div>
</RadioGroup>
</div>
{!isOnlyView ? (
<div className="space-y-12">
<div className="space-y-2 pb-6">
<Label>Or upload an agreement</Label>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<DocumentUpload
currentFile={currentFile}
setCurrentFile={setCurrentFile}
/>
{/* Link Content */}
{data.contentType === "LINK" && (
<div className="w-full space-y-2">
<Label htmlFor="link">Link to an agreement</Label>
<Input
className={`flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset placeholder:text-muted-foreground focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 ${
!isUrlValid
? "ring-red-500 focus:ring-red-500"
: "ring-input focus:ring-gray-400"
}`}
id="link"
type="text"
name="link"
required={data.contentType === "LINK"}
autoComplete="off"
data-1p-ignore
placeholder="https://www.papermark.com/nda"
value={data.link || ""}
onChange={(e) => {
const newValue = e.target.value;
setData({
...data,
link: newValue,
});
validateUrl(newValue);
}}
onBlur={(e) => {
validateUrl(e.target.value);
}}
disabled={isOnlyView}
/>
{urlError && (
<p className="mt-1 text-sm text-red-500">{urlError}</p>
)}
{!isOnlyView && (
<div className="space-y-12">
<div className="space-y-2 pb-6">
<Label>Or upload an agreement</Label>
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
<DocumentUpload
currentFile={currentFile}
setCurrentFile={setCurrentFile}
/>
</div>
</div>
</div>
</div>
)}
</div>
) : null}
)}
{/* Text Content */}
{data.contentType === "TEXT" && (
<div className="w-full space-y-2">
<Label htmlFor="textContent">Agreement Text</Label>
<Textarea
className="flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6"
id="textContent"
name="textContent"
required={data.contentType === "TEXT"}
placeholder="By accessing this document, you agree to maintain confidentiality of all information contained herein and not to share, copy, or distribute any content without prior written consent..."
value={data.textContent || ""}
onChange={(e) =>
setData({
...data,
textContent: e.target.value,
})
}
disabled={isOnlyView}
rows={6}
/>
<p className="text-xs text-muted-foreground">
This text will be displayed to users as a compliance agreement before they can access the content.
</p>
</div>
)}
</div>
</div>
<SheetFooter
@@ -345,7 +427,10 @@ export default function AgreementSheet({
<Button
type="submit"
loading={isLoading}
disabled={!isUrlValid && data.link.trim() !== ""}
disabled={
(data.contentType === "LINK" && !isUrlValid && data.link.trim() !== "") ||
(data.contentType === "TEXT" && !data.textContent.trim())
}
>
Create Agreement
</Button>
@@ -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"
>;
};
+9 -2
View File
@@ -235,6 +235,8 @@ export default function LinkSheet({
if (!preset) return;
setData((prev) => {
const isGroupLink = prev.audienceType === LinkAudienceType.GROUP;
return {
...prev,
name: prev.name, // Keep existing name
@@ -243,8 +245,13 @@ export default function LinkSheet({
emailProtected: preset.emailProtected ?? prev.emailProtected,
emailAuthenticated:
preset.emailAuthenticated ?? prev.emailAuthenticated,
allowList: preset.allowList || prev.allowList,
denyList: preset.denyList || prev.denyList,
// For group links, ignore allow/deny lists from presets as access is controlled by group membership
allowList: isGroupLink
? prev.allowList
: preset.allowList || prev.allowList,
denyList: isGroupLink
? prev.denyList
: preset.denyList || prev.denyList,
password: preset.password || prev.password,
enableCustomMetatag:
preset.enableCustomMetaTag ?? prev.enableCustomMetatag,
@@ -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>
</>
);
}
}
+5 -2
View File
@@ -137,12 +137,15 @@ export function SearchBoxPersisted({
currentQuery[urlParam] = debouncedValue;
}
// For custom domains, preserve the clean URL structure
const isCustomDomain = !!(router.query.domain && router.query.slug);
router.push(
{
pathname: router.pathname,
query: currentQuery,
},
undefined,
isCustomDomain ? `/${router.query.slug}` : undefined, // Preserve custom domain URL
{ shallow: true },
);
}, [debouncedValue]);
@@ -164,4 +167,4 @@ export function SearchBoxPersisted({
{...props}
/>
);
}
}
+7
View File
@@ -10,6 +10,7 @@ export function SettingsHeader() {
const { data: features } = useSWR<{
tokens: boolean;
incomingWebhooks: boolean;
slack: boolean;
}>(
teamInfo?.currentTeam?.id
? `/api/feature-flags?teamId=${teamInfo.currentTeam.id}`
@@ -67,6 +68,12 @@ export function SettingsHeader() {
href: `/settings/webhooks`,
segment: "webhooks",
},
{
label: "Slack",
href: `/settings/slack`,
segment: "slack",
disabled: !features?.slack,
},
{
label: "Tokens",
href: `/settings/tokens`,
@@ -0,0 +1,82 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function SlackSettingsSkeleton() {
return (
<div className="space-y-6">
{/* Header Skeleton */}
<div className="mb-4 flex items-center justify-between md:mb-8 lg:mb-12">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-80" />
</div>
<Skeleton className="h-10 w-32" />
</div>
{/* Main Content Skeleton */}
<div className="space-y-6">
{/* Card 1 */}
<div className="rounded-lg border bg-card p-6">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-6 w-40" />
<Skeleton className="h-4 w-60" />
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-48" />
</div>
<Skeleton className="h-6 w-11" />
</div>
<div className="space-y-3">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-64" />
</div>
<div className="flex items-center rounded-lg border border-border bg-background">
<div className="flex flex-1 items-center gap-2 px-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
</div>
<Skeleton className="h-6 w-px" />
<Skeleton className="h-8 w-32 shrink-0" />
</div>
</div>
</div>
</div>
</div>
{/* Card 2 */}
<div className="rounded-lg border bg-card p-6">
<div className="space-y-4">
<div className="space-y-2">
<Skeleton className="h-6 w-44" />
<Skeleton className="h-4 w-72" />
</div>
<div className="space-y-4">
<div className="flex space-x-6">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Skeleton className="h-4 w-12" />
<Skeleton className="h-10 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-10 w-full" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import React from "react";
interface SlackIconProps {
className?: string;
size?: number;
}
export function SlackIcon({ className = "", size = 24 }: SlackIconProps) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
className={className}
>
{/* Slack logo with proper colors */}
<path
d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z"
fill="#E01E5A"
/>
<path
d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.527 2.527 0 0 1 8.834 0a2.527 2.527 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.52 2.527 2.527 0 0 1-2.521 2.521H2.522A2.527 2.527 0 0 1 0 8.833a2.528 2.528 0 0 1 2.522-2.52h6.312z"
fill="#36C5F0"
/>
<path
d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.527 2.527 0 0 1 24 8.834a2.527 2.527 0 0 1-2.522 2.52h-2.522V8.834zM17.688 8.834a2.527 2.527 0 0 1-2.52 2.52 2.526 2.526 0 0 1-2.52-2.52V2.522A2.527 2.527 0 0 1 15.166 0a2.527 2.527 0 0 1 2.522 2.522v6.312z"
fill="#2EB67D"
/>
<path
d="M15.166 18.956a2.528 2.528 0 0 1 2.52 2.522A2.527 2.527 0 0 1 15.166 24a2.527 2.527 0 0 1-2.52-2.522v-2.52h2.52zM15.166 17.688a2.527 2.527 0 0 1-2.52-2.52 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.168a2.528 2.528 0 0 1-2.522 2.52h-6.312z"
fill="#ECB22E"
/>
</svg>
);
}
+5
View File
@@ -150,6 +150,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
url: "/settings/webhooks",
current: router.pathname.includes("settings/webhooks"),
},
{
title: "Slack",
url: "/settings/slack",
current: router.pathname.includes("settings/slack"),
},
{
title: "Billing",
url: "/settings/billing",
+58 -1
View File
@@ -1,12 +1,13 @@
"use client";
import * as React from "react";
import { useState } from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
@@ -134,7 +135,63 @@ const AlertDialogCancel = React.forwardRef<
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
interface SingleAlertDialogProps {
title: string;
description: string;
action: string;
onAction?: () => Promise<void> | void;
actionUpdate?: string;
disabled?: boolean;
loading?: boolean;
}
const CommonAlertDialog = ({
title,
description,
action,
onAction,
actionUpdate,
disabled = false,
loading = false,
}: SingleAlertDialogProps) => {
const [showDisconnectDialog, setShowDisconnectDialog] = useState(false);
const handleDisconnect = async () => {
if (onAction) await onAction();
setShowDisconnectDialog(false);
};
return (
<AlertDialog
open={showDisconnectDialog}
onOpenChange={setShowDisconnectDialog}
>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={disabled || loading}>
{loading ? `${actionUpdate}...` : action}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> {title} </AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDisconnect}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{action}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export {
CommonAlertDialog,
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,

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