Compare 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
Marc SeitzandGitHub bdadca6466 Merge pull request #1775 from mfts/cursor/enable-agreement-download-for-team-8eb9
Enable agreement download for team
2025-07-21 17:00:58 +02:00
Marc Seitz 5cda57bf06 chore: prettier 2025-07-21 16:47:21 +02:00
Cursor Agentandmarcftone 66497af2d9 Add timeout to document download fetch request.
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-21 14:35:30 +00:00
Cursor Agentandmarcftone ab00235829 Remove PPTX and XLSX specific handling from download API.
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-21 14:23:01 +00:00
Cursor Agentandmarcftone 8331434bef Enhance agreement download to support Papermark document URLs and file types
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-21 14:17:54 +00:00
Cursor Agentandmarcftone 2e84c92c96 Refactor agreement download to fetch file content from S3 dynamically
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-21 14:14:36 +00:00
Ashish Viradiya 84e206e484 define type 2025-07-21 19:39:13 +05:30
Cursor Agentandmarcftone 36e2c4f68c Change agreement download to POST request and sanitize filename
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-21 14:07:53 +00:00
Ashish Viradiya 2705c580f3 improvement: notify team if blocked email tried to access a link 2025-07-21 18:54:34 +05:30
Cursor Agentandmarcftone b3f918a65d Add download functionality for agreements with detailed text export
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-21 11:27:40 +00:00
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
Marc SeitzandGitHub 17f6604fb4 Merge pull request #1771 from mfts/cursor/export-document-visits-to-tinybird-asynchronously-61ac
fix: avoid double renders
2025-07-19 10:05:39 +02:00
Marc Seitz f9fe6577f9 fix: avoid double renders 2025-07-19 10:00:14 +02:00
Marc SeitzandGitHub 8b6d4448fc Merge pull request #1762 from mfts/cursor/export-document-visits-to-tinybird-asynchronously-61ac
Export document visits to tinybird asynchronously
2025-07-19 09:45:50 +02:00
Marc Seitz 1214a94730 refactor: cleanup startexport component 2025-07-19 09:37:46 +02:00
Marc Seitz b7909812a0 Merge branch 'cursor/export-document-visits-to-tinybird-asynchronously-61ac' of github.com:mfts/papermark into cursor/export-document-visits-to-tinybird-asynchronously-61ac 2025-07-19 09:27:32 +02:00
Marc Seitz deb120e6e4 feat: add try catch to avoid blocking popups 2025-07-19 09:27:19 +02:00
Marc Seitz c1f856c78d chore: cleaner 2025-07-19 09:26:28 +02:00
Marc SeitzGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
79d3fc1068 Update components/datarooms/export-visits-modal.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-07-19 09:18:08 +02:00
Marc Seitz f121b76d99 fix: csv row stringify 2025-07-19 09:15:04 +02:00
Marc Seitz 33a8c8b95a fix: lint 2025-07-19 09:14:53 +02:00
Marc Seitz 122af2b03b chore: remove 2025-07-19 09:14:45 +02:00
Marc Seitz 8885f64c74 feat: add export function and download option 2025-07-18 14:03:27 +02:00
Marc Seitz e367fadccf Merge branch 'main' into cursor/export-document-visits-to-tinybird-asynchronously-61ac 2025-07-18 13:57:13 +02:00
Marc Seitz 31c4be432b fix(emails): remove visited css 2025-07-18 12:04:35 +02:00
Marc Seitz 24ea34c2ce fix: hot fix emails 2025-07-18 11:12:56 +02:00
Marc Seitz 4a280b15d4 Merge branch 'main' into cursor/export-document-visits-to-tinybird-asynchronously-61ac 2025-07-17 21:48:13 +02:00
Marc SeitzandGitHub ff4bb0ad84 Merge pull request #1770 from mfts/chore/updatedeps
chore: update dependencies; migrate vercel/blob
2025-07-17 18:25:24 +02:00
Marc Seitz a5c98e8b2c fix: memory on annotate document 2025-07-17 18:15:00 +02:00
Marc SeitzandGitHub 577eb884ac Merge pull request #1769 from mfts/feat/preview-docs 2025-07-17 18:02:27 +02:00
Marc Seitz ec658d4eb8 feat: improvements 2025-07-17 17:49:04 +02:00
Marc Seitz 6bc0db96f8 chore: update dependencies; migrate vercel/blob 2025-07-17 17:21:39 +02:00
Marc Seitz c73a409375 feat: add document preview 2025-07-17 16:17:19 +02:00
Marc SeitzandGitHub 127e366436 Merge pull request #1764 from mfts/fix/memory
fix: memory for annotate document
2025-07-17 12:23:01 +02:00
Ashish Viradiya 950f7e12e3 added ability to change 'Your action is requested' 2025-07-17 04:26:31 +05:30
Marc SeitzandGitHub 1e107c8400 Merge pull request #1765 from mfts/feat/preview-docs
fix: improve watermarking for non-supported characters
2025-07-16 19:27:31 +02:00
Marc Seitz db0b9f8913 fix: rgb return function 2025-07-16 19:20:48 +02:00
Marc Seitz b3401b9b5b fix: improve watermarking for non-supported characters 2025-07-16 19:13:13 +02:00
Marc Seitz 7a381cdd5f fix: memory for annotate document 2025-07-16 17:14:01 +02:00
Marc SeitzandGitHub ec4d76d89d Merge pull request #1763 from mfts/fix/docs
fix
2025-07-16 15:27:10 +02:00
Marc Seitz 919174e988 fix 2025-07-16 15:21:38 +02:00
Marc SeitzandGitHub 1f3e17a4ad Merge pull request #1704 from AshishViradiya153/288/permissionSetting
feat: set group permission for doc from dataroom setting
2025-07-16 14:16:13 +02:00
Marc Seitz 3fc3dbc7cb fix: perf 2025-07-16 13:40:09 +02:00
Marc Seitz 10d19031b3 fix 2025-07-16 12:55:02 +02:00
Marc Seitz 6afd4a8cb5 feat: simplify migration file 2025-07-16 12:49:49 +02:00
Marc Seitz 2e66da1478 feat: add unified ask every time permissions modal 2025-07-16 12:49:34 +02:00
Marc SeitzandGitHub 92ec0d66c0 Merge pull request #1758 from mfts/cursor/rename-people-tab-to-team-b668
Rename people tab to team
2025-07-16 11:52:54 +02:00
Cursor Agentandmarcftone d0d2a54389 Replace database export jobs with Redis-based job storage
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-15 21:29:13 +00:00
Cursor Agentandmarcftone e0d8c3da23 Implement background export jobs with Trigger.dev for visits data
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-15 21:08:45 +00:00
Marc SeitzandGitHub 35af870cb2 Merge pull request #1761 from mfts/cursor/allow-managers-to-update-ignored-domains-0d7c
Allow managers to update ignored domains
2025-07-15 22:05:25 +02:00
Cursor Agentandmarcftone e5acb6e021 Allow managers to modify global block list and ignored domains
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-15 19:49:34 +00:00
Marc SeitzandGitHub eb81ba6537 Merge pull request #1759 from mfts/cursor/allow-admins-to-assign-admin-role-4956
Allow admins to assign admin role
2025-07-15 21:33:24 +02:00
Marc SeitzandGitHub 01d0fdadee Merge pull request #1760 from mfts/mfts-workflow-patch-1
Update cla.yml
2025-07-15 20:05:03 +02:00
Marc SeitzandGitHub 1c4df1595d Update cla.yml 2025-07-15 20:01:59 +02:00
Marc SeitzandGitHub 3e5be706c6 Update pages/api/teams/[teamId]/change-role.ts 2025-07-15 20:01:14 +02:00
Cursor Agentandmarcftone 795c0064f8 Prevent non-admin users from changing team member roles
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-15 17:57:31 +00:00
Cursor Agentandmarcftone 5b065609af Add support for ADMIN role in team member role management
Co-authored-by: marcftone <marcftone@gmail.com>
2025-07-15 17:49:32 +00:00
Marc Seitz dd0d34256c feat: unify link and group permissions default settings 2025-07-15 19:39:02 +02:00
Cursor Agentandyul.schnay 13c1c12286 Replace "People" with "Team" across settings pages and components
Co-authored-by: yul.schnay <yul.schnay@gmail.com>
2025-07-15 16:53:16 +00:00
Marc SeitzandGitHub 557f997074 Merge pull request #1756 from mfts/fix/video-processing
fix: don't optimized mp4 files
2025-07-15 15:45:09 +02:00
Marc Seitz 11392eaa1d fix: don't optimized mp4 files 2025-07-15 15:37:51 +02:00
Marc SeitzandGitHub 6f57c3efe5 Merge pull request #1755 from mfts/fix/whitelist
fix: isEmailMatched
2025-07-15 15:03:35 +02:00
Marc Seitz d0ae1a4926 fix: isEmailMatched 2025-07-15 14:58:46 +02:00
Marc Seitz 846cefcd22 Merge branch 'main' into 288/permissionSetting 2025-07-15 11:40:46 +02:00
Marc SeitzandGitHub 612f16c16d Merge pull request #1754 from AshishViradiya153/307/emailblocklist
Feat: Added global email block list for teams
2025-07-15 11:38:50 +02:00
Marc Seitz 1235e4bbee feat: add block list 2025-07-15 11:28:07 +02:00
Ashish Viradiya 97964c3f3a improvement 2025-07-15 10:49:03 +05:30
Ashish Viradiya 156f9953a8 feat: Added global email block list for teams 2025-07-15 10:27:24 +05:30
Ashish Viradiya 96f905b315 refector code 2025-07-15 09:09:24 +05:30
Ashish ViradiyaandGitHub 7343382e84 Merge branch 'main' into 288/permissionSetting 2025-07-15 08:47:04 +05:30
Ashish Viradiya 682800dfb3 Added link default permission 2025-07-15 08:33:05 +05:30
Marc SeitzandGitHub 28cf2b7701 Merge pull request #1752 from mfts/feat/enable-advanced-mode
feat: advanced mode and nda issue
2025-07-11 22:16:13 +02:00
Marc Seitz 075b9b1fde fix: agreements in presets 2025-07-11 22:08:45 +02:00
Marc Seitz 8d8c777116 feat: enable advanced mode on pro for individual docs 2025-07-11 22:03:59 +02:00
Marc SeitzandGitHub 7072d83de2 Merge pull request #1749 from AshishViradiya153/404/ignoreDomain
Feat: Added a global list of domains to ignore notification
2025-07-11 17:20:43 +02:00
Marc Seitz 1182a747e3 feat: upgrade ignore domains schema 2025-07-11 17:07:06 +02:00
Ashish Viradiya 5970450017 improvement 2025-07-10 16:07:50 +05:30
Ashish Viradiya 4118a12640 feat: Added a global list of domains to ignore notification 2025-07-10 02:02:20 +05:30
Ashish Viradiya 0503d904ac improvement 2025-07-08 23:03:46 +05:30
Marc SeitzandGitHub 77c0155d24 Merge pull request #1509 from AshishViradiya153/93/searchInViewerMode
Add search bar to Dataroom viewer mode
2025-07-08 19:17:41 +02:00
Marc Seitz c66b27c980 feat: add search indicator 2025-07-08 18:10:20 +02:00
Ashish Viradiya 185d699cad improvement: small screen shows only icon in btn with tooltip 2025-07-08 21:23:09 +05:30
Marc SeitzandGitHub 0bc209f65b Merge pull request #1747 from AshishViradiya153/loadingBug
bug: fix loading bug
2025-07-08 17:51:22 +02:00
Ashish Viradiya 522d50198b remove custome field if lable not added 2025-07-08 20:01:40 +05:30
Ashish Viradiya d68e631959 custome field on update 2025-07-08 19:38:39 +05:30
Ashish Viradiya 42dfd0d114 bug: fix loading bug 2025-07-08 19:01:11 +05:30
Marc SeitzandGitHub c9b170badb Merge pull request #1693 from AshishViradiya153/312/invitelinkexpire
update invitation expire link time to 7days
2025-07-08 13:30:15 +02:00
Marc Seitz bad29a4d44 refactor: merge into new dataroom-viewer 2025-07-08 13:29:22 +02:00
Marc SeitzandGitHub d30e3222ef Merge pull request #1713 from AshishViradiya153/325/bgColor
Added field for dynamic bg color
2025-07-08 13:24:21 +02:00
Marc Seitz cf3d0aa32f Merge branch 'main' into 93/searchInViewerMode 2025-07-08 13:18:35 +02:00
Marc SeitzandGitHub 2279fb4508 Merge pull request #1735 from AshishViradiya153/375/versionNumber
Added version number to dataroom index file
2025-07-08 12:38:49 +02:00
Marc SeitzandGitHub 2bee053ffc Merge pull request #1746 from AshishViradiya153/402/updatedAt
Bugfix: dataroom folder updatedAt not taking into account document up…
2025-07-07 20:57:25 +02:00
Marc SeitzandGitHub d8d21123bf Merge pull request #1728 from AshishViradiya153/379/trialImprovement
Improvement: Enable features in 7 day trial(and show correct popups)
2025-07-07 20:46:47 +02:00
Marc Seitz 8e10c6be63 feat: add more efficient way of counting proper effective updated at
even with deeply nested file updates
2025-07-07 20:46:35 +02:00
Marc Seitz eae23b1a1f chore: add note 2025-07-07 20:26:00 +02:00
Marc Seitz 7fc65ef699 fix: conditionals 2025-07-07 20:25:53 +02:00
Marc Seitz 752f9f8b13 Merge branch 'main' into 379/trialImprovement 2025-07-07 20:20:37 +02:00
Marc SeitzandGitHub c1e80fc8f1 Merge pull request #1737 from AshishViradiya153/389/stringLength
Increase dataroom name max length from 34 to 156
2025-07-07 19:55:34 +02:00
Marc SeitzandGitHub dad5d85465 Merge pull request #1662 from AshishViradiya153/252/preview-button
Prevent preview action during processing
2025-07-07 19:44:33 +02:00
Marc Seitz 791c5a747b Merge branch 'main' into 252/preview-button 2025-07-07 18:55:00 +02:00
Marc SeitzandGitHub 594e7540d8 Merge pull request #1741 from AshishViradiya153/398/enableEmbeds
Remove embedding from feature flags
2025-07-07 18:49:48 +02:00
Marc SeitzandGitHub ddb35e358c Merge pull request #1742 from AshishViradiya153/385/advancedMode
BugFix: only enable advanced mode on Business and upper plan
2025-07-07 17:52:06 +02:00
Marc SeitzandGitHub 3b48e6d8cb Merge pull request #1739 from AshishViradiya153/reportForm
fix: persist RadioGroup selection in report form
2025-07-07 17:51:35 +02:00
Marc SeitzandGitHub cc73caf024 Merge pull request #1659 from AshishViradiya153/304/folderCreation
Only append numbers to final folder name, not to path segments
2025-07-07 17:50:46 +02:00
Marc Seitz bd56bbc827 refactor: component name 2025-07-07 17:49:40 +02:00
Marc Seitz a1bd0505dc Merge branch 'main' into 304/folderCreation 2025-07-07 17:39:25 +02:00
Marc Seitz e4f01300fd chore: cleanup 2025-07-07 17:39:22 +02:00
Marc SeitzandGitHub 48369adb0a Merge pull request #1716 from AshishViradiya153/367/improve
improvement: Improve loading speed of visitors tab
2025-07-07 17:34:24 +02:00
Marc Seitz 8fe49be361 Merge branch 'main' into 304/folderCreation 2025-07-07 17:28:57 +02:00
Ashish Viradiya 3025f30be4 update 2025-07-07 19:37:24 +05:30
Ashish Viradiya 67ec53da8b Bugfix: dataroom folder updatedAt not taking into account document updatedAt 2025-07-07 19:22:04 +05:30
Marc SeitzandGitHub bfd7c17276 Merge pull request #1744 from AshishViradiya153/396/permissions
bugfix: inconsistency in the group permissions links
2025-07-07 14:57:12 +02:00
Marc Seitz a6b0c64a5d chore: update button based on group 2025-07-07 14:42:27 +02:00
Ashish Viradiya 136b74cf39 improve: showing loader on links save and update 2025-07-07 17:51:59 +05:30
Ashish Viradiya 13fd870ce7 ui improvement 2025-07-07 17:28:43 +05:30
Ashish Viradiya 4f00b1be79 bugfix: inconsistency in the group permissions links 2025-07-07 17:15:29 +05:30
Ashish Viradiya e1dd35d4d3 improvement 2025-07-07 03:12:36 +05:30
Ashish Viradiya 160b7bc288 BugFix: only enable advanced mode on Business and upper plan 2025-07-04 16:28:56 +05:30
Ashish Viradiya f9dbc7d565 remove embedding from feature flags 2025-07-04 15:56:22 +05:30
Ashish Viradiya aa556f4900 fix: persist RadioGroup selection in report form 2025-07-03 19:26:30 +05:30
Marc SeitzandGitHub cde74a0acf Merge pull request #1738 from AshishViradiya153/390
BugFix: Setting of granular permissions changed
2025-07-03 13:26:53 +02:00
Ashish Viradiya 9ac1e1e645 BugFix: Setting of granular permissions changed 2025-07-03 16:10:43 +05:30
Ashish Viradiya caa732aa21 increase dataroom name max length from 34 to 156 2025-07-03 13:44:46 +05:30
Ashish Viradiya 39abc7d5ac changed pg_trgm with B-tree index 2025-07-03 11:38:32 +05:30
Ashish Viradiya e28054eaae Added version number to dataroom index file 2025-07-02 06:43:04 +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
Marc Seitz 79aef1ec95 Merge branch 'main' into 367/improve 2025-07-01 20:41:12 +02:00
Marc SeitzandGitHub 07b442c343 Merge pull request #1731 from AshishViradiya153/386/bannerupdate
Improvement: Redesign banner for trial
2025-07-01 20:40:39 +02:00
Marc SeitzandGitHub 0457891ed3 Merge pull request #1732 from AshishViradiya153/387/redirection
Added redirection to permissions tab in data rooms after creating link
2025-07-01 16:25:24 +02:00
Marc SeitzandGitHub ecb4911a6a Merge pull request #1730 from AshishViradiya153/388
Updated plan modal
2025-07-01 15:11:33 +02:00
Ashish Viradiya d29736f0fa Added redirect to permissions in data rooms after creating link 2025-07-01 18:34:05 +05:30
Marc SeitzandGitHub 699c5d1d1d Merge pull request #1727 from AshishViradiya153/381/bugPopup
BugFix: Popup
2025-07-01 14:58:13 +02:00
Ashish Viradiya 8ea4104de2 Improvement: Redesign banner for trial 2025-07-01 18:25:54 +05:30
Ashish Viradiya fbb8fccbce advanced mode banner update 2025-07-01 17:47:53 +05:30
Ashish Viradiya ae46369e3f banner update 2025-07-01 17:25:20 +05:30
Ashish Viradiya 359b1bcac2 plan modal update 2025-07-01 17:17:58 +05:30
Marc SeitzandGitHub 6cd1e13ee3 Merge pull request #1729 from mfts/fix/extend-maxduration
chore: extend time for download visits
2025-07-01 13:13:25 +02:00
Marc Seitz a610f58914 chore: extend time for download visits 2025-07-01 13:03:34 +02:00
Ashish Viradiya 3d5be65561 improvement: Enable features in 7 day trial(and show correct popups) 2025-06-30 23:09:16 +05:30
Ashish Viradiya 612227e998 BugFix: Popup 2025-06-30 18:25:21 +05:30
Ashish Viradiya e569677358 Added data room trial button on each popup 2025-06-30 18:11:25 +05:30
Marc SeitzandGitHub 8829e8e429 Merge pull request #1725 from mfts/fix/dataroom-upload
feat: allow uploads with featureflags
2025-06-30 14:38:51 +02:00
Marc Seitz 60a1e5c0df fix 2025-06-30 14:34:42 +02:00
Marc Seitz d56ecf72cc feat: allow uploads with featureflags 2025-06-30 14:15:48 +02:00
Marc SeitzandGitHub 765a6acfa6 Merge pull request #1719 from AshishViradiya153/164/locationTinybird
Improvement: show visitor location/user-agent on video and dataroom views
2025-06-29 17:17:10 +02:00
Marc Seitz 60d7854af5 fix: improve user agent data mapping in export-visits API 2025-06-29 17:07:42 +02:00
Marc Seitz e53ec612d4 fix: tinybird pipe variable 2025-06-29 16:43:48 +02:00
Marc Seitz 89da80dceb feat: update tinybird pipe to select from pm_click datasource with fallback 2025-06-29 16:42:22 +02:00
Marc Seitz 14669a474c chore: revert separate tinybird pipes for video and dataroom user agent 2025-06-29 16:41:34 +02:00
Marc Seitz 4271188d4c Merge branch 'main' into 164/locationTinybird 2025-06-29 13:03:52 +02:00
Marc SeitzandGitHub ff56a4de28 Merge pull request #1710 from AshishViradiya153/fix/auth-configuration
Fix: fallback to force domain name in email verification URL
2025-06-29 12:58:52 +02:00
Marc Seitz 36c7a925b4 Merge branch 'main' into fix/auth-configuration 2025-06-29 12:51:12 +02:00
Marc SeitzandGitHub 616d6d4b40 Merge pull request #1709 from AshishViradiya153/362/sortOverviewtree
Sort the order of folders in overview tree
2025-06-29 12:50:35 +02:00
Marc Seitz 42abd683f6 Merge branch 'main' into 362/sortOverviewtree 2025-06-29 12:39:10 +02:00
Marc SeitzandGitHub 7064ad67a8 Merge pull request #1718 from AshishViradiya153/370/printDisabled
feat: Disable printing for document and dataroom views
2025-06-29 12:38:20 +02:00
Marc Seitz 59ce53971b Merge branch 'main' into 370/printDisabled 2025-06-29 12:31:16 +02:00
Marc SeitzandGitHub 3b8e394257 Merge pull request #1717 from AshishViradiya153/369/mailVerification
fix: ensure document views show as verified when dataroom is verified
2025-06-29 12:30:24 +02:00
Marc Seitz 5004f1e98f Merge branch 'main' into 370/printDisabled 2025-06-29 12:27:50 +02:00
Marc Seitz 933493fd61 feat: store verified status in dataroom session object 2025-06-29 12:22:05 +02:00
Marc Seitz 921ac193f4 chore: revert prev useEffect 2025-06-29 12:21:20 +02:00
Marc Seitz ed57f1e167 Merge branch 'main' into 369/mailVerification 2025-06-27 16:38:02 +02:00
Marc SeitzandGitHub 990963e881 Merge pull request #1697 from AshishViradiya153/331/linkUpdate
BugFix: cannot change custom urls if it's set for a link
2025-06-27 16:34:25 +02:00
Marc Seitz a4cac45815 Merge branch 'main' into 331/linkUpdate 2025-06-27 16:27:35 +02:00
Marc SeitzandGitHub aa68f54596 Merge pull request #1705 from AshishViradiya153/356/mutatedocs
bugfix: deleted document still shown until refresh
2025-06-27 16:22:47 +02:00
Marc Seitz a8a7bf59b1 Merge branch 'main' into 356/mutatedocs 2025-06-27 16:06:34 +02:00
Marc SeitzandGitHub c9c7b0bdda Merge pull request #1720 from AshishViradiya153/372/notionDocView
BugFix: Notion documents are not loading in data room
2025-06-27 13:50:53 +02:00
Marc Seitz f8bb14eb53 Merge branch 'main' into 372/notionDocView 2025-06-27 13:39:07 +02:00
Marc SeitzandGitHub e75e435ec6 Merge pull request #1723 from mfts/feat/multi-region
feat: add lambda name
2025-06-26 17:23:10 +02:00
Marc Seitz 86b5561d2e feat: add lambda name 2025-06-26 17:17:35 +02:00
Marc SeitzandGitHub 3c3f5291c2 Merge pull request #1722 from mfts/feat/multi-region
feat: add team-aware multi-region S3 storage support
2025-06-26 16:50:28 +02:00
Marc SeitzGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
fe0bc821aa Update app/api/views/route.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-26 16:35:24 +02:00
Marc SeitzGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
af854c1d7e Potential fix for code scanning alert no. 57: Use of externally-controlled format string
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-06-26 16:34:26 +02:00
Marc Seitz 8ab007d175 feat: add team-aware multi-region S3 storage support
Introduces team-specific S3 storage configuration and multi-region support, allowing teams to use either EU or US S3 buckets based on feature flags. Refactors file operations (upload, copy, delete, presigned URLs, Lambda invocations) to resolve storage region and credentials per team. Adds new storage config and multi-region S3Store, updates API routes and utility functions to use team-aware clients, and extends feature flags with 'usStorage'.
2025-06-26 11:42:35 +02:00
Ashish Viradiya 821c15117e bugFix: Notion documents are not loading in data room 2025-06-25 21:37:57 +05:30
Ashish Viradiya 18be2b155b feat: show visitor location/user-agent on video and dataroom views 2025-06-25 19:20:03 +05:30
Ashish Viradiya 3c80e144d5 remove duplication 2025-06-25 10:01:15 +05:30
Ashish Viradiya 3185a0805c improvement: hook update 2025-06-25 09:57:24 +05:30
Ashish Viradiya b2bd5210cf chore: update path 2025-06-25 09:52:20 +05:30
Ashish Viradiya eebe5a2060 feat: Disable printing for document and dataroom views 2025-06-25 09:47:15 +05:30
Ashish Viradiya e125121ae8 fix: ensure document views show as verified when dataroom is verified 2025-06-24 16:01:34 +05:30
Ashish Viradiya e27d47d564 improvement 2025-06-24 10:45:51 +05:30
Ashish Viradiya a630b2e172 chore: improvement 2025-06-24 08:37:20 +05:30
Ashish Viradiya c74a95ea80 improvement: Improve loading speed of visitors tab 2025-06-24 07:42:18 +05:30
Ashish Viradiya 07a5b69c4e added field for dynamic bg color 2025-06-20 12:06:32 +05:30
Marc SeitzandGitHub 1f10c7af03 Merge pull request #1712 from mfts/marc/pm-365-notion-document-disable-user-select
Marc/pm 365 notion document disable user select
2025-06-19 22:34:42 +02:00
Marc Seitz 259a025d6e chore: increase timeout to 5 mins 2025-06-19 21:24:04 +02:00
Marc Seitz b25bad7baa feat: disable drag and user select on notion 2025-06-19 21:23:50 +02:00
Ashish Viradiya e1ab66b4eb Fix: fallback to force domain name in email verification URL 2025-06-19 11:28:34 +05:30
Ashish Viradiya 046a243309 remove orderindex on move doc/folder 2025-06-19 07:50:39 +05:30
Ashish Viradiya 24da219061 remove duplication 2025-06-19 07:41:52 +05:30
Ashish Viradiya 4cca4c0135 tree state mutation after document/folder move 2025-06-19 07:35:47 +05:30
Ashish Viradiya 7c03528483 Sort the order of folders in overview tree 2025-06-19 04:46:13 +05:30
Marc SeitzandGitHub e8a432b1cd Merge pull request #1708 from mfts/fix/groups
fix: child folder is allowed but parent not
2025-06-18 21:14:07 +02:00
Marc Seitz 4ebb54b1a2 fix: missing file 2025-06-18 21:02:30 +02:00
Marc Seitz 555733830c fix: child folder is allowed but parent not 2025-06-18 20:56:35 +02:00
Marc SeitzandGitHub e5e517565e Merge pull request #1706 from mfts/fix/permissions
fix: permissions and more
2025-06-17 17:00:23 +02:00
Marc Seitz 9afc9d1867 fix 2025-06-17 16:56:26 +02:00
Marc Seitz 1fb882cad4 chore: update motion - again 2025-06-17 16:44:49 +02:00
Marc Seitz c0c0da6c5d fix 2025-06-17 16:43:37 +02:00
Marc Seitz 854b96c34b chore: revert motion dep update 2025-06-17 16:37:11 +02:00
Marc Seitz 40943e0a79 feat: remove permissions group from link if setting to all 2025-06-17 16:05:34 +02:00
Marc Seitz 790a730f74 chore: revert react-notion-x 2025-06-17 16:05:04 +02:00
Marc Seitz c589c38967 fix: add dataroom index file correct for permission groups 2025-06-17 13:21:58 +02:00
Marc Seitz ff40bfc9e6 feat: add indicator that dataroom is limited 2025-06-17 13:05:16 +02:00
Marc Seitz 9c093dae49 fix: mutate permissions after create 2025-06-17 12:27:57 +02:00
Marc Seitz a09cc2624b chore: update deps 2025-06-17 12:01:27 +02:00
Ashish Viradiya 92e0e1b7c4 update 2025-06-17 14:44:26 +05:30
Ashish Viradiya 1eded7eb81 bugfix: deleted document still shown until refresh 2025-06-17 14:28:48 +05:30
Ashish Viradiya 4a03d961ab improvement 2025-06-17 12:16:47 +05:30
Ashish ViradiyaandGitHub 94d0f49eb3 Merge branch 'main' into 288/permissionSetting 2025-06-17 11:41:10 +05:30
Ashish Viradiya a92fc7b083 feat: set group permission for doc from dataroom setting 2025-06-17 11:25:22 +05:30
Marc SeitzandGitHub 80c3c7579d Merge pull request #1703 from mfts/fix/pages
fix: gif in link's name
2025-06-16 08:43:10 +02:00
Marc Seitz 97fee2154e fix gif in link 2025-06-16 08:37:09 +02:00
Marc SeitzandGitHub 3f380f45ee Merge pull request #1700 from mfts/feat/biz
feat: improvements
2025-06-13 13:31:51 +02:00
Marc Seitz db7b2b5da8 feat: remove permissions group if no plan 2025-06-13 13:25:36 +02:00
Marc Seitz 59730a526e chore: update styles for navigation buttons 2025-06-13 13:21:48 +02:00
Marc Seitz 40268bac70 feat: limit permissions to datarooms 2025-06-13 13:16:00 +02:00
Marc SeitzandGitHub 72e6fbdf60 Merge pull request #1699 from mfts/feat/improvements
feat(ee): granular permissions
2025-06-13 12:29:24 +02:00
Marc Seitz bd9cffef60 chore: move tags 2025-06-12 18:06:05 +02:00
Marc Seitz b3f0dac16f feat: active status column 2025-06-12 18:05:55 +02:00
Marc Seitz 2f02c50612 feat: button layout 2025-06-12 18:05:45 +02:00
Marc Seitz 77365ccb74 feat: add permissiongroup if none exists 2025-06-12 17:49:12 +02:00
Marc Seitz 2c6064d108 chore: types 2025-06-12 17:49:03 +02:00
Marc Seitz 0081f6ca3f fix 2025-06-12 17:11:59 +02:00
Marc Seitz 94bc917189 feat: add toggle for archiving links 2025-06-12 13:43:48 +02:00
Marc Seitz fcf6ac7c85 fix: lint 2025-06-12 13:20:38 +02:00
Marc Seitz 42ec37153a feat: add permissions migration 2025-06-12 13:17:00 +02:00
Marc Seitz 32b5f697a6 chore: rabbit updates 2025-06-12 13:15:19 +02:00
Marc Seitz f4b57648a7 feat: add permission access controls to data room 2025-06-12 12:39:17 +02:00
Marc Seitz 350a4ba86b feat: update links table 2025-06-12 12:19:55 +02:00
Marc Seitz 6913c3c3af refactor: group link options 2025-06-12 12:19:28 +02:00
Marc Seitz ed0447002b chore: cleanup 2025-06-12 12:18:58 +02:00
Marc Seitz 982376385c feat(ee): add granular permissions 2025-06-12 12:18:43 +02:00
Marc SeitzandGitHub 64d1da5c6e Merge pull request #1698 from mfts/fix/banner
fix: banner cover on larger screens
2025-06-11 18:18:21 +02:00
Marc Seitz bba727a3a2 fix: banner cover on larger screens 2025-06-11 18:10:57 +02:00
Marc SeitzandGitHub 2a4e760c6e Merge pull request #1695 from AshishViradiya153/353/downloadDoc
Bug: Added proxy route to handle serverside download
2025-06-11 17:25:29 +02:00
Ashish Viradiya 4c7b173f0a update 2025-06-11 13:18:20 +05:30
Ashish Viradiya ac32521299 BugFix: cannot change custom urls if it's set for a link 2025-06-11 12:48:19 +05:30
Ashish Viradiya 7f84b6cd39 improvement 2025-06-11 11:08:45 +05:30
Ashish Viradiya 4ee35f0057 Bug: Added proxy route to handle serverside download 2025-06-11 09:37:16 +05:30
Ashish Viradiya ed24f05dd1 update invitation expire link time to 7days 2025-06-10 19:19:09 +05:30
Marc SeitzandGitHub 197e4821e8 Merge pull request #1691 from mfts/fix/bulk-watermark
fix: watermark documents on bulk download
2025-06-09 12:43:47 +02:00
Marc Seitz c978913500 Merge branch 'main' into fix/bulk-watermark 2025-06-09 12:36:15 +02:00
Marc SeitzandGitHub ed7ecf075d Merge pull request #1635 from AshishViradiya153/266/presetOption
Added enableNotification option in link preset
2025-06-07 20:20:23 +02:00
Marc Seitz 48d042d9db feat: add notifications (default off) to presets 2025-06-07 19:59:02 +02:00
Marc Seitz 59feaf97e7 Merge branch 'main' into 266/presetOption 2025-06-07 19:49:33 +02:00
Marc SeitzandGitHub a92a36f66c Merge pull request #1690 from mfts/fix/tria
fix(trial): enable visitors and advanced dr analytics
2025-06-06 00:08:14 +02:00
Marc Seitz 35a5524f20 fix: agreement url 2025-06-06 00:03:41 +02:00
Marc Seitz b1e00b9009 fix(trial): enable visitors and advanced dr analytics 2025-06-05 23:53:32 +02:00
Marc Seitz 68f2e1bf5b fix: bulk download 2025-06-05 23:48:55 +02:00
Marc SeitzandGitHub 9ead803e08 Merge pull request #1689 from AshishViradiya153/hotfix/teamMember
hotfix: update teamProvider
2025-06-04 08:58:28 +02:00
Ashish Viradiya e4f8298e6d hotfix: update teamProvider 2025-06-04 12:06:15 +05:30
Marc Seitz db33518509 Merge branch '304/folderCreation' of github.com:AshishViradiya153/papermark into 304/folderCreation 2025-06-03 19:11:57 +02:00
Marc Seitz 4770559959 Merge branch 'main' into 304/folderCreation 2025-06-03 19:11:51 +02:00
Marc SeitzandGitHub 4a4c979769 Merge pull request #1688 from mfts/feat/shadow
feat: add small border for vertical document gap
2025-06-03 19:10:34 +02:00
Marc Seitz 8761c587fe fix 2025-06-03 19:06:11 +02:00
Marc Seitz 4e365aaa43 feat: add small border for vertical document gap
especially if white on white
2025-06-03 18:57:35 +02:00
Marc SeitzandGitHub b127000e85 Merge pull request #1682 from AshishViradiya153/view/video-time-tracking
refactor: improve video tracking
2025-06-03 18:38:32 +02:00
Marc SeitzandGitHub d9c6958544 Merge pull request #1681 from AshishViradiya153/time-tracking
Improvement: Time Tracking on view pages
2025-06-03 18:15:25 +02:00
Marc SeitzandGitHub d93e429a39 Merge pull request #1683 from AshishViradiya153/329/fix-advanced-mode-csv
Fix: hide advanced mode for CSV and non-Excel sheet documents
2025-06-03 11:28:53 +02:00
Marc SeitzandGitHub 3c55cc9dc5 Merge pull request #1686 from mfts/fix/dataroom-banner
feat: add fixed height for dataroom banner
2025-06-02 18:25:31 +02:00
Marc Seitz 1bb5edab47 feat: add fixed height for dataroom banner 2025-06-02 18:18:04 +02:00
Marc SeitzandGitHub 52df46f2fd Merge pull request #1685 from mfts/fix/mobile
fix: interaction on mobile
2025-06-01 21:00:06 +02:00
Marc Seitz 70884506a3 chore: improvements 2025-06-01 20:55:59 +02:00
Marc Seitz 8569e34263 fix: custom domains on documents 2025-06-01 20:21:56 +02:00
Marc Seitz e956532bce feat: add more controls to prevent mobile actions 2025-06-01 20:21:39 +02:00
Marc SeitzandGitHub 1b3a74c427 Merge pull request #1684 from mfts/fix/people
fix: load people properly
2025-05-30 17:19:25 +02:00
Marc Seitz e61928e7ec fix: people are loading now 2025-05-30 17:11:09 +02:00
Ashish Viradiya 2391dc08e2 fix: hide advanced mode for CSV and non-Excel sheet documents 2025-05-30 18:54:27 +05:30
Ashish Viradiya d6ee8b0695 refactor: improve video tracking 2025-05-30 17:47:21 +05:30
Ashish Viradiya 198ac590b3 chore: cleanup 2025-05-30 16:39:48 +05:30
Ashish Viradiya 553d1d0f7f update away-poster layout 2025-05-30 15:13:27 +05:30
Ashish Viradiya 2ec5931a62 updated away popup 2025-05-30 14:19:14 +05:30
Ashish Viradiya 66798e54fd small update 2025-05-29 21:10:26 +05:30
Ashish Viradiya 38fbfc2dcd Feat: Added 10-second record view and session stop functionality 2025-05-29 21:02:05 +05:30
Ashish Viradiya b798de4403 time/tracking 2025-05-28 22:14:26 +05:30
Ashish ViradiyaandGitHub 7298363203 Merge branch 'main' into 304/folderCreation 2025-05-27 17:03:41 +05:30
Ashish Viradiya e6651c6be7 improvement 2025-05-27 17:01:32 +05:30
Marc SeitzandGitHub 2b440ceae5 Merge pull request #1679 from mfts/feat-fixes
feat: add highlights for features, and Free trial button
2025-05-27 09:39:01 +02:00
Marc Seitz 8a7def0aad fix 2025-05-27 09:31:48 +02:00
Marc Seitz 2b6e5fd16d Merge branch 'main' into feat-fixes 2025-05-27 09:31:19 +02:00
Ashish Viradiya ea15764efa seprate path for folder creation 2025-05-27 00:20:03 +05:30
Marc SeitzandGitHub 11be36c196 Merge pull request #1607 from AshishViradiya153/175-inviteOnTrial
feat: Enable invite team members on trial
2025-05-26 19:55:01 +02:00
Marc Seitz c2c165470b feat: add user team status migration 2025-05-26 19:33:35 +02:00
Marc Seitz eb598e76bc feat: only send notifications to active members 2025-05-26 19:33:26 +02:00
Marc Seitz aedf17edb7 feat: add other members to blocked after trial ends 2025-05-26 19:33:14 +02:00
Marc Seitz ba79db153e chore: cleanup 2025-05-26 19:32:48 +02:00
Marc Seitz 1ded637038 feat: update blocking modal with userteam status 2025-05-26 19:32:33 +02:00
Iuliia Shnai 77e483ede5 feat: upload new version tooltip added 2025-05-26 17:15:05 +02:00
Iuliia Shnai aa65baa644 feat: add highlights for features, and Free trial button 2025-05-26 14:36:41 +02:00
Marc Seitz 72ca10ec4b Merge branch 'main' into 175-inviteOnTrial 2025-05-26 14:08:21 +02:00
Marc SeitzandGitHub 7fbc0eff9b Merge pull request #1678 from mfts/chore/dependencies
chore: update dependencies
2025-05-26 13:53:43 +02:00
Marc Seitz 37930543c2 Merge branch 'main' into 304/folderCreation 2025-05-26 13:50:12 +02:00
Marc SeitzandGitHub 00be21fa38 Merge pull request #1675 from AshishViradiya153/bug/searchAllDoc
Fix: "sort=lastViewed" return 500 error
2025-05-26 13:43:08 +02:00
Marc Seitz c529baf497 refactor: change card 2025-05-26 13:42:48 +02:00
Marc Seitz 6b77c59035 fix 2025-05-26 13:40:29 +02:00
Marc Seitz 3e9241d384 chore: update dependencies 2025-05-26 13:30:57 +02:00
Marc SeitzandGitHub 8eee985240 Merge pull request #1677 from mfts/feat/dataroom-analytics
feat: add detailed analytics for data rooms plans
2025-05-25 14:03:31 +02:00
Marc Seitz 30467822a9 feat: add detailed analytics for data rooms plans 2025-05-25 13:54:27 +02:00
Ashish Viradiya 14b8e4d779 Fix: sort=lastViewed return 500 error 2025-05-24 22:07:05 +05:30
Ashish Viradiya 9c48c08dad Prevent preview action during processing 2025-05-16 01:06:00 +05:30
Ashish Viradiya fa1e9bbfb4 chore: cleanup 2025-05-15 23:36:24 +05:30
Ashish Viradiya df60d73684 Fix: Prevent duplicate folder creation in All Documents 2025-05-15 23:33:03 +05:30
Ashish Viradiya 34c5549498 added enableNotification option in link preset 2025-05-08 17:27:19 +05:30
Ashish Viradiya 0304ea9bdf feat: Enable invite team members on trial 2025-04-25 18:26:45 +05:30
Marc Seitz e996a4833f Merge branch 'main' into 93/searchInViewerMode 2025-03-04 13:09:12 +01:00
Ashish Viradiya 91254e0139 add search bar to Dataroom viewer mode 2025-02-20 21:11:28 +05:30
517 changed files with 40608 additions and 9764 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
path-to-document: 'https://github.com/mfts/papermark/blob/main/CLA.md'
# branch should not be protected
branch: 'main'
allowlist:
allowlist: cursoragent
# the followings are the optional inputs - If the optional inputs are not given, then default values will be taken
#remote-organization-name: enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository)
@@ -42,4 +42,4 @@ jobs:
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA
#use-dco-flag: true - If you are using DCO instead of CLA
+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 },
);
}
}
@@ -4,6 +4,7 @@ import { processDocument } from "@/lib/api/documents/process-document";
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
import { DocumentData } from "@/lib/documents/create-document";
import prisma from "@/lib/prisma";
import { supportsAdvancedExcelMode } from "@/lib/utils/get-content-type";
export async function POST(
request: NextRequest,
@@ -91,7 +92,9 @@ export async function POST(
const updatedDocumentData = {
...documentData,
enableExcelAdvancedMode: link.team?.enableExcelAdvancedMode ?? false,
enableExcelAdvancedMode: documentData.supportedFileType === "sheet" &&
link.team?.enableExcelAdvancedMode &&
supportsAdvancedExcelMode(documentData.contentType),
};
// 1. Create the document
+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`);
};
+136 -66
View File
@@ -1,10 +1,11 @@
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";
import { ipAddress, waitUntil } from "@vercel/functions";
import { getServerSession } from "next-auth";
import { parsePageId } from "notion-utils";
import { hashToken } from "@/lib/api/auth/token";
import {
@@ -16,16 +17,20 @@ 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 notion from "@/lib/notion";
import { addSignedUrls } from "@/lib/notion/utils";
import {
notifyDataroomAccess,
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 { generateOTP } from "@/lib/utils/generate-otp";
import { LOCALHOST_IP } from "@/lib/utils/geo";
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
import { validateEmail } from "@/lib/utils/validate-email";
export async function POST(request: NextRequest) {
@@ -98,6 +103,8 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
dataroomId: true,
emailProtected: true,
@@ -115,6 +122,7 @@ export async function POST(request: NextRequest) {
enableWatermark: true,
watermarkConfig: true,
groupId: true,
permissionGroupId: true,
audienceType: true,
allowDownload: true,
enableConversation: true,
@@ -122,6 +130,7 @@ export async function POST(request: NextRequest) {
team: {
select: {
plan: true,
globalBlockList: true,
},
},
customFields: {
@@ -211,6 +220,11 @@ export async function POST(request: NextRequest) {
linkId,
link.dataroomId!,
);
// If we have a dataroom session, use its verified status
if (dataroomSession) {
isEmailVerified = dataroomSession.verified;
}
}
// If there is no session, then we need to check if the link is protected and enforce the checks
@@ -267,21 +281,34 @@ export async function POST(request: NextRequest) {
);
}
// Check global block list first - this overrides all other access controls
const globalBlockCheck = checkGlobalBlockList(
email,
link.team?.globalBlockList,
);
if (globalBlockCheck.error) {
return NextResponse.json(
{ message: globalBlockCheck.error },
{ status: 400 },
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
// Check if email is allowed to visit the link
if (link.allowList && link.allowList.length > 0) {
// Extract the domain from the email address
const emailDomain = email.substring(email.lastIndexOf("@"));
// Determine if the email or its domain is allowed
const isAllowed = link.allowList.some((allowed) => {
return (
allowed === email ||
(allowed.startsWith("@") && emailDomain === allowed)
);
});
const isAllowed = link.allowList.some((allowed) =>
isEmailMatched(email, allowed),
);
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -291,19 +318,15 @@ export async function POST(request: NextRequest) {
// Check if email is denied to visit the link
if (link.denyList && link.denyList.length > 0) {
// Extract the domain from the email address
const emailDomain = email.substring(email.lastIndexOf("@"));
// Determine if the email or its domain is denied
const isDenied = link.denyList.some((denied) => {
return (
denied === email ||
(denied.startsWith("@") && emailDomain === denied)
);
});
const isDenied = link.denyList.some((denied) =>
isEmailMatched(email, denied),
);
// Deny access if the email is denied
if (isDenied) {
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -339,13 +362,14 @@ export async function POST(request: NextRequest) {
);
// Extract domain from email
const emailDomain = email.substring(email.lastIndexOf("@"));
const emailDomain = extractEmailDomain(email);
// Check domain access
const hasDomainAccess = group.domains.some(
(domain) => domain === emailDomain,
);
const hasDomainAccess = emailDomain
? group.domains.some((domain) => domain === emailDomain)
: false;
if (!isMember && !hasDomainAccess) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -566,6 +590,8 @@ export async function POST(request: NextRequest) {
where: { id: viewer.id },
data: { verified: isEmailVerified },
});
// Update the viewer object to reflect the new verified status
viewer.verified = isEmailVerified;
}
}
@@ -635,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 =
@@ -663,6 +707,7 @@ export async function POST(request: NextRequest) {
linkId,
newDataroomView?.id!,
ipAddress(request) ?? LOCALHOST_IP,
isEmailVerified,
viewer?.id,
);
@@ -745,14 +790,30 @@ 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
// otherwise, check if notion document,
// if notion, return recordMap and theme from document version file
// otherwise, return file from document version
let documentPages, documentVersion;
let recordMap, theme;
let sheetData;
if (hasPages) {
@@ -811,23 +872,6 @@ export async function POST(request: NextRequest) {
type: documentVersion.storageType,
});
}
if (documentVersion.type === "notion") {
// get theme `mode` param from document version file
const modeMatch = documentVersion.file.match(/[?&]mode=(dark|light)/);
theme = modeMatch ? modeMatch[1] : undefined;
let notionPageId = parsePageId(documentVersion.file, { uuid: false });
if (!notionPageId) {
notionPageId = "";
}
const pageId = notionPageId;
recordMap = await notion.getPage(pageId, { signFileUrls: false });
// TODO: separately sign the file urls until PR merged and published; ref: https://github.com/NotionX/react-notion-x/issues/580#issuecomment-2542823817
await addSignedUrls({ recordMap });
}
if (documentVersion.type === "sheet") {
const document = await prisma.document.findUnique({
where: { id: documentId },
@@ -836,9 +880,15 @@ export async function POST(request: NextRequest) {
useAdvancedExcelViewer = document?.advancedExcelEnabled ?? false;
if (useAdvancedExcelViewer) {
documentVersion.file = documentVersion.file.includes("https://")
? documentVersion.file
: `https://${process.env.NEXT_PRIVATE_ADVANCED_UPLOAD_DISTRIBUTION_HOST}/${documentVersion.file}`;
if (documentVersion.file.includes("https://")) {
documentVersion.file = documentVersion.file;
} else {
// Get team-specific storage config for advanced distribution host
const storageConfig = await getTeamStorageConfigById(
link.teamId!,
);
documentVersion.file = `https://${storageConfig.advancedDistributionHost}/${documentVersion.file}`;
}
} else {
const fileUrl = await getFile({
data: documentVersion.file,
@@ -854,10 +904,13 @@ export async function POST(request: NextRequest) {
// check if viewer can download the document based on group permissions
let canDownload: boolean = link.allowDownload ?? false;
const effectiveGroupId = link.groupId || link.permissionGroupId;
if (
link.allowDownload &&
link.audienceType === LinkAudienceType.GROUP &&
link.groupId &&
(link.audienceType === LinkAudienceType.GROUP ||
link.permissionGroupId) &&
effectiveGroupId &&
documentId &&
dataroomId
) {
@@ -873,18 +926,36 @@ export async function POST(request: NextRequest) {
if (!dataroomDocument) {
canDownload = false;
} else {
const groupDocumentPermission =
await prisma.viewerGroupAccessControls.findUnique({
where: {
groupId_itemId: {
groupId: link.groupId,
itemId: dataroomDocument.id,
if (link.groupId) {
// This is a ViewerGroup (legacy behavior)
const groupDocumentPermission =
await prisma.viewerGroupAccessControls.findUnique({
where: {
groupId_itemId: {
groupId: link.groupId,
itemId: dataroomDocument.id,
},
itemType: ItemType.DATAROOM_DOCUMENT,
},
itemType: ItemType.DATAROOM_DOCUMENT,
},
select: { canDownload: true },
});
canDownload = groupDocumentPermission?.canDownload ?? false;
select: { canDownload: true },
});
canDownload = groupDocumentPermission?.canDownload ?? false;
} else if (link.permissionGroupId) {
// This is a PermissionGroup (new behavior)
const permissionGroupDocumentPermission =
await prisma.permissionGroupAccessControls.findUnique({
where: {
groupId_itemId: {
groupId: link.permissionGroupId,
itemId: dataroomDocument.id,
},
itemType: ItemType.DATAROOM_DOCUMENT,
},
select: { canDownload: true },
});
canDownload =
permissionGroupDocumentPermission?.canDownload ?? false;
}
}
}
@@ -902,7 +973,7 @@ export async function POST(request: NextRequest) {
? documentVersion.file
: undefined,
pages: documentPages ? documentPages : undefined,
notionData: recordMap ? { recordMap, theme } : undefined,
notionData: undefined,
sheetData:
documentVersion &&
documentVersion.type === "sheet" &&
@@ -913,9 +984,7 @@ export async function POST(request: NextRequest) {
? documentVersion.type
: documentPages
? "pdf"
: recordMap
? "notion"
: undefined,
: undefined,
watermarkConfig: link.enableWatermark
? link.watermarkConfig
: undefined,
@@ -942,13 +1011,14 @@ export async function POST(request: NextRequest) {
const response = NextResponse.json(returnObject, { status: 200 });
// Create a dataroom session token if a dataroom session doesn't exist yet// Create a dataroom session token if a dataroom session doesn't exist yet
// Create a dataroom session token if a dataroom session doesn't exist yet
if (!dataroomSession && !isPreview) {
const newDataroomSession = await createDataroomSession(
dataroomId,
linkId,
dataroomView?.id!,
ipAddress(request) ?? LOCALHOST_IP,
isEmailVerified,
viewer?.id,
);
+56 -22
View File
@@ -1,5 +1,7 @@
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]";
import { ipAddress, waitUntil } from "@vercel/functions";
@@ -12,15 +14,17 @@ 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 { isEmailMatched } from "@/lib/utils/email-domain";
import { generateOTP } from "@/lib/utils/generate-otp";
import { LOCALHOST_IP } from "@/lib/utils/geo";
import { getIpAddress } from "@/lib/utils/ip";
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
import { validateEmail } from "@/lib/utils/validate-email";
export async function POST(request: NextRequest) {
@@ -82,6 +86,9 @@ export async function POST(request: NextRequest) {
id: linkId,
},
select: {
id: true,
name: true,
documentId: true,
emailProtected: true,
enableNotification: true,
emailAuthenticated: true,
@@ -99,6 +106,7 @@ export async function POST(request: NextRequest) {
team: {
select: {
plan: true,
globalBlockList: true,
},
},
customFields: {
@@ -206,21 +214,34 @@ export async function POST(request: NextRequest) {
);
}
// Check global block list first - this overrides all other access controls
const globalBlockCheck = checkGlobalBlockList(
email,
link.team?.globalBlockList,
);
if (globalBlockCheck.error) {
return NextResponse.json(
{ message: globalBlockCheck.error },
{ status: 400 },
);
}
if (globalBlockCheck.isBlocked) {
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
return NextResponse.json({ message: "Access denied" }, { status: 403 });
}
// Check if email is allowed to visit the link
if (link.allowList && link.allowList.length > 0) {
// Extract the domain from the email address
const emailDomain = email.substring(email.lastIndexOf("@"));
// Determine if the email or its domain is allowed
const isAllowed = link.allowList.some((allowed) => {
return (
allowed === email ||
(allowed.startsWith("@") && emailDomain === allowed)
);
});
const isAllowed = link.allowList.some((allowed) =>
isEmailMatched(email, allowed),
);
// Deny access if the email is not allowed
if (!isAllowed) {
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -230,19 +251,15 @@ export async function POST(request: NextRequest) {
// Check if email is denied to visit the link
if (link.denyList && link.denyList.length > 0) {
// Extract the domain from the email address
const emailDomain = email.substring(email.lastIndexOf("@"));
// Determine if the email or its domain is denied
const isDenied = link.denyList.some((denied) => {
return (
denied === email ||
(denied.startsWith("@") && emailDomain === denied)
);
});
const isDenied = link.denyList.some((denied) =>
isEmailMatched(email, denied),
);
// Deny access if the email is denied
if (isDenied) {
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
return NextResponse.json(
{ message: "Unauthorized access" },
{ status: 403 },
@@ -586,9 +603,13 @@ export async function POST(request: NextRequest) {
if (documentVersion.type === "sheet") {
if (useAdvancedExcelViewer) {
documentVersion.file = documentVersion.file.includes("https://")
? documentVersion.file
: `https://${process.env.NEXT_PRIVATE_ADVANCED_UPLOAD_DISTRIBUTION_HOST}/${documentVersion.file}`;
if (!documentVersion.file.includes("https://")) {
// Get team-specific storage config for advanced distribution host
const storageConfig = await getTeamStorageConfigById(
link.teamId!,
);
documentVersion.file = `https://${storageConfig.advancedDistributionHost}/${documentVersion.file}`;
}
} else {
const fileUrl = await getFile({
data: documentVersion.file,
@@ -616,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 = {
+45 -1
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTeam } from "@/context/team-context";
import { FileTextIcon, MoreVertical, TrashIcon } from "lucide-react";
import { DownloadIcon, FileTextIcon, MoreVertical, TrashIcon } from "lucide-react";
import { toast } from "sonner";
import { AgreementWithLinksCount } from "@/lib/swr/use-agreements";
@@ -57,6 +57,46 @@ export default function AgreementCard({
);
};
const handleDownload = async () => {
toast.promise(
fetch(
`/api/teams/${teamInfo?.currentTeam?.id}/agreements/${agreement.id}/download`,
{
method: "POST",
},
).then(async (response) => {
if (!response.ok) {
throw new Error("Failed to download agreement");
}
// Get the filename from the Content-Disposition header or use default
const contentDisposition = response.headers.get("Content-Disposition");
const filenameMatch = contentDisposition?.match(/filename="([^"]+)"/);
const filename = filenameMatch ? filenameMatch[1] : `${agreement.name}.txt`;
// Create a blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
// Cleanup
setTimeout(() => {
window.URL.revokeObjectURL(url);
document.body.removeChild(link);
}, 100);
}),
{
loading: "Downloading agreement...",
success: "Agreement downloaded successfully",
error: "Failed to download agreement",
},
);
};
return (
<>
<div className="flex items-center justify-between rounded-lg border p-4">
@@ -85,6 +125,10 @@ export default function AgreementCard({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleDownload}>
<DownloadIcon className="mr-2 h-4 w-4" />
Download agreement
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:bg-destructive focus:text-destructive-foreground"
onClick={() => setShowDeleteDialog(true)}
+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,
}),
+1 -1
View File
@@ -42,7 +42,7 @@ export default function ProBanner({
</p>
<div className="flex">
<UpgradePlanModal
clickedPlan={PlanEnum.Business}
clickedPlan={PlanEnum.Pro}
trigger={"pro_banner"}
>
<Button
+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}
/>
</>
);
}
+58 -14
View File
@@ -9,7 +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 } from "lucide-react";
import { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from "lucide-react";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
@@ -19,12 +19,31 @@ 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,
} from "@/components/ui/tooltip";
// Start Data Room Trial Button Component
const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
const router = useRouter();
const handleStartTrial = () => {
router.push("/welcome?type=dataroom-trial");
};
return (
<span
onClick={handleStartTrial}
className="cursor-pointer underline underline-offset-4 hover:text-foreground"
>
Start free Data Room trial
</span>
);
};
// Feature rendering component
const FeatureItem = ({ feature }: { feature: Feature }) => {
const baseClasses = `flex items-center ${feature.isHighlighted ? "bg-orange-50 -mx-6 px-6 py-2 -my-1 font-bold rounded-md dark:bg-orange-900/20" : ""}`;
@@ -33,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 && (
@@ -56,8 +79,19 @@ 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]" />
<span>{feature.text}</span>
{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 && (
<BadgeTooltip content={feature.tooltip}>
<CircleHelpIcon className="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" />
</BadgeTooltip>
)}
</div>
</div>
);
};
@@ -118,7 +152,7 @@ export function UpgradePlanModal({
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { plan: teamPlan, isCustomer, isOldAccount } = usePlan();
const { plan: teamPlan, isCustomer, isOldAccount, isTrial } = usePlan();
const analytics = useAnalytics();
const [showDataRoomsPlus, setShowDataRoomsPlus] = useState(false);
@@ -286,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") {
@@ -348,12 +383,21 @@ export function UpgradePlanModal({
<div className="flex flex-col items-center text-center text-sm text-muted-foreground">
All plans include unlimited viewers and page by page document
analytics.
<Link
href="/settings/upgrade"
className="underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
<div className="flex items-center gap-2">
<Link
href="/settings/upgrade"
className="underline underline-offset-4 hover:text-foreground"
>
See all plans
</Link>
{((teamPlan === "free" && !isTrial) ||
(teamPlan === "pro" && !isTrial)) && (
<>
<span>|</span>
<StartDataRoomTrialButton teamId={teamId} />
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
+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>
+2 -2
View File
@@ -44,7 +44,7 @@ export function AddDataroomModal({
const { isFree, isPro } = usePlan();
const analytics = useAnalytics();
const dataroomSchema = z.object({
name: z.string().min(3, {
name: z.string().trim().min(3, {
message: "Please provide a dataroom name with at least 3 characters.",
}),
});
@@ -140,7 +140,7 @@ export function AddDataroomModal({
id="dataroom-name"
placeholder="ACME Aquisition"
className="mb-4 mt-1 w-full"
onChange={(e) => setDataroomName(e.target.value.trim())}
onChange={(e) => setDataroomName(e.target.value)}
/>
<DialogFooter>
<Button type="submit" className="h-9 w-full" loading={loading}>
@@ -0,0 +1,145 @@
import { useMemo } from "react";
import { useDataroom } from "@/lib/swr/use-dataroom";
import { useDataroomDocumentStats } from "@/lib/swr/use-dataroom-document-stats";
import { useDataroomStats } from "@/lib/swr/use-dataroom-stats";
import StatsChart from "@/components/documents/stats-chart";
import { Gauge } from "@/components/ui/gauge";
interface DataroomAnalyticsOverviewProps {
selectedDocument: {
id: string;
name: string;
} | null;
setSelectedDocument: React.Dispatch<
React.SetStateAction<{
id: string;
name: string;
} | null>
>;
}
export default function DataroomAnalyticsOverview({
selectedDocument,
setSelectedDocument,
}: DataroomAnalyticsOverviewProps) {
const {
stats: dataroomStats,
loading: dataroomLoading,
error: dataroomError,
} = useDataroomStats();
// Memoize the most viewed document calculation
const mostViewedDocument = useMemo(() => {
if (!dataroomStats || selectedDocument) return null;
// Group views by document ID and count them
const viewsByDocument = dataroomStats.documentViews.reduce(
(acc, view) => {
if (!view.documentId) return acc;
if (!acc[view.documentId]) {
acc[view.documentId] = { count: 0, name: "" };
}
acc[view.documentId].count += 1;
return acc;
},
{} as Record<string, { count: number; name: string }>,
);
// Find document with most views
let maxViews = 0;
let mostViewedId = "";
Object.entries(viewsByDocument).forEach(([docId, data]) => {
if (data.count > maxViews) {
maxViews = data.count;
mostViewedId = docId;
}
});
// Return the most viewed document if found
return mostViewedId
? {
id: mostViewedId,
name: mostViewedId, // We'll update the name once we have it
}
: null;
}, [dataroomStats, selectedDocument]);
// Get document stats for either selected document or most viewed document
const documentId = selectedDocument?.id || mostViewedDocument?.id;
const {
stats: documentStats,
loading: documentLoading,
error: documentError,
} = useDataroomDocumentStats(documentId);
// If neither is selected or we're still loading dataroom stats
const loading = documentLoading || (dataroomLoading && !documentId);
const error = documentError || (dataroomError && !documentId);
if (loading) {
return <div>Loading analytics...</div>;
}
if (error) {
return <div>Error loading analytics</div>;
}
const completionRate = 0;
// Get display name for the currently viewed document
const displayName =
selectedDocument?.name ||
(mostViewedDocument?.name !== mostViewedDocument?.id
? mostViewedDocument?.name
: "Most viewed document");
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-6">
<div>
<h3 className="mb-4 text-lg font-medium">
{displayName ? displayName : "Document Engagement"}
</h3>
{documentStats && (
<StatsChart
documentId={documentId || ""}
totalPagesMax={documentStats?.totalPagesMax || 0}
statsData={{
stats: documentStats,
loading: false,
error: null,
}}
/>
)}
</div>
{/* INFO: hiding completion rate for now */}
{/* <div className="flex flex-col items-center justify-center rounded-lg border p-6">
<h3 className="mb-4 text-lg font-medium">
{displayName
? `${displayName} - Completion Rate`
: "Completion Rate"}
</h3>
<div className="flex flex-col items-center">
<Gauge value={completionRate} size="large" showValue={true} />
<p className="mt-4 text-sm text-muted-foreground">
Document has {documentStats?.totalViews || 0} view
{documentStats?.totalViews !== 1 ? "s" : ""} in this dataroom
</p>
{!selectedDocument && mostViewedDocument && (
<button
onClick={() => setSelectedDocument(mostViewedDocument)}
className="mt-2 text-xs text-primary hover:underline"
>
View all documents
</button>
)}
</div>
</div> */}
</div>
</div>
);
}
@@ -0,0 +1,331 @@
import { useCallback, useMemo } from "react";
import { Dispatch, SetStateAction } from "react";
import { useTeam } from "@/context/team-context";
import { ItemType } from "@prisma/client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getExpandedRowModel,
useReactTable,
} from "@tanstack/react-table";
import {
ChevronDown,
ChevronRight,
Download,
Eye,
File,
Folder,
} from "lucide-react";
import { useDataroomFoldersTree } from "@/lib/swr/use-dataroom";
import { useDataroomStats } from "@/lib/swr/use-dataroom-stats";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Define types for the file/folder structure with analytics
type FileOrFolder = {
id: string;
name: string;
subItems?: FileOrFolder[];
analytics: {
views: number;
downloads: number;
};
itemType: ItemType;
documentId?: string;
};
interface DocumentAnalyticsTreeProps {
dataroomId: string;
selectedDocument: {
id: string;
name: string;
} | null;
setSelectedDocument: Dispatch<
SetStateAction<{
id: string;
name: string;
} | null>
>;
}
export default function DocumentAnalyticsTree({
dataroomId,
selectedDocument,
setSelectedDocument,
}: DocumentAnalyticsTreeProps) {
const { folders, loading: foldersLoading } = useDataroomFoldersTree({
dataroomId,
include_documents: true,
});
const { stats: dataroomStats, loading: statsLoading } = useDataroomStats();
// Memoize the tree data building
const data = useMemo(() => {
if (!folders || foldersLoading || !dataroomStats || statsLoading) {
return [];
}
return buildTree(folders, dataroomStats);
}, [folders, foldersLoading, dataroomStats, statsLoading]);
const handleRowClick = useCallback(
(row: FileOrFolder) => {
// Only select documents, not folders
if (row.itemType === ItemType.DATAROOM_DOCUMENT && row.documentId) {
if (selectedDocument && selectedDocument.id === row.documentId) {
// If clicking on the already selected document, deselect it
setSelectedDocument(null);
} else {
// Otherwise select the clicked document
setSelectedDocument({
id: row.documentId,
name: row.name,
});
}
}
},
[selectedDocument, setSelectedDocument],
);
const createColumns = useCallback(
(): ColumnDef<FileOrFolder>[] => [
{
id: "expander",
header: () => null,
cell: ({ row }) => {
return row.getCanExpand() ? (
<Button
variant="ghost"
onClick={(e) => {
e.stopPropagation();
row.getToggleExpandedHandler()();
}}
className="h-6 w-6 p-0"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
) : null;
},
},
{
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>
),
},
{
id: "views",
header: () => (
<div className="flex items-center justify-center">
<Eye className="mr-1 h-4 w-4" />
<span>Views</span>
</div>
),
cell: ({ row }) => (
<div className="text-center">{row.original.analytics.views}</div>
),
},
{
id: "downloads",
header: () => (
<div className="flex items-center justify-center">
<Download className="mr-1 h-4 w-4" />
<span>Downloads</span>
</div>
),
cell: ({ row }) => (
<div className="text-center">{row.original.analytics.downloads}</div>
),
},
],
[],
);
const columns = useMemo(() => createColumns(), [createColumns]);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row) => row.subItems,
});
if (foldersLoading || statsLoading) {
return <div>Loading analytics data...</div>;
}
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} className="py-2 first:w-12">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={cn(
"cursor-pointer hover:bg-muted/50",
// Highlight the selected document
selectedDocument &&
row.original.documentId === selectedDocument.id &&
"bg-muted",
)}
onClick={() => handleRowClick(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
paddingLeft:
cell.column.id === "name"
? `${row.depth * 1.25 + 1}rem`
: undefined,
}}
className="py-2"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No analytics data available.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
// Build the tree structure with analytics data
const buildTree = (
items: any[],
statsData: any,
parentId: string | null = null,
): FileOrFolder[] => {
const getAnalytics = (id: string, documentId?: string) => {
// Count views for this specific item
const views =
statsData?.documentViews.filter(
(view: any) => view.itemId === id || view.documentId === documentId,
).length || 0;
// Count downloads for this specific item (assuming downloads are tracked in views with a downloadedAt field)
const downloads =
statsData?.documentViews.filter(
(view: any) =>
(view.itemId === id || view.documentId === documentId) &&
view.downloadedAt,
).length || 0;
return {
views,
downloads,
};
};
const result: FileOrFolder[] = [];
// Handle folders and their contents
items
.filter((item) => item.parentId === parentId && !item.document)
.forEach((folder) => {
const subItems = buildTree(items, statsData, folder.id);
// Add documents directly in this folder
const folderDocuments = (folder.documents || []).map((doc: any) => {
const analytics = getAnalytics(doc.id, doc.document.id);
return {
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
analytics,
itemType: ItemType.DATAROOM_DOCUMENT,
};
});
const allSubItems = [...subItems, ...folderDocuments];
// Calculate aggregated analytics for the folder
const folderAnalytics = {
views: allSubItems.reduce((sum, item) => sum + item.analytics.views, 0),
downloads: allSubItems.reduce(
(sum, item) => sum + item.analytics.downloads,
0,
),
};
result.push({
id: folder.id,
name: folder.name,
subItems: allSubItems,
analytics: folderAnalytics,
itemType: ItemType.DATAROOM_FOLDER,
});
});
// Handle documents at the current level
items
.filter(
(item) =>
(item.parentId === parentId && item.document) ||
(parentId === null && item.folderId === null && item.document),
)
.forEach((doc) => {
const analytics = getAnalytics(doc.id, doc.document.id);
result.push({
id: doc.id,
documentId: doc.document.id,
name: doc.document.name,
analytics,
itemType: ItemType.DATAROOM_DOCUMENT,
});
});
return result;
};
@@ -0,0 +1,104 @@
import { ChevronRight, Download, Eye, File, Folder } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Mock data for analytics preview
const mockAnalyticsData = [
{
id: "2",
name: "Financial Reports",
type: "folder",
views: 38,
downloads: 8,
},
{ id: "4", name: "Legal Documents", type: "folder", views: 22, downloads: 3 },
{
id: "1",
name: "Executive Summary",
type: "file",
views: 45,
downloads: 12,
},
{
id: "3",
name: "Market Analysis.pdf",
type: "file",
views: 29,
downloads: 15,
},
{ id: "5", name: "Product Roadmap", type: "file", views: 18, downloads: 7 },
{ id: "6", name: "Team Structure", type: "file", views: 14, downloads: 2 },
{ id: "7", name: "Marketing Plan", type: "file", views: 14, downloads: 2 },
];
export default function MockAnalyticsTable() {
return (
<div className="space-y-6">
<h3 className="mb-4 text-lg font-medium">Dataroom Analytics</h3>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>Name</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center">
<Eye className="mr-1 h-4 w-4" />
<span>Views</span>
</div>
</TableHead>
<TableHead className="text-center">
<div className="flex items-center justify-center">
<Download className="mr-1 h-4 w-4" />
<span>Downloads</span>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockAnalyticsData.map((row, index) => (
<TableRow
key={row.id}
className={cn(
"cursor-pointer transition-opacity hover:bg-muted/50",
// Fade out the last 2 rows
index >= 5 && "opacity-30",
)}
>
<TableCell>
{row.type === "folder" && (
<Button variant="ghost" className="h-6 w-6 p-0">
<ChevronRight className="h-4 w-4" />
</Button>
)}
</TableCell>
<TableCell>
<div className="flex items-center text-foreground">
{row.type === "folder" ? (
<Folder className="mr-2 h-5 w-5" />
) : (
<File className="mr-2 h-5 w-5" />
)}
<span className="truncate">{row.name}</span>
</div>
</TableCell>
<TableCell className="text-center">{row.views}</TableCell>
<TableCell className="text-center">{row.downloads}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
+97 -42
View File
@@ -3,6 +3,15 @@ import { useRouter } from "next/router";
import React, { useMemo } from "react";
import {
useDataroom,
useDataroomFolderWithParents,
} from "@/lib/swr/use-dataroom";
import {
HIERARCHICAL_DISPLAY_STYLE,
useHierarchicalDisplayName,
} from "@/lib/utils/hierarchical-display";
import {
Breadcrumb,
BreadcrumbEllipsis,
@@ -19,10 +28,70 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
useDataroom,
useDataroomFolderWithParents,
} from "@/lib/swr/use-dataroom";
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,
@@ -47,11 +116,10 @@ function BreadcrumbComponentBase({
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href={`/datarooms/${dataroomId}/documents`}>
{dataroom?.name || "Loading..."}
</Link>
</BreadcrumbLink>
<TruncatedBreadcrumbLink
href={`/datarooms/${dataroomId}/documents`}
text={dataroom?.name}
/>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
@@ -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>
@@ -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";
@@ -33,8 +37,9 @@ import {
} from "@/components/ui/dropdown-menu";
import { AddToDataroomModal } from "../documents/add-document-to-dataroom-modal";
import { DocumentPreviewButton } from "../documents/document-preview-button";
import FileProcessStatusBar from "../documents/file-process-status-bar";
import { SetGroupPermissionsModal } from "./groups/set-group-permissions-modal";
import { SetUnifiedPermissionsModal } from "./groups/set-unified-permissions-modal";
import { MoveToDataroomFolderModal } from "./move-dataroom-folder-modal";
type DocumentsCardProps = {
@@ -60,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);
@@ -204,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">
@@ -243,6 +257,19 @@ export default function DataroomDocumentCard({
</p>
</Link>
<DocumentPreviewButton
documentId={dataroomDocument.document.id}
primaryVersion={{
hasPages:
dataroomDocument.document.versions?.[0]?.hasPages || false,
type: dataroomDocument.document.type,
numPages: null, // Not available in this context
}}
variant="outline"
size="icon"
className="z-10 h-8 w-8 border-gray-200 bg-transparent hover:bg-gray-200 dark:border-gray-700 hover:dark:bg-gray-700 lg:h-9 lg:w-9"
/>
<DropdownMenu open={menuOpen} onOpenChange={handleMenuStateChange}>
<DropdownMenuTrigger asChild>
<Button
@@ -342,7 +369,7 @@ export default function DataroomDocumentCard({
/>
) : null}
{groupPermissionOpen ? (
<SetGroupPermissionsModal
<SetUnifiedPermissionsModal
open={groupPermissionOpen}
setOpen={setGroupPermissionOpen}
dataroomId={dataroomId}
+3 -4
View File
@@ -6,15 +6,14 @@ import { BellRingIcon } from "lucide-react";
import { useDataroom, useDataroomLinks } from "@/lib/swr/use-dataroom";
import LinkSheet from "@/components/links/link-sheet";
import { DataroomLinkSheet } from "@/components/links/link-sheet/dataroom-link-sheet";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "../ui/tooltip";
} from "@/components/ui/tooltip";
export const DataroomHeader = ({
title,
@@ -70,7 +69,7 @@ export const DataroomHeader = ({
Share
</Button>
</div>
<LinkSheet
<DataroomLinkSheet
linkType={"DATAROOM_LINK"}
isOpen={isLinkSheetOpen}
setIsOpen={setIsLinkSheetOpen}
+37 -6
View File
@@ -15,6 +15,7 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { DefaultPermissionStrategy } from "@prisma/client";
import {
ArchiveXIcon,
FileIcon,
@@ -27,15 +28,19 @@ import { toast } from "sonner";
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
import { useDataroomPermissions } from "@/lib/hooks/use-dataroom-permissions";
import {
DataroomFolderDocument,
DataroomFolderWithCount,
useDataroom,
} from "@/lib/swr/use-dataroom";
import useDataroomGroups from "@/lib/swr/use-dataroom-groups";
import useDataroomPermissionGroups from "@/lib/swr/use-dataroom-permission-groups";
import { useMediaQuery } from "@/lib/utils/use-media-query";
import { useRemoveDataroomItemsModal } from "@/components/datarooms/actions/remove-document-modal";
import DataroomDocumentCard from "@/components/datarooms/dataroom-document-card";
import { SetUnifiedPermissionsModal } from "@/components/datarooms/groups/set-unified-permissions-modal";
import { useDeleteFolderModal } from "@/components/documents/actions/delete-folder-modal";
import { DraggableItem } from "@/components/documents/drag-and-drop/draggable-item";
import { DroppableFolder } from "@/components/documents/drag-and-drop/droppable-folder";
@@ -52,7 +57,6 @@ import UploadZone, {
} from "@/components/upload-zone";
import { itemsMessage } from "./folders/utils";
import { SetGroupPermissionsModal } from "./groups/set-group-permissions-modal";
import { MoveToDataroomFolderModal } from "./move-dataroom-folder-modal";
type FolderOrDocument =
@@ -75,7 +79,10 @@ export function DataroomItemsList({
documentCount: number;
}) {
const { viewerGroups } = useDataroomGroups();
const { permissionGroups } = useDataroomPermissionGroups();
const { dataroom } = useDataroom();
const { isMobile } = useMediaQuery();
const { applyPermissions } = useDataroomPermissions();
const [uploads, setUploads] = useState<UploadState[]>([]);
const [rejectedFiles, setRejectedFiles] = useState<RejectedFile[]>([]);
@@ -547,11 +554,36 @@ export function DataroomItemsList({
dataroomDocumentId: string;
}[],
) => {
if (viewerGroups && viewerGroups.length > 0) {
setUploadedFiles(files);
// Check if there are any groups to apply permissions to
const hasAnyGroups =
(viewerGroups && viewerGroups.length > 0) ||
(permissionGroups && permissionGroups.length > 0);
if (!hasAnyGroups) return;
const documentIds = files.map((file) => file.documentId);
const strategy =
dataroom?.defaultPermissionStrategy ||
DefaultPermissionStrategy.INHERIT_FROM_PARENT;
if (strategy === DefaultPermissionStrategy.ASK_EVERY_TIME) {
setShowGroupPermissions(true);
setUploadedFiles(files);
} else if (strategy === DefaultPermissionStrategy.INHERIT_FROM_PARENT) {
const isRootLevel = !folderPathName || folderPathName.length === 0;
applyPermissions(
dataroomId,
documentIds,
"INHERIT_FROM_PARENT",
isRootLevel ? undefined : folderPathName?.join("/"),
(message: string) => toast.error(message),
).catch((error: any) => {
console.error("Failed to apply permissions:", error);
toast.error("Failed to apply permissions");
});
}
return;
// strategy === DefaultPermissionStrategy.HIDDEN_BY_DEFAULT - do nothing
};
return (
@@ -701,7 +733,7 @@ export function DataroomItemsList({
) : null}
{showGroupPermissions && dataroomId && (
<SetGroupPermissionsModal
<SetUnifiedPermissionsModal
open={showGroupPermissions}
setOpen={setShowGroupPermissions}
dataroomId={dataroomId}
@@ -710,7 +742,6 @@ export function DataroomItemsList({
setShowGroupPermissions(false);
setUploadedFiles([]);
}}
isAutoOpen
/>
)}
<DeleteFolderModal />
+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,
@@ -0,0 +1,488 @@
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;
exportId?: string;
startTime?: number;
}
interface ExportVisitsModalProps {
teamId: string;
dataroomId: string;
dataroomName: string;
groupId?: string;
groupName?: string;
onClose: () => void;
}
export function ExportVisitsModal({
teamId,
dataroomId,
dataroomName,
groupId,
groupName,
onClose,
}: ExportVisitsModalProps) {
const { data: session } = useSession();
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);
// Cleanup interval on component unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 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 {
// Get view count first
try {
const viewCountResponse = await fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/views-count${groupId ? `?groupId=${groupId}` : ""}`,
{ method: "GET" },
);
if (viewCountResponse.ok) {
const viewData = await viewCountResponse.json();
setViewCount(viewData.count || 0);
}
} catch (error) {
console.error("Error fetching view count:", error);
// Continue with export even if view count fails
}
// Trigger the background export job
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}`);
}
const data = await response.json();
if (data.exportId) {
const startTime = Date.now();
setExportStatus({
status: "PROCESSING",
exportId: data.exportId,
startTime,
});
// Clear any existing interval
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
// Start polling
pollIntervalRef.current = setInterval(async () => {
try {
const statusResponse = await fetch(
`/api/teams/${teamId}/export-jobs/${data.exportId}`,
{ method: "GET" },
);
if (statusResponse.ok) {
const statusData = await statusResponse.json();
// Update progress
setExportStatus((prev) => ({
...prev!,
progress: "Preparing export...",
}));
if (statusData.status === "COMPLETED" && statusData.isReady) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
// Create a direct link to the API endpoint (it will redirect to blob)
const downloadUrl = `/api/teams/${teamId}/export-jobs/${data.exportId}?download=true`;
try {
const link = window.document.createElement("a");
link.href = downloadUrl;
link.setAttribute(
"download",
`${statusData.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) {
// Fallback: open in new tab if programmatic download fails
window.open(downloadUrl, "_blank");
console.error("Download failed, opened in new tab:", error);
}
handleClose();
toast.success("Export successfully downloaded");
} else if (statusData.status === "FAILED") {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
handleClose();
toast.error(
`Export failed: ${statusData.error || "Unknown error"}`,
);
}
}
} catch (error) {
console.error("Error polling export status:", error);
}
}, 5000); // Poll every 5 seconds
// Clear interval after 10 minutes to prevent indefinite polling
setTimeout(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
handleClose();
}, 600000);
}
} catch (error) {
console.error("Error:", error);
toast.error(
"An error occurred while starting the export. Please try again.",
);
handleClose();
}
}, [teamId, dataroomId, groupId, dataroomName, groupName]);
// Send export via email
const sendExportEmail = async () => {
if (!exportStatus?.exportId || !session?.user?.email) return;
try {
const response = await fetch(
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}/send-email`,
{ method: "POST" },
);
if (response.ok) {
toast.success("Export will be sent to your email when ready");
handleClose();
} else {
toast.error("Failed to setup email notification");
}
} catch (error) {
console.error("Error sending email:", error);
toast.error("Failed to setup email notification");
}
};
// 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) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
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();
};
// Don't render anything if not visible and no modal to show
if (!showModal) {
return null;
}
const displayName = groupName
? `${dataroomName} - ${groupName}`
: dataroomName;
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 dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Export Visits</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{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>
) : 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>
<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={startNewExport}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Start New Export
</button>
</div>
</div>
)}
</div>
</div>
);
}
+72 -24
View File
@@ -9,11 +9,45 @@ 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";
import { buildNestedFolderStructure } from "./utils";
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,
@@ -24,30 +58,45 @@ const FolderComponent = memo(
}) => {
const router = useRouter();
// Memoize the rendering of the current folder's documents
const documents = useMemo(
() =>
folder.documents.map((doc) => (
<FileTree.File
key={doc.id}
name={doc.document.name}
onToggle={() => router.push(`/documents/${doc.document.id}`)}
/>
)),
[folder.documents, dataroomId, router.query.name],
// Get hierarchical display names
const folderDisplayName = useHierarchicalDisplayName(
folder.name,
folder.hierarchicalIndex,
);
// Recursively render child folders if they exist
const childFolders = useMemo(
const mixedItems = useMemo(() => {
const allItems: MixedItem[] = [
...(folder.childFolders || []).map((f) => ({
...f,
itemType: "folder" as const,
})),
...(folder.documents || []).map((d) => ({
...d,
itemType: "document" as const,
})),
];
return sortByIndexThenName(allItems);
}, [folder.childFolders, folder.documents]);
const renderedItems = useMemo(
() =>
folder.childFolders.map((childFolder) => (
<FolderComponent
key={childFolder.id}
dataroomId={dataroomId}
folder={childFolder}
/>
)),
[folder.childFolders, dataroomId],
mixedItems.map((item: MixedItem) => {
if (item.itemType === "folder") {
return (
<FolderComponent
key={item.id}
dataroomId={dataroomId}
folder={item}
/>
);
} else {
return (
<DocumentFileItem key={item.id} document={item} router={router} />
);
}
}),
[mixedItems, dataroomId, router],
);
const isActive =
@@ -69,14 +118,13 @@ const FolderComponent = memo(
return (
<FileTree.Folder
name={folder.name}
name={folderDisplayName}
key={folder.id}
active={isActive}
childActive={isChildActive}
onToggle={handleFolderClick}
>
{childFolders}
{documents}
{renderedItems}
</FileTree.Folder>
);
},
+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,
});
@@ -1,452 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTeam } from "@/context/team-context";
import { ItemType } from "@prisma/client";
import {
ArrowDownToLineIcon,
ArrowLeftIcon,
EyeIcon,
EyeOffIcon,
FileTextIcon,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import useDataroomGroups from "@/lib/swr/use-dataroom-groups";
import { cn } from "@/lib/utils";
import CloudDownloadOff from "@/components/shared/icons/cloud-download-off";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type GroupPermissions = Record<string, { view: boolean; download: boolean }>;
export function SetGroupPermissionsModal({
open,
setOpen,
dataroomId,
onComplete,
isAutoOpen,
uploadedFiles,
}: {
open: boolean;
setOpen: (open: boolean) => void;
dataroomId: string;
onComplete?: () => void;
isAutoOpen?: boolean;
uploadedFiles: {
documentId: string;
dataroomDocumentId: string;
fileName: string;
}[];
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const [selectedFile, setSelectedFile] = useState<{
documentId: string;
dataroomDocumentId: string;
fileName: string;
} | null>(null);
const [showFileList, setShowFileList] = useState<boolean>(
uploadedFiles.length > 1,
);
const currentDocumentId = useMemo(() => {
return selectedFile?.documentId || uploadedFiles[0]?.documentId;
}, [selectedFile, uploadedFiles]);
const currentDataroomDocumentId = useMemo(() => {
return (
selectedFile?.dataroomDocumentId || uploadedFiles[0]?.dataroomDocumentId
);
}, [selectedFile, uploadedFiles]);
const {
viewerGroups,
loading: viewerGroupsLoading,
mutate: mutateGroups,
} = useDataroomGroups({
documentId: currentDataroomDocumentId,
});
const [loading, setLoading] = useState(false);
const [selectedGroups, setSelectedGroups] = useState<GroupPermissions>({});
useEffect(() => {
if (uploadedFiles.length === 1) {
setSelectedFile(uploadedFiles[0]);
setShowFileList(false);
} else {
setShowFileList(true);
}
}, [uploadedFiles]);
useEffect(() => {
if (selectedFile) {
mutateGroups();
}
}, [selectedFile, mutateGroups]);
useEffect(() => {
if (viewerGroups) {
const initialGroups = viewerGroups.reduce<GroupPermissions>(
(acc, group) => ({
...acc,
[group.id]: {
view: group.accessControls?.[0]?.canView ?? false,
download: group.accessControls?.[0]?.canDownload ?? false,
},
}),
{},
);
setSelectedGroups(initialGroups);
}
}, [viewerGroups]);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setLoading(true);
try {
await Promise.all(
Object.entries(selectedGroups).map(([groupId, permissions]) => {
return fetch(
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/permissions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
dataroomId,
groupId,
permissions: {
[currentDataroomDocumentId]: {
itemType: ItemType.DATAROOM_DOCUMENT,
view: permissions.view,
download: permissions.download,
},
},
}),
},
);
}),
);
toast.success("Group permissions updated successfully!");
await Promise.all([
mutateGroups(),
mutate(
`/api/teams/${teamId}/datarooms/${dataroomId}/groups?documentId=${currentDocumentId}`,
),
]);
if (uploadedFiles.length > 1) {
setShowFileList(true);
} else {
onComplete?.();
setOpen(false);
}
} catch (error) {
console.error("Error updating group permissions:", error);
toast.error("Failed to update group permissions");
} finally {
setLoading(false);
}
};
const handleBack = () => {
setShowFileList(true);
};
const updatePermissions = (groupId: string, newPermissions: string[]) => {
const hasView = newPermissions.includes("view");
const hasDownload = newPermissions.includes("download");
const prevPermissions = selectedGroups[groupId] || {
view: false,
download: false,
};
if (
hasDownload &&
!hasView &&
!prevPermissions.view &&
!prevPermissions.download
) {
newPermissions = ["view", "download"];
} else if (hasDownload && !hasView) {
newPermissions = [];
}
setSelectedGroups((prev) => ({
...prev,
[groupId]: {
view: newPermissions.includes("view"),
download: newPermissions.includes("download"),
},
}));
};
const handleFileSelect = (file: (typeof uploadedFiles)[0]) => {
setSelectedFile(file);
setShowFileList(false);
mutateGroups();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader className="w-[90%]">
<div className="flex items-center gap-2">
{!showFileList && uploadedFiles.length > 1 && (
<Button
variant="ghost"
size="icon"
onClick={handleBack}
className="h-8 w-8"
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
)}
<div>
<DialogTitle>Set Group Permissions</DialogTitle>
<DialogDescription>
{showFileList
? "Select a file to set permissions"
: "Update group permissions for the selected file"}
</DialogDescription>
</div>
</div>
</DialogHeader>
{showFileList ? (
<div className="space-y-2">
{uploadedFiles.map((file) => (
<div
key={file.documentId}
className={cn(
"flex cursor-pointer items-center justify-between rounded-lg border p-3",
selectedFile?.documentId === file.documentId
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)}
onClick={() => handleFileSelect(file)}
>
<div className="flex items-center space-x-3">
<FileTextIcon className="h-5 w-5" />
<span>{file.fileName}</span>
</div>
{selectedFile?.documentId === file.documentId && (
<div className="text-sm text-primary">Selected</div>
)}
</div>
))}
</div>
) : (
<>
<div>
<p className="text-sm font-medium">Selected File</p>
<div className="mt-1 flex items-center space-x-2 rounded-lg border p-3">
<FileTextIcon className="h-5 w-5" />
<span>
{selectedFile?.fileName || uploadedFiles[0]?.fileName}
</span>
</div>
</div>
<form onSubmit={handleSubmit}>
<div className="rounded-md border">
<div className="max-h-[400px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary">
<TableRow>
<TableHead className="w-[60%]">Group</TableHead>
<TableHead className="w-[20%] text-center">
View
</TableHead>
<TableHead className="w-[20%] text-center">
Download
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="relative">
{viewerGroupsLoading ? (
<TableRow>
<TableCell colSpan={3} className="w-full text-center">
<Loader2 className="m-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : viewerGroups && viewerGroups.length > 0 ? (
viewerGroups?.map((group) => (
<TableRow key={group.id}>
<TableCell>
<div>
<p className="font-medium">{group.name}</p>
<p className="text-sm text-muted-foreground">
{group._count.members} members
</p>
</div>
</TableCell>
<TableCell className="text-center">
<ToggleGroup
type="multiple"
value={Object.entries(
selectedGroups[group.id] || {},
)
.filter(
([key, value]) => key === "view" && value,
)
.map(([key, _]) => key)}
onValueChange={(value) => {
const currentPermissions = Object.entries(
selectedGroups[group.id] || {},
)
.filter(([_, val]) => val)
.map(([key, _]) => key);
if (
currentPermissions.includes("view") &&
!value.includes("view")
) {
const newPerms = currentPermissions.filter(
(p) => p !== "view" && p !== "download",
);
updatePermissions(group.id, newPerms);
} else if (
!currentPermissions.includes("view") &&
value.includes("view")
) {
const newPerms = [
...currentPermissions,
"view",
];
updatePermissions(group.id, newPerms);
}
}}
className="justify-center"
>
<ToggleGroupItem
value="view"
aria-label="Toggle view"
size="sm"
className={cn(
"px-3 py-1 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background",
selectedGroups[group.id]?.view
? "data-[state=on]:bg-foreground data-[state=on]:text-background"
: "",
)}
>
{selectedGroups[group.id]?.view ? (
<EyeIcon className="h-5 w-5" />
) : (
<EyeOffIcon className="h-5 w-5" />
)}
</ToggleGroupItem>
</ToggleGroup>
</TableCell>
<TableCell className="text-center">
<ToggleGroup
type="multiple"
value={Object.entries(
selectedGroups[group.id] || {},
)
.filter(
([key, value]) =>
key === "download" && value,
)
.map(([key, _]) => key)}
onValueChange={(value) => {
const currentPermissions = Object.entries(
selectedGroups[group.id] || {},
)
.filter(([_, val]) => val)
.map(([key, _]) => key);
if (
value.includes("download") &&
!currentPermissions.includes("view")
) {
const newPerms = [
...currentPermissions,
"view",
"download",
];
updatePermissions(group.id, newPerms);
} else {
const newPerms = currentPermissions.filter(
(p) => p !== "download",
);
if (value.includes("download")) {
newPerms.push("download");
}
updatePermissions(group.id, newPerms);
}
}}
className="justify-center"
>
<ToggleGroupItem
value="download"
aria-label="Toggle download"
size="sm"
className={cn(
"px-3 py-1 text-muted-foreground hover:ring-1 hover:ring-gray-400 data-[state=on]:bg-foreground data-[state=on]:text-background",
selectedGroups[group.id]?.download
? "data-[state=on]:bg-foreground data-[state=on]:text-background"
: "",
)}
>
{selectedGroups[group.id]?.download ? (
<ArrowDownToLineIcon className="h-5 w-5" />
) : (
<CloudDownloadOff className="h-5 w-5" />
)}
</ToggleGroupItem>
</ToggleGroup>
</TableCell>
</TableRow>
))
) : !viewerGroupsLoading && viewerGroups?.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="w-full text-center">
No groups found
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</div>
<DialogFooter className="mt-6">
<Button
type="submit"
loading={loading}
disabled={!viewerGroups || viewerGroups.length === 0}
>
Save permissions
</Button>
</DialogFooter>
</form>
</>
)}
</DialogContent>
</Dialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -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>
);
}
@@ -27,7 +27,7 @@ export default function NotificationSettings({
}: NotificationSettingsProps) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { isDataroomsPlus } = usePlan();
const { isDataroomsPlus, isTrial } = usePlan();
const { data: dataroomData, mutate: mutateDataroom } = useSWR<{
id: string;
@@ -49,7 +49,7 @@ export default function NotificationSettings({
const handleNotificationToggle = async (checked: boolean) => {
if (!dataroomId || !teamId) return;
if (!isDataroomsPlus && !features?.roomChangeNotifications) {
if (!isDataroomsPlus && !isTrial && !features?.roomChangeNotifications) {
toast.error("This feature is not available in your plan");
return;
}
@@ -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>
@@ -0,0 +1,158 @@
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,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
type DefaultPermissionStrategy =
| "INHERIT_FROM_PARENT"
| "ASK_EVERY_TIME"
| "HIDDEN_BY_DEFAULT";
interface PermissionSettingsProps {
dataroomId: string;
}
export default function PermissionSettings({
dataroomId,
}: PermissionSettingsProps) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { data: dataroomData, mutate: mutateDataroom } = useSWR<{
id: string;
name: string;
pId: string;
defaultPermissionStrategy: DefaultPermissionStrategy;
}>(
teamId && dataroomId
? `/api/teams/${teamId}/datarooms/${dataroomId}`
: null,
fetcher,
);
const [isUpdating, setIsUpdating] = useState(false);
const handlePermissionChange = async (value: DefaultPermissionStrategy) => {
if (!dataroomId || !teamId || isUpdating || !dataroomData) return;
setIsUpdating(true);
const optimisticData = {
...dataroomData,
defaultPermissionStrategy: value,
};
const mutation = async () => {
const res = await fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
defaultPermissionStrategy: value,
}),
});
if (!res.ok) {
throw new Error("Failed to update permission settings");
}
return res.json();
};
try {
await toast.promise(
mutateDataroom(mutation(), {
optimisticData,
rollbackOnError: true,
populateCache: true,
revalidate: false,
}),
{
loading: "Updating permission settings...",
success: "Permission settings updated successfully",
error: (err) => err.message,
},
);
} catch (error) {
console.error(error);
} finally {
setIsUpdating(false);
}
};
const currentStrategy =
dataroomData?.defaultPermissionStrategy ?? "HIDDEN_BY_DEFAULT";
return (
<Card>
<CardHeader>
<CardTitle>Default File Permissions</CardTitle>
<CardDescription>
Configure how permissions are handled for new documents and folders
added to this dataroom.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<RadioGroup
value={currentStrategy}
onValueChange={handlePermissionChange}
disabled={isUpdating || !dataroomData}
className="space-y-3"
>
<div className="flex items-center space-x-3">
<RadioGroupItem value="INHERIT_FROM_PARENT" id="inherit" />
<div>
<Label htmlFor="inherit" className="text-sm font-medium">
Inherit from parent folder
</Label>
<p className="text-xs text-muted-foreground">
New documents and folders automatically inherit permissions from
their parent folder. Root-level items get view-only permissions
by default.
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<RadioGroupItem value="ASK_EVERY_TIME" id="ask" />
<div>
<Label htmlFor="ask" className="text-sm font-medium">
Ask every time
</Label>
<p className="text-xs text-muted-foreground">
Show permissions modal for each document upload to manually
configure permissions.
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<RadioGroupItem value="HIDDEN_BY_DEFAULT" id="hidden" />
<div>
<Label htmlFor="hidden" className="text-sm font-medium">
Hidden by default
</Label>
<p className="text-xs text-muted-foreground">
New documents and folders are hidden by default. Permissions
must be configured manually.
</p>
</div>
</div>
</RadioGroup>
</CardContent>
</Card>
);
}
@@ -1,7 +1,7 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { BellIcon, CogIcon } from "lucide-react";
import { BellIcon, CogIcon, DownloadIcon, ShieldIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -39,6 +39,30 @@ 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(
"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted",
{
"bg-muted font-medium": router.pathname.includes("permissions"),
},
)}
>
<ShieldIcon className="h-4 w-4" />
File Permissions
</Link>
</nav>
);
}
@@ -145,17 +145,20 @@ export function DataroomSortableList({
}
// Update local data using SWR's mutate
const baseKey = `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}`;
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}/folders${folderPathName ? `/${folderPathName.join(" / ")}` : "?root=true"}`,
`${baseKey}/folders${folderPathName ? `/${folderPathName.join("/")}` : "?root=true"}`,
);
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}${folderPathName ? `/folders/documents/${folderPathName.join("/")}` : "/documents"}`,
`${baseKey}${folderPathName ? `/folders/documents/${folderPathName.join("/")}` : "/documents"}`,
);
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);
@@ -244,7 +247,7 @@ export function DataroomSortableList({
className="gap-x-1"
>
<CheckIcon className="size-4" />
Save index
Save order
</Button>
</Portal>
<DeleteFolderModal />
+11 -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,51 +50,13 @@ 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) {
return;
}
const file = acceptedFiles[0];
const fileType = file.type;
const fileSizeLimitMB = getFileSizeLimit(fileType, fileSizeLimits); // in MB
@@ -226,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>
@@ -138,9 +138,33 @@ function DeleteItemsModal({
await mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/folders${parentFolderPath}`,
);
await mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/${folderPathName ? `folders/documents/${folderPathName.join("/")}` : "documents"}`,
);
if (folderPathName && folderPathName.length > 0) {
await mutate(
`/api/teams/${
teamInfo?.currentTeam?.id
}/folders/documents/${folderPathName.join("/")}`,
);
} else {
const { search, sort, page, limit } = router.query;
const queryParts = [];
if (search) queryParts.push(`query=${search}`);
if (sort) queryParts.push(`sort=${sort}`);
const pageNum = Number(page) || 1;
const limitNum = Number(limit) || 10;
const paginationParams =
search || sort ? `&page=${pageNum}&limit=${limitNum}` : "";
if (paginationParams) queryParts.push(paginationParams.substring(1));
const queryString =
queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
await mutate(
`/api/teams/${teamInfo?.currentTeam?.id}/documents${queryString}`,
);
}
setDeleting(false);
@@ -295,4 +319,4 @@ export function useDeleteDocumentsAndFoldersModal({
}),
[setShowDeleteItemsModal, DeleteItemsModalCallback],
);
}
}
+109 -32
View File
@@ -5,9 +5,11 @@ import { FormEvent, useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { PlanEnum } from "@/ee/stripe/constants";
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 {
@@ -16,10 +18,14 @@ import {
createNewDocumentVersion,
} 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";
import { getSupportedContentType } from "@/lib/utils/get-content-type";
import { SetUnifiedPermissionsModal } from "@/components/datarooms/groups/set-unified-permissions-modal";
import DocumentUpload from "@/components/document-upload";
import { Button } from "@/components/ui/button";
import {
@@ -40,7 +46,6 @@ import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { SetGroupPermissionsModal } from "../datarooms/groups/set-group-permissions-modal";
interface DataroomDocument {
id: string;
@@ -80,9 +85,11 @@ export function AddDocumentModal({
const teamInfo = useTeam();
const { canAddDocuments } = useLimits();
const { plan, isFree, isTrial } = usePlan();
const { dataroom } = useDataroom();
const teamId = teamInfo?.currentTeam?.id as string;
const { applyPermissions } = useDataroomPermissions();
useEffect(() => {
if (openModal) setIsOpen(openModal);
}, [openModal]);
@@ -137,6 +144,63 @@ export function AddDocumentModal({
}
};
const toastErrorMessage = () =>
toast.error(
"Failed to apply default permissions. Update the group permissions in the group settings.",
);
const applyUnifiedPermissionsToDocument = async (
document: any,
dataroomDocument: DataroomDocument & {
dataroom: {
_count: { viewerGroups: number; permissionGroups: number };
};
},
currentFolderPath?: string[],
): Promise<void> => {
const hasAnyGroups =
dataroomDocument.dataroom._count.viewerGroups > 0 ||
dataroomDocument.dataroom._count.permissionGroups > 0;
if (!hasAnyGroups) return;
const strategy =
dataroom?.defaultPermissionStrategy ||
DefaultPermissionStrategy.INHERIT_FROM_PARENT;
if (strategy === DefaultPermissionStrategy.ASK_EVERY_TIME) {
setShowGroupPermissions(true);
setUploadedFiles([
{
documentId: document.id,
dataroomDocumentId: dataroomDocument.id,
fileName: document.name,
},
]);
} else if (strategy === DefaultPermissionStrategy.INHERIT_FROM_PARENT) {
const isRootLevel = !currentFolderPath || currentFolderPath.length === 0;
try {
const result = await applyPermissions(
dataroomId!,
[document.id],
"INHERIT_FROM_PARENT",
isRootLevel ? undefined : currentFolderPath?.join("/"),
toastErrorMessage,
);
if (!result.success) {
console.error("Failed to apply permissions:", result.error);
toastErrorMessage();
}
} catch (error) {
console.error("Failed to apply permissions:", error);
toastErrorMessage();
}
}
// strategy === DefaultPermissionStrategy.HIDDEN_BY_DEFAULT - do nothing, documents remain hidden
};
const handleFileUpload = async (
event: FormEvent<HTMLFormElement>,
): Promise<void> => {
@@ -226,19 +290,16 @@ export function AddDocumentModal({
if (dataroomResponse?.ok) {
const dataroomDocument =
(await dataroomResponse.json()) as DataroomDocument & {
dataroom: { _count: { viewerGroups: number } };
dataroom: {
_count: { viewerGroups: number; permissionGroups: number };
};
};
if (dataroomDocument.dataroom._count.viewerGroups > 0) {
setShowGroupPermissions(true);
setUploadedFiles([
{
documentId: document.id,
dataroomDocumentId: dataroomDocument.id,
fileName: document.name,
},
]);
}
await applyUnifiedPermissionsToDocument(
document,
dataroomDocument,
currentFolderPath,
);
}
analytics.capture("Document Added", {
@@ -259,6 +320,7 @@ export function AddDocumentModal({
}
if (!newVersion) {
mutate(`/api/teams/${teamId}/documents`);
toast.success("Document uploaded. Redirecting to document page...");
analytics.capture("Document Added", {
@@ -327,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;
}
@@ -380,19 +461,16 @@ export function AddDocumentModal({
if (dataroomResponse?.ok) {
const dataroomDocument =
(await dataroomResponse.json()) as DataroomDocument & {
dataroom: { _count: { viewerGroups: number } };
dataroom: {
_count: { viewerGroups: number; permissionGroups: number };
};
};
if (dataroomDocument.dataroom._count.viewerGroups > 0) {
setShowGroupPermissions(true);
setUploadedFiles([
{
documentId: document.id,
dataroomDocumentId: dataroomDocument.id,
fileName: document.name,
},
]);
}
await applyUnifiedPermissionsToDocument(
document,
dataroomDocument,
currentFolderPath,
);
}
analytics.capture("Document Added", {
@@ -627,7 +705,7 @@ export function AddDocumentModal({
</Dialog>
{showGroupPermissions && dataroomId && (
<SetGroupPermissionsModal
<SetUnifiedPermissionsModal
open={showGroupPermissions}
setOpen={setShowGroupPermissions}
dataroomId={dataroomId}
@@ -637,7 +715,6 @@ export function AddDocumentModal({
setAddDocumentModalOpen?.(false);
setUploadedFiles([]);
}}
isAutoOpen
/>
)}
</>
@@ -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>
+56 -10
View File
@@ -8,10 +8,10 @@ import { PlanEnum } from "@/ee/stripe/constants";
import {
BetweenHorizontalStartIcon,
ChevronRight,
EyeIcon,
FileIcon,
FolderIcon,
FolderInputIcon,
Layers2Icon,
MoreVertical,
ServerIcon,
TrashIcon,
@@ -30,6 +30,7 @@ import { useCopyToClipboard } from "@/lib/utils/use-copy-to-clipboard";
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
import { DataroomTrialModal } from "@/components/datarooms/dataroom-trial-modal";
import { AddToDataroomModal } from "@/components/documents/add-document-to-dataroom-modal";
import { DocumentPreviewModal } from "@/components/documents/document-preview-modal";
import { MoveToFolderModal } from "@/components/documents/move-folder-modal";
import BarChart from "@/components/shared/icons/bar-chart";
import { Button } from "@/components/ui/button";
@@ -72,6 +73,7 @@ export default function DocumentsCard({
const [addDataroomOpen, setAddDataroomOpen] = useState<boolean>(false);
const [trialModalOpen, setTrialModalOpen] = useState<boolean>(false);
const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);
const [previewOpen, setPreviewOpen] = useState<boolean>(false);
const { datarooms } = useDatarooms();
@@ -131,23 +133,51 @@ export default function DocumentsCard({
return;
}
const page = Number(queryParams["page"]) || 1;
const pageSize = Number(queryParams["limit"]) || 10;
const queryParts = [];
if (searchQuery) queryParts.push(`query=${searchQuery}`);
if (sortQuery) queryParts.push(`sort=${sortQuery}`);
const paginationParams =
searchQuery || sortQuery ? `&page=${page}&limit=${pageSize}` : "";
if (paginationParams) queryParts.push(paginationParams.substring(1));
const queryString = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
const endpoint = currentFolderPath
? `/folders/documents/${currentFolderPath.join("/")}`
: "/documents";
: `/documents${queryString}`;
toast.promise(
fetch(`/api/teams/${teamInfo?.currentTeam?.id}/documents/${documentId}`, {
method: "DELETE",
}).then(() => {
mutate(`/api/teams/${teamInfo?.currentTeam?.id}${endpoint}`, null, {
populateCache: (_, docs) => {
return docs.filter(
(doc: DocumentWithLinksAndLinkCountAndViewCount) =>
doc.id !== documentId,
);
mutate(
`/api/teams/${teamInfo?.currentTeam?.id}${endpoint}`,
(currentData: any) => {
if (!currentData) return currentData;
if (Array.isArray(currentData)) {
return currentData.filter(
(doc: DocumentWithLinksAndLinkCountAndViewCount) =>
doc.id !== documentId,
);
} else if (currentData.documents) {
return {
...currentData,
documents: currentData.documents.filter(
(doc: DocumentWithLinksAndLinkCountAndViewCount) =>
doc.id !== documentId,
),
};
}
return currentData;
},
revalidate: false,
});
{
revalidate: false,
},
);
}),
{
loading: "Deleting document...",
@@ -316,6 +346,16 @@ export default function DocumentsCard({
</DropdownMenuTrigger>
<DropdownMenuContent align="end" ref={dropdownRef}>
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => {
setPreviewOpen(true);
setMenuOpen(false);
}}
>
<EyeIcon className="mr-2 h-4 w-4" />
Quick preview
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setMoveFolderOpen(true)}>
<FolderInputIcon className="mr-2 h-4 w-4" />
Move to folder
@@ -384,6 +424,12 @@ export default function DocumentsCard({
setOpen={setPlanModalOpen}
/>
) : null}
<DocumentPreviewModal
documentId={prismaDocument.id}
isOpen={previewOpen}
onClose={() => setPreviewOpen(false)}
/>
</>
);
}
+59 -75
View File
@@ -34,6 +34,7 @@ import {
DocumentWithVersion,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { supportsAdvancedExcelMode } from "@/lib/utils/get-content-type";
import { fileIcon } from "@/lib/utils/get-file-icon";
import FileUp from "@/components/shared/icons/file-up";
@@ -64,6 +65,7 @@ import { ButtonTooltip } from "../ui/tooltip";
import { AddDocumentModal } from "./add-document-modal";
import { AddToDataroomModal } from "./add-document-to-dataroom-modal";
import AlertBanner from "./alert";
import { ExportVisitsModal } from "./export-visits-modal";
export default function DocumentHeader({
prismaDocument,
@@ -93,6 +95,7 @@ export default function DocumentHeader({
const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);
const [planModalTrigger, setPlanModalTrigger] = useState<string>("");
const [selectedPlan, setSelectedPlan] = useState<PlanEnum>(PlanEnum.Pro);
const [exportModalOpen, setExportModalOpen] = useState<boolean>(false);
const nameRef = useRef<HTMLHeadingElement>(null);
const enterPressedRef = useRef<boolean>(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
@@ -314,46 +317,12 @@ export default function DocumentHeader({
};
// export method to fetch the visits data and convert to csv.
const exportVisitCounts = async (document: Document) => {
const exportVisitCounts = (document: Document) => {
if (isFree) {
toast.error("This feature is not available for your plan");
return;
}
try {
const response = await fetch(
`/api/teams/${teamId}/documents/${document.id}/export-visits`,
{ method: "GET" },
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Create and download the CSV file
const blob = new Blob([data.visits], { type: "text/csv;charset=utf-8;" });
const url = window.URL.createObjectURL(blob);
const link = window.document.createElement("a");
link.href = url;
link.setAttribute(
"download",
`${data.documentName}_visits_${formattedTime}.csv`,
);
link.rel = "noopener noreferrer";
window.document.body.appendChild(link);
link.click();
setTimeout(() => {
window.URL.revokeObjectURL(url);
window.document.body.removeChild(link);
}, 100);
toast.success("CSV file downloaded successfully");
} catch (error) {
console.error("Error:", error);
toast.error(
"An error occurred while downloading the CSV. Please try again.",
);
}
setExportModalOpen(true);
};
// Make a document download only or viewable
@@ -575,9 +544,9 @@ export default function DocumentHeader({
(!orientationLoading ? (
<ButtonTooltip content="Change orientation">
<Button
variant="ghost"
variant="outline"
size="icon"
className="hidden md:flex"
className="hidden size-8 sm:flex lg:size-9"
onClick={changeDocumentOrientation}
title={`Change document orientation to ${primaryVersion.isVertical ? "landscape" : "portrait"}`}
>
@@ -601,17 +570,19 @@ export default function DocumentHeader({
openModal={openAddDocModal}
setAddDocumentModalOpen={setOpenAddDocModal}
>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
setOpenAddDocModal(true);
}}
className="hidden md:flex"
>
<FileUp className="h-6 w-6" />
</Button>
<ButtonTooltip content="Upload new version">
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.stopPropagation();
setOpenAddDocModal(true);
}}
className="hidden size-8 md:flex lg:size-9"
>
<FileUp className="h-6 w-6" />
</Button>
</ButtonTooltip>
</AddDocumentModal>
)}
@@ -637,7 +608,7 @@ export default function DocumentHeader({
{actionRows.map((row, i) => (
<ul
key={i.toString()}
className="flex flex-wrap items-center justify-end gap-2 md:flex-nowrap md:gap-4"
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>
@@ -740,7 +711,8 @@ export default function DocumentHeader({
))} */}
{prismaDocument.type === "sheet" &&
!prismaDocument.advancedExcelEnabled &&
(isBusiness || isDatarooms || isTrial) && (
supportsAdvancedExcelMode(primaryVersion.contentType) &&
(isPro || isBusiness || isDatarooms || isTrial) && (
<DropdownMenuItem
onClick={() => enableAdvancedExcel(prismaDocument)}
>
@@ -925,33 +897,37 @@ export default function DocumentHeader({
/>
)}
{prismaDocument.type === "sheet" && (isFree || isPro) && (
<AlertBanner
id="advanced-excel-alert"
variant="default"
title="Advanced Excel mode"
description={
<>
You can turn on advanced excel mode by{" "}
<span
className="cursor-pointer underline underline-offset-4 hover:text-primary/80"
onClick={() =>
handleUpgradeClick(PlanEnum.Business, "advanced-excel-mode")
}
>
upgrading
</span>{" "}
to Business plan to preserve the file formatting. This uses the
Microsoft Office viewer.
</>
}
onClose={() => handleCloseAlert("advanced-excel-alert")}
/>
)}
{prismaDocument.type === "sheet" &&
supportsAdvancedExcelMode(primaryVersion.contentType) &&
isFree &&
!isTrial && (
<AlertBanner
id="advanced-excel-alert"
variant="default"
title="Advanced Excel mode"
description={
<>
You can turn on advanced excel mode by{" "}
<span
className="hover:text-primary/ 80 cursor-pointer underline underline-offset-4"
onClick={() =>
handleUpgradeClick(PlanEnum.Pro, "advanced-excel-mode")
}
>
upgrading
</span>{" "}
to Pro plan to preserve the file formatting. This uses the
Microsoft Office viewer.
</>
}
onClose={() => handleCloseAlert("advanced-excel-alert")}
/>
)}
{prismaDocument.type === "sheet" &&
!prismaDocument.advancedExcelEnabled &&
(isBusiness || isDatarooms || isTrial) && (
supportsAdvancedExcelMode(primaryVersion.contentType) &&
(isPro || isBusiness || isDatarooms || isTrial) && (
<AlertBanner
id="enable-advanced-excel-alert"
variant="default"
@@ -990,6 +966,14 @@ export default function DocumentHeader({
setOpen={setPlanModalOpen}
/>
) : null}
{exportModalOpen && (
<ExportVisitsModal
document={prismaDocument}
teamId={teamId}
onClose={() => setExportModalOpen(false)}
/>
)}
</header>
);
}
@@ -0,0 +1,121 @@
import { useState } from "react";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ButtonTooltip } from "@/components/ui/tooltip";
import { DocumentPreviewModal } from "./document-preview-modal";
interface DocumentPreviewButtonProps {
documentId: string;
primaryVersion?: {
hasPages?: boolean;
type?: string | null;
numPages?: number | null;
};
isProcessing?: boolean;
variant?: "ghost" | "outline" | "default";
size?: "sm" | "default" | "lg" | "icon";
children?: React.ReactNode;
className?: string;
showTooltip?: boolean;
}
export function DocumentPreviewButton({
documentId,
primaryVersion,
isProcessing = false,
variant = "ghost",
size = "icon",
children,
className,
showTooltip = true,
}: DocumentPreviewButtonProps) {
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
// Check if document type supports preview
const supportsPreview = () => {
if (!primaryVersion) return false;
// Support documents with pages (PDFs, docs, slides, etc.)
if (primaryVersion.hasPages) return true;
// Support image documents
if (primaryVersion.type === "image") return true;
// Don't support sheets, notion, videos, etc.
return false;
};
// Don't render if document type doesn't support preview
if (!supportsPreview()) {
return null;
}
const handlePreviewClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
e.preventDefault();
if (isProcessing) return;
setIsPreviewOpen(true);
};
const button = (
<Button
variant={variant}
size={size}
onClick={handlePreviewClick}
disabled={isProcessing}
className={className}
>
{children || (
<>
<EyeIcon className="h-4 w-4" />
{size !== "icon" && <span className="ml-1">Preview</span>}
</>
)}
</Button>
);
const wrappedButton = showTooltip ? (
<ButtonTooltip
content={
isProcessing
? "Document is still processing"
: "Quick preview of document"
}
>
{button}
</ButtonTooltip>
) : (
button
);
return (
<>
{wrappedButton}
<DocumentPreviewModal
documentId={documentId}
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
/>
</>
);
}
// Helper function to check if document is processing
export function isDocumentProcessing(primaryVersion?: {
hasPages?: boolean;
type?: string | null;
numPages?: number | null;
}) {
if (!primaryVersion) return false;
// Check if document type should have pages but doesn't
const shouldHavePages = ["pdf", "docs", "slides", "cad"].includes(
primaryVersion.type || "",
);
return shouldHavePages && !primaryVersion.hasPages;
}
@@ -0,0 +1,94 @@
import { useEffect } from "react";
import { XIcon } from "lucide-react";
import { toast } from "sonner";
import { useDocumentPreview } from "@/lib/swr/use-document-preview";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { PreviewViewer } from "./preview-viewers/preview-viewer";
interface DocumentPreviewModalProps {
documentId: string;
isOpen: boolean;
onClose: () => void;
}
export function DocumentPreviewModal({
documentId,
isOpen,
onClose,
}: DocumentPreviewModalProps) {
const {
document: documentData,
loading,
error,
} = useDocumentPreview(documentId, isOpen);
const handleClose = () => {
onClose();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
handleClose();
}
};
const handleContentClick = (e: React.MouseEvent) => {
// Prevent clicks from propagating to underlying elements
e.stopPropagation();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent
className="h-[99vh] w-[90%] rounded-lg bg-gray-900 p-0 data-[state=open]:slide-in-from-bottom-0 md:w-[80vw] md:max-w-[80vw]"
onKeyDown={handleKeyDown}
onClick={handleContentClick}
isPreviewDialog
>
{/* Header with close button */}
<div className="absolute right-4 top-4 z-50">
<Button
variant="ghost"
size="icon"
onClick={handleClose}
className="h-8 w-8 rounded-full bg-black/20 text-white hover:bg-black/40"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* Loading state */}
{loading && (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<LoadingSpinner className="mx-auto h-8 w-8 text-white" />
<p className="mt-2 text-sm text-gray-400">Loading preview...</p>
</div>
</div>
)}
{/* Error state */}
{error && (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<p className="text-red-400">
{(error as Error).message || "Failed to load document preview"}
</p>
</div>
</div>
)}
{/* Document preview */}
{documentData && !loading && !error && (
<PreviewViewer documentData={documentData} onClose={handleClose} />
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,468 @@
import { useCallback, useEffect, useRef, useState } from "react";
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;
exportId?: string;
startTime?: number;
}
interface ExportVisitsModalProps {
document: Document;
teamId: string;
onClose: () => void;
}
export function ExportVisitsModal({
document,
teamId,
onClose,
}: ExportVisitsModalProps) {
const { data: session } = useSession();
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);
// Cleanup interval on component unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// 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 {
// Get view count first
try {
const viewCountResponse = await fetch(
`/api/teams/${teamId}/documents/${document.id}/views-count`,
{ method: "GET" },
);
if (viewCountResponse.ok) {
const viewData = await viewCountResponse.json();
setViewCount(viewData.count || 0);
}
} catch (error) {
console.error("Error fetching view count:", error);
// Continue with export even if view count fails
}
// Trigger the background export job
const response = await fetch(
`/api/teams/${teamId}/documents/${document.id}/export-visits`,
{ method: "POST" },
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.exportId) {
const startTime = Date.now();
setExportStatus({
status: "PROCESSING",
exportId: data.exportId,
startTime,
});
// Clear any existing interval
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
// Start polling
pollIntervalRef.current = setInterval(async () => {
try {
const statusResponse = await fetch(
`/api/teams/${teamId}/export-jobs/${data.exportId}`,
{ method: "GET" },
);
if (statusResponse.ok) {
const statusData = await statusResponse.json();
// Update progress
setExportStatus((prev) => ({
...prev!,
progress: "Preparing export...",
}));
if (statusData.status === "COMPLETED" && statusData.isReady) {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
// Create a direct link to the API endpoint (it will redirect to blob)
const downloadUrl = `/api/teams/${teamId}/export-jobs/${data.exportId}?download=true`;
try {
const link = window.document.createElement("a");
link.href = downloadUrl;
link.setAttribute(
"download",
`${statusData.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) {
// Fallback: open in new tab if programmatic download fails
window.open(downloadUrl, "_blank");
console.error("Download failed, opened in new tab:", error);
}
handleClose();
toast.success("Export successfully downloaded");
} else if (statusData.status === "FAILED") {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
handleClose();
toast.error(
`Export failed: ${statusData.error || "Unknown error"}`,
);
}
}
} catch (error) {
console.error("Error polling export status:", error);
}
}, 5000); // Poll every 5 seconds
// Clear interval after 10 minutes to prevent indefinite polling
setTimeout(() => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
handleClose();
}, 600000);
}
} catch (error) {
console.error("Error:", error);
toast.error(
"An error occurred while starting the export. Please try again.",
);
handleClose();
}
}, [document.id, teamId, document.name]);
// Send export via email
const sendExportEmail = async () => {
if (!exportStatus?.exportId || !session?.user?.email) return;
try {
const response = await fetch(
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}/send-email`,
{ method: "POST" },
);
if (response.ok) {
toast.success("Export will be sent to your email when ready");
handleClose();
} else {
toast.error("Failed to setup email notification");
}
} catch (error) {
console.error("Error sending email:", error);
toast.error("Failed to setup email notification");
}
};
// 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) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
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();
};
// Don't render anything if not visible and no modal to show
if (!showModal) {
return null;
}
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 dark:bg-gray-800">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Export Visits</h3>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{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>
) : 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 && (
<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={startNewExport}
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
>
Start New Export
</button>
</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>
</>
);
}
@@ -0,0 +1,3 @@
export { PreviewImageViewer } from "./preview-image-viewer";
export { PreviewPagesViewer } from "./preview-pages-viewer";
export { PreviewViewer } from "./preview-viewer";
@@ -0,0 +1,65 @@
import { useState } from "react";
import { DocumentPreviewData } from "@/lib/types/document-preview";
import { cn } from "@/lib/utils";
interface PreviewImageViewerProps {
documentData: DocumentPreviewData;
onClose: () => void;
}
export function PreviewImageViewer({
documentData,
onClose,
}: PreviewImageViewerProps) {
const [imageLoaded, setImageLoaded] = useState(false);
const { file, documentName } = documentData;
if (!file) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-gray-400">Image not available</p>
</div>
);
}
const handleImageLoad = () => {
setImageLoaded(true);
};
return (
<div className="relative h-full w-full overflow-hidden">
{/* Document Title */}
<div className="absolute left-1/2 top-4 z-50 -translate-x-1/2">
<div className="rounded-lg bg-black/20 px-3 py-2 text-white">
<span className="text-sm font-medium">{documentName}</span>
</div>
</div>
{/* Image Content */}
<div className="flex h-full w-full items-center justify-center p-4">
<div className="relative max-h-full max-w-full">
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
</div>
)}
<img
src={file}
alt={documentName}
className={cn(
"max-h-[calc(100vh-120px)] max-w-full object-contain transition-opacity duration-200",
imageLoaded ? "opacity-100" : "opacity-0",
)}
onLoad={handleImageLoad}
onError={() => setImageLoaded(true)}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,184 @@
import { useCallback, useEffect, useState } from "react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { DocumentPreviewData } from "@/lib/types/document-preview";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
interface PreviewPagesViewerProps {
documentData: DocumentPreviewData;
onClose: () => void;
}
export function PreviewPagesViewer({
documentData,
onClose,
}: PreviewPagesViewerProps) {
const [currentPage, setCurrentPage] = useState(1);
const [imageCache, setImageCache] = useState<{ [key: number]: boolean }>({});
const [imageLoaded, setImageLoaded] = useState(imageCache[1] || false);
const { pages, numPages, documentName, isVertical } = documentData;
const goToNextPage = useCallback(() => {
if (currentPage < numPages) {
setCurrentPage(currentPage + 1);
setImageLoaded(imageCache[currentPage + 1] || false);
}
}, [currentPage, numPages, imageCache]);
const goToPreviousPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
setImageLoaded(imageCache[currentPage - 1] || false);
}
}, [currentPage, numPages, imageCache]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowLeft":
goToPreviousPage();
break;
case "ArrowRight":
goToNextPage();
break;
case "Escape":
onClose();
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [goToPreviousPage, goToNextPage, onClose]);
if (!pages || pages.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-gray-400">No pages available for preview</p>
</div>
);
}
const currentPageData = pages[currentPage - 1];
const handleImageLoad = () => {
setImageLoaded(true);
setImageCache((prev) => ({ ...prev, [currentPage]: true }));
};
if (!currentPageData) {
return (
<div className="flex h-full w-full items-center justify-center">
<p className="text-gray-400">Page not found</p>
</div>
);
}
return (
<div className="relative h-full w-full overflow-hidden">
{/* Navigation Controls */}
<div className="absolute left-4 top-4 z-50">
<div className="flex items-center gap-2 rounded-lg bg-black/20 px-3 py-2 text-white">
<span className="text-sm">
Page {currentPage} of {numPages}
</span>
</div>
</div>
{/* Document Title */}
<div className="absolute left-1/2 top-4 z-50 -translate-x-1/2">
<div className="rounded-lg bg-black/20 px-3 py-2 text-white">
<span className="text-sm font-medium">{documentName}</span>
</div>
</div>
{/* Previous Page Button */}
<Button
variant="ghost"
size="icon"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
className={cn(
"absolute left-4 top-1/2 z-40 h-12 w-12 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40",
currentPage <= 1 && "cursor-not-allowed opacity-50",
)}
>
<ChevronLeftIcon className="h-6 w-6" />
</Button>
{/* Next Page Button */}
<Button
variant="ghost"
size="icon"
onClick={goToNextPage}
disabled={currentPage >= numPages}
className={cn(
"absolute right-4 top-1/2 z-40 h-12 w-12 -translate-y-1/2 rounded-full bg-black/20 text-white hover:bg-black/40",
currentPage >= numPages && "cursor-not-allowed opacity-50",
)}
>
<ChevronRightIcon className="h-6 w-6" />
</Button>
{/* Page Content */}
<div className="flex h-full w-full items-center justify-center p-4">
<div className="relative max-h-full max-w-full">
{!imageLoaded && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
</div>
)}
<img
src={currentPageData.file}
alt={`Page ${currentPage}`}
className={cn(
"max-h-[calc(100vh-120px)] max-w-full object-contain transition-opacity duration-200",
imageLoaded ? "opacity-100" : "opacity-0",
)}
onLoad={handleImageLoad}
onError={() => {
setImageLoaded(true);
setImageCache((prev) => ({ ...prev, [currentPage]: true }));
}}
draggable={false}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
</div>
{/* Bottom Navigation */}
<div className="absolute bottom-4 left-1/2 z-50 -translate-x-1/2">
<div className="flex items-center gap-2 rounded-lg bg-black/20 px-4 py-2">
<Button
variant="ghost"
size="sm"
onClick={goToPreviousPage}
disabled={currentPage <= 1}
className="h-8 px-3 text-white hover:bg-white/10"
>
Previous
</Button>
<span className="text-sm text-white">
{currentPage} / {numPages}
</span>
<Button
variant="ghost"
size="sm"
onClick={goToNextPage}
disabled={currentPage >= numPages}
className="h-8 px-3 text-white hover:bg-white/10"
>
Next
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,68 @@
import { DocumentPreviewData } from "@/lib/types/document-preview";
import { PreviewImageViewer } from "./preview-image-viewer";
import { PreviewPagesViewer } from "./preview-pages-viewer";
interface PreviewViewerProps {
documentData: DocumentPreviewData;
onClose: () => void;
}
export function PreviewViewer({ documentData, onClose }: PreviewViewerProps) {
const renderViewer = () => {
// Documents with pages (PDFs, docs, slides)
if (documentData.pages && documentData.pages.length > 0) {
return (
<PreviewPagesViewer documentData={documentData} onClose={onClose} />
);
}
// Single image files
if (documentData.fileType === "image" && documentData.file) {
return (
<PreviewImageViewer documentData={documentData} onClose={onClose} />
);
}
// Excel/CSV files
if (documentData.fileType === "sheet" && documentData.sheetData) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<p className="text-gray-400">
Sheet preview coming soon. Please preview via a shared link.
</p>
</div>
</div>
);
}
// Notion documents (not fully supported yet)
if (documentData.fileType === "notion") {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<p className="text-gray-400">Notion document preview coming soon</p>
</div>
</div>
);
}
// Fallback for unsupported types
return (
<div className="flex h-full w-full items-center justify-center">
<div className="text-center">
<p className="text-gray-400">
Preview not available for this document type
</p>
</div>
</div>
);
};
return (
<div className="relative h-full w-full rounded-lg bg-gray-900">
{renderViewer()}
</div>
);
}
+11 -9
View File
@@ -5,6 +5,10 @@ import { PlanEnum } from "@/ee/stripe/constants";
import { toast } from "sonner";
import { z } from "zod";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -18,11 +22,8 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAnalytics } from "@/lib/analytics";
import { usePlan } from "@/lib/swr/use-billing";
import useLimits from "@/lib/swr/use-limits";
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
import { UpgradeButton } from "../ui/upgrade-button";
export function AddDomainModal({
open,
@@ -116,16 +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"}
>
<Button>Upgrade to Add Domain</Button>
</UpgradePlanModal>
highlightItem={["custom-domain"]}
trigger="add_domain_overview"
/>
);
} else {
return (
@@ -138,6 +139,7 @@ export function AddDomainModal({
open={open}
setOpen={setOpen}
trigger={"add_domain_link_sheet"}
highlightItem={["custom-domain"]}
/>
);
}
+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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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>
);
}
+79
View File
@@ -0,0 +1,79 @@
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ExportReady({
resourceName = "Export",
downloadUrl = "https://app.papermark.com/datarooms/123",
email = "email@example.com",
}: {
resourceName?: string;
downloadUrl: string;
email: string;
}) {
return (
<Html>
<Head />
<Preview>Your {resourceName} export is ready for download</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="text-sm leading-6 text-black">
The export you requested is ready to download for your Papermark
account. Make sure you&apos;re signed into this account, and click
below to download. The file will be available for the next three
days.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={downloadUrl}
style={{ padding: "12px 20px" }}
>
Download Export
</Button>
</Section>
<Text className="text-sm leading-6 text-black">
Export details:
</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>
<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>
);
}
@@ -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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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 visited:text-gray-400 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>

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