Compare commits
600
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4dac1c83 | ||
|
|
8abf39357b | ||
|
|
73fbef9f51 | ||
|
|
dc36c24cd5 | ||
|
|
6fadbfa212 | ||
|
|
daf8987141 | ||
|
|
f9266fc611 | ||
|
|
b8a4265edf | ||
|
|
4e86e2a8d9 | ||
|
|
1f5366e152 | ||
|
|
cca9d12eca | ||
|
|
4698748803 | ||
|
|
9b99839e51 | ||
|
|
2eba40a4c5 | ||
|
|
103a11e04f | ||
|
|
4d8856a92e | ||
|
|
ed336579e8 | ||
|
|
b0a4fbe17b | ||
|
|
2489d0c05e | ||
|
|
3ab376b522 | ||
|
|
6cd1820ce7 | ||
|
|
010aa4a4d1 | ||
|
|
4e856d351f | ||
|
|
0831a047e2 | ||
|
|
0999c6bd41 | ||
|
|
81c3a38148 | ||
|
|
46574bfdd6 | ||
|
|
a0f70fb05e | ||
|
|
bc84b2b84e | ||
|
|
7e0647f9f7 | ||
|
|
b71592aafa | ||
|
|
33899e693f | ||
|
|
6170b20205 | ||
|
|
96414bca5a | ||
|
|
3027d7e579 | ||
|
|
2060cca3a4 | ||
|
|
992216bb20 | ||
|
|
f8bf8aa6f6 | ||
|
|
914b7dd4c5 | ||
|
|
a6a52c4844 | ||
|
|
66d66ee515 | ||
|
|
cf9c3909ff | ||
|
|
5dc02cf6a5 | ||
|
|
6142c5746e | ||
|
|
7c9c7bcaf7 | ||
|
|
82ef46848e | ||
|
|
0b37bb9e27 | ||
|
|
9c02ad2223 | ||
|
|
ca9dc9b2b1 | ||
|
|
7eed8deb32 | ||
|
|
6d38f304b8 | ||
|
|
8c002304d8 | ||
|
|
fd05a86f50 | ||
|
|
2ccb23ddf6 | ||
|
|
0c618998b6 | ||
|
|
87c649f7f0 | ||
|
|
4fd7369dba | ||
|
|
8b87f23225 | ||
|
|
fa2257e564 | ||
|
|
16398bbe32 | ||
|
|
e842a56dae | ||
|
|
3c6fae3e53 | ||
|
|
635d39a5ea | ||
|
|
19de3594bc | ||
|
|
0f977e2fb2 | ||
|
|
37f0b15ae9 | ||
|
|
e55e40d5fe | ||
|
|
2bfc0238e2 | ||
|
|
4dd874c198 | ||
|
|
32661529e7 | ||
|
|
63062819eb | ||
|
|
ace2ed5a4a | ||
|
|
952da2c258 | ||
|
|
890d69b2a5 | ||
|
|
b42cc06989 | ||
|
|
9a908d9965 | ||
|
|
b22234a28f | ||
|
|
dbb6d9d520 | ||
|
|
a4913820c1 | ||
|
|
070ab6608b | ||
|
|
8311543423 | ||
|
|
1be79bbbfe | ||
|
|
4ac1e36788 | ||
|
|
610257a320 | ||
|
|
6e6122861a | ||
|
|
7e7f9359ef | ||
|
|
2cec85ad35 | ||
|
|
4c02f435b7 | ||
|
|
c000d99710 | ||
|
|
ad5c166938 | ||
|
|
0445962742 | ||
|
|
a6e9e50fcc | ||
|
|
b5cc6e15e1 | ||
|
|
d8c6660fa1 | ||
|
|
558835261f | ||
|
|
9eb480059e | ||
|
|
8236ae1e89 | ||
|
|
635e42a4f8 | ||
|
|
ddda4ef454 | ||
|
|
f93b083d92 | ||
|
|
db3d07369f | ||
|
|
7702eb3ca6 | ||
|
|
e6349f9db9 | ||
|
|
8679378e88 | ||
|
|
9428bb2fd2 | ||
|
|
33f60b143a | ||
|
|
e6d6dcdbaa | ||
|
|
f6290e6ab2 | ||
|
|
61b96fe360 | ||
|
|
0c77418265 | ||
|
|
d50d31a31b | ||
|
|
50bef1e894 | ||
|
|
e26609769c | ||
|
|
4bdf311cc0 | ||
|
|
f5c2403c57 | ||
|
|
22a1d1a171 | ||
|
|
3e087e6bb1 | ||
|
|
a4d96145b6 | ||
|
|
1a44cb7e71 | ||
|
|
c7450cae35 | ||
|
|
ead8f8a195 | ||
|
|
f3107d172d | ||
|
|
8b534abef9 | ||
|
|
ae07c72f20 | ||
|
|
e536082051 | ||
|
|
9c8d7caa45 | ||
|
|
72fc18d1b0 | ||
|
|
ef3b1a23e4 | ||
|
|
3d2500b109 | ||
|
|
b3c8b11295 | ||
|
|
5cf3b33d96 | ||
|
|
e954014f7f | ||
|
|
297a81326e | ||
|
|
8c08d6c99c | ||
|
|
bd45eddadb | ||
|
|
ec31082790 | ||
|
|
a6d0553cf5 | ||
|
|
0774f29438 | ||
|
|
5c80c410df | ||
|
|
9d8f0e5998 | ||
|
|
bebe96dd5c | ||
|
|
efb7ba9e4f | ||
|
|
0590bab122 | ||
|
|
d014bdfefa | ||
|
|
faf99e9ef3 | ||
|
|
2a791d0ca3 | ||
|
|
18bfebb11e | ||
|
|
294efb89dd | ||
|
|
d926083097 | ||
|
|
fbee53aeb2 | ||
|
|
d18cb16461 | ||
|
|
05cda54338 | ||
|
|
ac74bd2be0 | ||
|
|
d5df006e64 | ||
|
|
4637d71bb2 | ||
|
|
7fa983977b | ||
|
|
15dd0eff53 | ||
|
|
c6f08be03d | ||
|
|
8be2fa5366 | ||
|
|
ffc99b7e67 | ||
|
|
42d05ed647 | ||
|
|
2852de5572 | ||
|
|
e9bbaac5a0 | ||
|
|
c1f49e855e | ||
|
|
9dcd9b68af | ||
|
|
daefbde9bf | ||
|
|
6f92160d57 | ||
|
|
8ec18ea0c3 | ||
|
|
95c548f144 | ||
|
|
78554ded87 | ||
|
|
d335c7f1f7 | ||
|
|
67550162f2 | ||
|
|
e7e01dc6f0 | ||
|
|
6a604d7a13 | ||
|
|
615ac0220f | ||
|
|
029d765f5d | ||
|
|
0cdc74258f | ||
|
|
c2182a6042 | ||
|
|
e219c53739 | ||
|
|
1bb7e79a37 | ||
|
|
bf7c9ff5d4 | ||
|
|
c652ad607b | ||
|
|
141def2c54 | ||
|
|
31e221f175 | ||
|
|
ad04e8859f | ||
|
|
90aba22628 | ||
|
|
cf704906b4 | ||
|
|
fdcc36d961 | ||
|
|
a923ce6dfb | ||
|
|
0e92372907 | ||
|
|
7de964e4f0 | ||
|
|
f386f275e6 | ||
|
|
46d8922f59 | ||
|
|
44292347f1 | ||
|
|
c6fc0b824b | ||
|
|
eb70302dd2 | ||
|
|
0f3aeb5ea7 | ||
|
|
0dfdd2565e | ||
|
|
a8560359fd | ||
|
|
a4cb2e4e72 | ||
|
|
9c8841620a | ||
|
|
0df6b96d68 | ||
|
|
9226ba5a70 | ||
|
|
927796c0cd | ||
|
|
3988e6af49 | ||
|
|
f962b97729 | ||
|
|
2b918234d8 | ||
|
|
07c2821d4e | ||
|
|
6fe27092d4 | ||
|
|
09e1d58d07 | ||
|
|
b66dd7f3d1 | ||
|
|
179a93c31b | ||
|
|
9d50baec54 | ||
|
|
afc210d9cb | ||
|
|
cb05dd0656 | ||
|
|
ea59fdbdc5 | ||
|
|
116a903489 | ||
|
|
73798ce172 | ||
|
|
be8d482efc | ||
|
|
1a9b75a1a4 | ||
|
|
e1803c1f01 | ||
|
|
ff3ee45c14 | ||
|
|
3f3e8c8377 | ||
|
|
be52b37f3d | ||
|
|
1afc0832fc | ||
|
|
fcf5be17e6 | ||
|
|
9d81120017 | ||
|
|
30e8c6e8cf | ||
|
|
47b91488cb | ||
|
|
d28a2a257c | ||
|
|
428c0fbd2d | ||
|
|
fb5e4ef47f | ||
|
|
e404919c45 | ||
|
|
589902deb1 | ||
|
|
31d44dac27 | ||
|
|
15e8d1c7d7 | ||
|
|
20f423c3c1 | ||
|
|
7b8bd28ca5 | ||
|
|
13266da720 | ||
|
|
bfe0cdc963 | ||
|
|
bc1671f063 | ||
|
|
e99accc2ec | ||
|
|
59f27b3afd | ||
|
|
bf246ff863 | ||
|
|
2a1d1465cc | ||
|
|
b8cbf94b4d | ||
|
|
adc450c899 | ||
|
|
0d55dba60d | ||
|
|
f0d08fe2a4 | ||
|
|
b0fbddbd60 | ||
|
|
3d722e18ec | ||
|
|
4c6d90ac5d | ||
|
|
e4601bbd41 | ||
|
|
b437a4ba04 | ||
|
|
95c60ae564 | ||
|
|
c0da96f123 | ||
|
|
7198b4c205 | ||
|
|
a13c7d9a77 | ||
|
|
680fd69f4d | ||
|
|
f4d21c64e4 | ||
|
|
7144f46152 | ||
|
|
2da5c31de7 | ||
|
|
3844d87d6e | ||
|
|
373a15672c | ||
|
|
d023c95272 | ||
|
|
e6c8b4b80f | ||
|
|
6e6595b709 | ||
|
|
132c9815fa | ||
|
|
48f443bb97 | ||
|
|
4d77cfe342 | ||
|
|
c64a83400b | ||
|
|
3e54f057a0 | ||
|
|
ef1a00d712 | ||
|
|
a65049fe8d | ||
|
|
29236ae6b6 | ||
|
|
603b9bd3e7 | ||
|
|
1eb0e56f67 | ||
|
|
9c29192245 | ||
|
|
d7440bfe5e | ||
|
|
aad1e31d17 | ||
|
|
1545332b8b | ||
|
|
03f79d3f57 | ||
|
|
3c4a4a254d | ||
|
|
ab73dbc66f | ||
|
|
daf2d9b4a2 | ||
|
|
bdd67017b8 | ||
|
|
0e4d45a28a | ||
|
|
e1e7831513 | ||
|
|
d761f78873 | ||
|
|
13f3de029d | ||
|
|
b57487b9a3 | ||
|
|
bdadca6466 | ||
|
|
5cda57bf06 | ||
|
|
66497af2d9 | ||
|
|
ab00235829 | ||
|
|
8331434bef | ||
|
|
2e84c92c96 | ||
|
|
84e206e484 | ||
|
|
36e2c4f68c | ||
|
|
2705c580f3 | ||
|
|
b3f918a65d | ||
|
|
e5a1c11a80 | ||
|
|
f0d79474e0 | ||
|
|
17f6604fb4 | ||
|
|
f9fe6577f9 | ||
|
|
8b6d4448fc | ||
|
|
1214a94730 | ||
|
|
b7909812a0 | ||
|
|
deb120e6e4 | ||
|
|
c1f856c78d | ||
|
|
79d3fc1068 | ||
|
|
f121b76d99 | ||
|
|
33a8c8b95a | ||
|
|
122af2b03b | ||
|
|
8885f64c74 | ||
|
|
e367fadccf | ||
|
|
31c4be432b | ||
|
|
24ea34c2ce | ||
|
|
4a280b15d4 | ||
|
|
ff4bb0ad84 | ||
|
|
a5c98e8b2c | ||
|
|
577eb884ac | ||
|
|
ec658d4eb8 | ||
|
|
6bc0db96f8 | ||
|
|
c73a409375 | ||
|
|
127e366436 | ||
|
|
950f7e12e3 | ||
|
|
1e107c8400 | ||
|
|
db0b9f8913 | ||
|
|
b3401b9b5b | ||
|
|
7a381cdd5f | ||
|
|
ec4d76d89d | ||
|
|
919174e988 | ||
|
|
1f3e17a4ad | ||
|
|
3fc3dbc7cb | ||
|
|
10d19031b3 | ||
|
|
6afd4a8cb5 | ||
|
|
2e66da1478 | ||
|
|
92ec0d66c0 | ||
|
|
d0d2a54389 | ||
|
|
e0d8c3da23 | ||
|
|
35af870cb2 | ||
|
|
e5acb6e021 | ||
|
|
eb81ba6537 | ||
|
|
01d0fdadee | ||
|
|
1c4df1595d | ||
|
|
3e5be706c6 | ||
|
|
795c0064f8 | ||
|
|
5b065609af | ||
|
|
dd0d34256c | ||
|
|
13c1c12286 | ||
|
|
557f997074 | ||
|
|
11392eaa1d | ||
|
|
6f57c3efe5 | ||
|
|
d0ae1a4926 | ||
|
|
846cefcd22 | ||
|
|
612f16c16d | ||
|
|
1235e4bbee | ||
|
|
97964c3f3a | ||
|
|
156f9953a8 | ||
|
|
96f905b315 | ||
|
|
7343382e84 | ||
|
|
682800dfb3 | ||
|
|
28cf2b7701 | ||
|
|
075b9b1fde | ||
|
|
8d8c777116 | ||
|
|
7072d83de2 | ||
|
|
1182a747e3 | ||
|
|
5970450017 | ||
|
|
4118a12640 | ||
|
|
0503d904ac | ||
|
|
77c0155d24 | ||
|
|
c66b27c980 | ||
|
|
185d699cad | ||
|
|
0bc209f65b | ||
|
|
522d50198b | ||
|
|
d68e631959 | ||
|
|
42dfd0d114 | ||
|
|
c9b170badb | ||
|
|
bad29a4d44 | ||
|
|
d30e3222ef | ||
|
|
cf3d0aa32f | ||
|
|
2279fb4508 | ||
|
|
2bee053ffc | ||
|
|
d8d21123bf | ||
|
|
8e10c6be63 | ||
|
|
eae23b1a1f | ||
|
|
7fc65ef699 | ||
|
|
752f9f8b13 | ||
|
|
c1e80fc8f1 | ||
|
|
dad5d85465 | ||
|
|
791c5a747b | ||
|
|
594e7540d8 | ||
|
|
ddb35e358c | ||
|
|
3b48e6d8cb | ||
|
|
cc73caf024 | ||
|
|
bd56bbc827 | ||
|
|
a1bd0505dc | ||
|
|
e4f01300fd | ||
|
|
48369adb0a | ||
|
|
8fe49be361 | ||
|
|
3025f30be4 | ||
|
|
67ec53da8b | ||
|
|
bfd7c17276 | ||
|
|
a6b0c64a5d | ||
|
|
136b74cf39 | ||
|
|
13fd870ce7 | ||
|
|
4f00b1be79 | ||
|
|
e1dd35d4d3 | ||
|
|
160b7bc288 | ||
|
|
f9dbc7d565 | ||
|
|
aa556f4900 | ||
|
|
cde74a0acf | ||
|
|
9ac1e1e645 | ||
|
|
caa732aa21 | ||
|
|
39abc7d5ac | ||
|
|
e28054eaae | ||
|
|
7385393342 | ||
|
|
f70b1c06e0 | ||
|
|
79aef1ec95 | ||
|
|
07b442c343 | ||
|
|
0457891ed3 | ||
|
|
ecb4911a6a | ||
|
|
d29736f0fa | ||
|
|
699c5d1d1d | ||
|
|
8ea4104de2 | ||
|
|
fbb8fccbce | ||
|
|
ae46369e3f | ||
|
|
359b1bcac2 | ||
|
|
6cd1e13ee3 | ||
|
|
a610f58914 | ||
|
|
3d5be65561 | ||
|
|
612227e998 | ||
|
|
e569677358 | ||
|
|
8829e8e429 | ||
|
|
60a1e5c0df | ||
|
|
d56ecf72cc | ||
|
|
765a6acfa6 | ||
|
|
60d7854af5 | ||
|
|
e53ec612d4 | ||
|
|
89da80dceb | ||
|
|
14669a474c | ||
|
|
4271188d4c | ||
|
|
ff56a4de28 | ||
|
|
36c7a925b4 | ||
|
|
616d6d4b40 | ||
|
|
42abd683f6 | ||
|
|
7064ad67a8 | ||
|
|
59ce53971b | ||
|
|
3b8e394257 | ||
|
|
5004f1e98f | ||
|
|
933493fd61 | ||
|
|
921ac193f4 | ||
|
|
ed57f1e167 | ||
|
|
990963e881 | ||
|
|
a4cac45815 | ||
|
|
aa68f54596 | ||
|
|
a8a7bf59b1 | ||
|
|
c9c7b0bdda | ||
|
|
f8bb14eb53 | ||
|
|
e75e435ec6 | ||
|
|
86b5561d2e | ||
|
|
3c3f5291c2 | ||
|
|
fe0bc821aa | ||
|
|
af854c1d7e | ||
|
|
8ab007d175 | ||
|
|
821c15117e | ||
|
|
18be2b155b | ||
|
|
3c80e144d5 | ||
|
|
3185a0805c | ||
|
|
b2bd5210cf | ||
|
|
eebe5a2060 | ||
|
|
e125121ae8 | ||
|
|
e27d47d564 | ||
|
|
a630b2e172 | ||
|
|
c74a95ea80 | ||
|
|
07a5b69c4e | ||
|
|
1f10c7af03 | ||
|
|
259a025d6e | ||
|
|
b25bad7baa | ||
|
|
e1ab66b4eb | ||
|
|
046a243309 | ||
|
|
24da219061 | ||
|
|
4cca4c0135 | ||
|
|
7c03528483 | ||
|
|
e8a432b1cd | ||
|
|
4ebb54b1a2 | ||
|
|
555733830c | ||
|
|
e5e517565e | ||
|
|
9afc9d1867 | ||
|
|
1fb882cad4 | ||
|
|
c0c0da6c5d | ||
|
|
854b96c34b | ||
|
|
40943e0a79 | ||
|
|
790a730f74 | ||
|
|
c589c38967 | ||
|
|
ff40bfc9e6 | ||
|
|
9c093dae49 | ||
|
|
a09cc2624b | ||
|
|
92e0e1b7c4 | ||
|
|
1eded7eb81 | ||
|
|
4a03d961ab | ||
|
|
94d0f49eb3 | ||
|
|
a92fc7b083 | ||
|
|
80c3c7579d | ||
|
|
97fee2154e | ||
|
|
3f380f45ee | ||
|
|
db7b2b5da8 | ||
|
|
59730a526e | ||
|
|
40268bac70 | ||
|
|
72e6fbdf60 | ||
|
|
bd9cffef60 | ||
|
|
b3f0dac16f | ||
|
|
2f02c50612 | ||
|
|
77365ccb74 | ||
|
|
2c6064d108 | ||
|
|
0081f6ca3f | ||
|
|
94bc917189 | ||
|
|
fcf6ac7c85 | ||
|
|
42ec37153a | ||
|
|
32b5f697a6 | ||
|
|
f4b57648a7 | ||
|
|
350a4ba86b | ||
|
|
6913c3c3af | ||
|
|
ed0447002b | ||
|
|
982376385c | ||
|
|
64d1da5c6e | ||
|
|
bba727a3a2 | ||
|
|
2a4e760c6e | ||
|
|
4c7b173f0a | ||
|
|
ac32521299 | ||
|
|
7f84b6cd39 | ||
|
|
4ee35f0057 | ||
|
|
ed24f05dd1 | ||
|
|
197e4821e8 | ||
|
|
c978913500 | ||
|
|
ed7ecf075d | ||
|
|
48d042d9db | ||
|
|
59feaf97e7 | ||
|
|
a92a36f66c | ||
|
|
35a5524f20 | ||
|
|
b1e00b9009 | ||
|
|
68f2e1bf5b | ||
|
|
9ead803e08 | ||
|
|
e4f8298e6d | ||
|
|
db33518509 | ||
|
|
4770559959 | ||
|
|
4a4c979769 | ||
|
|
8761c587fe | ||
|
|
4e365aaa43 | ||
|
|
b127000e85 | ||
|
|
d9c6958544 | ||
|
|
d93e429a39 | ||
|
|
3c55cc9dc5 | ||
|
|
1bb5edab47 | ||
|
|
52df46f2fd | ||
|
|
70884506a3 | ||
|
|
8569e34263 | ||
|
|
e956532bce | ||
|
|
1b3a74c427 | ||
|
|
e61928e7ec | ||
|
|
2391dc08e2 | ||
|
|
d6ee8b0695 | ||
|
|
198ac590b3 | ||
|
|
553d1d0f7f | ||
|
|
2ec5931a62 | ||
|
|
66798e54fd | ||
|
|
38fbfc2dcd | ||
|
|
b798de4403 | ||
|
|
7298363203 | ||
|
|
e6651c6be7 | ||
|
|
2b440ceae5 | ||
|
|
8a7def0aad | ||
|
|
2b6e5fd16d | ||
|
|
ea15764efa | ||
|
|
11be36c196 | ||
|
|
c2c165470b | ||
|
|
eb598e76bc | ||
|
|
aedf17edb7 | ||
|
|
ba79db153e | ||
|
|
1ded637038 | ||
|
|
77e483ede5 | ||
|
|
aa65baa644 | ||
|
|
72ca10ec4b | ||
|
|
7fbc0eff9b | ||
|
|
37930543c2 | ||
|
|
00be21fa38 | ||
|
|
c529baf497 | ||
|
|
6b77c59035 | ||
|
|
3e9241d384 | ||
|
|
8eee985240 | ||
|
|
30467822a9 | ||
|
|
14b8e4d779 | ||
|
|
9c48c08dad | ||
|
|
fa1e9bbfb4 | ||
|
|
df60d73684 | ||
|
|
34c5549498 | ||
|
|
0304ea9bdf | ||
|
|
e996a4833f | ||
|
|
91254e0139 |
@@ -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
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function ProBanner({
|
||||
</p>
|
||||
<div className="flex">
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Business}
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger={"pro_banner"}
|
||||
>
|
||||
<Button
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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' 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 />
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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'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;
|
||||
@@ -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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'd love to hear from you!{" "}
|
||||
reply to it. I'd love to hear from you!
|
||||
</Text>
|
||||
|
||||
<Text className="text-xs">Stop this onboarding sequence</Text>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "powered by Papermark"</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 "powered by Papermark"</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
|
||||
|
||||
@@ -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'd love to
|
||||
hear from you!{" "}
|
||||
</Text>
|
||||
|
||||
{/* <Text className="text-xs">Stop this onboarding sequence. </Text> */}
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding3Email;
|
||||
@@ -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'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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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'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'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;
|
||||
@@ -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'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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrialEndFinalReminderEmail;
|
||||
@@ -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'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'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'm Iuliia, co-founder of Papermark. Thanks for upgrading!
|
||||
I'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;
|
||||
@@ -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'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'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's been your biggest win using Papermark?</Text>
|
||||
<Text>
|
||||
Marc here. It'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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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'd love to hear from you!
|
||||
</Text>
|
||||
</Section>
|
||||
<Footer />
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
@@ -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'd love to hear from you!
|
||||
</Text>
|
||||
<Text className="text-xs">
|
||||
To stop email notifications for this link, edit the link and
|
||||
uncheck "Receive email notification".
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Footer
|
||||
footerText={
|
||||
<>
|
||||
If you have any feedback or questions about this email, simply
|
||||
reply to it. I'd love to hear from you!
|
||||
<br />
|
||||
<br />
|
||||
To stop email notifications for this link, edit the link and
|
||||
uncheck "Receive email notification".
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user