Compare commits
827
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f95f0b54d | ||
|
|
957dd75754 | ||
|
|
caed976656 | ||
|
|
347d339c75 | ||
|
|
a014ff9bfb | ||
|
|
58f18f1a32 | ||
|
|
ccb4f8070a | ||
|
|
d00e56cd90 | ||
|
|
0329695864 | ||
|
|
10d00277bf | ||
|
|
28e07d0ff9 | ||
|
|
b46a29037a | ||
|
|
e74f80abff | ||
|
|
bd7192a402 | ||
|
|
5dc89f8614 | ||
|
|
ad4fb6e949 | ||
|
|
bc39c21b0e | ||
|
|
6d1da07779 | ||
|
|
6a0393fa63 | ||
|
|
99b3207691 | ||
|
|
cad9b67d69 | ||
|
|
1544e9569d | ||
|
|
c1d050c479 | ||
|
|
33907e5ba4 | ||
|
|
6292caa53a | ||
|
|
d43d21bced | ||
|
|
3d6d325770 | ||
|
|
e378cb6138 | ||
|
|
a981121faf | ||
|
|
aa9f4db408 | ||
|
|
c1cea5e9f6 | ||
|
|
2a389f885d | ||
|
|
a04db6625c | ||
|
|
6812fdd48a | ||
|
|
cbbc8410ba | ||
|
|
52500a427d | ||
|
|
c81e8a2ed3 | ||
|
|
5acd71b499 | ||
|
|
73444c8285 | ||
|
|
915df35378 | ||
|
|
7ce9bcfb43 | ||
|
|
5a0f25ec3a | ||
|
|
44d2af43fb | ||
|
|
34dbff53c8 | ||
|
|
7bdd6a61f6 | ||
|
|
a445d082be | ||
|
|
46bf92c5e9 | ||
|
|
283b0dd7a9 | ||
|
|
c09226d42e | ||
|
|
1d4e54cb3c | ||
|
|
6593d60509 | ||
|
|
a0c89638b5 | ||
|
|
491c010a17 | ||
|
|
7c73a20ce9 | ||
|
|
0358927fe3 | ||
|
|
e35f62e563 | ||
|
|
668349ad65 | ||
|
|
d2370f2686 | ||
|
|
d2ee26252c | ||
|
|
6d46db33fc | ||
|
|
7966c70fd0 | ||
|
|
1a399aedfc | ||
|
|
e3015ca590 | ||
|
|
9655d6bada | ||
|
|
474ea74a6f | ||
|
|
cb875d7d09 | ||
|
|
bdab9a56e6 | ||
|
|
c562fb6c18 | ||
|
|
29e1cdaff9 | ||
|
|
05c320e123 | ||
|
|
c248fb259e | ||
|
|
e492f36216 | ||
|
|
a9cd266279 | ||
|
|
a7aa1ecd35 | ||
|
|
65059f4562 | ||
|
|
be325c712f | ||
|
|
b322c4b313 | ||
|
|
93215c8111 | ||
|
|
fbe1241175 | ||
|
|
fa5f0ff7fa | ||
|
|
890718bcb5 | ||
|
|
f8e79fb747 | ||
|
|
787d80ac47 | ||
|
|
1ba44c287a | ||
|
|
3eae82f830 | ||
|
|
ecc00a8dbe | ||
|
|
4c1fdc8a5f | ||
|
|
f5b69f9f79 | ||
|
|
d506d2319a | ||
|
|
b1fd7c7ca3 | ||
|
|
db78af011b | ||
|
|
da3fbb9d9e | ||
|
|
e95baf78dd | ||
|
|
4253b2a6e8 | ||
|
|
fd9628565d | ||
|
|
3f133f8728 | ||
|
|
d1127ecf55 | ||
|
|
5e4523d413 | ||
|
|
de9190035e | ||
|
|
da5cb8aff1 | ||
|
|
e2357afb55 | ||
|
|
49a94c8549 | ||
|
|
ee1f3b65bc | ||
|
|
1aeb2cb28f | ||
|
|
dba9850ad9 | ||
|
|
2ffb20ec94 | ||
|
|
d020ac53a1 | ||
|
|
6a0702cd23 | ||
|
|
82f5ac83df | ||
|
|
d2abbeb886 | ||
|
|
f55b6aabd3 | ||
|
|
6ed3a8f4e7 | ||
|
|
fb2c8d3b24 | ||
|
|
a0286e1765 | ||
|
|
9071aae4c2 | ||
|
|
d581989f03 | ||
|
|
6de28a6447 | ||
|
|
a8d6b2f417 | ||
|
|
ea9df7f611 | ||
|
|
1fc1dda682 | ||
|
|
1d977d2290 | ||
|
|
7e5b416546 | ||
|
|
643320b860 | ||
|
|
277889b745 | ||
|
|
9d68da1284 | ||
|
|
7c8ff05a22 | ||
|
|
c2d4b5615f | ||
|
|
5db161a1cd | ||
|
|
a19cdea265 | ||
|
|
601e60b7a6 | ||
|
|
6b47cdc8f5 | ||
|
|
8df1e43ed6 | ||
|
|
f481fb2e65 | ||
|
|
da0ad391d2 | ||
|
|
97dca7cc55 | ||
|
|
3c8781af96 | ||
|
|
660e025647 | ||
|
|
53d09ef437 | ||
|
|
8698f710b6 | ||
|
|
4e87dd0aac | ||
|
|
91407a5d94 | ||
|
|
a0be39e551 | ||
|
|
77e15d7287 | ||
|
|
9c575472eb | ||
|
|
b16decab6b | ||
|
|
006c6e6826 | ||
|
|
f4812fc8a8 | ||
|
|
08e045ed11 | ||
|
|
6169d297a2 | ||
|
|
3a945f1c58 | ||
|
|
b4681866ae | ||
|
|
12eba5d39b | ||
|
|
9b1c4df214 | ||
|
|
f24bd26ad3 | ||
|
|
f6a09b1647 | ||
|
|
eb1d325021 | ||
|
|
9927f7c0ac | ||
|
|
c5373fc903 | ||
|
|
526c036e61 | ||
|
|
921b237b86 | ||
|
|
8e2d06669d | ||
|
|
2e6a4fe356 | ||
|
|
63ce7ba619 | ||
|
|
52c9411143 | ||
|
|
e80b59de3a | ||
|
|
e0bcad2cfe | ||
|
|
a209eab11f | ||
|
|
b1c5673076 | ||
|
|
e17d9f2ba5 | ||
|
|
08f0020049 | ||
|
|
6a42edb734 | ||
|
|
267d75848b | ||
|
|
804350fd27 | ||
|
|
b88dcb6055 | ||
|
|
505a5ea1e3 | ||
|
|
bf292d420e | ||
|
|
0b8d204514 | ||
|
|
8c1a36c60b | ||
|
|
7f45a4d251 | ||
|
|
6d5ba6b6e3 | ||
|
|
a5658720fc | ||
|
|
be934b9d98 | ||
|
|
5f699e33d8 | ||
|
|
1ed2ecac19 | ||
|
|
fda5c8af95 | ||
|
|
11fc479623 | ||
|
|
e8650b23ca | ||
|
|
1d94f79d55 | ||
|
|
b5613e9bb1 | ||
|
|
1ff5097b20 | ||
|
|
e38e30f16c | ||
|
|
b8e82ff7f1 | ||
|
|
04470ab8a3 | ||
|
|
96677f95ad | ||
|
|
fb749ad02b | ||
|
|
17d109b10a | ||
|
|
177d51dfb4 | ||
|
|
d67f501d8e | ||
|
|
426bc9f1fb | ||
|
|
898959b853 | ||
|
|
33d8a85f8e | ||
|
|
4c0b8c0991 | ||
|
|
c04212a7ef | ||
|
|
0f9ff62934 | ||
|
|
5253362286 | ||
|
|
911f60dbfd | ||
|
|
473463f946 | ||
|
|
a5bdadbe9d | ||
|
|
96365b750f | ||
|
|
4f2b68b87a | ||
|
|
cb9c53b29c | ||
|
|
332db018b2 | ||
|
|
381a2ca4b9 | ||
|
|
6a0b3854a1 | ||
|
|
04f6b48c07 | ||
|
|
7f4beb1ef9 | ||
|
|
170c685ee7 | ||
|
|
cf292e4ca0 | ||
|
|
f05046ee19 | ||
|
|
c48b6480b7 | ||
|
|
33c64d0e61 | ||
|
|
f410aab14d | ||
|
|
e3a48714b1 | ||
|
|
82d42cf0ae | ||
|
|
5d3680decf | ||
|
|
e1a172eb93 | ||
|
|
02dde44c65 | ||
|
|
28ee768154 | ||
|
|
e1bca40e73 | ||
|
|
ed13963d02 | ||
|
|
326a06e6b0 | ||
|
|
1fd21ad9da | ||
|
|
e17ece301a | ||
|
|
ba64f84182 | ||
|
|
d11a44ddeb | ||
|
|
d3803daf7b | ||
|
|
bf1c6b8bd5 | ||
|
|
911002818c | ||
|
|
889056b321 | ||
|
|
ad502eb732 | ||
|
|
02482486be | ||
|
|
85f6992afa | ||
|
|
ea6b5e5a67 | ||
|
|
cbcf9cabe1 | ||
|
|
e78f164063 | ||
|
|
820eac3197 | ||
|
|
d5cc515025 | ||
|
|
5ef9b92163 | ||
|
|
199cebfc1f | ||
|
|
c945bd3959 | ||
|
|
fea0e76a40 | ||
|
|
04fb2499a2 | ||
|
|
8553981b97 | ||
|
|
a877f8880e | ||
|
|
1d51ba955d | ||
|
|
373e05caa7 | ||
|
|
1dff5deb8e | ||
|
|
6624398277 | ||
|
|
74eba7ee0e | ||
|
|
dd93efeded | ||
|
|
4fb0ade585 | ||
|
|
66b646eb11 | ||
|
|
c67f6f0b1f | ||
|
|
57609e2958 | ||
|
|
253d355168 | ||
|
|
2402169622 | ||
|
|
b519e1802a | ||
|
|
49e81c6e61 | ||
|
|
596752eb42 | ||
|
|
9f14daf15e | ||
|
|
0d2d5a8b54 | ||
|
|
ecdf56622b | ||
|
|
c9e7b5780e | ||
|
|
c1edff4bc6 | ||
|
|
227709eab1 | ||
|
|
d8afb0f5e4 | ||
|
|
2dc5f00e3c | ||
|
|
41d25853d2 | ||
|
|
4b77499e12 | ||
|
|
95b82d34b9 | ||
|
|
a12900c701 | ||
|
|
418a2a0225 | ||
|
|
726ee0a463 | ||
|
|
502b48e05d | ||
|
|
09ea8cbfa3 | ||
|
|
1446601c84 | ||
|
|
b79cf8ae8e | ||
|
|
578f9a1b12 | ||
|
|
e5bc34c909 | ||
|
|
adf625b5a5 | ||
|
|
f4280e2fa4 | ||
|
|
961dff3c70 | ||
|
|
44a0206065 | ||
|
|
b2201b34f6 | ||
|
|
bb077a7cb4 | ||
|
|
e48b8ef100 | ||
|
|
44507a56cf | ||
|
|
3345c98b6e | ||
|
|
4c1e173ed4 | ||
|
|
5b497a7c9f | ||
|
|
cc44802ecd | ||
|
|
9aae2d5f93 | ||
|
|
d6776d05a4 | ||
|
|
0656512889 | ||
|
|
ea43a6aa7f | ||
|
|
f1320a809c | ||
|
|
cecde87312 | ||
|
|
ba26b342b0 | ||
|
|
dcd40b30f0 | ||
|
|
9e71a3e47b | ||
|
|
7b355d2c4e | ||
|
|
10d252d6d1 | ||
|
|
d3ea230dbc | ||
|
|
8bbc95b861 | ||
|
|
c1f96333dc | ||
|
|
f21da73b3a | ||
|
|
92b5c33937 | ||
|
|
6b73afdf94 | ||
|
|
ecc3a4de93 | ||
|
|
ad57a7d36f | ||
|
|
c6eeda232f | ||
|
|
a84729b3cd | ||
|
|
51b3684b73 | ||
|
|
6aeb79c211 | ||
|
|
5faba225e6 | ||
|
|
4f94e6d6b8 | ||
|
|
5f9899a142 | ||
|
|
ed45589118 | ||
|
|
d0918ceaff | ||
|
|
69c9cab22c | ||
|
|
b98f2e368c | ||
|
|
51027a2d92 | ||
|
|
f0496b0419 | ||
|
|
cb466d518a | ||
|
|
60fcd1a1ae | ||
|
|
ad624c1027 | ||
|
|
1acfcc0641 | ||
|
|
beff86b745 | ||
|
|
99c2b4249f | ||
|
|
45e138f4c9 | ||
|
|
8988cc489f | ||
|
|
c7780a0f18 | ||
|
|
ec19de5d90 | ||
|
|
8032f898e8 | ||
|
|
8ad900accf | ||
|
|
252266ba4b | ||
|
|
b30fe03b79 | ||
|
|
85a05132b1 | ||
|
|
8cb033178f | ||
|
|
07125420a6 | ||
|
|
09525ad5f2 | ||
|
|
4b44d8eaad | ||
|
|
6912c409fc | ||
|
|
d013a2ff77 | ||
|
|
d5569d6a1f | ||
|
|
873c74d078 | ||
|
|
715c702139 | ||
|
|
5454779c3c | ||
|
|
652327454a | ||
|
|
7d32b538e4 | ||
|
|
c58e7e5223 | ||
|
|
c0ef51ea7a | ||
|
|
e3986a560f | ||
|
|
c04d31c474 | ||
|
|
0f0fcbdb6b | ||
|
|
dbc4acdac7 | ||
|
|
3a0e2d9677 | ||
|
|
e71bad4b6c | ||
|
|
a86a5f4cfc | ||
|
|
a010662461 | ||
|
|
10b19a2cec | ||
|
|
a50d41407d | ||
|
|
b77c555249 | ||
|
|
cb8e24c87f | ||
|
|
fb56b57527 | ||
|
|
2548ace748 | ||
|
|
9097b97929 | ||
|
|
5984dd4d6a | ||
|
|
76abad0fe3 | ||
|
|
2bf735296d | ||
|
|
b1fd391b44 | ||
|
|
5b5462e3e7 | ||
|
|
42e6acfc49 | ||
|
|
4d2e19d4ed | ||
|
|
1594dc9342 | ||
|
|
ddf3f99789 | ||
|
|
288ac0ca9e | ||
|
|
5bb4b7b769 | ||
|
|
55d3158033 | ||
|
|
02e8a9e1f8 | ||
|
|
e83574ca24 | ||
|
|
97eea0dc74 | ||
|
|
e40d75b24e | ||
|
|
3f3f117349 | ||
|
|
a6fd026e5f | ||
|
|
548c463d18 | ||
|
|
0354acb32a | ||
|
|
82ceb78d20 | ||
|
|
b483f7553e | ||
|
|
8050cb3e9e | ||
|
|
fcd1a9dc04 | ||
|
|
44e966f6dc | ||
|
|
77613fa21e | ||
|
|
189242e3d1 | ||
|
|
3ffb52c4cb | ||
|
|
c239fb0eed | ||
|
|
35debf0592 | ||
|
|
0a1cd3fad3 | ||
|
|
cd27f3043c | ||
|
|
8083b65792 | ||
|
|
0ba04d51b9 | ||
|
|
202f587cb9 | ||
|
|
3ba6ca9c76 | ||
|
|
ceaa616d51 | ||
|
|
1af7f350a8 | ||
|
|
df8669a790 | ||
|
|
ea2ece0e06 | ||
|
|
3110d67e8e | ||
|
|
76d9fc3456 | ||
|
|
dc0865f144 | ||
|
|
e7fee43fb7 | ||
|
|
8a740dcfb0 | ||
|
|
9900821e1b | ||
|
|
27b78608fc | ||
|
|
9bb6b39441 | ||
|
|
e38f675e3c | ||
|
|
7cb94e0b3d | ||
|
|
8f0c41967c | ||
|
|
960bb6b1b1 | ||
|
|
414083df37 | ||
|
|
ee2bfddfe9 | ||
|
|
8676032c14 | ||
|
|
6a096ae235 | ||
|
|
b9cd4906c9 | ||
|
|
f758ef24c5 | ||
|
|
9fc302dd55 | ||
|
|
88b6919a59 | ||
|
|
a62c1d2510 | ||
|
|
7e0b6fd07c | ||
|
|
a5792660a3 | ||
|
|
a57d70771a | ||
|
|
e30c6cb678 | ||
|
|
8385c6c77c | ||
|
|
75c21b1300 | ||
|
|
bb4ecde6fd | ||
|
|
23c7eacd36 | ||
|
|
52fa820bdc | ||
|
|
954092ec9a | ||
|
|
b398c090ba | ||
|
|
aceac03f8b | ||
|
|
5fa3603767 | ||
|
|
4c9dd4be0a | ||
|
|
cbd7919c31 | ||
|
|
376613cc4e | ||
|
|
843684c649 | ||
|
|
8b67536e82 | ||
|
|
e6ec5aa99b | ||
|
|
67fca5771c | ||
|
|
9e40c914e4 | ||
|
|
d2417497eb | ||
|
|
761fbd774f | ||
|
|
03540a8915 | ||
|
|
ce3520fcce | ||
|
|
24ef2b0f4b | ||
|
|
ff6b30e6f3 | ||
|
|
cef7587281 | ||
|
|
7564417119 | ||
|
|
50ff7f13e2 | ||
|
|
c6cb964779 | ||
|
|
eafb9b389e | ||
|
|
8dce83f854 | ||
|
|
7c106be0f5 | ||
|
|
55fad3e9eb | ||
|
|
1a7e94493d | ||
|
|
a619d1c3fa | ||
|
|
f7030df62f | ||
|
|
1e30da078c | ||
|
|
a9458f4cf7 | ||
|
|
3b485c82e7 | ||
|
|
0457bb4773 | ||
|
|
bff0e89273 | ||
|
|
d78d5c4269 | ||
|
|
debb862223 | ||
|
|
2fbf873412 | ||
|
|
a73a7e8655 | ||
|
|
c213bf927b | ||
|
|
761219da63 | ||
|
|
fe272ebddf | ||
|
|
22db982372 | ||
|
|
f906ebc044 | ||
|
|
713bbef996 | ||
|
|
154a584c43 | ||
|
|
b807dcabd3 | ||
|
|
b4626defb8 | ||
|
|
d69a578c91 | ||
|
|
26030f7dcc | ||
|
|
d22dcb7277 | ||
|
|
6c21e680c3 | ||
|
|
ba28d663a3 | ||
|
|
da5c2cf082 | ||
|
|
f51a6fa785 | ||
|
|
4c6499deb9 | ||
|
|
0121f80cec | ||
|
|
ce73e73715 | ||
|
|
8df61bca90 | ||
|
|
f94794653a | ||
|
|
676865e99e | ||
|
|
7ea6840423 | ||
|
|
0058410523 | ||
|
|
d4fd7776d8 | ||
|
|
0aed75d4a1 | ||
|
|
ff8f9effb2 | ||
|
|
8b5e28e3d7 | ||
|
|
9792926822 | ||
|
|
c802a4d7ce | ||
|
|
fd88d11f98 | ||
|
|
6ecbaf7d87 | ||
|
|
a9aafebd0b | ||
|
|
38cc36ba00 | ||
|
|
3c1ef4a5cb | ||
|
|
7fa72639b6 | ||
|
|
6ac3e266a8 | ||
|
|
9e954eac19 | ||
|
|
ee55b085e4 | ||
|
|
fa044d0cd5 | ||
|
|
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 | ||
|
|
a6d5900e18 | ||
|
|
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 | ||
|
|
84e206e484 | ||
|
|
2705c580f3 | ||
|
|
e5a1c11a80 | ||
|
|
f0d79474e0 | ||
|
|
950f7e12e3 | ||
|
|
0503d904ac | ||
|
|
185d699cad | ||
|
|
7385393342 | ||
|
|
f70b1c06e0 | ||
|
|
e569677358 |
@@ -1 +0,0 @@
|
||||
oss-gg
|
||||
@@ -2,7 +2,7 @@ Copyright (c) 2023-present Papermark, Inc.
|
||||
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under https://github.com/mfts/papermark/tree/main/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
|
||||
- All content that resides under https://github.com/mfts/papermark/tree/main/ee and https://github.com/mfts/papermark/tree/main/app/(ee) directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
|
||||
- All third party components incorporated into the Papermark Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
Generated
+359
-296
@@ -34,209 +34,252 @@
|
||||
},
|
||||
"black": {
|
||||
"hashes": [
|
||||
"sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f",
|
||||
"sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd",
|
||||
"sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea",
|
||||
"sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981",
|
||||
"sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b",
|
||||
"sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7",
|
||||
"sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8",
|
||||
"sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175",
|
||||
"sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d",
|
||||
"sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392",
|
||||
"sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad",
|
||||
"sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f",
|
||||
"sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f",
|
||||
"sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b",
|
||||
"sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875",
|
||||
"sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3",
|
||||
"sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800",
|
||||
"sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65",
|
||||
"sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2",
|
||||
"sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812",
|
||||
"sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50",
|
||||
"sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"
|
||||
"sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783",
|
||||
"sha256:09524b0e6af8ba7a3ffabdfc7a9922fb9adef60fed008c7cd2fc01f3048e6e6f",
|
||||
"sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5",
|
||||
"sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf",
|
||||
"sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43",
|
||||
"sha256:17dcc893da8d73d8f74a596f64b7c98ef5239c2cd2b053c0f25912c4494bf9ea",
|
||||
"sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be",
|
||||
"sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f",
|
||||
"sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d",
|
||||
"sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892",
|
||||
"sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a",
|
||||
"sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828",
|
||||
"sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655",
|
||||
"sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a",
|
||||
"sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b",
|
||||
"sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7",
|
||||
"sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f",
|
||||
"sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83",
|
||||
"sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f",
|
||||
"sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5",
|
||||
"sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5",
|
||||
"sha256:b162653ed89eb942758efeb29d5e333ca5bb90e5130216f8369857db5955a7da",
|
||||
"sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce",
|
||||
"sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59",
|
||||
"sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a",
|
||||
"sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b",
|
||||
"sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8"
|
||||
],
|
||||
"version": "==24.10.0"
|
||||
"version": "==25.12.0"
|
||||
},
|
||||
"certifi": {
|
||||
"hashes": [
|
||||
"sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56",
|
||||
"sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"
|
||||
"sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b",
|
||||
"sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==2024.12.14"
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==2025.11.12"
|
||||
},
|
||||
"cffi": {
|
||||
"hashes": [
|
||||
"sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8",
|
||||
"sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2",
|
||||
"sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1",
|
||||
"sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15",
|
||||
"sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36",
|
||||
"sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824",
|
||||
"sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8",
|
||||
"sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36",
|
||||
"sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17",
|
||||
"sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf",
|
||||
"sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc",
|
||||
"sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3",
|
||||
"sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed",
|
||||
"sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702",
|
||||
"sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1",
|
||||
"sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8",
|
||||
"sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903",
|
||||
"sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6",
|
||||
"sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d",
|
||||
"sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b",
|
||||
"sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e",
|
||||
"sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be",
|
||||
"sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c",
|
||||
"sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683",
|
||||
"sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9",
|
||||
"sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c",
|
||||
"sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8",
|
||||
"sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1",
|
||||
"sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4",
|
||||
"sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655",
|
||||
"sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67",
|
||||
"sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595",
|
||||
"sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0",
|
||||
"sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65",
|
||||
"sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41",
|
||||
"sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6",
|
||||
"sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401",
|
||||
"sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6",
|
||||
"sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3",
|
||||
"sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16",
|
||||
"sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93",
|
||||
"sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e",
|
||||
"sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4",
|
||||
"sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964",
|
||||
"sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c",
|
||||
"sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576",
|
||||
"sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0",
|
||||
"sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3",
|
||||
"sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662",
|
||||
"sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3",
|
||||
"sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff",
|
||||
"sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5",
|
||||
"sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd",
|
||||
"sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f",
|
||||
"sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5",
|
||||
"sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14",
|
||||
"sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d",
|
||||
"sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9",
|
||||
"sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7",
|
||||
"sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382",
|
||||
"sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a",
|
||||
"sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e",
|
||||
"sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a",
|
||||
"sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4",
|
||||
"sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99",
|
||||
"sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87",
|
||||
"sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"
|
||||
"sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb",
|
||||
"sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b",
|
||||
"sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f",
|
||||
"sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9",
|
||||
"sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44",
|
||||
"sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2",
|
||||
"sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c",
|
||||
"sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75",
|
||||
"sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65",
|
||||
"sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e",
|
||||
"sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a",
|
||||
"sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e",
|
||||
"sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25",
|
||||
"sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a",
|
||||
"sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe",
|
||||
"sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b",
|
||||
"sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91",
|
||||
"sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592",
|
||||
"sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187",
|
||||
"sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c",
|
||||
"sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1",
|
||||
"sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94",
|
||||
"sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba",
|
||||
"sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb",
|
||||
"sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165",
|
||||
"sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529",
|
||||
"sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca",
|
||||
"sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c",
|
||||
"sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6",
|
||||
"sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c",
|
||||
"sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0",
|
||||
"sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743",
|
||||
"sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63",
|
||||
"sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5",
|
||||
"sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5",
|
||||
"sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4",
|
||||
"sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d",
|
||||
"sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b",
|
||||
"sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93",
|
||||
"sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205",
|
||||
"sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27",
|
||||
"sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512",
|
||||
"sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d",
|
||||
"sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c",
|
||||
"sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037",
|
||||
"sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26",
|
||||
"sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322",
|
||||
"sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb",
|
||||
"sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c",
|
||||
"sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8",
|
||||
"sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4",
|
||||
"sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414",
|
||||
"sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9",
|
||||
"sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664",
|
||||
"sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9",
|
||||
"sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775",
|
||||
"sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739",
|
||||
"sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc",
|
||||
"sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062",
|
||||
"sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe",
|
||||
"sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9",
|
||||
"sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92",
|
||||
"sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5",
|
||||
"sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13",
|
||||
"sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d",
|
||||
"sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26",
|
||||
"sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f",
|
||||
"sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495",
|
||||
"sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b",
|
||||
"sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6",
|
||||
"sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c",
|
||||
"sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef",
|
||||
"sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5",
|
||||
"sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18",
|
||||
"sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad",
|
||||
"sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3",
|
||||
"sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7",
|
||||
"sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5",
|
||||
"sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534",
|
||||
"sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49",
|
||||
"sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2",
|
||||
"sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5",
|
||||
"sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453",
|
||||
"sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.17.1"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.0.0"
|
||||
},
|
||||
"charset-normalizer": {
|
||||
"hashes": [
|
||||
"sha256:0167ddc8ab6508fe81860a57dd472b2ef4060e8d378f0cc555707126830f2537",
|
||||
"sha256:01732659ba9b5b873fc117534143e4feefecf3b2078b0a6a2e925271bb6f4cfa",
|
||||
"sha256:01ad647cdd609225c5350561d084b42ddf732f4eeefe6e678765636791e78b9a",
|
||||
"sha256:04432ad9479fa40ec0f387795ddad4437a2b50417c69fa275e212933519ff294",
|
||||
"sha256:0907f11d019260cdc3f94fbdb23ff9125f6b5d1039b76003b5b0ac9d6a6c9d5b",
|
||||
"sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd",
|
||||
"sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601",
|
||||
"sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd",
|
||||
"sha256:0af291f4fe114be0280cdd29d533696a77b5b49cfde5467176ecab32353395c4",
|
||||
"sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d",
|
||||
"sha256:1a2bc9f351a75ef49d664206d51f8e5ede9da246602dc2d2726837620ea034b2",
|
||||
"sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313",
|
||||
"sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd",
|
||||
"sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa",
|
||||
"sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8",
|
||||
"sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1",
|
||||
"sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2",
|
||||
"sha256:2a75d49014d118e4198bcee5ee0a6f25856b29b12dbf7cd012791f8a6cc5c496",
|
||||
"sha256:2bdfe3ac2e1bbe5b59a1a63721eb3b95fc9b6817ae4a46debbb4e11f6232428d",
|
||||
"sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b",
|
||||
"sha256:2fb9bd477fdea8684f78791a6de97a953c51831ee2981f8e4f583ff3b9d9687e",
|
||||
"sha256:311f30128d7d333eebd7896965bfcfbd0065f1716ec92bd5638d7748eb6f936a",
|
||||
"sha256:329ce159e82018d646c7ac45b01a430369d526569ec08516081727a20e9e4af4",
|
||||
"sha256:345b0426edd4e18138d6528aed636de7a9ed169b4aaf9d61a8c19e39d26838ca",
|
||||
"sha256:363e2f92b0f0174b2f8238240a1a30142e3db7b957a5dd5689b0e75fb717cc78",
|
||||
"sha256:3a3bd0dcd373514dcec91c411ddb9632c0d7d92aed7093b8c3bbb6d69ca74408",
|
||||
"sha256:3bed14e9c89dcb10e8f3a29f9ccac4955aebe93c71ae803af79265c9ca5644c5",
|
||||
"sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3",
|
||||
"sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f",
|
||||
"sha256:4532bff1b8421fd0a320463030c7520f56a79c9024a4e88f01c537316019005a",
|
||||
"sha256:49402233c892a461407c512a19435d1ce275543138294f7ef013f0b63d5d3765",
|
||||
"sha256:4c0907b1928a36d5a998d72d64d8eaa7244989f7aaaf947500d3a800c83a3fd6",
|
||||
"sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146",
|
||||
"sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6",
|
||||
"sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9",
|
||||
"sha256:619a609aa74ae43d90ed2e89bdd784765de0a25ca761b93e196d938b8fd1dbbd",
|
||||
"sha256:6e27f48bcd0957c6d4cb9d6fa6b61d192d0b13d5ef563e5f2ae35feafc0d179c",
|
||||
"sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f",
|
||||
"sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545",
|
||||
"sha256:7461baadb4dc00fd9e0acbe254e3d7d2112e7f92ced2adc96e54ef6501c5f176",
|
||||
"sha256:75832c08354f595c760a804588b9357d34ec00ba1c940c15e31e96d902093770",
|
||||
"sha256:7709f51f5f7c853f0fb938bcd3bc59cdfdc5203635ffd18bf354f6967ea0f824",
|
||||
"sha256:78baa6d91634dfb69ec52a463534bc0df05dbd546209b79a3880a34487f4b84f",
|
||||
"sha256:7974a0b5ecd505609e3b19742b60cee7aa2aa2fb3151bc917e6e2646d7667dcf",
|
||||
"sha256:7a4f97a081603d2050bfaffdefa5b02a9ec823f8348a572e39032caa8404a487",
|
||||
"sha256:7b1bef6280950ee6c177b326508f86cad7ad4dff12454483b51d8b7d673a2c5d",
|
||||
"sha256:7d053096f67cd1241601111b698f5cad775f97ab25d81567d3f59219b5f1adbd",
|
||||
"sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b",
|
||||
"sha256:807f52c1f798eef6cf26beb819eeb8819b1622ddfeef9d0977a8502d4db6d534",
|
||||
"sha256:80ed5e856eb7f30115aaf94e4a08114ccc8813e6ed1b5efa74f9f82e8509858f",
|
||||
"sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b",
|
||||
"sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9",
|
||||
"sha256:89149166622f4db9b4b6a449256291dc87a99ee53151c74cbd82a53c8c2f6ccd",
|
||||
"sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125",
|
||||
"sha256:8c60ca7339acd497a55b0ea5d506b2a2612afb2826560416f6894e8b5770d4a9",
|
||||
"sha256:91b36a978b5ae0ee86c394f5a54d6ef44db1de0815eb43de826d41d21e4af3de",
|
||||
"sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11",
|
||||
"sha256:97f68b8d6831127e4787ad15e6757232e14e12060bec17091b85eb1486b91d8d",
|
||||
"sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35",
|
||||
"sha256:9f0b8b1c6d84c8034a44893aba5e767bf9c7a211e313a9605d9c617d7083829f",
|
||||
"sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda",
|
||||
"sha256:ab36c8eb7e454e34e60eb55ca5d241a5d18b2c6244f6827a30e451c42410b5f7",
|
||||
"sha256:b010a7a4fd316c3c484d482922d13044979e78d1861f0e0650423144c616a46a",
|
||||
"sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971",
|
||||
"sha256:b7b2d86dd06bfc2ade3312a83a5c364c7ec2e3498f8734282c6c3d4b07b346b8",
|
||||
"sha256:b97e690a2118911e39b4042088092771b4ae3fc3aa86518f84b8cf6888dbdb41",
|
||||
"sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d",
|
||||
"sha256:c0429126cf75e16c4f0ad00ee0eae4242dc652290f940152ca8c75c3a4b6ee8f",
|
||||
"sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757",
|
||||
"sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a",
|
||||
"sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886",
|
||||
"sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77",
|
||||
"sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76",
|
||||
"sha256:d973f03c0cb71c5ed99037b870f2be986c3c05e63622c017ea9816881d2dd247",
|
||||
"sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85",
|
||||
"sha256:d9c3cdf5390dcd29aa8056d13e8e99526cda0305acc038b96b30352aff5ff2bb",
|
||||
"sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7",
|
||||
"sha256:dccbe65bd2f7f7ec22c4ff99ed56faa1e9f785482b9bbd7c717e26fd723a1d1e",
|
||||
"sha256:dd78cfcda14a1ef52584dbb008f7ac81c1328c0f58184bf9a84c49c605002da6",
|
||||
"sha256:e218488cd232553829be0664c2292d3af2eeeb94b32bea483cf79ac6a694e037",
|
||||
"sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1",
|
||||
"sha256:ea0d8d539afa5eb2728aa1932a988a9a7af94f18582ffae4bc10b3fbdad0626e",
|
||||
"sha256:eab677309cdb30d047996b36d34caeda1dc91149e4fdca0b1a039b3f79d9a807",
|
||||
"sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407",
|
||||
"sha256:ecddf25bee22fe4fe3737a399d0d177d72bc22be6913acfab364b40bce1ba83c",
|
||||
"sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12",
|
||||
"sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3",
|
||||
"sha256:f30bf9fd9be89ecb2360c7d94a711f00c09b976258846efe40db3d05828e8089",
|
||||
"sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd",
|
||||
"sha256:fc54db6c8593ef7d4b2a331b58653356cf04f67c960f584edb7c3d8c97e8f39e",
|
||||
"sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00",
|
||||
"sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616"
|
||||
"sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad",
|
||||
"sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93",
|
||||
"sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394",
|
||||
"sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89",
|
||||
"sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc",
|
||||
"sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86",
|
||||
"sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63",
|
||||
"sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d",
|
||||
"sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f",
|
||||
"sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8",
|
||||
"sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0",
|
||||
"sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505",
|
||||
"sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161",
|
||||
"sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af",
|
||||
"sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152",
|
||||
"sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318",
|
||||
"sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72",
|
||||
"sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4",
|
||||
"sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e",
|
||||
"sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3",
|
||||
"sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576",
|
||||
"sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c",
|
||||
"sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1",
|
||||
"sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8",
|
||||
"sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1",
|
||||
"sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2",
|
||||
"sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44",
|
||||
"sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26",
|
||||
"sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88",
|
||||
"sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016",
|
||||
"sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede",
|
||||
"sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf",
|
||||
"sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a",
|
||||
"sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc",
|
||||
"sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0",
|
||||
"sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84",
|
||||
"sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db",
|
||||
"sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1",
|
||||
"sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7",
|
||||
"sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed",
|
||||
"sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8",
|
||||
"sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133",
|
||||
"sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e",
|
||||
"sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef",
|
||||
"sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14",
|
||||
"sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2",
|
||||
"sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0",
|
||||
"sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d",
|
||||
"sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828",
|
||||
"sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f",
|
||||
"sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf",
|
||||
"sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6",
|
||||
"sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328",
|
||||
"sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090",
|
||||
"sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa",
|
||||
"sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381",
|
||||
"sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c",
|
||||
"sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb",
|
||||
"sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc",
|
||||
"sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a",
|
||||
"sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec",
|
||||
"sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc",
|
||||
"sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac",
|
||||
"sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e",
|
||||
"sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313",
|
||||
"sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569",
|
||||
"sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3",
|
||||
"sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d",
|
||||
"sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525",
|
||||
"sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894",
|
||||
"sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3",
|
||||
"sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9",
|
||||
"sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a",
|
||||
"sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9",
|
||||
"sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14",
|
||||
"sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25",
|
||||
"sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50",
|
||||
"sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf",
|
||||
"sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1",
|
||||
"sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3",
|
||||
"sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac",
|
||||
"sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e",
|
||||
"sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815",
|
||||
"sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c",
|
||||
"sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6",
|
||||
"sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6",
|
||||
"sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e",
|
||||
"sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4",
|
||||
"sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84",
|
||||
"sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69",
|
||||
"sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15",
|
||||
"sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191",
|
||||
"sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0",
|
||||
"sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897",
|
||||
"sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd",
|
||||
"sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2",
|
||||
"sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794",
|
||||
"sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d",
|
||||
"sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074",
|
||||
"sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3",
|
||||
"sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224",
|
||||
"sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838",
|
||||
"sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a",
|
||||
"sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d",
|
||||
"sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d",
|
||||
"sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f",
|
||||
"sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8",
|
||||
"sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490",
|
||||
"sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966",
|
||||
"sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9",
|
||||
"sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3",
|
||||
"sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e",
|
||||
"sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.4.1"
|
||||
"version": "==3.4.4"
|
||||
},
|
||||
"click": {
|
||||
"hashes": [
|
||||
@@ -248,30 +291,22 @@
|
||||
},
|
||||
"clickhouse-toolset": {
|
||||
"hashes": [
|
||||
"sha256:0d9b5f1c306e6ca1641776c55fc445c6ef700e459502a2343706566dd8527a1a",
|
||||
"sha256:0f906fbb04a00088bf24394501f5e6c47582a5232939a1441bb511f5fb00b522",
|
||||
"sha256:14a4616b6b5b2bbc024bce0665861a8c52e67250f52d64acf0fc33d42b18ed1b",
|
||||
"sha256:29adeb6a17bacf4c9548c6203f3d352fda3fecbfd31669008f4befb9ab94b80b",
|
||||
"sha256:360cd8b534415e28bb2ade8662d788997a8ec626eb2e46413da4bb6cd89a6804",
|
||||
"sha256:4836cec2f14a0d466bd1ea0ca8e99211e42f5a9e59d8e853c093f1cfcd8e43cd",
|
||||
"sha256:64f0013cf2f630363d2d634bee90e3756693a549e28521851b0a45e09c3741bf",
|
||||
"sha256:6bf908bc72775f27993f17f87dad8ec67c577a65e6461df3f298f86b8f53a6a5",
|
||||
"sha256:7ebc217ab4442189611fd8e12a8a99927fe126cf87f2cd6db42cd6851828ec84",
|
||||
"sha256:82a3b89d06e17c64e80b8aaec1a719427741e24f90894f2de3276df627310e09",
|
||||
"sha256:831b507f3b1d89049ef46cad6cebceb9f9274fd659dba6aae4fc2f3a950b2074",
|
||||
"sha256:84927b186d231dfe5486f5e00061da7b20b87df35d895dffc9ced3ac01ecd2bc",
|
||||
"sha256:85424e2a801904adf408b957fd61553dee8a514b0bbffc0ec7278624f20d80b8",
|
||||
"sha256:8991d64f9208339d165db748563105927886656791af0b1492c5c01fcd9b2d7f",
|
||||
"sha256:9270dea4b3a363d0542492453a5c991b230480f1b6d1703d869ad178af5ff078",
|
||||
"sha256:b8bc27aaa36e54d90e8db4fa30b202855a92fd4b3a093387bef0e0e52cfaae64",
|
||||
"sha256:bfe9e6df80feb35217f7e85218ef0c4abd6e9eed96e90dcf63be86de704fa29c",
|
||||
"sha256:d76d1484be2055f7479e96969f8d5c57d53a4322d108be200aaffcc23904bbc8",
|
||||
"sha256:df2cd28761ec673e13881219fc03081ea733e1af85c45a5a4fb25a0be68d133e",
|
||||
"sha256:e7c4bf2bce1f547bff03d02e0cfa22b837c45a9eca1ffafb117242cd304d6856",
|
||||
"sha256:fab34e6755b651c5ed6f8aec4313358fe6b2bf9f460c5493043dacb1cac90ba2"
|
||||
"sha256:259911a325dafa89e7ee8637e701eebcf24a0ed775b45532d91861fd19d81dec",
|
||||
"sha256:26ab6d2b4cc60bdf64228ace795867f135d9ccbb193b4f184f4233a01955d6e5",
|
||||
"sha256:26c9dca366bdcb167aee50208e48bcfccc1e3b7225385ae3cc3563619848addd",
|
||||
"sha256:337d2dd6de8f95722945d48fd33a4abc0a7ea0ca81a26d2fcd661764408434ca",
|
||||
"sha256:4ea62ffd811449f0d77b8fdeab699a0719465463d4e614d9633d9fbb7b99ec29",
|
||||
"sha256:51a23b0704a40d9d2dfbbeb4e6bf1ba426e3b5d0e35221ee7771716786bde138",
|
||||
"sha256:8ef49b41c7e8eabb504e6c401ec1e6b4067babf745bae1368135a0727f50ac86",
|
||||
"sha256:9fa163cf88cce25b0d3a2344b237bd56ee5cd55d1c114d74f24be10a0d7ab74f",
|
||||
"sha256:b2eea180d5665528134bfddec4048c661fc044fa8970bedc9593b1db29627d7e",
|
||||
"sha256:ccbeba4f5dfa67f55d27533935e29e70071bb7eff288d1efbbe64e01b2f6e89a",
|
||||
"sha256:d50285d528d932779b2dc64be245a255782111d20a9ddd8369d19ff6ed3a78e9",
|
||||
"sha256:dd15c3189149e1d0b45039f95e4c19c036618eee98a586627f825e70881cbf51",
|
||||
"sha256:fa84e14fb9c3e3c3208bf022608302d7b7024cba4a2e849be8faf689da4505e7"
|
||||
],
|
||||
"markers": "python_version < '3.13' and python_version >= '3.8'",
|
||||
"version": "==0.33.dev0"
|
||||
"markers": "python_version < '3.14' and python_version >= '3.8'",
|
||||
"version": "==0.34.dev0"
|
||||
},
|
||||
"colorama": {
|
||||
"hashes": [
|
||||
@@ -283,11 +318,11 @@
|
||||
},
|
||||
"croniter": {
|
||||
"hashes": [
|
||||
"sha256:32a5ec04e97ec0837bcdf013767abd2e71cceeefd3c2e14c804098ce51ad6cd9",
|
||||
"sha256:d6ed8386d5f4bbb29419dc1b65c4909c04a2322bd15ec0dc5b2877bfa1b75c7a"
|
||||
"sha256:924a38fda88f675ec6835667e1d32ac37ff0d65509c2152729d16ff205e32a65",
|
||||
"sha256:f17f877be1d93b9e3191151584a19d8b367b017ab0febc8c5472b9300da61c4c"
|
||||
],
|
||||
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==1.3.8"
|
||||
"version": "==1.3.15"
|
||||
},
|
||||
"cryptography": {
|
||||
"hashes": [
|
||||
@@ -328,11 +363,11 @@
|
||||
},
|
||||
"gitpython": {
|
||||
"hashes": [
|
||||
"sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110",
|
||||
"sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269"
|
||||
"sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c",
|
||||
"sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"
|
||||
],
|
||||
"markers": "python_version >= '3.7'",
|
||||
"version": "==3.1.44"
|
||||
"version": "==3.1.45"
|
||||
},
|
||||
"humanfriendly": {
|
||||
"hashes": [
|
||||
@@ -344,19 +379,19 @@
|
||||
},
|
||||
"idna": {
|
||||
"hashes": [
|
||||
"sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9",
|
||||
"sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"
|
||||
"sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea",
|
||||
"sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"
|
||||
],
|
||||
"markers": "python_version >= '3.6'",
|
||||
"version": "==3.10"
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==3.11"
|
||||
},
|
||||
"mypy-extensions": {
|
||||
"hashes": [
|
||||
"sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d",
|
||||
"sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"
|
||||
"sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505",
|
||||
"sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"
|
||||
],
|
||||
"markers": "python_version >= '3.5'",
|
||||
"version": "==1.0.0"
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==1.1.0"
|
||||
},
|
||||
"packaging": {
|
||||
"hashes": [
|
||||
@@ -384,11 +419,11 @@
|
||||
},
|
||||
"pycparser": {
|
||||
"hashes": [
|
||||
"sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6",
|
||||
"sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"
|
||||
"sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2",
|
||||
"sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.22"
|
||||
"markers": "implementation_name != 'PyPy'",
|
||||
"version": "==2.23"
|
||||
},
|
||||
"pydantic": {
|
||||
"hashes": [
|
||||
@@ -507,72 +542,100 @@
|
||||
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
|
||||
"version": "==2.9.0.post0"
|
||||
},
|
||||
"pyyaml": {
|
||||
"pytokens": {
|
||||
"hashes": [
|
||||
"sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff",
|
||||
"sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48",
|
||||
"sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086",
|
||||
"sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e",
|
||||
"sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133",
|
||||
"sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5",
|
||||
"sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484",
|
||||
"sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee",
|
||||
"sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5",
|
||||
"sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68",
|
||||
"sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a",
|
||||
"sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf",
|
||||
"sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99",
|
||||
"sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8",
|
||||
"sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85",
|
||||
"sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19",
|
||||
"sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc",
|
||||
"sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a",
|
||||
"sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1",
|
||||
"sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317",
|
||||
"sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c",
|
||||
"sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631",
|
||||
"sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d",
|
||||
"sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652",
|
||||
"sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5",
|
||||
"sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e",
|
||||
"sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b",
|
||||
"sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8",
|
||||
"sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476",
|
||||
"sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706",
|
||||
"sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563",
|
||||
"sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237",
|
||||
"sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b",
|
||||
"sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083",
|
||||
"sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180",
|
||||
"sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425",
|
||||
"sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e",
|
||||
"sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f",
|
||||
"sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725",
|
||||
"sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183",
|
||||
"sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab",
|
||||
"sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774",
|
||||
"sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725",
|
||||
"sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e",
|
||||
"sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5",
|
||||
"sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d",
|
||||
"sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290",
|
||||
"sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44",
|
||||
"sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed",
|
||||
"sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4",
|
||||
"sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba",
|
||||
"sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12",
|
||||
"sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"
|
||||
"sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a",
|
||||
"sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==6.0.2"
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"pyyaml": {
|
||||
"hashes": [
|
||||
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
|
||||
"sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a",
|
||||
"sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3",
|
||||
"sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956",
|
||||
"sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6",
|
||||
"sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c",
|
||||
"sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65",
|
||||
"sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a",
|
||||
"sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0",
|
||||
"sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b",
|
||||
"sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1",
|
||||
"sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6",
|
||||
"sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7",
|
||||
"sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e",
|
||||
"sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007",
|
||||
"sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310",
|
||||
"sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4",
|
||||
"sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9",
|
||||
"sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295",
|
||||
"sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea",
|
||||
"sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0",
|
||||
"sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e",
|
||||
"sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac",
|
||||
"sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9",
|
||||
"sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7",
|
||||
"sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35",
|
||||
"sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb",
|
||||
"sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b",
|
||||
"sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69",
|
||||
"sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5",
|
||||
"sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b",
|
||||
"sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c",
|
||||
"sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369",
|
||||
"sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd",
|
||||
"sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824",
|
||||
"sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198",
|
||||
"sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065",
|
||||
"sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c",
|
||||
"sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c",
|
||||
"sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764",
|
||||
"sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196",
|
||||
"sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b",
|
||||
"sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00",
|
||||
"sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac",
|
||||
"sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8",
|
||||
"sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e",
|
||||
"sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28",
|
||||
"sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3",
|
||||
"sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5",
|
||||
"sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4",
|
||||
"sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b",
|
||||
"sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf",
|
||||
"sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5",
|
||||
"sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702",
|
||||
"sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8",
|
||||
"sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788",
|
||||
"sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da",
|
||||
"sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d",
|
||||
"sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
|
||||
"sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c",
|
||||
"sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba",
|
||||
"sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f",
|
||||
"sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917",
|
||||
"sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5",
|
||||
"sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26",
|
||||
"sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f",
|
||||
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b",
|
||||
"sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be",
|
||||
"sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c",
|
||||
"sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3",
|
||||
"sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6",
|
||||
"sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926",
|
||||
"sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==6.0.3"
|
||||
},
|
||||
"requests": {
|
||||
"hashes": [
|
||||
"sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760",
|
||||
"sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"
|
||||
"sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6",
|
||||
"sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"
|
||||
],
|
||||
"markers": "python_version >= '3.8'",
|
||||
"version": "==2.32.3"
|
||||
"markers": "python_version >= '3.9'",
|
||||
"version": "==2.32.5"
|
||||
},
|
||||
"shandy-sqlfmt": {
|
||||
"hashes": [
|
||||
@@ -600,12 +663,12 @@
|
||||
},
|
||||
"tinybird-cli": {
|
||||
"hashes": [
|
||||
"sha256:5ea2c759fff89d5a1104cdfdd5fd8e2bfde68445ea8635e1ef1031403e558863",
|
||||
"sha256:7e855f9338b2e982237e25bb3ad9f0e36c9305ab546c01c68df6c31ad59b2e05"
|
||||
"sha256:a1d0332d4c8f8ef16cedbed6e5a872d5f372604a6bdc6bb9872ebea8e33e0fda",
|
||||
"sha256:f2c4a95f9e4f79cd7bdc39fef1ec093cba2289b7d6a43d888eff26f1b860c370"
|
||||
],
|
||||
"index": "pypi",
|
||||
"markers": "python_version < '3.13' and python_version >= '3.9'",
|
||||
"version": "==5.13.1"
|
||||
"markers": "python_version < '3.14' and python_version >= '3.9'",
|
||||
"version": "==5.21.1"
|
||||
},
|
||||
"toposort": {
|
||||
"hashes": [
|
||||
@@ -639,11 +702,11 @@
|
||||
},
|
||||
"typing-extensions": {
|
||||
"hashes": [
|
||||
"sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d",
|
||||
"sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
|
||||
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466",
|
||||
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"
|
||||
],
|
||||
"markers": "python_version < '3.13'",
|
||||
"version": "==4.12.2"
|
||||
"version": "==4.15.0"
|
||||
},
|
||||
"urllib3": {
|
||||
"hashes": [
|
||||
|
||||
+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.
|
||||
@@ -8,9 +8,8 @@ import { waitUntil } from "@vercel/functions";
|
||||
import { hashToken } from "@/lib/api/auth/token";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { redis } from "@/lib/redis";
|
||||
import { sendEmail } from "@/lib/resend";
|
||||
import { sendEmail, subscribe, unsubscribe } from "@/lib/resend";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import { subscribe, unsubscribe } from "@/lib/unsend";
|
||||
|
||||
import EmailUpdated from "@/components/emails/email-updated";
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { LogoCloud } from "@/components/shared/logo-cloud";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function EmailVerificationClient() {
|
||||
const router = useRouter();
|
||||
const codeInputRef = useRef<HTMLInputElement>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailLocked, setEmailLocked] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isExpired, setIsExpired] = useState(false);
|
||||
const [showEmailDeliveryNotice, setShowEmailDeliveryNotice] = useState(false);
|
||||
|
||||
// Check sessionStorage for pending verification email on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const pendingEmail = sessionStorage.getItem("pendingVerificationEmail");
|
||||
if (pendingEmail) {
|
||||
setEmail(pendingEmail);
|
||||
setEmailLocked(true);
|
||||
// Clear the stored email after reading
|
||||
sessionStorage.removeItem("pendingVerificationEmail");
|
||||
// Focus the code input
|
||||
setTimeout(() => {
|
||||
codeInputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
} catch {
|
||||
// sessionStorage not available, user will need to enter email manually
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Show email delivery notice after 5 seconds when waiting for verification
|
||||
useEffect(() => {
|
||||
if (!emailLocked) {
|
||||
setShowEmailDeliveryNotice(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setShowEmailDeliveryNotice(true);
|
||||
}, 10000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [emailLocked]);
|
||||
|
||||
// Code verification
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/verify-code", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email.trim().toLowerCase(),
|
||||
code: code.trim().toUpperCase(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
if (
|
||||
response.status === 410 ||
|
||||
response.status === 401 ||
|
||||
data.error?.includes("expired") ||
|
||||
data.error?.includes("Invalid code")
|
||||
) {
|
||||
setIsExpired(true);
|
||||
setError("This code has expired or is invalid.");
|
||||
} else if (response.status === 429) {
|
||||
setError(
|
||||
data.error || "Too many attempts. Please wait before trying again.",
|
||||
);
|
||||
} else {
|
||||
setError(data.error || "Verification failed. Please try again.");
|
||||
}
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to the callback URL
|
||||
if (data.callbackUrl) {
|
||||
router.push(data.callbackUrl);
|
||||
} else {
|
||||
// No callback URL in response - stop loading and show error
|
||||
setIsLoading(false);
|
||||
setError("Unable to complete sign-in: missing callback URL. Please try again.");
|
||||
}
|
||||
} catch (err) {
|
||||
setError("An error occurred. Please try again.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show expired state
|
||||
if (isExpired) {
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-wrap">
|
||||
<div className="flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2">
|
||||
<div className="z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]">
|
||||
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
|
||||
<img
|
||||
src="/_static/papermark-logo.svg"
|
||||
alt="Papermark Logo"
|
||||
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
|
||||
/>
|
||||
<span className="text-balance text-3xl font-semibold text-gray-900">
|
||||
Code Expired
|
||||
</span>
|
||||
<h3 className="text-balance text-sm text-gray-800">
|
||||
This login code has expired or has already been used.
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 pt-8 sm:px-12">
|
||||
<Link href="/login">
|
||||
<Button className="focus:shadow-outline w-full transform rounded bg-gray-800 px-4 py-2 text-white transition-colors duration-300 ease-in-out hover:bg-gray-900 focus:outline-none">
|
||||
Request a new code
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TestimonialSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-wrap">
|
||||
{/* Left part */}
|
||||
<div className="flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2">
|
||||
<div
|
||||
className="absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div className="z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]">
|
||||
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
|
||||
<img
|
||||
src="/_static/papermark-logo.svg"
|
||||
alt="Papermark Logo"
|
||||
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
|
||||
/>
|
||||
<Link href="/">
|
||||
<span className="text-balance text-3xl font-semibold text-gray-900">
|
||||
Check your email
|
||||
</span>
|
||||
</Link>
|
||||
<h3 className="text-balance text-sm text-gray-800">
|
||||
{emailLocked ? (
|
||||
<>
|
||||
We sent a login code to{" "}
|
||||
<span className="font-medium">{email}</span>
|
||||
</>
|
||||
) : (
|
||||
"Enter your email and the code we sent you"
|
||||
)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Delayed email delivery notice */}
|
||||
{showEmailDeliveryNotice && emailLocked && (
|
||||
<div className="mx-4 mt-4 rounded-lg border border-orange-200 bg-orange-50 p-4 sm:mx-12">
|
||||
<p className="text-sm text-orange-800">
|
||||
Due to a recent Microsoft outage, we are experiencing delivery
|
||||
issues with Outlook and Microsoft email accounts.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-orange-800">
|
||||
Check your junk/spam and quarantine folders and ensure that{" "}
|
||||
<a
|
||||
href="mailto:system@papermark.com"
|
||||
className="font-medium text-orange-600 underline hover:text-orange-700"
|
||||
>
|
||||
system@papermark.com
|
||||
</a>{" "}
|
||||
is on your allowed senders list.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className="flex flex-col gap-4 px-4 pt-8 sm:px-12"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* Only show email field if not locked from sessionStorage */}
|
||||
{!emailLocked && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border-0 bg-background bg-white px-3 py-2 text-sm text-gray-900 ring-1 ring-gray-200 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="code">Verification Code</Label>
|
||||
<Input
|
||||
ref={codeInputRef}
|
||||
id="code"
|
||||
placeholder="Enter 10-character code"
|
||||
type="text"
|
||||
autoCapitalize="characters"
|
||||
autoComplete="one-time-code"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
maxLength={10}
|
||||
className="flex h-10 w-full rounded-md border-0 bg-background bg-white px-3 py-2 font-mono text-lg tracking-widest text-gray-900 ring-1 ring-gray-200 transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground placeholder:font-sans placeholder:text-sm placeholder:tracking-normal focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLoading}
|
||||
disabled={isLoading || !email || code.length < 10}
|
||||
className="focus:shadow-outline w-full transform rounded bg-gray-800 px-4 py-2 text-white transition-colors duration-300 ease-in-out hover:bg-gray-900 focus:outline-none"
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 px-4 text-center text-sm text-muted-foreground sm:px-12">
|
||||
Didn't receive a code?{" "}
|
||||
<Link href="/login" className="text-gray-900 underline">
|
||||
Try again
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p className="mt-10 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-12">
|
||||
By clicking continue, you acknowledge that you have read and agree
|
||||
to Papermark's{" "}
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<TestimonialSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestimonialSection() {
|
||||
return (
|
||||
<div className="relative hidden w-full justify-center overflow-hidden bg-black md:flex md:w-1/2 lg:w-1/2">
|
||||
<div className="relative m-0 flex h-full min-h-[700px] w-full p-0">
|
||||
<div
|
||||
className="relative flex h-full w-full flex-col justify-between"
|
||||
id="features"
|
||||
>
|
||||
{/* Testimonial top 2/3 */}
|
||||
<div
|
||||
className="flex w-full flex-col items-center justify-center"
|
||||
style={{ height: "66.6666%" }}
|
||||
>
|
||||
{/* Image container */}
|
||||
<div className="mb-4 h-64 w-80">
|
||||
<img
|
||||
className="h-full w-full rounded-2xl object-cover shadow-2xl"
|
||||
src="/_static/testimonials/backtrace.jpeg"
|
||||
alt="Backtrace Capital"
|
||||
/>
|
||||
</div>
|
||||
{/* Text content */}
|
||||
<div className="max-w-xl text-center">
|
||||
<blockquote className="text-balance font-normal leading-8 text-white sm:text-xl sm:leading-9">
|
||||
<p>
|
||||
"We raised our €30M Fund with Papermark Data Rooms. Love
|
||||
the customization, security and ease of use."
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption className="mt-4">
|
||||
<div className="text-balance font-normal text-white">
|
||||
Michael Münnix
|
||||
</div>
|
||||
<div className="text-balance font-light text-gray-400">
|
||||
Partner, Backtrace Capital
|
||||
</div>
|
||||
</figcaption>
|
||||
</div>
|
||||
</div>
|
||||
{/* White block with logos bottom 1/3, full width/height */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 flex w-full flex-col items-center justify-center bg-white"
|
||||
style={{ height: "33.3333%" }}
|
||||
>
|
||||
<div className="mb-4 max-w-xl text-balance text-center font-semibold text-gray-900">
|
||||
Trusted by teams at
|
||||
</div>
|
||||
<LogoCloud />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Metadata } from "next";
|
||||
|
||||
import EmailVerificationClient from "./page-client";
|
||||
|
||||
const data = {
|
||||
description: "Verify your login to Papermark",
|
||||
title: "Verify Login | Papermark",
|
||||
url: "/auth/email",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://www.papermark.com"),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
openGraph: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
url: data.url,
|
||||
siteName: "Papermark",
|
||||
images: [
|
||||
{
|
||||
url: "/_static/meta-image.png",
|
||||
width: 800,
|
||||
height: 600,
|
||||
},
|
||||
],
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
creator: "@papermarkio",
|
||||
images: ["/_static/meta-image.png"],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function EmailVerificationPage() {
|
||||
return <EmailVerificationClient />;
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -24,6 +23,7 @@ import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function Login() {
|
||||
const { next } = useParams as { next?: string };
|
||||
const router = useRouter();
|
||||
|
||||
const [lastUsed, setLastUsed] = useLastUsed();
|
||||
const authMethods = ["google", "email", "linkedin", "passkey"] as const;
|
||||
@@ -85,15 +85,22 @@ export default function Login() {
|
||||
...(next && next.length > 0 ? { callbackUrl: next } : {}),
|
||||
}).then((res) => {
|
||||
if (res?.ok && !res?.error) {
|
||||
setEmail("");
|
||||
setLastUsed("credentials");
|
||||
setEmailButtonText("Email sent - check your inbox!");
|
||||
toast.success("Email sent - check your inbox!");
|
||||
// Store email in sessionStorage for the verification page
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
"pendingVerificationEmail",
|
||||
emailValidation.data,
|
||||
);
|
||||
} catch {
|
||||
// sessionStorage not available, verification page will show email input
|
||||
}
|
||||
router.push("/auth/email");
|
||||
} else {
|
||||
setEmailButtonText("Error sending email - try again?");
|
||||
toast.error("Error sending email - try again?");
|
||||
setClickedMethod(undefined);
|
||||
}
|
||||
setClickedMethod(undefined);
|
||||
});
|
||||
}}
|
||||
>
|
||||
@@ -145,9 +152,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 +174,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 +195,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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-177
@@ -1,179 +1,6 @@
|
||||
import { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import NotFound from "@/pages/404";
|
||||
|
||||
import { generateChecksum } from "@/lib/utils/generate-checksum";
|
||||
|
||||
import { LogoCloud } from "@/components/shared/logo-cloud";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const data = {
|
||||
description: "Verify login to Papermark",
|
||||
title: "Verify | Papermark",
|
||||
url: "/verify",
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://www.papermark.com"),
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
openGraph: {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
url: data.url,
|
||||
siteName: "Papermark",
|
||||
images: [
|
||||
{
|
||||
url: "/_static/meta-image.png",
|
||||
width: 800,
|
||||
height: 600,
|
||||
},
|
||||
],
|
||||
locale: "en_US",
|
||||
type: "website",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
creator: "@papermarkio",
|
||||
images: ["/_static/meta-image.png"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function VerifyPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { verification_url?: string; checksum?: string };
|
||||
}) {
|
||||
const { verification_url, checksum } = searchParams;
|
||||
|
||||
if (!verification_url || !checksum) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
// Server-side validation
|
||||
const isValidVerificationUrl = (url: string, checksum: string): boolean => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.origin !== process.env.NEXTAUTH_URL) return false;
|
||||
const expectedChecksum = generateChecksum(url);
|
||||
return checksum === expectedChecksum;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isValidVerificationUrl(verification_url, checksum)) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full flex-wrap">
|
||||
{/* Left part */}
|
||||
<div className="flex w-full justify-center bg-gray-50 md:w-1/2 lg:w-1/2">
|
||||
<div
|
||||
className="absolute inset-x-0 top-10 -z-10 flex transform-gpu justify-center overflow-hidden blur-3xl"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div className="z-10 mx-5 mt-[calc(1vh)] h-fit w-full max-w-md overflow-hidden rounded-lg sm:mx-0 sm:mt-[calc(2vh)] md:mt-[calc(3vh)]">
|
||||
<div className="items-left flex flex-col space-y-3 px-4 py-6 pt-8 sm:px-12">
|
||||
<img
|
||||
src="/_static/papermark-logo.svg"
|
||||
alt="Papermark Logo"
|
||||
className="-mt-8 mb-36 h-7 w-auto self-start sm:mb-32 md:mb-48"
|
||||
/>
|
||||
<Link href="/">
|
||||
<span className="text-balance text-3xl font-semibold text-gray-900">
|
||||
Verify your login
|
||||
</span>
|
||||
</Link>
|
||||
<h3 className="text-balance text-sm text-gray-800">
|
||||
Share documents. Not attachments.
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 px-4 pt-8 sm:px-12">
|
||||
<div className="relative">
|
||||
<Link href={verification_url}>
|
||||
<Button className="focus:shadow-outline w-full transform rounded bg-gray-800 px-4 py-2 text-white transition-colors duration-300 ease-in-out hover:bg-gray-900 focus:outline-none">
|
||||
Verify email
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-10 w-full max-w-md px-4 text-xs text-muted-foreground sm:px-12">
|
||||
By clicking continue, you acknowledge that you have read and agree
|
||||
to Papermark's{" "}
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/terms`}
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/privacy`}
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden w-full justify-center overflow-hidden bg-black md:flex md:w-1/2 lg:w-1/2">
|
||||
<div className="relative m-0 flex h-full min-h-[700px] w-full p-0">
|
||||
<div
|
||||
className="relative flex h-full w-full flex-col justify-between"
|
||||
id="features"
|
||||
>
|
||||
{/* Testimonial top 2/3 */}
|
||||
<div
|
||||
className="flex w-full flex-col items-center justify-center"
|
||||
style={{ height: "66.6666%" }}
|
||||
>
|
||||
{/* Image container */}
|
||||
<div className="mb-4 h-64 w-80">
|
||||
<img
|
||||
className="h-full w-full rounded-2xl object-cover shadow-2xl"
|
||||
src="/_static/testimonials/backtrace.jpeg"
|
||||
alt="Backtrace Capital"
|
||||
/>
|
||||
</div>
|
||||
{/* Text content */}
|
||||
<div className="max-w-xl text-center">
|
||||
<blockquote className="text-balance font-normal leading-8 text-white sm:text-xl sm:leading-9">
|
||||
<p>
|
||||
"We raised our €30M Fund with Papermark Data Rooms.
|
||||
Love the customization, security and ease of use."
|
||||
</p>
|
||||
</blockquote>
|
||||
<figcaption className="mt-4">
|
||||
<div className="text-balance font-normal text-white">
|
||||
Michael Münnix
|
||||
</div>
|
||||
<div className="text-balance font-light text-gray-400">
|
||||
Partner, Backtrace Capital
|
||||
</div>
|
||||
</figcaption>
|
||||
</div>
|
||||
</div>
|
||||
{/* White block with logos bottom 1/3, full width/height */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 flex w-full flex-col items-center justify-center bg-white"
|
||||
style={{ height: "33.3333%" }}
|
||||
>
|
||||
<div className="mb-4 max-w-xl text-balance text-center font-semibold text-gray-900">
|
||||
Trusted by teams at
|
||||
</div>
|
||||
<LogoCloud />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Legacy verify page - redirect to new auth email flow
|
||||
export default async function VerifyPage() {
|
||||
redirect("/auth/email");
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../ee/LICENSE.md
|
||||
@@ -0,0 +1 @@
|
||||
../ee/README.md
|
||||
@@ -0,0 +1,153 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { generateChatTitle } from "@/ee/features/ai/lib/chat/generate-chat-title";
|
||||
import { getFilteredDataroomDocumentIds } from "@/ee/features/ai/lib/chat/get-filtered-dataroom-document-ids";
|
||||
import { sendMessage } from "@/ee/features/ai/lib/chat/send-message";
|
||||
import { validateChatAccess } from "@/ee/features/ai/lib/permissions/validate-chat-access";
|
||||
import { sendMessageSchema } from "@/ee/features/ai/schemas/chat";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { getFeatureFlags } from "@/lib/featureFlags";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat/[chatId]/messages
|
||||
* Send a message and get streaming response
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { chatId: string } },
|
||||
) {
|
||||
try {
|
||||
const { chatId } = params;
|
||||
const body = await req.json();
|
||||
const validation = sendMessageSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid request body",
|
||||
details: validation.error,
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const { content, filterDocumentId, filterDataroomDocumentIds } =
|
||||
validation.data;
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const viewerId = searchParams.get("viewerId");
|
||||
|
||||
let userId: string | undefined;
|
||||
|
||||
if (session) {
|
||||
userId = (session.user as CustomUser).id;
|
||||
}
|
||||
|
||||
// Validate access
|
||||
const hasAccess = await validateChatAccess({
|
||||
chatId,
|
||||
userId,
|
||||
viewerId: viewerId || undefined,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Get chat details
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
messages: {
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
return new Response(JSON.stringify({ error: "Chat not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId: chat.teamId });
|
||||
if (!features.ai) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "AI features are not available for this team",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!chat.vectorStoreId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Vector store not available. Please index documents first.",
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
filterDataroomDocumentIds &&
|
||||
filterDataroomDocumentIds.length > 0 &&
|
||||
!chat.dataroomId
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
"Dataroom document filters are only allowed for dataroom chats",
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Generate title from first message if not set
|
||||
if (!chat.title && chat.messages.length === 0) {
|
||||
const title = await generateChatTitle(content);
|
||||
await prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { title },
|
||||
});
|
||||
}
|
||||
|
||||
// Get filtered dataroom document IDs based on permissions
|
||||
let filteredDataroomDocumentIds: string[] | undefined;
|
||||
|
||||
if (chat.dataroomId && chat.linkId) {
|
||||
filteredDataroomDocumentIds = await getFilteredDataroomDocumentIds(
|
||||
chat.dataroomId,
|
||||
chat.linkId,
|
||||
);
|
||||
}
|
||||
|
||||
// Send message and get streaming response
|
||||
const result = await sendMessage({
|
||||
chatId,
|
||||
content,
|
||||
vectorStoreId: chat.vectorStoreId,
|
||||
filteredDataroomDocumentIds,
|
||||
filterDocumentId,
|
||||
userSelectedDataroomDocumentIds: filterDataroomDocumentIds,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
} catch (error) {
|
||||
console.error("Error sending message:", error);
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { validateChatAccess } from "@/ee/features/ai/lib/permissions/validate-chat-access";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { getFeatureFlags } from "@/lib/featureFlags";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* GET /api/ai/chat/[chatId]
|
||||
* Get chat details with messages
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { chatId: string } },
|
||||
) {
|
||||
try {
|
||||
const { chatId } = params;
|
||||
const session = await getServerSession(authOptions);
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const viewerId = searchParams.get("viewerId");
|
||||
|
||||
let userId: string | undefined;
|
||||
|
||||
if (session) {
|
||||
userId = (session.user as CustomUser).id;
|
||||
}
|
||||
|
||||
// Validate access
|
||||
const hasAccess = await validateChatAccess({
|
||||
chatId,
|
||||
userId,
|
||||
viewerId: viewerId || undefined,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Fetch chat with messages
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
messages: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
document: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
dataroom: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
return NextResponse.json({ error: "Chat not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId: chat.teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(chat);
|
||||
} catch (error) {
|
||||
console.error("Error fetching chat:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ai/chat/[chatId]
|
||||
* Delete a chat
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { chatId: string } },
|
||||
) {
|
||||
try {
|
||||
const { chatId } = params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Validate ownership
|
||||
const hasAccess = await validateChatAccess({
|
||||
chatId,
|
||||
userId,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify user owns this chat
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
});
|
||||
|
||||
if (!chat || chat.userId !== userId) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId: chat.teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete chat (messages will cascade)
|
||||
await prisma.chat.delete({
|
||||
where: { id: chatId },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting chat:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { createChat } from "@/ee/features/ai/lib/chat/create-chat";
|
||||
import { createChatSchema } from "@/ee/features/ai/schemas/chat";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
|
||||
import { getFeatureFlags } from "@/lib/featureFlags";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* POST /api/ai/chat
|
||||
* Create a new chat session
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const validation = createChatSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request body", details: validation.error },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { documentId, dataroomId, linkId, viewId, title, viewerId } =
|
||||
validation.data;
|
||||
|
||||
// Check for either internal user or external viewer authentication
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
let userId: string | undefined;
|
||||
let teamId: string;
|
||||
|
||||
// Internal user flow
|
||||
if (session) {
|
||||
userId = (session.user as CustomUser).id;
|
||||
|
||||
// Get team ID from document or dataroom
|
||||
if (dataroomId) {
|
||||
const dataroom = await prisma.dataroom.findUnique({
|
||||
where: { id: dataroomId },
|
||||
select: {
|
||||
teamId: true,
|
||||
agentsEnabled: true,
|
||||
vectorStoreId: true,
|
||||
team: {
|
||||
select: {
|
||||
agentsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!dataroom) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dataroom not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if team has AI enabled first
|
||||
if (!dataroom.team?.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataroom.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this dataroom" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
teamId = dataroom.teamId;
|
||||
|
||||
// Verify user is member of team
|
||||
const userTeam = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userTeam) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
} else if (documentId) {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: { id: documentId },
|
||||
select: {
|
||||
teamId: true,
|
||||
agentsEnabled: true,
|
||||
team: {
|
||||
select: {
|
||||
agentsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json(
|
||||
{ error: "Document not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if team has AI enabled first
|
||||
if (!document.team?.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!document.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this document" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
teamId = document.teamId;
|
||||
|
||||
// Verify user is member of team
|
||||
const userTeam = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userTeam) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Either documentId or dataroomId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
// External viewer flow
|
||||
else if (linkId && viewId && viewerId) {
|
||||
// Verify dataroom session if this is a dataroom link
|
||||
if (dataroomId) {
|
||||
const dataroomSession = await verifyDataroomSession(
|
||||
req,
|
||||
linkId,
|
||||
dataroomId,
|
||||
);
|
||||
|
||||
if (!dataroomSession || dataroomSession.viewerId !== viewerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized - invalid session" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify link has AI enabled
|
||||
const link = await prisma.link.findUnique({
|
||||
where: { id: linkId, dataroomId },
|
||||
select: {
|
||||
enableAIAgents: true,
|
||||
isArchived: true,
|
||||
dataroom: { select: { teamId: true, agentsEnabled: true } },
|
||||
team: { select: { agentsEnabled: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
return NextResponse.json(
|
||||
{ error: "Link not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (link?.isArchived) {
|
||||
return NextResponse.json(
|
||||
{ error: "Link is archived" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!link?.enableAIAgents) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this link" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!link?.dataroom) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dataroom not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!link?.dataroom?.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this dataroom" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!link?.team?.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
teamId = link?.dataroom?.teamId;
|
||||
} else if (documentId) {
|
||||
// Verify link access for document
|
||||
const link = await prisma.link.findUnique({
|
||||
where: { id: linkId, documentId },
|
||||
select: {
|
||||
enableAIAgents: true,
|
||||
isArchived: true,
|
||||
document: { select: { teamId: true, agentsEnabled: true } },
|
||||
team: { select: { agentsEnabled: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!link) {
|
||||
return NextResponse.json(
|
||||
{ error: "Link not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (link?.isArchived) {
|
||||
return NextResponse.json(
|
||||
{ error: "Link is archived" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if link has AI enabled
|
||||
if (!link.enableAIAgents) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this link" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!link.document?.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this document" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!link?.team?.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!link.document || !link.document?.teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Document not found" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
teamId = link.document?.teamId;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Either documentId or dataroomId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify viewer exists
|
||||
const viewer = await prisma.viewer.findUnique({
|
||||
where: { id: viewerId, teamId },
|
||||
});
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json(
|
||||
{ error: "Viewer not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication required" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get vector store ID
|
||||
let vectorStoreId: string | undefined;
|
||||
|
||||
if (dataroomId) {
|
||||
const dataroom = await prisma.dataroom.findUnique({
|
||||
where: { id: dataroomId },
|
||||
select: { vectorStoreId: true },
|
||||
});
|
||||
vectorStoreId = dataroom?.vectorStoreId || undefined;
|
||||
} else if (documentId) {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: { vectorStoreId: true },
|
||||
});
|
||||
vectorStoreId = team?.vectorStoreId || undefined;
|
||||
}
|
||||
|
||||
// Create the chat
|
||||
const chat = await createChat({
|
||||
teamId,
|
||||
documentId,
|
||||
dataroomId,
|
||||
linkId,
|
||||
viewId,
|
||||
userId,
|
||||
viewerId,
|
||||
vectorStoreId,
|
||||
title,
|
||||
});
|
||||
|
||||
return NextResponse.json(chat, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating chat:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/ai/chat
|
||||
* List chats with filters
|
||||
* Supports both internal users (via session) and external viewers (via viewerId param)
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
|
||||
const chatListQuerySchema = z.object({
|
||||
viewerId: z.string().cuid().optional(),
|
||||
teamId: z.string().cuid().optional(),
|
||||
documentId: z.string().cuid().optional(),
|
||||
dataroomId: z.string().cuid().optional(),
|
||||
linkId: z.string().cuid().optional(),
|
||||
viewId: z.string().cuid().optional(),
|
||||
});
|
||||
|
||||
const queryObj = {
|
||||
viewerId: searchParams.get("viewerId") ?? undefined,
|
||||
teamId: searchParams.get("teamId") ?? undefined,
|
||||
documentId: searchParams.get("documentId") ?? undefined,
|
||||
dataroomId: searchParams.get("dataroomId") ?? undefined,
|
||||
linkId: searchParams.get("linkId") ?? undefined,
|
||||
viewId: searchParams.get("viewId") ?? undefined,
|
||||
};
|
||||
|
||||
const { viewerId, teamId, documentId, dataroomId, linkId, viewId } =
|
||||
chatListQuerySchema.parse(queryObj);
|
||||
|
||||
// Combined flow: logged-in user with viewerId
|
||||
// This handles the case where a team member is also viewing as a viewer
|
||||
if (session && viewerId && (dataroomId || documentId)) {
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Verify viewer exists
|
||||
const viewer = await prisma.viewer.findUnique({
|
||||
where: { id: viewerId },
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!viewer) {
|
||||
return NextResponse.json(
|
||||
{ error: "Viewer not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user is member of team that the viewer is associated with
|
||||
const userTeam = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId: viewer.teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userTeam) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId: viewer.teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build where clause - find chats with this userId OR viewerId
|
||||
const baseWhere: any = {};
|
||||
|
||||
if (documentId) {
|
||||
baseWhere.documentId = documentId;
|
||||
}
|
||||
|
||||
if (dataroomId) {
|
||||
baseWhere.dataroomId = dataroomId;
|
||||
}
|
||||
|
||||
if (linkId) {
|
||||
baseWhere.linkId = linkId;
|
||||
}
|
||||
|
||||
// Fetch chats that belong to either userId or viewerId
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: {
|
||||
...baseWhere,
|
||||
OR: [{ userId }, { viewerId }],
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json(chats);
|
||||
}
|
||||
|
||||
// External viewer flow (not logged in)
|
||||
if (viewerId && linkId && viewId) {
|
||||
// Build where clause for viewer's chats
|
||||
const where: any = {
|
||||
viewerId,
|
||||
linkId,
|
||||
};
|
||||
|
||||
if (dataroomId) {
|
||||
// verify dataroom session
|
||||
const dataroomSession = await verifyDataroomSession(
|
||||
req,
|
||||
linkId,
|
||||
dataroomId,
|
||||
);
|
||||
|
||||
if (!dataroomSession || dataroomSession.viewerId !== viewerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized - invalid session" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
where.dataroomId = dataroomId;
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
// verify document session
|
||||
const view = await prisma.view.findUnique({
|
||||
where: { id: viewId, linkId, documentId },
|
||||
select: {
|
||||
viewerId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!view || view.viewerId !== viewerId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized - invalid session" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
where.documentId = documentId;
|
||||
}
|
||||
|
||||
// Fetch viewer's chats
|
||||
const chats = await prisma.chat.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
lastMessageAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(chats);
|
||||
}
|
||||
|
||||
// Internal user flow (logged in, no viewerId)
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user is member of team
|
||||
const userTeam = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userTeam) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build where clause
|
||||
const where: any = {
|
||||
teamId,
|
||||
userId, // Only show user's own chats
|
||||
};
|
||||
|
||||
if (documentId) {
|
||||
where.documentId = documentId;
|
||||
}
|
||||
|
||||
if (dataroomId) {
|
||||
where.dataroomId = dataroomId;
|
||||
}
|
||||
|
||||
// Fetch chats
|
||||
const chats = await prisma.chat.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return NextResponse.json(chats);
|
||||
} catch (error) {
|
||||
console.error("Error fetching chats:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* GET /api/ai/store/runs/[runId]
|
||||
* Get the status of a Trigger.dev run for polling
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { runId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { runId } = params;
|
||||
|
||||
const run = await runs.retrieve(runId);
|
||||
|
||||
// Verify the user is authorized to access this run
|
||||
const runTeamId = run.metadata?.teamId as string | undefined;
|
||||
const userTeams = await prisma.userTeam.findMany({
|
||||
where: { userId: (session.user as CustomUser).id },
|
||||
select: { teamId: true },
|
||||
});
|
||||
const userTeamIds = userTeams.map((ut) => ut.teamId);
|
||||
|
||||
if (!runTeamId || !userTeamIds.includes(runTeamId)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
id: run.id,
|
||||
status: run.status,
|
||||
metadata: run.metadata,
|
||||
isCompleted: run.isCompleted,
|
||||
isFailed: run.isFailed,
|
||||
output: run.output,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error retrieving run status:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to retrieve run status" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
SUPPORTED_AI_CONTENT_TYPES,
|
||||
addFileToVectorStoreTask,
|
||||
processDocumentForAITask,
|
||||
} from "@/ee/features/ai/lib/trigger";
|
||||
import { createDataroomVectorStore } from "@/ee/features/ai/lib/vector-stores/create-dataroom-vector-store";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { getFeatureFlags } from "@/lib/featureFlags";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* POST /api/ai/store/teams/[teamId]/datarooms/[dataroomId]
|
||||
* Index all documents in a dataroom into its vector store
|
||||
* Returns batch runIds for status tracking via polling
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { dataroomId: string; teamId: string } },
|
||||
) {
|
||||
try {
|
||||
const { dataroomId, teamId } = params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Verify user is member of team
|
||||
const userTeam = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userTeam) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get dataroom and documents
|
||||
const dataroom = await prisma.dataroom.findUnique({
|
||||
where: {
|
||||
id: dataroomId,
|
||||
teamId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
agentsEnabled: true,
|
||||
},
|
||||
},
|
||||
documents: {
|
||||
include: {
|
||||
document: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
fileId: true,
|
||||
contentType: true,
|
||||
storageType: true,
|
||||
originalFile: true,
|
||||
file: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!dataroom) {
|
||||
return NextResponse.json(
|
||||
{ error: "Dataroom not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if team has AI enabled
|
||||
if (!dataroom.team.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataroom.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this dataroom" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create or get vector store
|
||||
let vectorStoreId = dataroom.vectorStoreId;
|
||||
|
||||
if (!vectorStoreId) {
|
||||
vectorStoreId = await createDataroomVectorStore({
|
||||
dataroomId,
|
||||
teamId: dataroom.teamId,
|
||||
name: dataroom.name,
|
||||
});
|
||||
|
||||
// Update dataroom with vector store ID
|
||||
await prisma.dataroom.update({
|
||||
where: { id: dataroomId },
|
||||
data: { vectorStoreId },
|
||||
});
|
||||
}
|
||||
|
||||
// Track triggered runs for status polling
|
||||
const triggeredRuns: Array<{
|
||||
documentId: string;
|
||||
documentName: string;
|
||||
runId: string;
|
||||
}> = [];
|
||||
const skippedDocuments: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
// Trigger processing for each document
|
||||
for (const dataroomDoc of dataroom.documents) {
|
||||
const document = dataroomDoc.document;
|
||||
const primaryVersion = document.versions[0];
|
||||
|
||||
if (!primaryVersion) {
|
||||
errors.push(`No primary version for document: ${document.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already indexed in this dataroom
|
||||
if (dataroomDoc.vectorStoreFileId) {
|
||||
skippedDocuments.push(document.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if document type is supported
|
||||
const contentType = primaryVersion.contentType || "";
|
||||
if (!SUPPORTED_AI_CONTENT_TYPES.includes(contentType)) {
|
||||
errors.push(
|
||||
`Unsupported file type for document: ${document.name} (${contentType})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine file path
|
||||
const filePath =
|
||||
primaryVersion.originalFile && contentType !== "application/pdf"
|
||||
? primaryVersion.originalFile
|
||||
: primaryVersion.file;
|
||||
|
||||
const fileMetadata = {
|
||||
teamId: dataroom.teamId,
|
||||
documentId: document.id,
|
||||
documentName: document.name,
|
||||
versionId: primaryVersion.id,
|
||||
dataroomId: dataroom.id,
|
||||
dataroomDocumentId: dataroomDoc.id,
|
||||
dataroomFolderId: dataroomDoc.folderId || "root",
|
||||
};
|
||||
|
||||
let handle;
|
||||
|
||||
// If document already has fileId, just add to vector store
|
||||
if (primaryVersion.fileId) {
|
||||
handle = await addFileToVectorStoreTask.trigger({
|
||||
fileId: primaryVersion.fileId,
|
||||
vectorStoreId,
|
||||
metadata: fileMetadata,
|
||||
});
|
||||
} else {
|
||||
// Trigger full processing
|
||||
handle = await processDocumentForAITask.trigger(
|
||||
{
|
||||
documentId: document.id,
|
||||
documentVersionId: primaryVersion.id,
|
||||
teamId: dataroom.teamId,
|
||||
vectorStoreId,
|
||||
documentName: document.name,
|
||||
filePath,
|
||||
storageType: primaryVersion.storageType,
|
||||
contentType,
|
||||
metadata: fileMetadata,
|
||||
},
|
||||
{
|
||||
idempotencyKey: `ai-index-dataroom-${dataroom.id}-${primaryVersion.id}`,
|
||||
tags: [
|
||||
`team_${dataroom.teamId}`,
|
||||
`dataroom_${dataroom.id}`,
|
||||
`document_${document.id}`,
|
||||
`version_${primaryVersion.id}`,
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
triggeredRuns.push({
|
||||
documentId: document.id,
|
||||
documentName: document.name,
|
||||
runId: handle.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error triggering processing for ${document.name}:`,
|
||||
error,
|
||||
);
|
||||
errors.push(
|
||||
`Failed to trigger processing for: ${document.name} - ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
vectorStoreId,
|
||||
totalDocuments: dataroom.documents.length,
|
||||
triggeredCount: triggeredRuns.length,
|
||||
skippedCount: skippedDocuments.length,
|
||||
runs: triggeredRuns,
|
||||
skipped: skippedDocuments.length > 0 ? skippedDocuments : undefined,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error indexing dataroom:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
addFileToVectorStoreTask,
|
||||
processDocumentForAITask,
|
||||
SUPPORTED_AI_CONTENT_TYPES,
|
||||
} from "@/ee/features/ai/lib/trigger";
|
||||
import { createTeamVectorStore } from "@/ee/features/ai/lib/vector-stores/create-team-vector-store";
|
||||
import { removeFileFromVectorStore } from "@/ee/features/ai/lib/vector-stores/remove-file-from-vector-store";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { getFeatureFlags } from "@/lib/featureFlags";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* POST /api/ai/store/teams/[teamId]/documents/[documentId]
|
||||
* Index a single document into the team vector store
|
||||
* Returns runId for status tracking via polling
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { documentId: string; teamId: string } },
|
||||
) {
|
||||
try {
|
||||
const { documentId, teamId } = params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Verify user is member of team
|
||||
const userTeam = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userTeam) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get document and version
|
||||
const document = await prisma.document.findUnique({
|
||||
where: { id: documentId, teamId },
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
agentsEnabled: true,
|
||||
vectorStoreId: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json(
|
||||
{ error: "Document not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if team has AI enabled
|
||||
if (!document.team.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!document.agentsEnabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI agents are not enabled for this document" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const primaryVersion = document.versions[0];
|
||||
|
||||
if (!primaryVersion) {
|
||||
return NextResponse.json(
|
||||
{ error: "No primary version found for this document" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create or get team vector store
|
||||
let vectorStoreId = document.team.vectorStoreId;
|
||||
|
||||
if (!vectorStoreId) {
|
||||
vectorStoreId = await createTeamVectorStore(
|
||||
document.teamId,
|
||||
document.team.name,
|
||||
);
|
||||
|
||||
// Update team with vector store ID
|
||||
await prisma.team.update({
|
||||
where: { id: document.teamId },
|
||||
data: { vectorStoreId },
|
||||
});
|
||||
}
|
||||
|
||||
// Check if document type is supported
|
||||
const contentType = primaryVersion.contentType || "";
|
||||
if (!SUPPORTED_AI_CONTENT_TYPES.includes(contentType)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Unsupported file type: ${contentType}. Supported types: PDF, Excel, and images (JPEG, PNG, WebP).`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Determine file path - use originalFile for non-PDF, file for PDF
|
||||
const filePath =
|
||||
primaryVersion.originalFile && contentType !== "application/pdf"
|
||||
? primaryVersion.originalFile
|
||||
: primaryVersion.file;
|
||||
|
||||
const fileMetadata = {
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
documentName: document.name,
|
||||
versionId: primaryVersion.id,
|
||||
folderId: document.folderId || "root",
|
||||
};
|
||||
|
||||
// Check if document already has a fileId - use lightweight task
|
||||
if (primaryVersion.fileId) {
|
||||
// Document already processed, just add to vector store
|
||||
const handle = await addFileToVectorStoreTask.trigger({
|
||||
fileId: primaryVersion.fileId,
|
||||
vectorStoreId,
|
||||
metadata: fileMetadata,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
runId: handle.id,
|
||||
isReprocessing: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger full document processing task
|
||||
const handle = await processDocumentForAITask.trigger(
|
||||
{
|
||||
documentId: document.id,
|
||||
documentVersionId: primaryVersion.id,
|
||||
teamId: document.teamId,
|
||||
vectorStoreId,
|
||||
documentName: document.name,
|
||||
filePath,
|
||||
storageType: primaryVersion.storageType,
|
||||
contentType,
|
||||
metadata: fileMetadata,
|
||||
},
|
||||
{
|
||||
idempotencyKey: `ai-index-${document.teamId}-${primaryVersion.id}`,
|
||||
tags: [
|
||||
`team_${document.teamId}`,
|
||||
`document_${document.id}`,
|
||||
`version_${primaryVersion.id}`,
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
runId: handle.id,
|
||||
isReprocessing: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error indexing document:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ai/store/teams/[teamId]/documents/[documentId]
|
||||
* Remove a document from the team vector store
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { documentId: string; teamId: string } },
|
||||
) {
|
||||
try {
|
||||
const { documentId, teamId } = params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Verify user is member of team
|
||||
const userTeam = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!userTeam) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Get document and verify user access
|
||||
const document = await prisma.document.findUnique({
|
||||
where: { id: documentId, teamId },
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
vectorStoreId: true,
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
vectorStoreFileId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return NextResponse.json(
|
||||
{ error: "Document not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId: document.teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const primaryVersion = document.versions[0];
|
||||
const vectorStoreId = document.team.vectorStoreId;
|
||||
|
||||
if (!primaryVersion?.vectorStoreFileId || !vectorStoreId) {
|
||||
return NextResponse.json(
|
||||
{ error: "Document is not indexed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Remove file from vector store
|
||||
await removeFileFromVectorStore(
|
||||
vectorStoreId,
|
||||
primaryVersion.vectorStoreFileId,
|
||||
);
|
||||
|
||||
// Clear vector store file ID
|
||||
await prisma.documentVersion.update({
|
||||
where: { id: primaryVersion.id },
|
||||
data: { vectorStoreFileId: null },
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error removing document from vector store:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { getVectorStoreInfo } from "@/ee/features/ai/lib/vector-stores/get-vector-store-info";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { getFeatureFlags } from "@/lib/featureFlags";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* GET /api/ai/store/teams/[teamId]
|
||||
* Get team vector store information
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { teamId: string } },
|
||||
) {
|
||||
try {
|
||||
const { teamId } = params;
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Verify user is member of team
|
||||
const userTeam = await prisma.userTeam.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userTeam) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Check if AI feature is enabled for this team
|
||||
const features = await getFeatureFlags({ teamId });
|
||||
if (!features.ai) {
|
||||
return NextResponse.json(
|
||||
{ error: "AI features are not available for this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Get team with vector store ID
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
vectorStoreId: true,
|
||||
agentsEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ error: "Team not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!team.vectorStoreId) {
|
||||
return NextResponse.json({
|
||||
agentsEnabled: team.agentsEnabled,
|
||||
vectorStoreId: null,
|
||||
info: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Get vector store info
|
||||
const info = await getVectorStoreInfo(team.vectorStoreId);
|
||||
|
||||
return NextResponse.json({
|
||||
agentsEnabled: team.agentsEnabled,
|
||||
vectorStoreId: team.vectorStoreId,
|
||||
info,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching team vector store info:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { WorkflowEngine } from "@/ee/features/workflows/lib/engine";
|
||||
import {
|
||||
AccessRequestSchema,
|
||||
VerifyEmailRequestSchema,
|
||||
} from "@/ee/features/workflows/lib/types";
|
||||
import { ipAddress, waitUntil } from "@vercel/functions";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createDataroomSession } from "@/lib/auth/dataroom-auth";
|
||||
import { createLinkSession } from "@/lib/auth/link-session";
|
||||
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { ratelimit } from "@/lib/redis";
|
||||
import { generateOTP } from "@/lib/utils/generate-otp";
|
||||
import { LOCALHOST_IP } from "@/lib/utils/geo";
|
||||
|
||||
// POST /app/(ee)/api/workflow-entry/domains/[domain]/[slug]/[action]
|
||||
// where action is "verify" or "access"
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { domainSlug: string[] } },
|
||||
) {
|
||||
try {
|
||||
const domainSlug = params.domainSlug;
|
||||
|
||||
if (!domainSlug || domainSlug.length < 3) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid URL format. Expected: domain/slug/action" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const domain = domainSlug[0];
|
||||
const slug = domainSlug[1];
|
||||
const action = domainSlug[2]; // "verify" or "access"
|
||||
|
||||
if (action !== "verify" && action !== "access") {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid action. Must be 'verify' or 'access'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find workflow by entry link's domain and slug
|
||||
const link = await prisma.link.findUnique({
|
||||
where: {
|
||||
domainSlug_slug: {
|
||||
domainSlug: domain,
|
||||
slug: slug,
|
||||
},
|
||||
linkType: "WORKFLOW_LINK",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isArchived: true,
|
||||
deletedAt: true,
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
isActive: true,
|
||||
name: true,
|
||||
teamId: true,
|
||||
entryLinkId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!link || !link.workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow entry link not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!link.workflow.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: "This workflow is currently inactive" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (link.isArchived || link.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: "This link is no longer available" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Handle verify action
|
||||
if (action === "verify") {
|
||||
return handleVerify(req, link);
|
||||
}
|
||||
|
||||
// Handle access action
|
||||
if (action === "access") {
|
||||
return handleAccess(req, link);
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
} catch (error) {
|
||||
console.error("Error in workflow entry endpoint:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle verify (send OTP)
|
||||
async function handleVerify(req: NextRequest, link: any) {
|
||||
const body = await req.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = VerifyEmailRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email address" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
// Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding
|
||||
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
|
||||
`workflow-otp:${link.id}:${email}`,
|
||||
);
|
||||
if (!emailLimitSuccess) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Please wait before requesting another code. Try again in 30 seconds.",
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
|
||||
const ipAddressValue = ipAddress(req);
|
||||
const { success } = await ratelimit(10, "1 m").limit(
|
||||
`workflow-otp:${ipAddressValue}`,
|
||||
);
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please try again later." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete any existing OTP codes for this workflow entry + email
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
identifier: `workflow-otp:${link.id}:${email}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate and store OTP
|
||||
const otpCode = generateOTP();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + 10); // expires in 10 minutes
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
token: otpCode,
|
||||
identifier: `workflow-otp:${link.id}:${email}`,
|
||||
expires: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Send OTP email
|
||||
waitUntil(
|
||||
sendOtpVerificationEmail(email, otpCode, false, link.workflow.teamId),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Verification code sent to your email",
|
||||
});
|
||||
}
|
||||
|
||||
// Handle access (verify OTP and execute workflow)
|
||||
async function handleAccess(req: NextRequest, link: any) {
|
||||
const body = await req.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = AccessRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request data" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { email, code } = validation.data;
|
||||
|
||||
// Rate limiting
|
||||
const ipAddressValue = ipAddress(req) ?? LOCALHOST_IP;
|
||||
const { success } = await ratelimit(10, "1 m").limit(
|
||||
`workflow-verify:${ipAddressValue}`,
|
||||
);
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please try again later." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify OTP code
|
||||
const verification = await prisma.verificationToken.findUnique({
|
||||
where: {
|
||||
token: code,
|
||||
identifier: `workflow-otp:${link.id}:${email}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid verification code", resetVerification: true },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check OTP expiration
|
||||
if (Date.now() > verification.expires.getTime()) {
|
||||
await prisma.verificationToken.delete({
|
||||
where: { token: code },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Verification code expired", resetVerification: true },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete OTP after successful verification
|
||||
await prisma.verificationToken.delete({
|
||||
where: { token: code },
|
||||
});
|
||||
|
||||
// Execute workflow engine
|
||||
const engine = new WorkflowEngine();
|
||||
const userAgent = req.headers.get("user-agent") ?? "unknown";
|
||||
const referrer = req.headers.get("referer") ?? undefined;
|
||||
|
||||
const executionResult = await engine.execute(link.workflow.entryLinkId, {
|
||||
visitorEmail: email,
|
||||
visitorIp: ipAddressValue,
|
||||
userAgent,
|
||||
referrer,
|
||||
});
|
||||
|
||||
if (!executionResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: executionResult.error || "Workflow execution failed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find or create viewer
|
||||
let viewer = await prisma.viewer.findUnique({
|
||||
where: {
|
||||
teamId_email: {
|
||||
teamId: link.workflow.teamId,
|
||||
email: email,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!viewer) {
|
||||
viewer = await prisma.viewer.create({
|
||||
data: {
|
||||
email: email,
|
||||
verified: true,
|
||||
teamId: link.workflow.teamId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Create a view record for the workflow execution
|
||||
const view = await prisma.view.create({
|
||||
data: {
|
||||
linkId: link.id,
|
||||
viewerEmail: email,
|
||||
viewerId: viewer.id,
|
||||
verified: true,
|
||||
teamId: link.workflow.teamId,
|
||||
documentId: executionResult.targetDocumentId ?? undefined,
|
||||
dataroomId: executionResult.targetDataroomId ?? undefined,
|
||||
viewType:
|
||||
executionResult.targetLinkType === "DATAROOM_LINK"
|
||||
? "DATAROOM_VIEW"
|
||||
: "DOCUMENT_VIEW",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Create link session for the target link
|
||||
const { token: sessionToken, expiresAt } = await createLinkSession(
|
||||
executionResult.targetLinkId!,
|
||||
executionResult.targetLinkType!,
|
||||
view.id,
|
||||
email,
|
||||
ipAddressValue,
|
||||
userAgent,
|
||||
true, // verified
|
||||
viewer.id, // viewerId
|
||||
executionResult.targetDocumentId,
|
||||
executionResult.targetDataroomId,
|
||||
);
|
||||
|
||||
// Parse target URL safely with fallback
|
||||
let targetPath = "/";
|
||||
let cookieFlagId = executionResult.targetLinkId;
|
||||
|
||||
if (executionResult.targetUrl) {
|
||||
try {
|
||||
const parsedUrl = new URL(executionResult.targetUrl);
|
||||
targetPath = parsedUrl.pathname;
|
||||
const pathSegment = parsedUrl.pathname.split("/").pop();
|
||||
if (pathSegment) {
|
||||
cookieFlagId = pathSegment;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse target URL, using fallback values");
|
||||
}
|
||||
}
|
||||
|
||||
// Set link session cookie (httpOnly)
|
||||
cookies().set(`pm_ls_${executionResult.targetLinkId}`, sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(expiresAt),
|
||||
path: "/",
|
||||
});
|
||||
|
||||
// Set client-readable flag cookie for auto-login detection
|
||||
const flagCookieId = `pm_link_flag_${cookieFlagId}`;
|
||||
cookies().set(flagCookieId, "true", {
|
||||
httpOnly: false, // Client-readable
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(expiresAt),
|
||||
path: targetPath,
|
||||
});
|
||||
|
||||
// If routing to a dataroom, also create and set dataroom session cookie
|
||||
if (
|
||||
executionResult.targetLinkType === "DATAROOM_LINK" &&
|
||||
executionResult.targetDataroomId &&
|
||||
viewer
|
||||
) {
|
||||
const dataroomSession = await createDataroomSession(
|
||||
executionResult.targetDataroomId,
|
||||
executionResult.targetLinkId!,
|
||||
view.id,
|
||||
ipAddressValue,
|
||||
true, // verified
|
||||
viewer.id,
|
||||
);
|
||||
|
||||
cookies().set(
|
||||
`pm_drs_${executionResult.targetLinkId}`,
|
||||
dataroomSession.token,
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(dataroomSession.expiresAt),
|
||||
path: "/",
|
||||
},
|
||||
);
|
||||
|
||||
// Set client-readable flag cookie for dataroom
|
||||
const dataroomFlagId = `pm_drs_flag_${cookieFlagId}`;
|
||||
cookies().set(dataroomFlagId, "true", {
|
||||
httpOnly: false, // Client-readable
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(dataroomSession.expiresAt),
|
||||
path: targetPath,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
targetUrl: executionResult.targetUrl,
|
||||
targetLinkId: executionResult.targetLinkId,
|
||||
targetLinkType: executionResult.targetLinkType,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { WorkflowEngine } from "@/ee/features/workflows/lib/engine";
|
||||
import { AccessRequestSchema } from "@/ee/features/workflows/lib/types";
|
||||
import { ipAddress } from "@vercel/functions";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createDataroomSession } from "@/lib/auth/dataroom-auth";
|
||||
import { createLinkSession } from "@/lib/auth/link-session";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { ratelimit } from "@/lib/redis";
|
||||
import { LOCALHOST_IP } from "@/lib/utils/geo";
|
||||
|
||||
// POST /app/(ee)/api/workflow-entry/[entryLinkId]/access - Verify OTP and execute workflow
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { entryLinkId: string } },
|
||||
) {
|
||||
try {
|
||||
const { entryLinkId } = params;
|
||||
const body = await req.json();
|
||||
|
||||
// Validate entryLinkId format
|
||||
const linkIdValidation = z.string().cuid().safeParse(entryLinkId);
|
||||
if (!linkIdValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid link ID format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = AccessRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request data" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { email, code } = validation.data;
|
||||
|
||||
// Rate limiting
|
||||
const ipAddressValue = ipAddress(req) ?? LOCALHOST_IP;
|
||||
const { success } = await ratelimit(10, "1 m").limit(
|
||||
`workflow-verify:${ipAddressValue}`,
|
||||
);
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please try again later." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find workflow by entry link
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: { entryLinkId },
|
||||
select: {
|
||||
id: true,
|
||||
isActive: true,
|
||||
teamId: true,
|
||||
entryLink: {
|
||||
select: {
|
||||
id: true,
|
||||
isArchived: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow entry link not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!workflow.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: "This workflow is currently inactive" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (workflow.entryLink.isArchived || workflow.entryLink.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: "This link is no longer available" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Verify OTP code
|
||||
const verification = await prisma.verificationToken.findUnique({
|
||||
where: {
|
||||
token: code,
|
||||
identifier: `workflow-otp:${entryLinkId}:${email}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!verification) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid verification code", resetVerification: true },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check OTP expiration
|
||||
if (Date.now() > verification.expires.getTime()) {
|
||||
await prisma.verificationToken.delete({
|
||||
where: { token: code },
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: "Verification code expired", resetVerification: true },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete OTP after successful verification
|
||||
await prisma.verificationToken.delete({
|
||||
where: { token: code },
|
||||
});
|
||||
|
||||
// Execute workflow engine
|
||||
const engine = new WorkflowEngine();
|
||||
const userAgent = req.headers.get("user-agent") ?? "unknown";
|
||||
const referrer = req.headers.get("referer") ?? undefined;
|
||||
|
||||
const executionResult = await engine.execute(entryLinkId, {
|
||||
visitorEmail: email,
|
||||
visitorIp: ipAddressValue,
|
||||
userAgent,
|
||||
referrer,
|
||||
});
|
||||
|
||||
if (!executionResult.success) {
|
||||
return NextResponse.json(
|
||||
{ error: executionResult.error || "Workflow execution failed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find or create viewer
|
||||
let viewer = await prisma.viewer.findUnique({
|
||||
where: {
|
||||
teamId_email: {
|
||||
teamId: workflow.teamId,
|
||||
email: email,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!viewer) {
|
||||
viewer = await prisma.viewer.create({
|
||||
data: {
|
||||
email: email,
|
||||
verified: true,
|
||||
teamId: workflow.teamId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Create a view record for the workflow execution
|
||||
const view = await prisma.view.create({
|
||||
data: {
|
||||
linkId: entryLinkId,
|
||||
viewerEmail: email,
|
||||
viewerId: viewer.id,
|
||||
verified: true,
|
||||
teamId: workflow.teamId,
|
||||
viewType:
|
||||
executionResult.targetLinkType === "DATAROOM_LINK"
|
||||
? "DATAROOM_VIEW"
|
||||
: "DOCUMENT_VIEW",
|
||||
documentId: executionResult.targetDocumentId ?? undefined,
|
||||
dataroomId: executionResult.targetDataroomId ?? undefined,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Create link session for the target link
|
||||
const { token: sessionToken, expiresAt } = await createLinkSession(
|
||||
executionResult.targetLinkId!,
|
||||
executionResult.targetLinkType!,
|
||||
view.id,
|
||||
email,
|
||||
ipAddressValue,
|
||||
userAgent,
|
||||
true, // verified
|
||||
viewer.id, // viewerId
|
||||
executionResult.targetDocumentId,
|
||||
executionResult.targetDataroomId,
|
||||
);
|
||||
|
||||
// Parse target URL safely with fallback
|
||||
let targetPath = `/view/${executionResult.targetLinkId}`;
|
||||
let cookieFlagId = executionResult.targetLinkId;
|
||||
|
||||
if (executionResult.targetUrl) {
|
||||
try {
|
||||
const parsedUrl = new URL(executionResult.targetUrl);
|
||||
targetPath = parsedUrl.pathname;
|
||||
const pathSegment = parsedUrl.pathname.split("/").pop();
|
||||
if (pathSegment) {
|
||||
cookieFlagId = pathSegment;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse target URL, using fallback values");
|
||||
}
|
||||
}
|
||||
|
||||
// Set link session cookie (httpOnly)
|
||||
cookies().set(`pm_ls_${executionResult.targetLinkId}`, sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(expiresAt),
|
||||
path: "/",
|
||||
});
|
||||
|
||||
// Set client-readable flag cookie for auto-login detection
|
||||
const flagCookieId = `pm_link_flag_${cookieFlagId}`;
|
||||
cookies().set(flagCookieId, "true", {
|
||||
httpOnly: false, // Client-readable
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(expiresAt),
|
||||
path: targetPath,
|
||||
});
|
||||
|
||||
// If routing to a dataroom, also create and set dataroom session cookie
|
||||
if (
|
||||
executionResult.targetLinkType === "DATAROOM_LINK" &&
|
||||
executionResult.targetDataroomId &&
|
||||
viewer
|
||||
) {
|
||||
const dataroomSession = await createDataroomSession(
|
||||
executionResult.targetDataroomId,
|
||||
executionResult.targetLinkId!,
|
||||
view.id,
|
||||
ipAddressValue,
|
||||
true, // verified
|
||||
viewer.id,
|
||||
);
|
||||
|
||||
cookies().set(
|
||||
`pm_drs_${executionResult.targetLinkId}`,
|
||||
dataroomSession.token,
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(dataroomSession.expiresAt),
|
||||
path: "/",
|
||||
},
|
||||
);
|
||||
|
||||
// Set client-readable flag cookie for dataroom
|
||||
const dataroomFlagId = `pm_drs_flag_${cookieFlagId}`;
|
||||
cookies().set(dataroomFlagId, "true", {
|
||||
httpOnly: false, // Client-readable
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
expires: new Date(dataroomSession.expiresAt),
|
||||
path: targetPath,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
targetUrl: executionResult.targetUrl,
|
||||
targetLinkId: executionResult.targetLinkId,
|
||||
targetLinkType: executionResult.targetLinkType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error verifying workflow access:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { ipAddress, waitUntil } from "@vercel/functions";
|
||||
import { z } from "zod";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { ratelimit } from "@/lib/redis";
|
||||
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
|
||||
import { generateOTP } from "@/lib/utils/generate-otp";
|
||||
import { validateEmail } from "@/lib/utils/validate-email";
|
||||
import { VerifyEmailRequestSchema } from "@/ee/features/workflows/lib/types";
|
||||
|
||||
// POST /app/(ee)/api/workflow-entry/[entryLinkId]/verify - Send OTP
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { entryLinkId: string } },
|
||||
) {
|
||||
try {
|
||||
const { entryLinkId } = params;
|
||||
const body = await req.json();
|
||||
|
||||
// Validate entryLinkId format
|
||||
const linkIdValidation = z.string().cuid().safeParse(entryLinkId);
|
||||
if (!linkIdValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid link ID format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate request body
|
||||
const validation = VerifyEmailRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email address" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { email } = validation.data;
|
||||
|
||||
// Find workflow by entry link
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: { entryLinkId },
|
||||
select: {
|
||||
id: true,
|
||||
isActive: true,
|
||||
name: true,
|
||||
teamId: true,
|
||||
entryLink: {
|
||||
select: {
|
||||
id: true,
|
||||
isArchived: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow entry link not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!workflow.isActive) {
|
||||
return NextResponse.json(
|
||||
{ error: "This workflow is currently inactive" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (workflow.entryLink.isArchived || workflow.entryLink.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ error: "This link is no longer available" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limit per email/entryLink combination (1 per 30 seconds) to prevent OTP flooding
|
||||
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
|
||||
`workflow-otp:${entryLinkId}:${email}`,
|
||||
);
|
||||
if (!emailLimitSuccess) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"Please wait before requesting another code. Try again in 30 seconds.",
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
|
||||
const ipAddressValue = ipAddress(req);
|
||||
const { success } = await ratelimit(10, "1 m").limit(
|
||||
`workflow-otp:${ipAddressValue}`,
|
||||
);
|
||||
if (!success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Please try again later." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete any existing OTP codes for this workflow entry + email
|
||||
await prisma.verificationToken.deleteMany({
|
||||
where: {
|
||||
identifier: `workflow-otp:${entryLinkId}:${email}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate and store OTP
|
||||
const otpCode = generateOTP();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMinutes(expiresAt.getMinutes() + 10); // expires in 10 minutes
|
||||
|
||||
await prisma.verificationToken.create({
|
||||
data: {
|
||||
token: otpCode,
|
||||
identifier: `workflow-otp:${entryLinkId}:${email}`,
|
||||
expires: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Send OTP email
|
||||
waitUntil(
|
||||
sendOtpVerificationEmail(email, otpCode, false, workflow.teamId),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Verification code sent to your email",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error sending workflow verification code:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { z } from "zod";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
// GET /app/(ee)/api/workflows/[workflowId]/executions?teamId=xxx - List workflow executions
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Parse and validate pagination parameters
|
||||
const rawPage = Number.parseInt(searchParams.get("page") || "1", 10);
|
||||
const rawLimit = Number.parseInt(searchParams.get("limit") || "20", 10);
|
||||
|
||||
// Apply defaults for invalid values and enforce constraints
|
||||
const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;
|
||||
const limit = Number.isNaN(rawLimit) || rawLimit < 1
|
||||
? 20
|
||||
: Math.min(Math.max(rawLimit, 1), 100); // Min 1, Max 100
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z.object({
|
||||
workflowId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
}).safeParse({ workflowId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid ID format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check workflow exists and belongs to team
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: workflowId,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch executions with pagination
|
||||
const [executions, totalCount] = await Promise.all([
|
||||
prisma.workflowExecution.findMany({
|
||||
where: { workflowId },
|
||||
include: {
|
||||
stepLogs: {
|
||||
select: {
|
||||
id: true,
|
||||
workflowStepId: true,
|
||||
conditionsMatched: true,
|
||||
executedAt: true,
|
||||
duration: true,
|
||||
error: true,
|
||||
},
|
||||
orderBy: { executedAt: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { startedAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.workflowExecution.count({
|
||||
where: { workflowId },
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
executions,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
totalCount,
|
||||
totalPages: Math.ceil(totalCount / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching workflow executions:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
UpdateWorkflowRequestSchema,
|
||||
formatZodError,
|
||||
} from "@/ee/features/workflows/lib/validation";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
// GET /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Get single workflow with details
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z
|
||||
.object({
|
||||
workflowId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
})
|
||||
.safeParse({ workflowId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch workflow
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: workflowId,
|
||||
teamId: teamId, // Ensure workflow belongs to the team
|
||||
},
|
||||
include: {
|
||||
entryLink: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
domainSlug: true,
|
||||
},
|
||||
},
|
||||
steps: {
|
||||
orderBy: { stepOrder: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Build entry URL
|
||||
const entryUrl =
|
||||
workflow.entryLink.domainSlug && workflow.entryLink.slug
|
||||
? `https://${workflow.entryLink.domainSlug}/${workflow.entryLink.slug}`
|
||||
: `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${workflow.entryLink.id}`;
|
||||
|
||||
return NextResponse.json({
|
||||
...workflow,
|
||||
entryUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching workflow:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Update workflow
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z
|
||||
.object({
|
||||
workflowId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
})
|
||||
.safeParse({ workflowId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = UpdateWorkflowRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request data",
|
||||
details: formatZodError(validation.error),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check workflow exists and belongs to team
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: workflowId,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update workflow
|
||||
const updatedWorkflow = await prisma.workflow.update({
|
||||
where: { id: workflowId },
|
||||
data: validation.data,
|
||||
include: {
|
||||
entryLink: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
domainSlug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updatedWorkflow);
|
||||
} catch (error) {
|
||||
console.error("Error updating workflow:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /app/(ee)/api/workflows/[workflowId]?teamId=xxx - Delete workflow
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z
|
||||
.object({
|
||||
workflowId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
})
|
||||
.safeParse({ workflowId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch workflow to check existence and get entryLinkId
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: workflowId,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
entryLinkId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Delete workflow and entry link in transaction
|
||||
// Note: Steps and executions will cascade delete via Prisma relations
|
||||
await prisma.$transaction([
|
||||
// Delete workflow first (cascade deletes steps and executions)
|
||||
prisma.workflow.delete({
|
||||
where: { id: workflowId },
|
||||
}),
|
||||
// Delete entry link
|
||||
prisma.link.update({
|
||||
where: { id: workflow.entryLinkId },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
isArchived: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting workflow:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
UpdateWorkflowStepRequestSchema,
|
||||
formatZodError,
|
||||
validateActions,
|
||||
validateConditions,
|
||||
} from "@/ee/features/workflows/lib/validation";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
// PATCH /app/(ee)/api/workflows/[workflowId]/steps/[stepId]?teamId=xxx - Update step
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string; stepId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId, stepId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z
|
||||
.object({
|
||||
workflowId: z.string().cuid(),
|
||||
stepId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
})
|
||||
.safeParse({ workflowId, stepId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = UpdateWorkflowStepRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request data",
|
||||
details: formatZodError(validation.error),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate conditions if provided
|
||||
let validatedConditions: any | undefined;
|
||||
if (validation.data.conditions) {
|
||||
const conditionsValidation = validateConditions(
|
||||
validation.data.conditions,
|
||||
);
|
||||
if (!conditionsValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: conditionsValidation.error },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
validatedConditions = conditionsValidation.data;
|
||||
}
|
||||
|
||||
// Validate actions if provided
|
||||
let validatedActions: any[] | undefined;
|
||||
if (validation.data.actions) {
|
||||
const actionsValidation = validateActions(validation.data.actions);
|
||||
if (!actionsValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: actionsValidation.error },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Use actionsValidation.data so enrichment persists
|
||||
validatedActions = actionsValidation.data;
|
||||
|
||||
// Validate target link exists and belongs to the team
|
||||
const routeAction = validatedActions.find((a) => a.type === "route");
|
||||
if (routeAction && routeAction.targetLinkId) {
|
||||
// First get the workflow to get teamId
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: { id: workflowId },
|
||||
select: { teamId: true },
|
||||
});
|
||||
|
||||
if (workflow) {
|
||||
const targetLink = await prisma.link.findUnique({
|
||||
where: {
|
||||
id: routeAction.targetLinkId,
|
||||
teamId: workflow.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetLink) {
|
||||
return NextResponse.json(
|
||||
{ error: "Target link not found or not accessible" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update action with target details (mutates validatedActions)
|
||||
if (
|
||||
targetLink.linkType === "DOCUMENT_LINK" &&
|
||||
targetLink.documentId
|
||||
) {
|
||||
routeAction.targetDocumentId = targetLink.documentId;
|
||||
} else if (
|
||||
targetLink.linkType === "DATAROOM_LINK" &&
|
||||
targetLink.dataroomId
|
||||
) {
|
||||
routeAction.targetDataroomId = targetLink.dataroomId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check step exists and workflow belongs to team
|
||||
const step = await prisma.workflowStep.findUnique({
|
||||
where: {
|
||||
id: stepId,
|
||||
workflowId: workflowId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!step || step.workflow.teamId !== teamId) {
|
||||
return NextResponse.json({ error: "Step not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Extract emails and domains from conditions to sync with link allowList (if conditions updated)
|
||||
let allowListItems: string[] | undefined;
|
||||
if (validatedConditions) {
|
||||
allowListItems = [];
|
||||
if (validatedConditions.items) {
|
||||
validatedConditions.items.forEach((condition: any) => {
|
||||
const values = Array.isArray(condition.value)
|
||||
? condition.value
|
||||
: [condition.value];
|
||||
if (condition.type === "domain") {
|
||||
// Add @ prefix for domains in allowList
|
||||
allowListItems!.push(...values.map((v: string) => `@${v}`));
|
||||
} else if (condition.type === "email") {
|
||||
allowListItems!.push(...values);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get target link ID (either from update or existing step)
|
||||
let targetLinkId: string | undefined;
|
||||
if (validatedActions) {
|
||||
const routeAction = validatedActions.find((a) => a.type === "route");
|
||||
targetLinkId = routeAction?.targetLinkId;
|
||||
} else {
|
||||
// Get from existing step
|
||||
const existingStep = await prisma.workflowStep.findUnique({
|
||||
where: { id: stepId },
|
||||
select: { actions: true },
|
||||
});
|
||||
const existingActions = existingStep?.actions as any[];
|
||||
const existingRouteAction = existingActions?.find(
|
||||
(a) => a.type === "route",
|
||||
);
|
||||
targetLinkId = existingRouteAction?.targetLinkId;
|
||||
}
|
||||
|
||||
// Build update data with validated conditions and actions (if provided)
|
||||
const updateData: any = { ...validation.data };
|
||||
if (validatedConditions) {
|
||||
updateData.conditions = validatedConditions;
|
||||
}
|
||||
if (validatedActions) {
|
||||
updateData.actions = validatedActions;
|
||||
}
|
||||
|
||||
// Update step and optionally update link allowList in transaction
|
||||
const updates: any[] = [
|
||||
prisma.workflowStep.update({
|
||||
where: { id: stepId },
|
||||
data: updateData as any,
|
||||
}),
|
||||
];
|
||||
|
||||
// If we have allowList updates and a target link, sync the link
|
||||
if (allowListItems !== undefined && targetLinkId) {
|
||||
updates.push(
|
||||
prisma.link.update({
|
||||
where: { id: targetLinkId },
|
||||
data: { allowList: allowListItems },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const [updatedStep] = await prisma.$transaction(updates);
|
||||
|
||||
return NextResponse.json(updatedStep);
|
||||
} catch (error) {
|
||||
console.error("Error updating workflow step:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /app/(ee)/api/workflows/[workflowId]/steps/[stepId]?teamId=xxx - Delete step
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string; stepId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId, stepId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z
|
||||
.object({
|
||||
workflowId: z.string().cuid(),
|
||||
stepId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
})
|
||||
.safeParse({ workflowId, stepId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json({ error: "Invalid ID format" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check step exists and workflow belongs to team
|
||||
const step = await prisma.workflowStep.findUnique({
|
||||
where: {
|
||||
id: stepId,
|
||||
workflowId: workflowId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stepOrder: true,
|
||||
workflow: {
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!step || step.workflow.teamId !== teamId) {
|
||||
return NextResponse.json({ error: "Step not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get steps that need reordering (those after the deleted step)
|
||||
const stepsToReorder = await prisma.workflowStep.findMany({
|
||||
where: {
|
||||
workflowId,
|
||||
stepOrder: { gt: step.stepOrder },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
stepOrder: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Delete step and reorder remaining steps in a transaction
|
||||
await prisma.$transaction([
|
||||
prisma.workflowStep.delete({
|
||||
where: { id: stepId },
|
||||
}),
|
||||
...stepsToReorder.map((s) =>
|
||||
prisma.workflowStep.update({
|
||||
where: { id: s.id },
|
||||
data: { stepOrder: s.stepOrder - 1 },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting workflow step:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { z } from "zod";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
import {
|
||||
CreateWorkflowStepRequestSchema,
|
||||
ReorderStepsRequestSchema,
|
||||
formatZodError,
|
||||
validateConditions,
|
||||
validateActions,
|
||||
} from "@/ee/features/workflows/lib/validation";
|
||||
import { ReorderStepsRequest } from "@/ee/features/workflows/lib/types";
|
||||
|
||||
// GET /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - List all steps
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z.object({
|
||||
workflowId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
}).safeParse({ workflowId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid ID format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check workflow exists and belongs to team
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: workflowId,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch steps
|
||||
const steps = await prisma.workflowStep.findMany({
|
||||
where: { workflowId },
|
||||
orderBy: { stepOrder: "asc" },
|
||||
});
|
||||
|
||||
// Enrich steps with target link details
|
||||
const enrichedSteps = await Promise.all(
|
||||
steps.map(async (step) => {
|
||||
const actions = step.actions as any[];
|
||||
const routeAction = actions.find((a) => a.type === "route");
|
||||
|
||||
if (routeAction && routeAction.targetLinkId) {
|
||||
const targetLink = await prisma.link.findUnique({
|
||||
where: { id: routeAction.targetLinkId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
domainSlug: true,
|
||||
linkType: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...step,
|
||||
targetLink,
|
||||
};
|
||||
}
|
||||
|
||||
return step;
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json(enrichedSteps);
|
||||
} catch (error) {
|
||||
console.error("Error fetching workflow steps:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - Create a new step
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z.object({
|
||||
workflowId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
}).safeParse({ workflowId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid ID format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = CreateWorkflowStepRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request data",
|
||||
details: formatZodError(validation.error),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { name, conditions, actions } = validation.data;
|
||||
|
||||
// Validate conditions and actions
|
||||
const conditionsValidation = validateConditions(conditions);
|
||||
if (!conditionsValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: conditionsValidation.error },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const actionsValidation = validateActions(actions);
|
||||
if (!actionsValidation.valid) {
|
||||
return NextResponse.json(
|
||||
{ error: actionsValidation.error },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check workflow exists and belongs to team
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: workflowId,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
teamId: true,
|
||||
steps: {
|
||||
select: { stepOrder: true },
|
||||
orderBy: { stepOrder: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate target link exists and belongs to the team
|
||||
// Use actionsValidation.data so enrichment persists
|
||||
const routeAction = actionsValidation.data.find((a) => a.type === "route");
|
||||
if (routeAction && routeAction.targetLinkId) {
|
||||
const targetLink = await prisma.link.findUnique({
|
||||
where: {
|
||||
id: routeAction.targetLinkId,
|
||||
teamId: workflow.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetLink) {
|
||||
return NextResponse.json(
|
||||
{ error: "Target link not found or not accessible" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update action with target details (mutates actionsValidation.data)
|
||||
if (targetLink.linkType === "DOCUMENT_LINK" && targetLink.documentId) {
|
||||
routeAction.targetDocumentId = targetLink.documentId;
|
||||
} else if (
|
||||
targetLink.linkType === "DATAROOM_LINK" &&
|
||||
targetLink.dataroomId
|
||||
) {
|
||||
routeAction.targetDataroomId = targetLink.dataroomId;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate next step order
|
||||
const maxStepOrder = workflow.steps[0]?.stepOrder ?? -1;
|
||||
const nextStepOrder = maxStepOrder + 1;
|
||||
|
||||
// Extract emails and domains from conditions to sync with link allowList
|
||||
const allowListItems: string[] = [];
|
||||
if (conditionsValidation.data.items) {
|
||||
conditionsValidation.data.items.forEach((condition: any) => {
|
||||
const values = Array.isArray(condition.value)
|
||||
? condition.value
|
||||
: [condition.value];
|
||||
if (condition.type === "domain") {
|
||||
// Add @ prefix for domains in allowList
|
||||
allowListItems.push(...values.map((v: string) => `@${v}`));
|
||||
} else if (condition.type === "email") {
|
||||
allowListItems.push(...values);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create step and conditionally update target link's allowList in transaction
|
||||
const transactionSteps: any[] = [
|
||||
prisma.workflowStep.create({
|
||||
data: {
|
||||
workflowId,
|
||||
name,
|
||||
stepOrder: nextStepOrder,
|
||||
stepType: "ROUTER",
|
||||
conditions: conditionsValidation.data as any,
|
||||
actions: actionsValidation.data as any,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// Only update link allowList if we have a route action with a target link
|
||||
if (routeAction && routeAction.targetLinkId) {
|
||||
transactionSteps.push(
|
||||
prisma.link.update({
|
||||
where: { id: routeAction.targetLinkId },
|
||||
data: {
|
||||
allowList: allowListItems,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const [newStep] = await prisma.$transaction(transactionSteps);
|
||||
|
||||
return NextResponse.json(newStep, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("Error creating workflow step:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /app/(ee)/api/workflows/[workflowId]/steps?teamId=xxx - Reorder steps
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: { workflowId: string } },
|
||||
) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { workflowId } = params;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate IDs format
|
||||
const idsValidation = z.object({
|
||||
workflowId: z.string().cuid(),
|
||||
teamId: z.string().cuid(),
|
||||
}).safeParse({ workflowId, teamId });
|
||||
|
||||
if (!idsValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid ID format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = ReorderStepsRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request data",
|
||||
details: formatZodError(validation.error),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check workflow exists and belongs to team
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {
|
||||
id: workflowId,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow) {
|
||||
return NextResponse.json(
|
||||
{ error: "Workflow not found" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
// Update step orders in a transaction
|
||||
await prisma.$transaction(
|
||||
validation.data.steps.map((step) =>
|
||||
prisma.workflowStep.update({
|
||||
where: {
|
||||
id: step.stepId,
|
||||
workflowId, // Ensure step belongs to this workflow
|
||||
},
|
||||
data: {
|
||||
stepOrder: step.stepOrder,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error reordering workflow steps:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
CreateWorkflowRequestSchema,
|
||||
formatZodError,
|
||||
} from "@/ee/features/workflows/lib/validation";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getFeatureFlags } from "@/lib/featureFlags";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
// GET /app/(ee)/api/workflows?teamId=xxx - List all workflows for a team
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate teamId format
|
||||
const teamIdValidation = z.string().cuid().safeParse(teamId);
|
||||
if (!teamIdValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid teamId format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check user is part of the team
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: (session.user as CustomUser).id,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch workflows with entry link and step count
|
||||
const workflows = await prisma.workflow.findMany({
|
||||
where: { teamId },
|
||||
include: {
|
||||
entryLink: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
domainSlug: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
steps: true,
|
||||
executions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json(workflows);
|
||||
} catch (error) {
|
||||
console.error("Error fetching workflows:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /app/(ee)/api/workflows?teamId=xxx - Create a new workflow
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
const searchParams = req.nextUrl.searchParams;
|
||||
const teamId = searchParams.get("teamId");
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json(
|
||||
{ error: "teamId parameter is required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate teamId format
|
||||
const teamIdValidation = z.string().cuid().safeParse(teamId);
|
||||
if (!teamIdValidation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid teamId format" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check user is part of the team using userTeam table
|
||||
const teamAccess = await prisma.userTeam.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId: userId,
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamAccess) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized to access this team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
|
||||
// Validate request body
|
||||
const validation = CreateWorkflowRequestSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid request data",
|
||||
details: formatZodError(validation.error),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { name, description, domain, slug } = validation.data;
|
||||
|
||||
// Get team details for plan check
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
id: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ error: "Team not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check if workflows feature flag is enabled
|
||||
const featureFlags = await getFeatureFlags({ teamId });
|
||||
if (!featureFlags.workflows) {
|
||||
return NextResponse.json(
|
||||
{ error: "This feature is not available for your team" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check plan - require Business or DataRooms plan
|
||||
if (team.plan === "free" || team.plan === "pro") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Workflows require a Business or Data Rooms plan",
|
||||
requiresUpgrade: true,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate domain and slug
|
||||
let domainId: string | null = null;
|
||||
let domainSlug: string | null = null;
|
||||
|
||||
if (domain && slug) {
|
||||
// Check if domain exists and belongs to team
|
||||
const domainRecord = await prisma.domain.findUnique({
|
||||
where: {
|
||||
slug: domain,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
if (!domainRecord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Domain not found or not associated with this team" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
domainId = domainRecord.id;
|
||||
domainSlug = domainRecord.slug;
|
||||
|
||||
// Check if slug is already in use on this domain
|
||||
const existingLink = await prisma.link.findUnique({
|
||||
where: {
|
||||
domainSlug_slug: {
|
||||
slug: slug,
|
||||
domainSlug: domain,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
return NextResponse.json(
|
||||
{ error: "This slug is already in use on the selected domain" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create workflow with entry link in a transaction
|
||||
const workflow = await prisma.$transaction(async (tx) => {
|
||||
// Create entry link
|
||||
const entryLink = await tx.link.create({
|
||||
data: {
|
||||
linkType: "WORKFLOW_LINK",
|
||||
teamId,
|
||||
name: `${name} - Entry Link`,
|
||||
slug: slug || null,
|
||||
domainId: domainId,
|
||||
domainSlug: domainSlug,
|
||||
emailProtected: true, // Workflows always require email
|
||||
emailAuthenticated: true, // Workflows always require OTP
|
||||
allowDownload: false,
|
||||
enableNotification: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Create workflow
|
||||
const newWorkflow = await tx.workflow.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
teamId,
|
||||
entryLinkId: entryLink.id,
|
||||
isActive: true,
|
||||
},
|
||||
include: {
|
||||
entryLink: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
domainSlug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return newWorkflow;
|
||||
});
|
||||
|
||||
// Build entry URL
|
||||
const entryUrl =
|
||||
workflow.entryLink.domainSlug && workflow.entryLink.slug
|
||||
? `https://${workflow.entryLink.domainSlug}/${workflow.entryLink.slug}`
|
||||
: `${process.env.NEXT_PUBLIC_MARKETING_URL}/view/${workflow.entryLink.id}`;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
...workflow,
|
||||
entryUrl,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error creating workflow:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { fetchAndDeleteLoginCodeData } from "@/lib/emails/send-verification-request";
|
||||
import { ratelimit } from "@/lib/redis";
|
||||
|
||||
// Rate limiters
|
||||
const emailRateLimit = ratelimit(5, "1 m"); // 5 attempts per minute per email
|
||||
const ipRateLimit = ratelimit(10, "1 m"); // 10 attempts per minute per IP
|
||||
|
||||
function getClientIp(request: NextRequest): string {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
const realIp = request.headers.get("x-real-ip");
|
||||
return forwarded?.split(",")[0]?.trim() || realIp || "unknown";
|
||||
}
|
||||
|
||||
// POST: Verify via email + code
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { email, code } = body;
|
||||
|
||||
// Type checks first to prevent calling .trim() on non-strings
|
||||
if (typeof email !== "string" || typeof code !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Email and code are required." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Normalize after type check
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const normalizedCode = code.trim().toUpperCase();
|
||||
|
||||
// Validate non-empty email and exact code length
|
||||
if (!normalizedEmail || normalizedCode.length !== 10) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email or code format." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
// Check both rate limits
|
||||
const [emailLimit, ipLimit] = await Promise.all([
|
||||
emailRateLimit.limit(`verify_code:${normalizedEmail}`),
|
||||
ipRateLimit.limit(`verify_code:ip:${ip}`),
|
||||
]);
|
||||
|
||||
if (!emailLimit.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Too many attempts. Please wait before trying again.",
|
||||
retryAfter: Math.ceil((emailLimit.reset - Date.now()) / 1000),
|
||||
remaining: 0,
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!ipLimit.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Too many attempts. Please wait before trying again.",
|
||||
retryAfter: Math.ceil((ipLimit.reset - Date.now()) / 1000),
|
||||
remaining: 0,
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Atomically fetch and delete to prevent TOCTOU race condition
|
||||
const loginCodeData = await fetchAndDeleteLoginCodeData(
|
||||
normalizedEmail,
|
||||
normalizedCode,
|
||||
);
|
||||
|
||||
if (!loginCodeData) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Invalid code. Please check your email and try again.",
|
||||
remaining: emailLimit.remaining,
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { callbackUrl } = loginCodeData;
|
||||
|
||||
return NextResponse.json({ callbackUrl });
|
||||
} catch (error) {
|
||||
console.error("Error verifying code:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Verification failed. Please try again." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,56 @@
|
||||
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
|
||||
import { sendWelcomeEmail } from "@/lib/emails/send-welcome";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { subscribe } from "@/lib/resend";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const rawBody = await req.text();
|
||||
await verifyQstashSignature({ req, rawBody });
|
||||
|
||||
const { userId } = JSON.parse(rawBody);
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return new Response("User not found. Skipping...", { status: 200 });
|
||||
}
|
||||
|
||||
// this shouldn't happen but just in case
|
||||
if (!user.email) {
|
||||
return new Response("User email not found. Skipping...", { status: 200 });
|
||||
}
|
||||
|
||||
await Promise.allSettled([
|
||||
// send welcome email
|
||||
sendWelcomeEmail({
|
||||
user: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
}),
|
||||
// subscribe user to the mailing list
|
||||
subscribe(user.email),
|
||||
]);
|
||||
|
||||
return new Response("Welcome email sent and user subscribed.", {
|
||||
status: 200,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return new Response(
|
||||
"Error sending welcome email and subscribing user to mailing list.",
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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));
|
||||
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);
|
||||
|
||||
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,115 @@
|
||||
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 team: Pick<Team, "id" | "plan"> | null = null;
|
||||
|
||||
try {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
const { code, state } = oAuthCallbackSchema.parse(getSearchParams(req.url));
|
||||
|
||||
// Find workspace that initiated the Stripe app install
|
||||
const stateKey = `slack:install:state:${state}`;
|
||||
const teamId = await redis.get<string>(stateKey);
|
||||
|
||||
if (!teamId) {
|
||||
return NextResponse.json({ error: "Invalid state" }, { status: 400 });
|
||||
}
|
||||
await redis.del(stateKey);
|
||||
|
||||
team = await prisma.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plan: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return NextResponse.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
code,
|
||||
client_id: env.SLACK_CLIENT_ID,
|
||||
client_secret: env.SLACK_CLIENT_SECRET,
|
||||
redirect_uri: `${process.env.NEXT_PUBLIC_BASE_URL}/api/integrations/slack/oauth/callback`,
|
||||
});
|
||||
const ac = new AbortController();
|
||||
const to = setTimeout(() => ac.abort(), 10000);
|
||||
const response = await fetch("https://slack.com/api/oauth.v2.access", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: ac.signal,
|
||||
}).finally(() => clearTimeout(to));
|
||||
|
||||
const data = await response.json();
|
||||
if (!data?.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Slack OAuth error: ${data?.error || "unknown"}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
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?success=true`);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// Legacy magic link route - redirect to new code-based flow
|
||||
export async function GET(request: NextRequest) {
|
||||
return NextResponse.redirect(new URL("/auth/email", request.url));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
|
||||
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { ItemType, LinkAudienceType } from "@prisma/client";
|
||||
@@ -16,6 +17,10 @@ import { PreviewSession, verifyPreviewSession } from "@/lib/auth/preview-auth";
|
||||
import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verification";
|
||||
import { getFile } from "@/lib/files/get-file";
|
||||
import { newId } from "@/lib/id-helper";
|
||||
import {
|
||||
notifyDataroomAccess,
|
||||
notifyDocumentView,
|
||||
} from "@/lib/integrations/slack/events";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { ratelimit } from "@/lib/redis";
|
||||
import { parseSheet } from "@/lib/sheet";
|
||||
@@ -98,6 +103,8 @@ export async function POST(request: NextRequest) {
|
||||
id: linkId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
documentId: true,
|
||||
dataroomId: true,
|
||||
emailProtected: true,
|
||||
@@ -106,6 +113,7 @@ export async function POST(request: NextRequest) {
|
||||
password: true,
|
||||
domainSlug: true,
|
||||
isArchived: true,
|
||||
deletedAt: true,
|
||||
slug: true,
|
||||
domainId: true,
|
||||
allowList: true,
|
||||
@@ -124,6 +132,8 @@ export async function POST(request: NextRequest) {
|
||||
select: {
|
||||
plan: true,
|
||||
globalBlockList: true,
|
||||
agentsEnabled: true,
|
||||
pauseStartsAt: true,
|
||||
},
|
||||
},
|
||||
customFields: {
|
||||
@@ -133,6 +143,12 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
},
|
||||
enableUpload: true,
|
||||
dataroom: {
|
||||
select: {
|
||||
agentsEnabled: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -147,6 +163,13 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (link.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ message: "Link has been deleted." },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
let isEmailVerified: boolean = false;
|
||||
let hashedVerificationToken: string | null = null;
|
||||
// Check if the user is part of the team and therefore skip verification steps
|
||||
@@ -286,6 +309,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
if (globalBlockCheck.isBlocked) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
|
||||
|
||||
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -298,6 +323,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Deny access if the email is not allowed
|
||||
if (!isAllowed) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -314,6 +341,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Deny access if the email is denied
|
||||
if (isDenied) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -356,6 +385,7 @@ export async function POST(request: NextRequest) {
|
||||
: false;
|
||||
|
||||
if (!isMember && !hasDomainAccess) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -370,6 +400,21 @@ export async function POST(request: NextRequest) {
|
||||
if (link.emailAuthenticated && !code && !token && !dataroomVerified) {
|
||||
const ipAddressValue = ipAddress(request);
|
||||
|
||||
// Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding
|
||||
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
|
||||
`send-otp:${linkId}:${email}`,
|
||||
);
|
||||
if (!emailLimitSuccess) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
"Please wait before requesting another code. Try again in 30 seconds.",
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
|
||||
const { success } = await ratelimit(10, "1 m").limit(
|
||||
`send-otp:${ipAddressValue}`,
|
||||
);
|
||||
@@ -617,6 +662,11 @@ export async function POST(request: NextRequest) {
|
||||
}),
|
||||
};
|
||||
|
||||
const isPaused =
|
||||
link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()
|
||||
? true
|
||||
: false;
|
||||
|
||||
// ** DATAROOM_VIEW **
|
||||
if (viewType === "DATAROOM_VIEW") {
|
||||
console.log("viewType is DATAROOM_VIEW");
|
||||
@@ -645,8 +695,28 @@ export async function POST(request: NextRequest) {
|
||||
dataroomId,
|
||||
teamId: link.teamId!,
|
||||
enableNotification: link.enableNotification,
|
||||
isPaused,
|
||||
}),
|
||||
);
|
||||
|
||||
if (link.teamId && !isPreview) {
|
||||
waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
await notifyDataroomAccess({
|
||||
teamId: link.teamId!,
|
||||
dataroomId,
|
||||
linkId,
|
||||
viewerEmail: verifiedEmail ?? email,
|
||||
viewerId: viewer?.id,
|
||||
teamIsPaused: isPaused,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error sending Slack notification:", error);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dataroomViewId =
|
||||
@@ -663,6 +733,8 @@ export async function POST(request: NextRequest) {
|
||||
viewerId: viewer?.id,
|
||||
conversationsEnabled: link.enableConversation,
|
||||
enableVisitorUpload: link.enableUpload,
|
||||
agentsEnabled: link.dataroom?.agentsEnabled ?? false,
|
||||
dataroomName: link.dataroom?.name,
|
||||
...(isTeamMember && { isTeamMember: true }),
|
||||
};
|
||||
|
||||
@@ -742,6 +814,7 @@ export async function POST(request: NextRequest) {
|
||||
dataroomId,
|
||||
teamId: link.teamId!,
|
||||
enableNotification: link.enableNotification,
|
||||
isPaused,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -758,6 +831,26 @@ 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,
|
||||
teamIsPaused: isPaused,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error sending Slack notification:", error);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// if document version has pages, then return pages
|
||||
@@ -821,6 +914,7 @@ export async function POST(request: NextRequest) {
|
||||
type: documentVersion.storageType,
|
||||
});
|
||||
}
|
||||
// For link documents, the file is already a URL, no processing needed
|
||||
if (documentVersion.type === "sheet") {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: { id: documentId },
|
||||
@@ -917,7 +1011,8 @@ export async function POST(request: NextRequest) {
|
||||
(documentVersion.type === "pdf" ||
|
||||
documentVersion.type === "image" ||
|
||||
documentVersion.type === "zip" ||
|
||||
documentVersion.type === "video")) ||
|
||||
documentVersion.type === "video" ||
|
||||
documentVersion.type === "link")) ||
|
||||
(documentVersion && useAdvancedExcelViewer)
|
||||
? documentVersion.file
|
||||
: undefined,
|
||||
@@ -955,6 +1050,8 @@ export async function POST(request: NextRequest) {
|
||||
canDownload: canDownload,
|
||||
viewerId: viewer?.id,
|
||||
conversationsEnabled: link.enableConversation,
|
||||
agentsEnabled: link.dataroom?.agentsEnabled ?? false,
|
||||
dataroomName: link.dataroom?.name,
|
||||
...(isTeamMember && { isTeamMember: true }),
|
||||
};
|
||||
|
||||
|
||||
+70
-2
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { reportDeniedAccessAttempt } from "@/ee/features/access-notifications";
|
||||
import { getTeamStorageConfigById } from "@/ee/features/storage/config";
|
||||
// Import authOptions directly from the source
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
@@ -13,13 +14,14 @@ import { sendOtpVerificationEmail } from "@/lib/emails/send-email-otp-verificati
|
||||
import { getFeatureFlags } from "@/lib/featureFlags";
|
||||
import { getFile } from "@/lib/files/get-file";
|
||||
import { newId } from "@/lib/id-helper";
|
||||
import { notifyDocumentView } from "@/lib/integrations/slack/events";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { ratelimit } from "@/lib/redis";
|
||||
import { parseSheet } from "@/lib/sheet";
|
||||
import { recordLinkView } from "@/lib/tracking/record-link-view";
|
||||
import { CustomUser, WatermarkConfigSchema } from "@/lib/types";
|
||||
import { checkPassword, decryptEncrpytedPassword, log } from "@/lib/utils";
|
||||
import { extractEmailDomain, isEmailMatched } from "@/lib/utils/email-domain";
|
||||
import { isEmailMatched } from "@/lib/utils/email-domain";
|
||||
import { generateOTP } from "@/lib/utils/generate-otp";
|
||||
import { LOCALHOST_IP } from "@/lib/utils/geo";
|
||||
import { checkGlobalBlockList } from "@/lib/utils/global-block-list";
|
||||
@@ -84,12 +86,16 @@ export async function POST(request: NextRequest) {
|
||||
id: linkId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
documentId: true,
|
||||
emailProtected: true,
|
||||
enableNotification: true,
|
||||
emailAuthenticated: true,
|
||||
password: true,
|
||||
domainSlug: true,
|
||||
isArchived: true,
|
||||
deletedAt: true,
|
||||
slug: true,
|
||||
allowList: true,
|
||||
denyList: true,
|
||||
@@ -102,6 +108,8 @@ export async function POST(request: NextRequest) {
|
||||
select: {
|
||||
plan: true,
|
||||
globalBlockList: true,
|
||||
agentsEnabled: true,
|
||||
pauseStartsAt: true,
|
||||
},
|
||||
},
|
||||
customFields: {
|
||||
@@ -110,6 +118,11 @@ export async function POST(request: NextRequest) {
|
||||
label: true,
|
||||
},
|
||||
},
|
||||
document: {
|
||||
select: {
|
||||
agentsEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,6 +139,13 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (link.deletedAt) {
|
||||
return NextResponse.json(
|
||||
{ message: "Link has been deleted." },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
let isEmailVerified: boolean = false;
|
||||
let hashedVerificationToken: string | null = null;
|
||||
// Check if the user is part of the team and therefore skip verification steps
|
||||
@@ -221,6 +241,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
if (globalBlockCheck.isBlocked) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "global"));
|
||||
|
||||
return NextResponse.json({ message: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
@@ -233,6 +255,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Deny access if the email is not allowed
|
||||
if (!isAllowed) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "allow"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -249,6 +273,8 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
// Deny access if the email is denied
|
||||
if (isDenied) {
|
||||
waitUntil(reportDeniedAccessAttempt(link, email, "deny"));
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Unauthorized access" },
|
||||
{ status: 403 },
|
||||
@@ -262,6 +288,21 @@ export async function POST(request: NextRequest) {
|
||||
if (link.emailAuthenticated && !code && !token) {
|
||||
const ipAddressValue = ipAddress(request);
|
||||
|
||||
// Rate limit per email/link combination (1 per 30 seconds) to prevent OTP flooding
|
||||
const { success: emailLimitSuccess } = await ratelimit(1, "30 s").limit(
|
||||
`send-otp:${linkId}:${email}`,
|
||||
);
|
||||
if (!emailLimitSuccess) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
"Please wait before requesting another code. Try again in 30 seconds.",
|
||||
},
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Additional IP-based rate limit (10 per minute) to prevent abuse across different emails
|
||||
const { success } = await ratelimit(10, "1 m").limit(
|
||||
`send-otp:${ipAddressValue}`,
|
||||
);
|
||||
@@ -612,6 +653,11 @@ export async function POST(request: NextRequest) {
|
||||
console.timeEnd("get-file");
|
||||
}
|
||||
|
||||
const isPaused =
|
||||
link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()
|
||||
? true
|
||||
: false;
|
||||
|
||||
if (newView) {
|
||||
// Record view in the background to avoid blocking the response
|
||||
waitUntil(
|
||||
@@ -624,20 +670,41 @@ export async function POST(request: NextRequest) {
|
||||
documentId,
|
||||
teamId: link.teamId!,
|
||||
enableNotification: link.enableNotification,
|
||||
isPaused,
|
||||
}),
|
||||
);
|
||||
if (!isPreview) {
|
||||
waitUntil(
|
||||
notifyDocumentView({
|
||||
teamId: link.teamId!,
|
||||
documentId,
|
||||
linkId,
|
||||
viewerEmail: email ?? undefined,
|
||||
viewerId: viewer?.id ?? undefined,
|
||||
teamIsPaused: isPaused,
|
||||
}).catch((error) => {
|
||||
console.error("Error sending Slack notification:", error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if AI agents should be enabled (requires both team and document level)
|
||||
const agentsEnabled =
|
||||
link.team?.agentsEnabled && link.document?.agentsEnabled;
|
||||
|
||||
const returnObject = {
|
||||
message: "View recorded",
|
||||
viewId: !isPreview && newView ? newView.id : undefined,
|
||||
viewerId: viewer?.id ?? undefined,
|
||||
isPreview: isPreview ? true : undefined,
|
||||
file:
|
||||
(documentVersion &&
|
||||
(documentVersion.type === "pdf" ||
|
||||
documentVersion.type === "image" ||
|
||||
documentVersion.type === "zip" ||
|
||||
documentVersion.type === "video")) ||
|
||||
documentVersion.type === "video" ||
|
||||
documentVersion.type === "link")) ||
|
||||
(documentVersion && useAdvancedExcelViewer)
|
||||
? documentVersion.file
|
||||
: undefined,
|
||||
@@ -668,6 +735,7 @@ export async function POST(request: NextRequest) {
|
||||
: undefined,
|
||||
verificationToken: hashedVerificationToken ?? undefined,
|
||||
...(isTeamMember && { isTeamMember: true }),
|
||||
...(agentsEnabled && { agentsEnabled: true }),
|
||||
};
|
||||
|
||||
return NextResponse.json(returnObject);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
|
||||
import PlausibleProvider from "next-plausible";
|
||||
|
||||
import "@/styles/globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
@@ -49,12 +47,6 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<PlausibleProvider
|
||||
domain="papermark.io"
|
||||
enabled={process.env.NEXT_PUBLIC_VERCEL_ENV === "production"}
|
||||
/>
|
||||
</head>
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { useOptimisticUpdate } from "../hooks/use-optimistic-update";
|
||||
import { Switch } from "../ui/switch";
|
||||
import { useOptimisticUpdate } from "@/components/hooks/use-optimistic-update";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
// TODO: MAIL SUBSCRIPTION FOR LATER USE
|
||||
export const UpdateMailSubscribe = () => {
|
||||
const { data, isLoading, update } = useOptimisticUpdate<{
|
||||
subscribed: boolean;
|
||||
}>("/api/user/subscribe", {
|
||||
loading: "Updating email preferences...",
|
||||
success: `Your ${process.env.NEXT_PUBLIC_BASE_URL} email preferences has been updated!`,
|
||||
success: "Your email preferences have been updated!",
|
||||
error: "Failed to update email preferences. Please try again.",
|
||||
});
|
||||
|
||||
@@ -25,7 +24,8 @@ export const UpdateMailSubscribe = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Switch
|
||||
checked={data?.subscribed}
|
||||
checked={data?.subscribed ?? true}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
update(() => subscribe(checked), { subscribed: checked });
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,448 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[80%] flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={100}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="px-1.5 py-1 text-sm">
|
||||
<span>{tooltip}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch",
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden",
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_li>p]:m-0 [&_li>p]:inline",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children,
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute right-2 top-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
hoverOnly?: boolean;
|
||||
};
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
hoverOnly = false,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements,
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread],
|
||||
);
|
||||
|
||||
const animationProps = hoverOnly
|
||||
? {
|
||||
initial: { backgroundPosition: "100% center" },
|
||||
whileHover: { backgroundPosition: "0% center" },
|
||||
}
|
||||
: {
|
||||
initial: { backgroundPosition: "100% center" },
|
||||
animate: { backgroundPosition: "0% center" },
|
||||
};
|
||||
|
||||
const transitionProps = hoverOnly
|
||||
? {
|
||||
duration,
|
||||
ease: "linear" as const,
|
||||
}
|
||||
: {
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
{...animationProps}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--muted-foreground), var(--muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={transitionProps}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -34,13 +34,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
|
||||
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;
|
||||
@@ -151,11 +152,20 @@ const columns: ColumnDef<Document>[] = [
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.lastViewed ? timeAgo(row.original.lastViewed) : "-"}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.lastViewed ? (
|
||||
<TimestampTooltip
|
||||
timestamp={row.original.lastViewed}
|
||||
side="right"
|
||||
rows={["local", "utc", "unix"]}
|
||||
>
|
||||
<div className="select-none text-sm text-muted-foreground">
|
||||
{timeAgo(row.original.lastViewed)}
|
||||
</div>
|
||||
</TimestampTooltip>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">-</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -221,20 +231,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 +256,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) => (
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { cn, timeAgo } from "@/lib/utils";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { downloadCSV } from "@/lib/utils/csv";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
@@ -36,14 +41,10 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
|
||||
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
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;
|
||||
@@ -213,11 +214,20 @@ const columns: ColumnDef<Link>[] = [
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{row.original.lastViewed ? timeAgo(row.original.lastViewed) : "-"}
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.lastViewed ? (
|
||||
<TimestampTooltip
|
||||
timestamp={row.original.lastViewed}
|
||||
side="right"
|
||||
rows={["local", "utc", "unix"]}
|
||||
>
|
||||
<div className="select-none text-sm text-muted-foreground">
|
||||
{timeAgo(row.original.lastViewed)}
|
||||
</div>
|
||||
</TimestampTooltip>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">-</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -285,20 +295,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 +320,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) => (
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
|
||||
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ value: "24h", label: "Last 24 hours", shortcut: "D" },
|
||||
|
||||
@@ -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";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
BadgeCheckIcon,
|
||||
BadgeInfoIcon,
|
||||
ChevronDownIcon,
|
||||
@@ -24,8 +25,6 @@ import {
|
||||
Download,
|
||||
DownloadCloudIcon,
|
||||
FileBadgeIcon,
|
||||
FileDigitIcon,
|
||||
MoreHorizontalIcon,
|
||||
ServerIcon,
|
||||
ThumbsDownIcon,
|
||||
ThumbsUpIcon,
|
||||
@@ -38,11 +37,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,
|
||||
@@ -52,16 +46,12 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
|
||||
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;
|
||||
@@ -86,7 +76,7 @@ interface View {
|
||||
const columns: ColumnDef<View>[] = [
|
||||
{
|
||||
accessorKey: "viewerEmail",
|
||||
header: "Recent Visits",
|
||||
header: "Recent Views",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center overflow-visible sm:space-x-3">
|
||||
<VisitorAvatar viewerEmail={row.original.viewerEmail} />
|
||||
@@ -259,7 +249,7 @@ const columns: ColumnDef<View>[] = [
|
||||
"px-0",
|
||||
)}
|
||||
>
|
||||
Last Visited
|
||||
Last Viewed
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<ChevronUpIcon className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
@@ -271,9 +261,15 @@ const columns: ColumnDef<View>[] = [
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{timeAgo(row.original.viewedAt)}
|
||||
</div>
|
||||
<TimestampTooltip
|
||||
timestamp={row.original.viewedAt}
|
||||
side="right"
|
||||
rows={["local", "utc", "unix"]}
|
||||
>
|
||||
<div className="select-none text-sm text-muted-foreground">
|
||||
{timeAgo(row.original.viewedAt)}
|
||||
</div>
|
||||
</TimestampTooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -287,13 +283,13 @@ export default function ViewsTable({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const teamInfo = useTeam();
|
||||
const { isTrial, isFree } = usePlan();
|
||||
const { isTrial, isFree, isPaused } = usePlan();
|
||||
const { interval = "7d" } = router.query;
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "viewedAt", desc: true },
|
||||
]);
|
||||
|
||||
const { data: views } = useSWR<View[]>(
|
||||
const { data } = useSWR<{ views: View[]; hiddenFromPause: number }>(
|
||||
teamInfo?.currentTeam?.id
|
||||
? `/api/analytics?type=views&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === "custom" ? `&startDate=${format(startDate, "MM-dd-yyyy")}&endDate=${format(endDate, "MM-dd-yyyy")}` : ""}`
|
||||
: null,
|
||||
@@ -304,6 +300,9 @@ export default function ViewsTable({
|
||||
},
|
||||
);
|
||||
|
||||
const views = data?.views;
|
||||
const hiddenFromPause = data?.hiddenFromPause ?? 0;
|
||||
|
||||
const table = useReactTable({
|
||||
data: views || [],
|
||||
columns,
|
||||
@@ -341,20 +340,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 (
|
||||
@@ -368,6 +362,22 @@ export default function ViewsTable({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isPaused && hiddenFromPause > 0 && (
|
||||
<div className="flex flex-col items-start justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950 sm:flex-row sm:items-center">
|
||||
<span className="flex items-center gap-x-1 text-sm">
|
||||
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
|
||||
{hiddenFromPause} view{hiddenFromPause !== 1 ? "s" : ""} occurred
|
||||
after your team was paused and{" "}
|
||||
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="text-sm font-medium text-orange-600 underline hover:text-orange-700"
|
||||
>
|
||||
Unpause subscription to see all views
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<UpgradeOrExportButton />
|
||||
</div>
|
||||
@@ -438,7 +448,7 @@ export default function ViewsTable({
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<p>No visits in the last {interval}</p>
|
||||
<p>No views in the last {interval}</p>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useState } from "react";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
} from "@tanstack/react-table";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
BadgeCheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
@@ -25,8 +27,11 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
|
||||
import { downloadCSV } from "@/lib/utils/csv";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Gauge } from "@/components/ui/gauge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -39,11 +44,7 @@ import { BadgeTooltip } from "@/components/ui/tooltip";
|
||||
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
|
||||
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;
|
||||
@@ -53,6 +54,7 @@ interface Visitor {
|
||||
uniqueDocuments: number;
|
||||
verified: boolean;
|
||||
totalDuration: number;
|
||||
viewerName?: string | null;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<Visitor>[] = [
|
||||
@@ -65,13 +67,18 @@ const columns: ColumnDef<Visitor>[] = [
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="focus:outline-none">
|
||||
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{row.original.email}{" "}
|
||||
{row.original.viewerName || row.original.email}{" "}
|
||||
{row.original.verified && (
|
||||
<BadgeTooltip content="Verified visitor">
|
||||
<BadgeCheckIcon className="h-4 w-4 text-emerald-500 hover:text-emerald-600" />
|
||||
</BadgeTooltip>
|
||||
)}
|
||||
</p>
|
||||
{row.original.viewerName && row.original.email && (
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
{row.original.email}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{row.original.uniqueDocuments} document
|
||||
{row.original.uniqueDocuments !== 1 ? "s" : ""} viewed
|
||||
@@ -216,13 +223,13 @@ export default function VisitorsTable({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const teamInfo = useTeam();
|
||||
const { isTrial, isFree } = usePlan();
|
||||
const { isTrial, isFree, isPaused } = usePlan();
|
||||
const { interval = "7d" } = router.query;
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "lastActive", desc: true },
|
||||
]);
|
||||
|
||||
const { data: visitors } = useSWR<Visitor[]>(
|
||||
const { data } = useSWR<{ visitors: Visitor[]; hiddenFromPause: number }>(
|
||||
teamInfo?.currentTeam?.id
|
||||
? `/api/analytics?type=visitors&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === "custom" ? `&startDate=${format(startDate, "MM-dd-yyyy")}&endDate=${format(endDate, "MM-dd-yyyy")}` : ""}`
|
||||
: null,
|
||||
@@ -233,6 +240,9 @@ export default function VisitorsTable({
|
||||
},
|
||||
);
|
||||
|
||||
const visitors = data?.visitors;
|
||||
const hiddenFromPause = data?.hiddenFromPause ?? 0;
|
||||
|
||||
const table = useReactTable({
|
||||
data: visitors || [],
|
||||
columns,
|
||||
@@ -269,20 +279,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 (
|
||||
@@ -296,10 +301,26 @@ export default function VisitorsTable({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isPaused && hiddenFromPause > 0 && (
|
||||
<div className="flex flex-col items-start justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950 sm:flex-row sm:items-center">
|
||||
<span className="flex items-center gap-x-1 text-sm">
|
||||
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
|
||||
{hiddenFromPause} view{hiddenFromPause !== 1 ? "s" : ""} occurred
|
||||
after your team was paused and{" "}
|
||||
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/billing"
|
||||
className="text-sm font-medium text-orange-600 underline hover:text-orange-700"
|
||||
>
|
||||
Unpause subscription to see all views
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<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,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -37,17 +38,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)
|
||||
@@ -179,10 +181,17 @@ export function AddSeatModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-col sm:justify-center">
|
||||
<Button onClick={handleSubmit} className="w-full" disabled={loading}>
|
||||
{loading ? "Redirecting..." : "Proceed to checkout"}
|
||||
</Button>
|
||||
<Link
|
||||
href="/settings/upgrade"
|
||||
className="block w-full text-center text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
or upgrade to higher plan
|
||||
</Link>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -5,20 +5,18 @@ import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
|
||||
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,
|
||||
}: {
|
||||
setShowProAnnualBanner: Dispatch<SetStateAction<boolean | null>>;
|
||||
}) {
|
||||
const plausible = usePlausible();
|
||||
const router = useRouter();
|
||||
const teamInfo = useTeam();
|
||||
const { plan: teamPlan, trial, isCustomer, isOldAccount } = usePlan();
|
||||
@@ -27,7 +25,6 @@ export default function ProAnnualBanner({
|
||||
|
||||
const handleHideBanner = () => {
|
||||
setShowProAnnualBanner(false);
|
||||
plausible("clickedHideProAnnualBanner");
|
||||
Cookies.set("hideProAnnualBanner", "pro-annual-banner", {
|
||||
expires: 7,
|
||||
});
|
||||
@@ -63,7 +60,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,
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { PlanEnum } from "@/ee/stripe/constants";
|
||||
import Cookies from "js-cookie";
|
||||
import { usePlausible } from "next-plausible";
|
||||
|
||||
import X from "@/components/shared/icons/x";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,11 +13,8 @@ export default function ProBanner({
|
||||
}: {
|
||||
setShowProBanner: Dispatch<SetStateAction<boolean | null>>;
|
||||
}) {
|
||||
const plausible = usePlausible();
|
||||
|
||||
const handleHideBanner = () => {
|
||||
setShowProBanner(false);
|
||||
plausible("clickedHideBanner");
|
||||
Cookies.set("hideProBanner", "pro-banner", {
|
||||
expires: 7,
|
||||
});
|
||||
@@ -42,16 +38,10 @@ export default function ProBanner({
|
||||
</p>
|
||||
<div className="flex">
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
clickedPlan={PlanEnum.Business}
|
||||
trigger={"pro_banner"}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="grow"
|
||||
onClick={() => {
|
||||
plausible("clickedProBanner");
|
||||
}}
|
||||
>
|
||||
<Button type="button" className="grow">
|
||||
Upgrade
|
||||
</Button>
|
||||
</UpgradePlanModal>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
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 {
|
||||
BanIcon,
|
||||
CirclePauseIcon,
|
||||
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 +27,59 @@ 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,
|
||||
isDataroomsPremium,
|
||||
isPaused,
|
||||
isCancelled,
|
||||
isCustomer,
|
||||
startsAt,
|
||||
endsAt,
|
||||
pauseStartsAt,
|
||||
pauseEndsAt,
|
||||
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"
|
||||
| "cancellation";
|
||||
}) => {
|
||||
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 +98,276 @@ 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")}
|
||||
/>
|
||||
</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>
|
||||
<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: "cancellation" })}
|
||||
className="text-red-500"
|
||||
>
|
||||
<BanIcon className="h-4 w-4" />
|
||||
Cancel subscription
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</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">
|
||||
{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>
|
||||
<>
|
||||
<div className="rounded-lg">
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isDataroomsPremium
|
||||
? "Premium"
|
||||
: isDataroomsPlus
|
||||
? "Datarooms+"
|
||||
: 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 && pauseStartsAt && (
|
||||
<CardDescription>
|
||||
<span className="font-medium text-foreground">
|
||||
Subscription{" "}
|
||||
{new Date(pauseStartsAt) > new Date()
|
||||
? "will pause on"
|
||||
: "paused on"}
|
||||
:{" "}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{new Date(pauseStartsAt).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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,8 +106,19 @@ export function UpgradePlanModal({
|
||||
trigger: trigger,
|
||||
teamId: teamInfo?.currentTeam?.id,
|
||||
});
|
||||
|
||||
// Track upgrade click for email scheduling (with trigger info for personalization)
|
||||
if (teamInfo?.currentTeam?.id) {
|
||||
fetch(`/api/teams/${teamInfo.currentTeam.id}/billing/track-upgrade-click`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trigger }),
|
||||
}).catch(() => {
|
||||
// Silently fail - this is just for tracking
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [open, trigger]);
|
||||
}, [open, trigger, teamInfo?.currentTeam?.id]);
|
||||
|
||||
// Track analytics event when child button is present
|
||||
const handleUpgradeClick = () => {
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
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,
|
||||
CircleHelpIcon,
|
||||
Sparkles,
|
||||
Users2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { capitalize, cn } from "@/lib/utils";
|
||||
|
||||
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" : ""}`;
|
||||
|
||||
if (feature.isUsers) {
|
||||
return (
|
||||
<div className={cn("justify-between gap-x-8", baseClasses)}>
|
||||
<div className="flex items-center gap-x-3">
|
||||
{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 && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="cursor-help">
|
||||
<Users2Icon className="h-4 w-4 text-gray-500" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{feature.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("text-sm", baseClasses)}>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
// Segmented control component for Base/Plus/Premium selection
|
||||
const PlanSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: "base" | "plus" | "premium";
|
||||
onChange: (value: "base" | "plus" | "premium") => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-1 flex w-full rounded-lg border border-gray-200 p-1">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
|
||||
value === "base"
|
||||
? "bg-gray-300 text-foreground dark:bg-gray-600 dark:text-white"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
|
||||
)}
|
||||
onClick={() => onChange("base")}
|
||||
>
|
||||
Base
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
|
||||
value === "plus"
|
||||
? "bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
|
||||
)}
|
||||
onClick={() => onChange("plus")}
|
||||
>
|
||||
Plus
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
|
||||
value === "premium"
|
||||
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
|
||||
)}
|
||||
onClick={() => onChange("premium")}
|
||||
>
|
||||
Premium
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function UpgradePlanModalWithDiscount({
|
||||
clickedPlan,
|
||||
trigger,
|
||||
open,
|
||||
setOpen,
|
||||
highlightItem,
|
||||
children,
|
||||
}: {
|
||||
clickedPlan: PlanEnum;
|
||||
trigger?: string;
|
||||
open?: boolean;
|
||||
setOpen?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
highlightItem?: string[];
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [period, setPeriod] = useState<"yearly" | "monthly">("yearly");
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const {
|
||||
plan: teamPlan,
|
||||
isCustomer,
|
||||
isOldAccount,
|
||||
isTrial,
|
||||
isFree,
|
||||
} = usePlan();
|
||||
const analytics = useAnalytics();
|
||||
const [dataRoomsPlanSelection, setDataRoomsPlanSelection] = useState<
|
||||
"base" | "plus" | "premium"
|
||||
>("base");
|
||||
|
||||
// Get next higher plan for current user
|
||||
const getNextHigherPlan = () => {
|
||||
if (isFree) return PlanEnum.Pro; // Free users see Pro as next
|
||||
if (teamPlan === "pro") return PlanEnum.Business;
|
||||
if (teamPlan === "business") return PlanEnum.DataRooms;
|
||||
if (teamPlan === "datarooms") return PlanEnum.DataRoomsPlus;
|
||||
if (teamPlan === "datarooms-plus") return PlanEnum.DataRoomsPremium;
|
||||
return null;
|
||||
};
|
||||
|
||||
const nextHigherPlan = getNextHigherPlan();
|
||||
|
||||
const plansToShow = useMemo(() => {
|
||||
switch (clickedPlan) {
|
||||
case PlanEnum.Pro:
|
||||
return [PlanEnum.Pro, PlanEnum.Business];
|
||||
case PlanEnum.Business:
|
||||
return [PlanEnum.Business, PlanEnum.DataRooms];
|
||||
case PlanEnum.DataRooms:
|
||||
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
|
||||
case PlanEnum.DataRoomsPlus:
|
||||
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
|
||||
case PlanEnum.DataRoomsPremium:
|
||||
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
|
||||
default:
|
||||
return [PlanEnum.Pro, PlanEnum.Business];
|
||||
}
|
||||
}, [clickedPlan]);
|
||||
|
||||
// Track analytics event when modal is opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
analytics.capture("Upgrade Button Clicked", {
|
||||
trigger: trigger,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// Track upgrade click for email scheduling (with trigger info for personalization)
|
||||
if (teamId) {
|
||||
fetch(`/api/teams/${teamId}/billing/track-upgrade-click`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trigger }),
|
||||
}).catch(() => {
|
||||
// Silently fail - this is just for tracking
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setDataRoomsPlanSelection("base");
|
||||
}
|
||||
}, [open, trigger, teamId]);
|
||||
|
||||
const handleUpgradeClick = () => {
|
||||
analytics.capture("Upgrade Button Clicked", {
|
||||
trigger: trigger,
|
||||
teamId,
|
||||
});
|
||||
};
|
||||
|
||||
// If button is present, clone it and add onClick handler
|
||||
const buttonChild = React.isValidElement<{
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
}>(children)
|
||||
? React.cloneElement(children, { onClick: handleUpgradeClick })
|
||||
: children;
|
||||
|
||||
// Calculate 30% discounted yearly price
|
||||
const getDiscountedYearlyPrice = (yearlyPrice: number) => {
|
||||
return Math.round(yearlyPrice * 0.7); // Round to whole number
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{buttonChild}</DialogTrigger>
|
||||
<DialogContent
|
||||
className="max-h-[90vh] min-h-fit overflow-y-auto bg-white p-0 text-foreground dark:bg-gray-900"
|
||||
style={{
|
||||
width: "95vw",
|
||||
maxWidth: "1200px",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
{/* Left Sidebar - Discount Information */}
|
||||
<div className="flex h-full w-full flex-col border-b border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 lg:w-64 lg:border-b-0 lg:border-r">
|
||||
<div className="mb-2">
|
||||
<h2 className="text-sm font-bold uppercase text-[#fb7a00]">
|
||||
SWITCH TO YEARLY
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mb-6 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Save <span className="font-bold text-[#fb7a00]">30%</span> on
|
||||
yearly plans
|
||||
</p>
|
||||
|
||||
{/* Decorative elements - Holiday section - Centered vertically */}
|
||||
<div className="relative flex flex-1 flex-col items-center justify-center">
|
||||
<div className="mb-3 h-16 w-16 text-[#fb7a00] opacity-30">
|
||||
<Sparkles className="h-full w-full" />
|
||||
</div>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="h-px flex-1 bg-[#fb7a00]"></div>
|
||||
<p className="text-sm font-semibold text-[#fb7a00]">NEW YEAR'S</p>
|
||||
<div className="h-px flex-1 bg-[#fb7a00]"></div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
New Year's offer
|
||||
</p>
|
||||
{/* <div className="absolute bottom-0 h-12 w-12 text-[#fb7a00] opacity-20">
|
||||
<Sparkles className="h-full w-full" />
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
Ends Jan 15 2026
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Right Content - Plans */}
|
||||
<div className="flex-1 p-6">
|
||||
{/* Monthly/Yearly Toggle */}
|
||||
<div className="mb-6 flex items-center justify-center">
|
||||
<span className="mr-2 text-sm">
|
||||
Monthly{" "}
|
||||
{period === "monthly" && (
|
||||
<span className="text-[#fb7a00]"></span>
|
||||
)}
|
||||
</span>
|
||||
<Switch
|
||||
checked={period === "yearly"}
|
||||
onCheckedChange={() =>
|
||||
setPeriod(period === "monthly" ? "yearly" : "monthly")
|
||||
}
|
||||
/>
|
||||
<span className="ml-2 text-sm">Annually</span>
|
||||
</div>
|
||||
|
||||
<div className="isolate grid grid-cols-1 gap-4 overflow-hidden rounded-xl p-4 md:grid-cols-2">
|
||||
{plansToShow.map((planOption) => {
|
||||
const isDataRoomsUpgrade = plansToShow.includes(
|
||||
PlanEnum.DataRooms,
|
||||
);
|
||||
|
||||
// Determine which plan to show based on selection for Data Rooms
|
||||
let effectivePlan = planOption;
|
||||
let displayPlanName = planOption;
|
||||
|
||||
if (planOption === PlanEnum.DataRooms && isDataRoomsUpgrade) {
|
||||
if (dataRoomsPlanSelection === "plus") {
|
||||
effectivePlan = PlanEnum.DataRoomsPlus;
|
||||
displayPlanName = PlanEnum.DataRoomsPlus;
|
||||
} else if (dataRoomsPlanSelection === "premium") {
|
||||
effectivePlan = PlanEnum.DataRoomsPremium;
|
||||
displayPlanName = PlanEnum.DataRoomsPremium;
|
||||
}
|
||||
}
|
||||
|
||||
const planFeatures = getPlanFeatures(effectivePlan, {
|
||||
period,
|
||||
});
|
||||
|
||||
const planData = PLANS.find((p) => p.name === displayPlanName);
|
||||
const monthlyPrice = planData?.price.monthly.amount || 0;
|
||||
const yearlyPrice = planData?.price.yearly.amount || 0;
|
||||
const discountedYearlyPrice =
|
||||
getDiscountedYearlyPrice(yearlyPrice);
|
||||
|
||||
// Determine if this plan should have orange styling
|
||||
const isOrangePlan = isFree
|
||||
? displayPlanName === PlanEnum.Business ||
|
||||
displayPlanName === PlanEnum.DataRoomsPlus
|
||||
: displayPlanName === nextHigherPlan;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={displayPlanName}
|
||||
className={`relative flex flex-col rounded-lg border ${
|
||||
isOrangePlan
|
||||
? "border-[#fb7a00]"
|
||||
: displayPlanName === PlanEnum.DataRoomsPremium
|
||||
? "border-gray-900"
|
||||
: "border-gray-200"
|
||||
} bg-white p-6 shadow-sm dark:bg-gray-900`}
|
||||
>
|
||||
<div className="mb-4 border-b border-gray-200 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-balance text-xl font-medium text-gray-900 dark:text-white">
|
||||
{displayPlanName}
|
||||
</h3>
|
||||
</div>
|
||||
{period === "yearly" &&
|
||||
(() => {
|
||||
const monthlyForYear = monthlyPrice * 12;
|
||||
const yearlyWithDiscount = Math.round(
|
||||
yearlyPrice * 12 * 0.7,
|
||||
);
|
||||
const savings = monthlyForYear - yearlyWithDiscount;
|
||||
return savings > 0 ? (
|
||||
<span className="absolute right-2 top-2 rounded bg-[#fb7a00]/10 px-2 py-1 text-xs font-medium text-[#fb7a00]">
|
||||
Save €{savings}/year
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
{period === "monthly" && isOrangePlan && (
|
||||
<span className="absolute right-2 top-2 rounded bg-[#fb7a00] px-2 py-1 text-xs text-white">
|
||||
{displayPlanName === PlanEnum.Business &&
|
||||
"Most popular"}
|
||||
{displayPlanName === PlanEnum.DataRoomsPlus &&
|
||||
"Best deal"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
{period === "yearly" ? (
|
||||
// Yearly: Show discounted price with crossed out original
|
||||
<>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white">
|
||||
€{discountedYearlyPrice}
|
||||
</span>
|
||||
<span className="text-xl text-gray-500 line-through">
|
||||
€{yearlyPrice}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-white/75">
|
||||
/month, billed annually
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
// Monthly: Show regular monthly price (no discount)
|
||||
<>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-balance text-4xl font-medium tabular-nums text-gray-900 dark:text-white">
|
||||
€{monthlyPrice}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500 dark:text-white/75">
|
||||
/month
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{planOption === PlanEnum.DataRooms &&
|
||||
isDataRoomsUpgrade &&
|
||||
!plansToShow.includes(PlanEnum.DataRoomsPlus) && (
|
||||
<PlanSelector
|
||||
value={dataRoomsPlanSelection}
|
||||
onChange={setDataRoomsPlanSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-white">
|
||||
{planFeatures.featureIntro}
|
||||
</p>
|
||||
|
||||
<ul className="mb-6 mt-2 space-y-2 text-sm leading-6 text-gray-600 dark:text-muted-foreground">
|
||||
{planFeatures.features.map((feature, i) => (
|
||||
<li key={i}>
|
||||
<FeatureItem
|
||||
feature={{
|
||||
...feature,
|
||||
isHighlighted: highlightItem?.includes(
|
||||
feature.id,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Button
|
||||
variant={isOrangePlan ? "default" : "outline"}
|
||||
className={`w-full py-2 text-sm ${
|
||||
isOrangePlan
|
||||
? "bg-[#fb7a00]/90 text-white hover:bg-[#fb7a00]"
|
||||
: "bg-gray-800 text-white hover:bg-gray-900 hover:text-white dark:hover:bg-gray-700/80"
|
||||
}`}
|
||||
loading={selectedPlan === planOption}
|
||||
disabled={selectedPlan !== null}
|
||||
onClick={() => {
|
||||
const priceId = getPriceIdFromPlan({
|
||||
planName: displayPlanName,
|
||||
period,
|
||||
isOld: isOldAccount,
|
||||
});
|
||||
|
||||
setSelectedPlan(planOption);
|
||||
// Apply 30% coupon only for yearly plans
|
||||
// Uses same logic as retention flow: getCouponFromPlan() in API routes
|
||||
const applyDiscount = period === "yearly";
|
||||
if (isCustomer && teamPlan !== "free") {
|
||||
// For existing customers: Apply coupon directly to subscription (same as retention flow)
|
||||
fetch(`/api/teams/${teamId}/billing/manage`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
priceId,
|
||||
upgradePlan: true,
|
||||
applyYearlyDiscount: applyDiscount, // Only true for yearly plans
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const errorData = await res
|
||||
.json()
|
||||
.catch(() => null);
|
||||
const errorMessage =
|
||||
errorData?.error ||
|
||||
errorData?.message ||
|
||||
res.statusText ||
|
||||
"Failed to upgrade plan";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
const url = await res.json();
|
||||
router.push(url);
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message || err);
|
||||
setSelectedPlan(null);
|
||||
});
|
||||
} else {
|
||||
// For new customers: Add coupon to Stripe checkout session
|
||||
fetch(
|
||||
`/api/teams/${teamId}/billing/upgrade?priceId=${priceId}${applyDiscount ? "&applyYearlyDiscount=true" : ""}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
)
|
||||
.then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const errorData = await res
|
||||
.json()
|
||||
.catch(() => null);
|
||||
const errorMessage =
|
||||
errorData?.error ||
|
||||
errorData?.message ||
|
||||
res.statusText ||
|
||||
"Failed to start checkout";
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
const data = await res.json();
|
||||
const { id: sessionId } = data;
|
||||
const stripe = await getStripe(isOldAccount);
|
||||
stripe?.redirectToCheckout({ sessionId });
|
||||
})
|
||||
.catch((err) => {
|
||||
alert(err.message || err);
|
||||
setSelectedPlan(null);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectedPlan === planOption
|
||||
? "Redirecting to Stripe..."
|
||||
: `Upgrade to ${displayPlanName} ${capitalize(period)}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6 flex flex-col items-center text-center text-sm text-muted-foreground">
|
||||
All plans include unlimited visitors and page by page document
|
||||
analytics.
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/settings/upgrade-holiday-offer"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,7 @@ import { getStripe } from "@/ee/stripe/client";
|
||||
import { Feature, PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
|
||||
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
|
||||
import { PLANS } from "@/ee/stripe/utils";
|
||||
import { CheckIcon, Users2Icon, CircleHelpIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { CheckIcon, CircleHelpIcon, Users2Icon, XIcon } from "lucide-react";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
@@ -20,11 +19,11 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
BadgeTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
BadgeTooltip,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
// Start Data Room Trial Button Component
|
||||
@@ -32,7 +31,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleStartTrial = () => {
|
||||
router.push('/welcome?type=dataroom-trial');
|
||||
router.push("/welcome?type=dataroom-trial");
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -40,7 +39,7 @@ const StartDataRoomTrialButton = ({ teamId }: { teamId?: string }) => {
|
||||
onClick={handleStartTrial}
|
||||
className="cursor-pointer underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Start free Data Room trial
|
||||
Start free data room trial
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -53,7 +52,11 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
|
||||
return (
|
||||
<div className={cn("justify-between gap-x-8", baseClasses)}>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
|
||||
{feature.isNotIncluded ? (
|
||||
<XIcon className="h-5 w-5 flex-shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<CheckIcon className="h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
|
||||
)}
|
||||
<span>{feature.text}</span>
|
||||
</div>
|
||||
{feature.tooltip && (
|
||||
@@ -76,7 +79,11 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
|
||||
|
||||
return (
|
||||
<div className={cn("text-sm", baseClasses)}>
|
||||
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
|
||||
{feature.isNotIncluded ? (
|
||||
<XIcon className="mr-3 h-5 w-5 flex-shrink-0 text-gray-500" />
|
||||
) : (
|
||||
<CheckIcon className="mr-3 h-5 w-5 flex-shrink-0 text-[#fb7a00]" />
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{feature.text}</span>
|
||||
{feature.tooltip && (
|
||||
@@ -89,37 +96,48 @@ const FeatureItem = ({ feature }: { feature: Feature }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Segmented control component for Base/Plus selection
|
||||
// Segmented control component for Base/Plus/Premium selection
|
||||
const PlanSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
value: "base" | "plus" | "premium";
|
||||
onChange: (value: "base" | "plus" | "premium") => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-1 flex w-1/2 rounded-lg border border-gray-200 p-1">
|
||||
<div className="mt-1 flex w-full rounded-lg border border-gray-200 p-1">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
|
||||
!value
|
||||
value === "base"
|
||||
? "bg-gray-300 text-foreground dark:bg-gray-600 dark:text-white"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
|
||||
)}
|
||||
onClick={() => onChange(false)}
|
||||
onClick={() => onChange("base")}
|
||||
>
|
||||
Base
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
|
||||
value
|
||||
value === "plus"
|
||||
? "bg-gray-800 text-white dark:bg-gray-200 dark:text-gray-900"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
|
||||
)}
|
||||
onClick={() => onChange("plus")}
|
||||
>
|
||||
Plus
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1 text-sm transition-colors",
|
||||
value === "premium"
|
||||
? "bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900"
|
||||
: "text-gray-600 hover:text-gray-900 dark:text-muted-foreground dark:hover:text-white",
|
||||
)}
|
||||
onClick={() => onChange(true)}
|
||||
onClick={() => onChange("premium")}
|
||||
>
|
||||
Plus
|
||||
Premium
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -147,7 +165,9 @@ export function UpgradePlanModal({
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const { plan: teamPlan, isCustomer, isOldAccount, isTrial } = usePlan();
|
||||
const analytics = useAnalytics();
|
||||
const [showDataRoomsPlus, setShowDataRoomsPlus] = useState(false);
|
||||
const [dataRoomsPlanSelection, setDataRoomsPlanSelection] = useState<
|
||||
"base" | "plus" | "premium"
|
||||
>("base");
|
||||
|
||||
const plansToShow = useMemo(() => {
|
||||
switch (clickedPlan) {
|
||||
@@ -158,7 +178,9 @@ export function UpgradePlanModal({
|
||||
case PlanEnum.DataRooms:
|
||||
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
|
||||
case PlanEnum.DataRoomsPlus:
|
||||
return [PlanEnum.DataRooms, PlanEnum.DataRoomsPlus];
|
||||
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
|
||||
case PlanEnum.DataRoomsPremium:
|
||||
return [PlanEnum.DataRoomsPlus, PlanEnum.DataRoomsPremium];
|
||||
default:
|
||||
return [PlanEnum.Pro, PlanEnum.Business];
|
||||
}
|
||||
@@ -171,10 +193,21 @@ export function UpgradePlanModal({
|
||||
trigger: trigger,
|
||||
teamId,
|
||||
});
|
||||
|
||||
// Track upgrade click for email scheduling (with trigger info for personalization)
|
||||
if (teamId) {
|
||||
fetch(`/api/teams/${teamId}/billing/track-upgrade-click`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trigger }),
|
||||
}).catch(() => {
|
||||
// Silently fail - this is just for tracking
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setShowDataRoomsPlus(false);
|
||||
setDataRoomsPlanSelection("base");
|
||||
}
|
||||
}, [open, trigger]);
|
||||
}, [open, trigger, teamId]);
|
||||
|
||||
const handleUpgradeClick = () => {
|
||||
analytics.capture("Upgrade Button Clicked", {
|
||||
@@ -215,20 +248,26 @@ export function UpgradePlanModal({
|
||||
|
||||
<div className="isolate grid grid-cols-1 gap-4 overflow-hidden rounded-xl p-4 md:grid-cols-2">
|
||||
{plansToShow.map((planOption) => {
|
||||
const planFeatures = getPlanFeatures(planOption, {
|
||||
period,
|
||||
showDataRoomsPlus:
|
||||
planOption === PlanEnum.DataRooms && showDataRoomsPlus,
|
||||
});
|
||||
|
||||
// Get the effective plan name for display
|
||||
const displayPlanName =
|
||||
planOption === PlanEnum.DataRooms && showDataRoomsPlus
|
||||
? PlanEnum.DataRoomsPlus
|
||||
: planOption;
|
||||
|
||||
const isDataRoomsUpgrade = plansToShow.includes(PlanEnum.DataRooms);
|
||||
|
||||
// Determine which plan to show based on selection for Data Rooms
|
||||
let effectivePlan = planOption;
|
||||
let displayPlanName = planOption;
|
||||
|
||||
if (planOption === PlanEnum.DataRooms && isDataRoomsUpgrade) {
|
||||
if (dataRoomsPlanSelection === "plus") {
|
||||
effectivePlan = PlanEnum.DataRoomsPlus;
|
||||
displayPlanName = PlanEnum.DataRoomsPlus;
|
||||
} else if (dataRoomsPlanSelection === "premium") {
|
||||
effectivePlan = PlanEnum.DataRoomsPremium;
|
||||
displayPlanName = PlanEnum.DataRoomsPremium;
|
||||
}
|
||||
}
|
||||
|
||||
const planFeatures = getPlanFeatures(effectivePlan, {
|
||||
period,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={displayPlanName}
|
||||
@@ -244,7 +283,7 @@ export function UpgradePlanModal({
|
||||
<div className="mb-4 border-b border-gray-200 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-balance text-xl font-medium text-gray-900 dark:text-white">
|
||||
Papermark {displayPlanName}
|
||||
{displayPlanName}
|
||||
</h3>
|
||||
</div>
|
||||
<span
|
||||
@@ -252,7 +291,7 @@ export function UpgradePlanModal({
|
||||
"absolute right-2 top-2 rounded px-2 py-1 text-xs text-white",
|
||||
planOption === PlanEnum.Business && "bg-[#fb7a00]",
|
||||
displayPlanName === PlanEnum.DataRoomsPlus &&
|
||||
"bg-gray-900 dark:bg-gray-100 dark:text-gray-900",
|
||||
"bg-gray-800 dark:bg-gray-200 dark:text-gray-900",
|
||||
)}
|
||||
>
|
||||
{planOption === PlanEnum.Business && "Most popular"}
|
||||
@@ -270,7 +309,7 @@ export function UpgradePlanModal({
|
||||
}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-white/75">
|
||||
/month
|
||||
/month{period === "yearly" && ", billed annually"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -278,8 +317,8 @@ export function UpgradePlanModal({
|
||||
isDataRoomsUpgrade &&
|
||||
!plansToShow.includes(PlanEnum.DataRoomsPlus) && (
|
||||
<PlanSelector
|
||||
value={showDataRoomsPlus}
|
||||
onChange={setShowDataRoomsPlus}
|
||||
value={dataRoomsPlanSelection}
|
||||
onChange={setDataRoomsPlanSelection}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -313,10 +352,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") {
|
||||
@@ -373,17 +413,23 @@ export function UpgradePlanModal({
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center text-sm text-muted-foreground">
|
||||
All plans include unlimited viewers and page by page document
|
||||
All plans include unlimited visitors and page by page document
|
||||
analytics.
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/settings/upgrade"
|
||||
href={`/settings/upgrade${
|
||||
clickedPlan === PlanEnum.Pro
|
||||
? "?view=documents"
|
||||
: clickedPlan === PlanEnum.Business
|
||||
? "?view=business-datarooms"
|
||||
: ""
|
||||
}`}
|
||||
className="underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
See all plans
|
||||
</Link>
|
||||
{trigger === "sidebar_datarooms" &&
|
||||
((teamPlan === "free" && !isTrial) || (teamPlan === "pro" && !isTrial)) && (
|
||||
{((teamPlan === "free" && !isTrial) ||
|
||||
(teamPlan === "pro" && !isTrial)) && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<StartDataRoomTrialButton teamId={teamId} />
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { PlanEnum, getPlanFeatures } from "@/ee/stripe/constants";
|
||||
import { getPriceIdFromPlan } from "@/ee/stripe/functions/get-price-id-from-plan";
|
||||
import { PLANS } from "@/ee/stripe/utils";
|
||||
import Cookies from "js-cookie";
|
||||
import { CheckIcon, Sparkles, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { UpgradePlanModal } from "./upgrade-plan-modal";
|
||||
|
||||
interface YearlyUpgradeBannerProps {
|
||||
setShowBanner: Dispatch<SetStateAction<boolean | null>>;
|
||||
}
|
||||
|
||||
export default function YearlyUpgradeBanner({
|
||||
setShowBanner,
|
||||
}: YearlyUpgradeBannerProps) {
|
||||
const router = useRouter();
|
||||
const { plan: teamPlan } = usePlan();
|
||||
|
||||
const handleHideBanner = () => {
|
||||
setShowBanner(false);
|
||||
Cookies.set("hideYearlyUpgradeBanner", "yearly-upgrade-banner", {
|
||||
expires: 7,
|
||||
});
|
||||
};
|
||||
|
||||
// Get next higher plan
|
||||
const getNextPlan = () => {
|
||||
if (teamPlan === "pro") return PlanEnum.Business;
|
||||
if (teamPlan === "business") return PlanEnum.DataRooms;
|
||||
if (teamPlan === "datarooms") return PlanEnum.DataRoomsPlus;
|
||||
if (teamPlan === "datarooms-plus") return PlanEnum.DataRoomsPremium;
|
||||
return null;
|
||||
};
|
||||
|
||||
const nextPlan = getNextPlan();
|
||||
|
||||
if (!nextPlan) return null; // Don't show banner if no next plan
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.aside
|
||||
initial={{ x: 400, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 400, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className={cn(
|
||||
"fixed right-0 top-1/2 z-50 w-80 -translate-y-1/2 rounded-l-lg border-b border-l border-t border-gray-200 bg-white p-6 shadow-xl dark:border-gray-800 dark:bg-gray-900",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHideBanner}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-[#fb7a00]" />
|
||||
<span className="text-lg font-bold text-black dark:text-white">
|
||||
UPGRADE YOUR PLAN
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Save up to 35% with yearly plans
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Next Plan Card - Orange */}
|
||||
{(() => {
|
||||
const planData = PLANS.find((p) => p.name === nextPlan);
|
||||
if (!planData) return null;
|
||||
|
||||
const yearlyPrice = planData.price.yearly.amount;
|
||||
const monthlyPrice = planData.price.monthly.amount;
|
||||
const monthlyForYear = monthlyPrice * 12;
|
||||
const yearlyForYear = yearlyPrice * 12;
|
||||
const savings = monthlyForYear - yearlyForYear;
|
||||
const planFeatures = getPlanFeatures(nextPlan, {
|
||||
period: "yearly",
|
||||
maxFeatures: 4,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-[#fb7a00] bg-orange-50 p-3 dark:bg-orange-900/20">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-xs font-bold text-[#fb7a00]">
|
||||
RECOMMENDED
|
||||
</span>
|
||||
{savings > 0 && (
|
||||
<span className="text-xs font-medium text-[#fb7a00]">
|
||||
Save €{savings}/year
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-1 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{nextPlan} plan
|
||||
</p>
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
€{yearlyPrice}
|
||||
</span>
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
/mo, billed annually
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="mb-3 space-y-1.5 text-xs text-gray-700 dark:text-gray-300">
|
||||
{planFeatures.features.slice(0, 4).map((feature, i) => (
|
||||
<li key={i} className="flex items-center gap-2">
|
||||
<CheckIcon className="h-3.5 w-3.5 flex-shrink-0 text-[#fb7a00]" />
|
||||
<span className="leading-tight">{feature.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<UpgradePlanModal
|
||||
clickedPlan={nextPlan}
|
||||
trigger="yearly_upgrade_banner"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="w-full bg-[#fb7a00] py-3 leading-tight text-white hover:bg-[#fb7a00]/90"
|
||||
>
|
||||
Upgrade to {nextPlan} Yearly
|
||||
</Button>
|
||||
</UpgradePlanModal>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<p
|
||||
onClick={() => router.push("/settings/upgrade")}
|
||||
className="mt-4 cursor-pointer text-center text-xs text-gray-500 underline hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
|
||||
>
|
||||
Compare all plans
|
||||
</p>
|
||||
</motion.aside>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { AssistantStatus, type Message } from "ai/react";
|
||||
import Textarea from "react-textarea-autosize";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEnterSubmit } from "@/lib/utils/use-enter-submit";
|
||||
|
||||
import ArrowUp from "../shared/icons/arrow-up";
|
||||
|
||||
export function ChatInput({
|
||||
status,
|
||||
error,
|
||||
input,
|
||||
setInput,
|
||||
handleInputChange,
|
||||
submitMessage,
|
||||
messages,
|
||||
}: {
|
||||
status: AssistantStatus;
|
||||
error: unknown;
|
||||
input: string;
|
||||
setInput: (input: string) => void;
|
||||
handleInputChange: (e: any) => void;
|
||||
submitMessage: (e: any) => Promise<void>;
|
||||
messages: Message[];
|
||||
}) {
|
||||
const { formRef, onKeyDown } = useEnterSubmit();
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
if (status === "awaiting_message") {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="relative inset-x-0 bottom-0">
|
||||
<div className="mx-auto sm:max-w-3xl sm:px-4">
|
||||
<div className="space-y-4 bg-background px-4 py-4 md:py-4">
|
||||
<form onSubmit={submitMessage} ref={formRef}>
|
||||
<div className="relative flex max-h-60 w-full flex-col overflow-hidden rounded-xl bg-background pr-8 ring-1 ring-muted-foreground/50 focus-within:ring-1 focus-within:ring-foreground sm:pr-12">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
tabIndex={0}
|
||||
rows={1}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={status !== "awaiting_message"}
|
||||
className="min-h-[60px] w-full resize-none border-none bg-transparent px-4 py-[1.3rem] focus:ring-0 sm:text-sm"
|
||||
value={input}
|
||||
placeholder="Message Papermark Assistant..."
|
||||
onChange={handleInputChange}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === "in_progress" || input === ""}
|
||||
title="Send message"
|
||||
className="h-10 w-10 rounded-md p-1 md:p-2"
|
||||
>
|
||||
<ArrowUp className="h-full w-full" />
|
||||
<span className="sr-only">Send message</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconArrowElbow({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn("h-4 w-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M200 32v144a8 8 0 0 1-8 8H67.31l34.35 34.34a8 8 0 0 1-11.32 11.32l-48-48a8 8 0 0 1 0-11.32l48-48a8 8 0 0 1 11.32 11.32L67.31 168H184V32a8 8 0 0 1 16 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type Message } from "ai";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import Skeleton from "../Skeleton";
|
||||
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
|
||||
export interface ChatList {
|
||||
messages: Message[];
|
||||
status: "in_progress" | "awaiting_message";
|
||||
}
|
||||
|
||||
export function ChatList({ messages, status }: ChatList) {
|
||||
if (!messages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto max-w-2xl px-4">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index}>
|
||||
<ChatMessage message={message} />
|
||||
{index < messages.length - 1 && (
|
||||
<Separator className="my-4 bg-background" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{status === "in_progress" && (
|
||||
<>
|
||||
<Separator className="my-4 bg-background" />
|
||||
<div
|
||||
key={"loading-message"}
|
||||
className="group relative mb-4 ml-5 flex items-start whitespace-pre-wrap"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow">
|
||||
<PapermarkSparkle />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<div className="select-none font-semibold">
|
||||
Papermark Assistant
|
||||
</div>
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type Message } from "ai";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCopyToClipboard } from "@/lib/utils/use-copy-to-clipboard";
|
||||
|
||||
import Check from "../shared/icons/check";
|
||||
import Copy from "../shared/icons/copy";
|
||||
|
||||
interface ChatMessageActionsProps extends React.ComponentProps<"div"> {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function ChatMessageActions({
|
||||
message,
|
||||
className,
|
||||
...props
|
||||
}: ChatMessageActionsProps) {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
|
||||
|
||||
const onCopy = () => {
|
||||
if (isCopied) return;
|
||||
copyToClipboard(message.content);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end md:absolute md:-top-2 md:right-0 md:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={onCopy}>
|
||||
{isCopied ? <Check /> : <Copy />}
|
||||
<span className="sr-only">Copy message</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { type Message } from "ai";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import AlertCircle from "../shared/icons/alert-circle";
|
||||
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
|
||||
import UserRound from "../shared/icons/user-round";
|
||||
import { ChatMessageActions } from "./chat-message-actions";
|
||||
|
||||
// map role to icon and name
|
||||
const mapMessageRole = {
|
||||
user: { icon: <UserRound />, name: "You" },
|
||||
system: { icon: <AlertCircle />, name: "System" },
|
||||
assistant: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
|
||||
function: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
|
||||
data: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
|
||||
tool: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
|
||||
};
|
||||
|
||||
export interface ChatMessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, ...props }: ChatMessageProps) {
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"group relative mb-4 flex items-start whitespace-pre-wrap md:ml-5",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow",
|
||||
message.role === "user"
|
||||
? "bg-background"
|
||||
: message.role === "system"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{mapMessageRole[message.role].icon}
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<div className="group relative flex w-[calc(100%-50px)] flex-col">
|
||||
<div className="select-none font-semibold">
|
||||
{mapMessageRole[message.role].name}
|
||||
</div>
|
||||
<div className="prose break-words font-light dark:prose-invert prose-p:leading-relaxed prose-pre:p-0">
|
||||
<p className="mb-2 last:mb-0">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChatMessageActions className="group-hover:flex" message={message} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import { useAtBottom } from "@/lib/utils/use-at-bottom";
|
||||
|
||||
interface ChatScrollAnchorProps {
|
||||
trackVisibility?: boolean;
|
||||
}
|
||||
|
||||
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
|
||||
const isAtBottom = useAtBottom();
|
||||
const { ref, entry, inView } = useInView({
|
||||
trackVisibility,
|
||||
delay: 100,
|
||||
rootMargin: "0px 0px -100px 0px",
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAtBottom && trackVisibility && !inView) {
|
||||
entry?.target.scrollIntoView({
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, [inView, entry, isAtBottom, trackVisibility]);
|
||||
|
||||
return <div ref={ref} className="h-px w-full" />;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
type Message,
|
||||
experimental_useAssistant as useAssistant,
|
||||
} from "ai/react";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { BasePlan } from "@/lib/swr/use-billing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatList } from "./chat-list";
|
||||
import { ChatScrollAnchor } from "./chat-scroll-anchor";
|
||||
import { EmptyScreen } from "./empty-screen";
|
||||
|
||||
export interface ChatProps extends React.ComponentProps<"div"> {
|
||||
initialMessages: Message[];
|
||||
threadId?: string;
|
||||
firstPage?: string;
|
||||
isPublic?: boolean;
|
||||
userId?: string;
|
||||
plan?: BasePlan | null;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
initialMessages,
|
||||
threadId,
|
||||
firstPage,
|
||||
className,
|
||||
isPublic,
|
||||
userId,
|
||||
plan,
|
||||
}: ChatProps) {
|
||||
const {
|
||||
status,
|
||||
messages: hookMessages,
|
||||
input: hookInput,
|
||||
submitMessage,
|
||||
handleInputChange,
|
||||
error,
|
||||
} = useAssistant({
|
||||
api: "/api/assistants/chat",
|
||||
threadId: threadId,
|
||||
body: {
|
||||
isPublic: isPublic,
|
||||
userId: userId,
|
||||
plan: plan,
|
||||
},
|
||||
});
|
||||
|
||||
const [combinedMessages, setCombinedMessages] = useState<Message[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error instanceof Error) {
|
||||
let content: string = "";
|
||||
if (isPublic) {
|
||||
content =
|
||||
"You have reached your request limit for the day. Sign up for a free account to continue using Papermark Assistant.";
|
||||
}
|
||||
if (userId && plan !== "pro") {
|
||||
content =
|
||||
"You have reached your request limit for the day. Upgrade to a paid account to continue using Papermark Assistant.";
|
||||
}
|
||||
|
||||
const message: Message = {
|
||||
role: "system",
|
||||
content: content,
|
||||
id: nanoid(),
|
||||
};
|
||||
|
||||
setCombinedMessages((prev) => [...prev, message]);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
// Concatenate existing messages with messages from the hook
|
||||
// and reverse the order so that the newest messages are at the bottom:
|
||||
const reversedMessages = [...initialMessages].reverse();
|
||||
setCombinedMessages([...reversedMessages, ...hookMessages]);
|
||||
}, [initialMessages, hookMessages]);
|
||||
|
||||
let isLoading;
|
||||
if (status === "in_progress") {
|
||||
isLoading = true;
|
||||
} else if (status === "awaiting_message") {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
const [_, setInput] = useState<string>(hookInput);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-[calc(100vh-96px)] overflow-y-auto pb-[20px] pt-24",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{combinedMessages.length ? (
|
||||
<>
|
||||
<ChatList messages={combinedMessages} status={status} />
|
||||
{!isPublic ? (
|
||||
<ChatScrollAnchor trackVisibility={isLoading} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
firstPage={firstPage}
|
||||
handleInputChange={handleInputChange}
|
||||
setInput={setInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput
|
||||
status={status}
|
||||
error={error}
|
||||
messages={combinedMessages}
|
||||
input={hookInput}
|
||||
setInput={setInput}
|
||||
submitMessage={submitMessage}
|
||||
handleInputChange={handleInputChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import Sparkle from "../shared/icons/sparkle";
|
||||
|
||||
const exampleMessages = [
|
||||
{
|
||||
heading: "Summarize the document",
|
||||
message: "Summarize the document in a few sentences and bullet points.",
|
||||
},
|
||||
{
|
||||
heading: "Tell me about the team",
|
||||
message:
|
||||
"Tell me more about the team behind the company. If there's no team page in the document, then let me know.",
|
||||
},
|
||||
{
|
||||
heading: "Ask a question",
|
||||
message: "What does the company do?",
|
||||
},
|
||||
{
|
||||
heading: "What is this document about?",
|
||||
message: "Please tell me in one sentence what this document is about.",
|
||||
},
|
||||
];
|
||||
|
||||
export function EmptyScreen({
|
||||
firstPage,
|
||||
setInput,
|
||||
handleInputChange,
|
||||
}: {
|
||||
firstPage?: string;
|
||||
setInput: (input: string) => void;
|
||||
handleInputChange: (e: any) => void;
|
||||
}) {
|
||||
const manageInput = (message: string) => {
|
||||
setInput(message);
|
||||
handleInputChange(message);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto w-1/2 md:w-1/3">
|
||||
<div className="flex items-center justify-center">
|
||||
{firstPage ? (
|
||||
firstPage.includes("cloudfront.net") ? (
|
||||
<img
|
||||
className="rounded-md object-contain ring-1 ring-gray-700"
|
||||
src={firstPage}
|
||||
alt={`Page 1`}
|
||||
fetchPriority="high"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={firstPage}
|
||||
width={768}
|
||||
height={100}
|
||||
className="rounded-md object-contain ring-1 ring-gray-700"
|
||||
alt="First page of the document"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-center rounded-md">
|
||||
<p className="text-center text-2xl">
|
||||
Chat with{" "}
|
||||
<span className="text-2xl font-bold tracking-tighter ">
|
||||
Papermark
|
||||
</span>
|
||||
's pitchdeck
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center text-lg">
|
||||
What would you like to know?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 mx-auto max-w-2xl px-4">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{exampleMessages.map((message, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="h-auto p-4 text-sm"
|
||||
onClick={() => {
|
||||
return handleInputChange({
|
||||
target: { value: message.message },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{message.heading}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { PlanEnum } from "@/ee/stripe/constants";
|
||||
import { ListOrderedIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
|
||||
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
|
||||
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 { isDatarooms, isDataroomsPlus, isTrial } = usePlan();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const isDataroomIndexEnabled = isFeatureEnabled("dataroomIndex");
|
||||
const hasDataroomsPlan = isDatarooms || isDataroomsPlus || isTrial;
|
||||
const hasDataroomsPlusPlan = isDataroomsPlus;
|
||||
|
||||
// Show button if: feature flag is enabled OR user has datarooms plan or higher
|
||||
const shouldShowButton = isDataroomIndexEnabled || hasDataroomsPlan;
|
||||
|
||||
// Allow usage if: feature flag is enabled OR user has datarooms-plus plan
|
||||
const canUseFeature = isDataroomIndexEnabled || hasDataroomsPlusPlan;
|
||||
|
||||
// Don't render if conditions aren't met
|
||||
if (!shouldShowButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleRebuildIndex = async () => {
|
||||
if (!canUseFeature) {
|
||||
toast.error("Upgrade to Data Rooms Plus plan to use this feature.");
|
||||
return;
|
||||
}
|
||||
|
||||
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>
|
||||
{canUseFeature ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRebuildIndex} loading={isLoading}>
|
||||
<ListOrderedIcon className="h-4 w-4" />
|
||||
Rebuild Index
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.DataRoomsPlus}
|
||||
trigger="datarooms_rebuild_index_button"
|
||||
highlightItem={["indexing"]}
|
||||
>
|
||||
<Button>Upgrade to rebuild index</Button>
|
||||
</UpgradePlanModal>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -60,9 +60,7 @@ function RemoveDataroomItemsModal({
|
||||
).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(
|
||||
`Failed to remove dataroom document ${documentId}: ${error.message}`,
|
||||
);
|
||||
throw new Error(error.message || "Failed to remove dataroom document");
|
||||
}
|
||||
analytics.capture("Dataroom Document Removed", {
|
||||
team: teamInfo?.currentTeam?.id,
|
||||
@@ -78,9 +76,7 @@ function RemoveDataroomItemsModal({
|
||||
).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(
|
||||
`Failed to remove folderId folder ${folderId}: ${error.message}`,
|
||||
);
|
||||
throw new Error(error.message || "Failed to remove dataroom folder");
|
||||
}
|
||||
analytics.capture("Dataroom folder Removed", {
|
||||
team: teamInfo?.currentTeam?.id,
|
||||
|
||||
@@ -4,25 +4,56 @@ import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { PlanEnum } from "@/ee/stripe/constants";
|
||||
import {
|
||||
Brain,
|
||||
BriefcaseIcon,
|
||||
BuildingIcon,
|
||||
FileTextIcon,
|
||||
FolderKanbanIcon,
|
||||
FolderIcon,
|
||||
HomeIcon,
|
||||
LineChartIcon,
|
||||
RocketIcon,
|
||||
ShoppingCartIcon,
|
||||
Sparkles,
|
||||
TrendingUpIcon,
|
||||
XIcon,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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 { useAnalytics } from "@/lib/analytics";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
|
||||
|
||||
@@ -39,21 +70,219 @@ export function AddDataroomModal({
|
||||
const [dataroomName, setDataroomName] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [open, setOpen] = useState<boolean>(openModal);
|
||||
const [activeTab, setActiveTab] = useState<string>("create");
|
||||
const [dataroomType, setDataroomType] = useState<string>("");
|
||||
const [aiDescription, setAiDescription] = useState<string>("");
|
||||
const [aiGenerating, setAiGenerating] = useState<boolean>(false);
|
||||
const [generatedFolders, setGeneratedFolders] = useState<any[] | null>(null);
|
||||
const [showPreview, setShowPreview] = useState<boolean>(false);
|
||||
const [selectedFolderPaths, setSelectedFolderPaths] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [editingFolderPath, setEditingFolderPath] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState<string>("");
|
||||
|
||||
const teamInfo = useTeam();
|
||||
const { isFree, isPro } = usePlan();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
const useTemplate = activeTab === "generate";
|
||||
const useAI = activeTab === "ai";
|
||||
|
||||
const dataroomSchema = z.object({
|
||||
name: z.string().trim().min(3, {
|
||||
message: "Please provide a dataroom name with at least 3 characters.",
|
||||
}),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
const dataroomSchemaWithType = z.object({
|
||||
type: z.enum(
|
||||
[
|
||||
"startup-fundraising",
|
||||
"raising-first-fund",
|
||||
"ma-acquisition",
|
||||
"series-a-plus",
|
||||
"real-estate-transaction",
|
||||
"fund-management",
|
||||
"portfolio-management",
|
||||
"project-management",
|
||||
"sales-dataroom",
|
||||
],
|
||||
{
|
||||
errorMap: () => ({ message: "Please select a dataroom type." }),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const TEMPLATES = [
|
||||
{
|
||||
id: "startup-fundraising",
|
||||
name: "Startup Fundraising",
|
||||
icon: RocketIcon,
|
||||
},
|
||||
{
|
||||
id: "series-a-plus",
|
||||
name: "Series A+",
|
||||
icon: TrendingUpIcon,
|
||||
},
|
||||
{
|
||||
id: "raising-first-fund",
|
||||
name: "Raising a Fund",
|
||||
icon: LineChartIcon,
|
||||
},
|
||||
{
|
||||
id: "ma-acquisition",
|
||||
name: "M&A / Acquisition",
|
||||
icon: BriefcaseIcon,
|
||||
},
|
||||
{
|
||||
id: "sales-dataroom",
|
||||
name: "Sales Data Room",
|
||||
icon: ShoppingCartIcon,
|
||||
},
|
||||
{
|
||||
id: "real-estate-transaction",
|
||||
name: "Real Estate",
|
||||
icon: HomeIcon,
|
||||
},
|
||||
{
|
||||
id: "fund-management",
|
||||
name: "Fund Management",
|
||||
icon: BuildingIcon,
|
||||
},
|
||||
{
|
||||
id: "portfolio-management",
|
||||
name: "Portfolio Management",
|
||||
icon: FolderKanbanIcon,
|
||||
},
|
||||
{
|
||||
id: "project-management",
|
||||
name: "Project Management",
|
||||
icon: FileTextIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const handleGenerateFolders = async () => {
|
||||
if (!aiDescription.trim()) {
|
||||
return toast.error("Please describe what kind of dataroom you want to create.");
|
||||
}
|
||||
|
||||
setAiGenerating(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai-structure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: aiDescription.trim(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const { message } = await response.json();
|
||||
setAiGenerating(false);
|
||||
toast.error(message || "Failed to generate folder structure");
|
||||
return;
|
||||
}
|
||||
|
||||
const { name, folders } = await response.json();
|
||||
setGeneratedFolders(folders);
|
||||
// Set the generated name as default, but allow user to edit it
|
||||
if (name) {
|
||||
setDataroomName(name);
|
||||
}
|
||||
// Initialize all folders as selected
|
||||
initializeFolderSelection(folders);
|
||||
setShowPreview(true);
|
||||
setAiGenerating(false);
|
||||
} catch (error) {
|
||||
setAiGenerating(false);
|
||||
toast.error("Error generating folder structure. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: any) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const validation = dataroomSchema.safeParse({ name: dataroomName });
|
||||
// For AI generation, if we're in preview mode, create the dataroom
|
||||
if (useAI && showPreview && generatedFolders) {
|
||||
// Validate dataroom name
|
||||
if (!dataroomName.trim()) {
|
||||
return toast.error("Please provide a dataroom name.");
|
||||
}
|
||||
|
||||
if (dataroomName.trim().length < 3) {
|
||||
return toast.error(
|
||||
"Please provide a dataroom name with at least 3 characters.",
|
||||
);
|
||||
}
|
||||
|
||||
// Filter folders based on selection
|
||||
const filteredFolders = filterSelectedFolders(generatedFolders);
|
||||
|
||||
if (filteredFolders.length === 0) {
|
||||
return toast.error("Please select at least one folder to include.");
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate-ai`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: dataroomName.trim(),
|
||||
folders: filteredFolders,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const { message } = await response.json();
|
||||
setLoading(false);
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const { dataroom } = await response.json();
|
||||
|
||||
analytics.capture("Dataroom Generated with AI", {
|
||||
dataroomName: dataroomName.trim(),
|
||||
});
|
||||
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);
|
||||
toast.success("Dataroom successfully generated! 🎉");
|
||||
router.push(`/datarooms/${dataroom.id}/documents`);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
toast.error("Error creating dataroom. Please try again.");
|
||||
return;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setOpen(false);
|
||||
if (openModal && setOpenModal) setOpenModal(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate based on whether template is enabled
|
||||
const schema = useTemplate ? dataroomSchemaWithType : dataroomSchema;
|
||||
const validation = schema.safeParse({
|
||||
name: useTemplate ? undefined : dataroomName,
|
||||
type: useTemplate ? dataroomType : undefined,
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
return toast.error(validation.error.errors[0].message);
|
||||
}
|
||||
@@ -61,18 +290,25 @@ export function AddDataroomModal({
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: dataroomName.trim(),
|
||||
}),
|
||||
// Use different endpoint based on whether template is enabled
|
||||
const endpoint = useTemplate
|
||||
? `/api/teams/${teamInfo?.currentTeam?.id}/datarooms/generate`
|
||||
: `/api/teams/${teamInfo?.currentTeam?.id}/datarooms`;
|
||||
|
||||
const body = useTemplate
|
||||
? {
|
||||
type: dataroomType,
|
||||
// Name will be taken from template
|
||||
}
|
||||
: { name: dataroomName.trim() };
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
);
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const { message } = await response.json();
|
||||
@@ -83,10 +319,21 @@ export function AddDataroomModal({
|
||||
|
||||
const { dataroom } = await response.json();
|
||||
|
||||
analytics.capture("Dataroom Created", { dataroomName: dataroomName });
|
||||
analytics.capture(
|
||||
useTemplate ? "Dataroom Generated" : "Dataroom Created",
|
||||
{
|
||||
dataroomName: dataroomName,
|
||||
...(useTemplate && { dataroomType: dataroomType }),
|
||||
},
|
||||
);
|
||||
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
|
||||
toast.success("Dataroom successfully created! 🎉");
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`);
|
||||
toast.success(
|
||||
useTemplate
|
||||
? "Dataroom successfully created! 🎉"
|
||||
: "Dataroom successfully created! 🎉",
|
||||
);
|
||||
router.push(`/datarooms/${dataroom.id}/documents`);
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
@@ -116,38 +363,498 @@ export function AddDataroomModal({
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setOpen(false);
|
||||
setDataroomName("");
|
||||
setActiveTab("create");
|
||||
setDataroomType("");
|
||||
setAiDescription("");
|
||||
setGeneratedFolders(null);
|
||||
setShowPreview(false);
|
||||
setSelectedFolderPaths(new Set());
|
||||
setEditingFolderPath(null);
|
||||
setEditingFolderName("");
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
if (openModal && setOpenModal) setOpenModal(false);
|
||||
};
|
||||
|
||||
// Generate a unique path for each folder
|
||||
const generateFolderPath = (folder: any, parentPath: string = ""): string => {
|
||||
const currentPath = parentPath ? `${parentPath}/${folder.name}` : folder.name;
|
||||
return currentPath;
|
||||
};
|
||||
|
||||
// Update folder name in the folder structure
|
||||
const updateFolderName = (
|
||||
folders: any[],
|
||||
targetPath: string,
|
||||
newName: string,
|
||||
currentPath: string = "",
|
||||
): any[] => {
|
||||
return folders.map((folder) => {
|
||||
const folderPath = generateFolderPath(folder, currentPath);
|
||||
|
||||
if (folderPath === targetPath) {
|
||||
// Update this folder's name
|
||||
return { ...folder, name: newName };
|
||||
}
|
||||
|
||||
// Recursively update subfolders
|
||||
if (folder.subfolders && folder.subfolders.length > 0) {
|
||||
return {
|
||||
...folder,
|
||||
subfolders: updateFolderName(
|
||||
folder.subfolders,
|
||||
targetPath,
|
||||
newName,
|
||||
folderPath,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return folder;
|
||||
});
|
||||
};
|
||||
|
||||
// Handle folder name edit
|
||||
const handleFolderNameEdit = (path: string, currentName: string) => {
|
||||
setEditingFolderPath(path);
|
||||
setEditingFolderName(currentName);
|
||||
};
|
||||
|
||||
// Save edited folder name
|
||||
const saveFolderName = () => {
|
||||
if (!editingFolderPath || !generatedFolders) return;
|
||||
|
||||
if (editingFolderName.trim()) {
|
||||
const updatedFolders = updateFolderName(
|
||||
generatedFolders,
|
||||
editingFolderPath,
|
||||
editingFolderName.trim(),
|
||||
);
|
||||
setGeneratedFolders(updatedFolders);
|
||||
|
||||
// Update selected paths if needed (recalculate paths)
|
||||
initializeFolderSelection(updatedFolders);
|
||||
}
|
||||
|
||||
setEditingFolderPath(null);
|
||||
setEditingFolderName("");
|
||||
};
|
||||
|
||||
// Cancel editing
|
||||
const cancelFolderNameEdit = () => {
|
||||
setEditingFolderPath(null);
|
||||
setEditingFolderName("");
|
||||
};
|
||||
|
||||
// Toggle folder selection
|
||||
const toggleFolderSelection = (path: string, folder: any) => {
|
||||
const newSelected = new Set(selectedFolderPaths);
|
||||
|
||||
if (newSelected.has(path)) {
|
||||
// If unchecking, remove this folder and all its subfolders
|
||||
newSelected.delete(path);
|
||||
const removeSubfolders = (f: any, currentPath: string) => {
|
||||
if (f.subfolders) {
|
||||
f.subfolders.forEach((sub: any) => {
|
||||
const subPath = generateFolderPath(sub, currentPath);
|
||||
newSelected.delete(subPath);
|
||||
removeSubfolders(sub, subPath);
|
||||
});
|
||||
}
|
||||
};
|
||||
removeSubfolders(folder, path);
|
||||
} else {
|
||||
// If checking, add this folder
|
||||
newSelected.add(path);
|
||||
}
|
||||
|
||||
setSelectedFolderPaths(newSelected);
|
||||
};
|
||||
|
||||
// Filter folders based on selection
|
||||
const filterSelectedFolders = (folders: any[], parentPath: string = ""): any[] => {
|
||||
return folders
|
||||
.map((folder) => {
|
||||
const currentPath = generateFolderPath(folder, parentPath);
|
||||
const isSelected = selectedFolderPaths.has(currentPath);
|
||||
|
||||
if (!isSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filteredSubfolders = folder.subfolders
|
||||
? filterSelectedFolders(folder.subfolders, currentPath)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...folder,
|
||||
subfolders: filteredSubfolders && filteredSubfolders.length > 0
|
||||
? filteredSubfolders
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
.filter((folder) => folder !== null);
|
||||
};
|
||||
|
||||
// Initialize all folders as selected when preview is shown
|
||||
const initializeFolderSelection = (folders: any[], parentPath: string = "") => {
|
||||
const paths = new Set<string>();
|
||||
const collectPaths = (f: any[], currentPath: string = "") => {
|
||||
f.forEach((folder) => {
|
||||
const folderPath = generateFolderPath(folder, currentPath);
|
||||
paths.add(folderPath);
|
||||
if (folder.subfolders) {
|
||||
collectPaths(folder.subfolders, folderPath);
|
||||
}
|
||||
});
|
||||
};
|
||||
collectPaths(folders);
|
||||
setSelectedFolderPaths(paths);
|
||||
};
|
||||
|
||||
const renderFolderPreview = (
|
||||
folders: any[],
|
||||
indent = 0,
|
||||
parentPath: string = "",
|
||||
) => {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{folders.map((folder, index) => {
|
||||
const currentPath = generateFolderPath(folder, parentPath);
|
||||
const isSelected = selectedFolderPaths.has(currentPath);
|
||||
|
||||
return (
|
||||
<div key={`${parentPath}-${index}`} className="pl-4">
|
||||
<div className="flex items-center gap-2 py-1">
|
||||
<div
|
||||
className="h-4 w-4"
|
||||
style={{ marginLeft: `${indent * 16}px` }}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleFolderSelection(currentPath, folder)}
|
||||
id={`folder-${currentPath}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`folder-${currentPath}`}
|
||||
className="flex items-center gap-2 cursor-pointer flex-1"
|
||||
>
|
||||
<FolderIcon className="h-4 w-4 text-gray-500" />
|
||||
{editingFolderPath === currentPath ? (
|
||||
<Input
|
||||
value={editingFolderName}
|
||||
onChange={(e) => setEditingFolderName(e.target.value)}
|
||||
onBlur={saveFolderName}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveFolderName();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancelFolderNameEdit();
|
||||
}
|
||||
}}
|
||||
className="h-6 text-sm px-2 py-1"
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm hover:bg-gray-100 dark:hover:bg-gray-800 px-1 py-0.5 rounded cursor-text"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFolderNameEdit(currentPath, folder.name);
|
||||
}}
|
||||
title="Click to edit folder name"
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
{folder.subfolders && folder.subfolders.length > 0 && (
|
||||
<div className="ml-4">
|
||||
{renderFolderPreview(folder.subfolders, indent + 1, currentPath)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader className="text-start">
|
||||
<DialogTitle>Create dataroom</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start creating a dataroom with a name.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Label htmlFor="dataroom-name" className="opacity-80">
|
||||
Dataroom Name
|
||||
</Label>
|
||||
<Input
|
||||
id="dataroom-name"
|
||||
placeholder="ACME Aquisition"
|
||||
className="mb-4 mt-1 w-full"
|
||||
onChange={(e) => setDataroomName(e.target.value)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="h-9 w-full" loading={loading}>
|
||||
Add new dataroom
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<DialogContent className="border-none bg-transparent text-foreground shadow-none sm:max-w-[575px] [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">Create Dataroom</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Create a new dataroom or generate one with pre-configured folders
|
||||
</DialogDescription>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value);
|
||||
// Reset AI-related state when switching tabs
|
||||
if (value !== "ai") {
|
||||
setAiDescription("");
|
||||
setGeneratedFolders(null);
|
||||
setShowPreview(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="create">Create from scratch</TabsTrigger>
|
||||
<TabsTrigger value="generate">Create from template</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="ai"
|
||||
className={`flex items-center gap-2 ${
|
||||
activeTab === "ai"
|
||||
? "text-orange-500 data-[state=active]:text-orange-500"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<Sparkles
|
||||
className={`h-4 w-4 ${
|
||||
activeTab === "ai" ? "text-orange-500" : "text-current"
|
||||
}`}
|
||||
/>
|
||||
Generate with AI
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="create">
|
||||
<Card className="relative outline-none focus:outline-none">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle>Create dataroom</CardTitle>
|
||||
<CardDescription>
|
||||
Start creating a dataroom with a name.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col space-y-4 outline-none"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dataroom-name-create">
|
||||
Dataroom Name{" "}
|
||||
<span className="text-black dark:text-white">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="dataroom-name-create"
|
||||
placeholder="ACME Acquisition"
|
||||
value={dataroomName}
|
||||
onChange={(e) => setDataroomName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
Add new dataroom
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="generate">
|
||||
<Card className="relative outline-none focus:outline-none">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle>Create dataroom</CardTitle>
|
||||
<CardDescription>
|
||||
Create a dataroom with pre-configured folder structure.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col space-y-4 outline-none"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Select Template{" "}
|
||||
<span className="text-black dark:text-white">*</span>
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 divide-x divide-y divide-border overflow-hidden rounded-md border ">
|
||||
{TEMPLATES.map((template) => {
|
||||
const Icon = template.icon;
|
||||
const isSelected = dataroomType === template.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
type="button"
|
||||
onClick={() => setDataroomType(template.id)}
|
||||
className={`relative flex min-h-[120px] flex-col items-center justify-center space-y-3 overflow-hidden p-4 transition-colors ${
|
||||
isSelected
|
||||
? "bg-gray-200 dark:bg-gray-800"
|
||||
: "hover:bg-gray-100 hover:dark:bg-gray-800"
|
||||
}`}
|
||||
>
|
||||
<Icon className="pointer-events-none h-auto w-10 text-foreground" />
|
||||
<p className="text-sm">{template.name}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loading}
|
||||
disabled={!dataroomType}
|
||||
>
|
||||
Create new dataroom
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ai">
|
||||
<Card className="relative outline-none focus:outline-none">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
<CardHeader className="space-y-3">
|
||||
<CardTitle>Generate dataroom with AI</CardTitle>
|
||||
<CardDescription>
|
||||
AI will create a unique and proven data room structure for your use case.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!showPreview ? (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ai-description">
|
||||
Describe your dataroom in details{" "}
|
||||
<span className="text-black dark:text-white">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="ai-description"
|
||||
placeholder="A data room for a Series B fundraising round for a AI startup called 'Acme AI'. Create advanced data room with IP information and financials."
|
||||
value={aiDescription}
|
||||
onChange={(e) => setAiDescription(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
loading={aiGenerating}
|
||||
onClick={handleGenerateFolders}
|
||||
disabled={!aiDescription.trim() || aiGenerating}
|
||||
>
|
||||
Generate data room structure
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col space-y-4 outline-none"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="dataroom-name-ai">
|
||||
Dataroom Name{" "}
|
||||
<span className="text-black dark:text-white">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="dataroom-name-ai"
|
||||
placeholder="AI Generated Data Room"
|
||||
value={dataroomName}
|
||||
onChange={(e) => setDataroomName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Generated Folder Structure{" "}
|
||||
<span className="text-xs text-muted-foreground font-normal">
|
||||
(Select folders to include)
|
||||
</span>
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={
|
||||
generatedFolders &&
|
||||
selectedFolderPaths.size > 0 &&
|
||||
generatedFolders.every((folder) => {
|
||||
const path = generateFolderPath(folder);
|
||||
return selectedFolderPaths.has(path);
|
||||
})
|
||||
? true
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked && generatedFolders) {
|
||||
initializeFolderSelection(generatedFolders);
|
||||
} else {
|
||||
setSelectedFolderPaths(new Set());
|
||||
}
|
||||
}}
|
||||
id="select-all"
|
||||
/>
|
||||
<label
|
||||
htmlFor="select-all"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Select/Deselect All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto rounded-md border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
{generatedFolders && renderFolderPreview(generatedFolders)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowPreview(false);
|
||||
setGeneratedFolders(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
loading={loading}
|
||||
>
|
||||
Create Dataroom
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
useDataroom,
|
||||
useDataroomFolderWithParents,
|
||||
} from "@/lib/swr/use-dataroom";
|
||||
import {
|
||||
HIERARCHICAL_DISPLAY_STYLE,
|
||||
useHierarchicalDisplayName,
|
||||
} from "@/lib/utils/hierarchical-display";
|
||||
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -25,6 +29,70 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
import { TruncatedBreadcrumbLink } from "../layouts/breadcrumb";
|
||||
|
||||
const BreadcrumbFolderItem = ({
|
||||
folder,
|
||||
dataroomId,
|
||||
isLast,
|
||||
}: {
|
||||
folder: any;
|
||||
dataroomId: string;
|
||||
isLast: boolean;
|
||||
}) => {
|
||||
const displayName = useHierarchicalDisplayName(
|
||||
folder.name,
|
||||
folder.hierarchicalIndex,
|
||||
);
|
||||
|
||||
if (isLast) {
|
||||
return (
|
||||
<BreadcrumbPage
|
||||
className="max-w-[200px] truncate"
|
||||
style={HIERARCHICAL_DISPLAY_STYLE}
|
||||
>
|
||||
{displayName}
|
||||
</BreadcrumbPage>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/documents${folder.path}`}
|
||||
className="max-w-[200px] truncate"
|
||||
style={HIERARCHICAL_DISPLAY_STYLE}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
);
|
||||
};
|
||||
|
||||
const BreadcrumbDropdownItem = ({
|
||||
folder,
|
||||
dataroomId,
|
||||
}: {
|
||||
folder: any;
|
||||
dataroomId: string;
|
||||
}) => {
|
||||
const displayName = useHierarchicalDisplayName(
|
||||
folder.name,
|
||||
folder.hierarchicalIndex,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/documents${folder.path}`}
|
||||
className="w-full"
|
||||
style={HIERARCHICAL_DISPLAY_STYLE}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
function BreadcrumbComponentBase({
|
||||
name,
|
||||
dataroomId,
|
||||
@@ -69,54 +137,41 @@ function BreadcrumbComponentBase({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{folders.slice(0, -2).map((folder, index) => (
|
||||
<DropdownMenuItem key={index}>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/documents${folder.path}`}
|
||||
className="w-full"
|
||||
>
|
||||
{folder.name}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<BreadcrumbDropdownItem
|
||||
key={index}
|
||||
folder={folder}
|
||||
dataroomId={dataroomId}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/documents${folders[folders.length - 2].path}`}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{folders[folders.length - 2].name}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
<BreadcrumbFolderItem
|
||||
folder={folders[folders.length - 2]}
|
||||
dataroomId={dataroomId}
|
||||
isLast={false}
|
||||
/>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage className="max-w-[200px] truncate">
|
||||
{folders[folders.length - 1].name}
|
||||
</BreadcrumbPage>
|
||||
<BreadcrumbFolderItem
|
||||
folder={folders[folders.length - 1]}
|
||||
dataroomId={dataroomId}
|
||||
isLast={true}
|
||||
/>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
) : (
|
||||
folders?.map((folder, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<BreadcrumbItem>
|
||||
{index === folders.length - 1 ? (
|
||||
<BreadcrumbPage className="max-w-[200px] truncate">
|
||||
{folder.name}
|
||||
</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/documents${folder.path}`}
|
||||
className="max-w-[200px] truncate"
|
||||
>
|
||||
{folder.name}
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
<BreadcrumbFolderItem
|
||||
folder={folder}
|
||||
dataroomId={dataroomId}
|
||||
isLast={index === folders.length - 1}
|
||||
/>
|
||||
</BreadcrumbItem>
|
||||
{index < folders.length - 1 && <BreadcrumbSeparator />}
|
||||
</React.Fragment>
|
||||
@@ -141,4 +196,4 @@ const BreadcrumbComponent = () => {
|
||||
return MemoizedBreadcrumbComponent;
|
||||
};
|
||||
|
||||
export { BreadcrumbComponent };
|
||||
export { BreadcrumbComponent };
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
import { TagColorProps } from "@/lib/types";
|
||||
|
||||
import TagBadge from "@/components/links/link-sheet/tags/tag-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type DataroomTag = {
|
||||
tag: {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type DataroomWithDetails = {
|
||||
id: string;
|
||||
name: string;
|
||||
internalName?: string | null;
|
||||
_count: {
|
||||
documents: number;
|
||||
views: number;
|
||||
};
|
||||
activeLinkCount: number;
|
||||
lastViewedAt: Date | null;
|
||||
createdAt: Date;
|
||||
tags?: DataroomTag[];
|
||||
};
|
||||
|
||||
interface DataroomCardProps {
|
||||
dataroom: DataroomWithDetails;
|
||||
}
|
||||
|
||||
export default function DataroomCard({ dataroom }: DataroomCardProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const isActive = dataroom.activeLinkCount > 0;
|
||||
const activeLinkCount = dataroom.activeLinkCount;
|
||||
const lastViewedAt = dataroom.lastViewedAt;
|
||||
const hasDocuments = dataroom._count.documents > 0;
|
||||
|
||||
const handleButtonClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!hasDocuments) {
|
||||
// If no documents, go to documents tab
|
||||
router.push(`/datarooms/${dataroom.id}/documents`);
|
||||
} else if (!isActive) {
|
||||
// If has documents but inactive, go to permissions/share
|
||||
router.push(`/datarooms/${dataroom.id}/permissions`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="group relative overflow-hidden duration-500 hover:border-primary/50">
|
||||
<Link href={`/datarooms/${dataroom.id}/documents`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="truncate text-lg">
|
||||
{dataroom.internalName || dataroom.name}
|
||||
</CardTitle>
|
||||
{dataroom.internalName && (
|
||||
<p className="truncate text-sm text-muted-foreground mt-0.5">
|
||||
{dataroom.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
isActive ? "bg-green-500" : "bg-gray-400"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
isActive ? "text-green-600" : "text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{isActive
|
||||
? "Dataroom has active links"
|
||||
: "No active links"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{dataroom.tags && dataroom.tags.length > 0 && (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
{dataroom.tags.slice(0, 3).map((tagItem) => (
|
||||
<TagBadge
|
||||
key={tagItem.tag.id}
|
||||
name={tagItem.tag.name}
|
||||
color={tagItem.tag.color as TagColorProps}
|
||||
withIcon
|
||||
/>
|
||||
))}
|
||||
{dataroom.tags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{dataroom.tags.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Stats List */}
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">Documents</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{dataroom._count.documents}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">Views</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{dataroom._count.views}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">Active Links</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{activeLinkCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t pt-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{lastViewedAt ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<span>
|
||||
Viewed{" "}
|
||||
{formatDistanceToNow(new Date(lastViewedAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Last viewed</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span>No views yet</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{!hasDocuments ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<span>Add Documents</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Add documents to dataroom</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
!isActive && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={handleButtonClick}
|
||||
>
|
||||
<span>Share</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Share dataroom</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,10 @@ import { type DataroomFolderDocument } from "@/lib/swr/use-dataroom";
|
||||
import { type DocumentWithLinksAndLinkCountAndViewCount } from "@/lib/types";
|
||||
import { cn, nFormatter, timeAgo } from "@/lib/utils";
|
||||
import { fileIcon } from "@/lib/utils/get-file-icon";
|
||||
import {
|
||||
HIERARCHICAL_DISPLAY_STYLE,
|
||||
useHierarchicalDisplayName,
|
||||
} from "@/lib/utils/hierarchical-display";
|
||||
|
||||
import BarChart from "@/components/shared/icons/bar-chart";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -61,6 +65,12 @@ export default function DataroomDocumentCard({
|
||||
theme === "light" || (theme === "system" && systemTheme === "light");
|
||||
const router = useRouter();
|
||||
|
||||
// Get hierarchical display name
|
||||
const displayName = useHierarchicalDisplayName(
|
||||
dataroomDocument.document.name,
|
||||
dataroomDocument.hierarchicalIndex,
|
||||
);
|
||||
|
||||
const [isFirstClick, setIsFirstClick] = useState<boolean>(false);
|
||||
const [menuOpen, setMenuOpen] = useState<boolean>(false);
|
||||
const [moveFolderOpen, setMoveFolderOpen] = useState<boolean>(false);
|
||||
@@ -127,7 +137,11 @@ export default function DataroomDocumentCard({
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
).then(() => {
|
||||
).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || "Failed to remove document");
|
||||
}
|
||||
mutate(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/${dataroomId}${endpoint}`,
|
||||
null,
|
||||
@@ -145,7 +159,7 @@ export default function DataroomDocumentCard({
|
||||
{
|
||||
loading: "Removing document...",
|
||||
success: "Document removed successfully.",
|
||||
error: "Failed to remove document. Try again.",
|
||||
error: (err) => err.message || "Failed to remove document. Try again.",
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -205,8 +219,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">
|
||||
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
export const DataroomHeader = ({
|
||||
title,
|
||||
description,
|
||||
internalName,
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
internalName?: string | null;
|
||||
actions?: React.ReactNode[];
|
||||
}) => {
|
||||
const [isLinkSheetOpen, setIsLinkSheetOpen] = useState<boolean>(false);
|
||||
@@ -39,9 +41,14 @@ export const DataroomHeader = ({
|
||||
<section className="mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex min-h-10 items-center gap-x-2 space-y-1">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
|
||||
{title}
|
||||
</h1>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground sm:text-2xl">
|
||||
{internalName || title}
|
||||
</h1>
|
||||
{internalName && (
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
)}
|
||||
</div>
|
||||
{dataroom?.enableChangeNotifications ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -618,6 +618,7 @@ export function DataroomItemsList({
|
||||
setUploads={setUploads}
|
||||
setRejectedFiles={setRejectedFiles}
|
||||
dataroomId={dataroomId}
|
||||
dataroomName={dataroom?.name}
|
||||
>
|
||||
{isMobile ? (
|
||||
<div>
|
||||
@@ -631,7 +632,7 @@ export function DataroomItemsList({
|
||||
</Portal>
|
||||
{mixedItems.length === 0 && (
|
||||
<div className="flex h-full justify-center">
|
||||
<EmptyDocuments />
|
||||
<EmptyDocuments isDataroom={true} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -700,7 +701,7 @@ export function DataroomItemsList({
|
||||
|
||||
{mixedItems.length === 0 && (
|
||||
<div className="flex h-full justify-center">
|
||||
<EmptyDocuments />
|
||||
<EmptyDocuments isDataroom={true} />
|
||||
</div>
|
||||
)}
|
||||
</DndContext>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,8 @@ import { E164Number } from "libphonenumber-js";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -21,8 +23,6 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
import { UpgradePlanModal } from "../billing/upgrade-plan-modal";
|
||||
import { PhoneInput } from "../ui/phone-input";
|
||||
import {
|
||||
@@ -55,6 +55,26 @@ export function DataroomTrialModal({
|
||||
const teamInfo = useTeam();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
// Helper function to convert industry to proper dataroom name
|
||||
const getDataroomName = (industryValue: string) => {
|
||||
const industryNames: Record<string, string> = {
|
||||
"finance-banking": "Finance and Banking Data Room",
|
||||
legal: "Legal Data Room",
|
||||
"real-estate": "Real Estate Data Room",
|
||||
technology: "Technology Data Room",
|
||||
pharmaceuticals: "Pharmaceuticals Data Room",
|
||||
energy: "Energy Data Room",
|
||||
manufacturing: "Manufacturing Data Room",
|
||||
healthcare: "Healthcare Data Room",
|
||||
consulting: "Consulting and Professional Services Data Room",
|
||||
government: "Government and Public Sector Data Room",
|
||||
entertainment: "Entertainment and Media Data Room",
|
||||
other: "Data Room",
|
||||
};
|
||||
|
||||
return industryNames[industryValue] || "Data Room";
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: any) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -66,6 +86,8 @@ export function DataroomTrialModal({
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const dataroomName = getDataroomName(industry);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/datarooms/trial`,
|
||||
@@ -75,7 +97,7 @@ export function DataroomTrialModal({
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Dataroom #1",
|
||||
name: dataroomName,
|
||||
fullName: name,
|
||||
companyName,
|
||||
industry,
|
||||
@@ -93,13 +115,16 @@ export function DataroomTrialModal({
|
||||
}
|
||||
|
||||
analytics.capture("Dataroom Trial Created", {
|
||||
dataroomName: "Dataroom #1",
|
||||
dataroomName: dataroomName,
|
||||
industry,
|
||||
companySize,
|
||||
});
|
||||
toast.success("Dataroom successfully created! 🎉");
|
||||
|
||||
await mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
|
||||
await Promise.all([
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),
|
||||
]);
|
||||
router.push("/datarooms");
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
|
||||
@@ -3,6 +3,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ExportJob } from "@/lib/redis-job-store";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface ExportStatus {
|
||||
status: string;
|
||||
progress?: string;
|
||||
@@ -31,6 +35,9 @@ export function ExportVisitsModal({
|
||||
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [viewCount, setViewCount] = useState<number | null>(null);
|
||||
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
|
||||
const [showNewExport, setShowNewExport] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const exportStartedRef = useRef<boolean>(false);
|
||||
|
||||
@@ -43,18 +50,45 @@ export function ExportVisitsModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startExport = useCallback(async () => {
|
||||
// Fetch existing exports when modal opens
|
||||
useEffect(() => {
|
||||
const fetchExistingExports = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowModal(true);
|
||||
|
||||
const endpoint = groupId
|
||||
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
|
||||
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
|
||||
|
||||
const response = await fetch(endpoint, { method: "GET" });
|
||||
|
||||
if (response.ok) {
|
||||
const exports = await response.json();
|
||||
setExistingExports(exports);
|
||||
} else {
|
||||
console.error("Failed to fetch existing exports");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching existing exports:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchExistingExports();
|
||||
}, [teamId, dataroomId, groupId]);
|
||||
|
||||
const startNewExport = useCallback(async () => {
|
||||
// Prevent double triggering
|
||||
if (exportStartedRef.current) {
|
||||
console.warn("Export already started, skipping duplicate request");
|
||||
return;
|
||||
}
|
||||
exportStartedRef.current = true;
|
||||
setShowNewExport(true);
|
||||
|
||||
try {
|
||||
// Show modal immediately
|
||||
setShowModal(true);
|
||||
|
||||
// Get view count first
|
||||
try {
|
||||
const viewCountResponse = await fetch(
|
||||
@@ -72,10 +106,11 @@ export function ExportVisitsModal({
|
||||
}
|
||||
|
||||
// Trigger the background export job
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : ""}/export-visits`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const endpoint = groupId
|
||||
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
|
||||
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
|
||||
|
||||
const response = await fetch(endpoint, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
@@ -174,11 +209,6 @@ export function ExportVisitsModal({
|
||||
}
|
||||
}, [teamId, dataroomId, groupId, dataroomName, groupName]);
|
||||
|
||||
// Start export immediately when component mounts
|
||||
useEffect(() => {
|
||||
startExport();
|
||||
}, [startExport]);
|
||||
|
||||
// Send export via email
|
||||
const sendExportEmail = async () => {
|
||||
if (!exportStatus?.exportId || !session?.user?.email) return;
|
||||
@@ -201,6 +231,72 @@ export function ExportVisitsModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel export
|
||||
const cancelExport = async () => {
|
||||
if (!exportStatus?.exportId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/export-jobs/${exportStatus.exportId}`,
|
||||
{ method: "PATCH" },
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Export cancelled successfully");
|
||||
handleClose();
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
toast.error(errorData.error || "Failed to cancel export");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cancelling export:", error);
|
||||
toast.error("Failed to cancel export");
|
||||
}
|
||||
};
|
||||
|
||||
// Download existing export
|
||||
const downloadExport = async (exportId: string, resourceName: string) => {
|
||||
try {
|
||||
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
|
||||
const link = window.document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`${resourceName || dataroomName}_${groupName ? `${groupName}_` : ""}visits_${new Date().toISOString().split("T")[0]}.csv`,
|
||||
);
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.display = "none";
|
||||
|
||||
window.document.body.appendChild(link);
|
||||
link.click();
|
||||
window.document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error("Failed to download export");
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// Get status badge color
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "COMPLETED":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
|
||||
case "PROCESSING":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
|
||||
case "PENDING":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
|
||||
case "FAILED":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close - cleanup and call parent onClose
|
||||
const handleClose = () => {
|
||||
if (pollIntervalRef.current) {
|
||||
@@ -209,6 +305,10 @@ export function ExportVisitsModal({
|
||||
}
|
||||
setShowModal(false);
|
||||
setExportStatus(null);
|
||||
setShowNewExport(false);
|
||||
setExistingExports([]); // Reset existing exports
|
||||
setViewCount(null); // Reset view count
|
||||
setLoading(true); // Reset loading state
|
||||
exportStartedRef.current = false; // Reset for potential reuse
|
||||
onClose();
|
||||
};
|
||||
@@ -224,9 +324,9 @@ export function ExportVisitsModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Export Visits</h3>
|
||||
<h3 className="text-lg font-semibold">Export Views</h3>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
@@ -247,48 +347,141 @@ export function ExportVisitsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{exportStatus?.progress || "Processing export..."}
|
||||
</span>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
Exporting visits for: {displayName}
|
||||
</div>
|
||||
|
||||
{viewCount !== null && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
|
||||
) : showNewExport ? (
|
||||
// Show export progress
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{exportStatus?.progress || "Processing export..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewCount !== null && viewCount > 10 && session?.user?.email && (
|
||||
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
|
||||
<p className="mb-2 font-medium text-muted-foreground">
|
||||
Large export detected ({viewCount} visits)
|
||||
</p>
|
||||
<p className="mb-3 text-muted-foreground">
|
||||
This export may take several minutes. We recommend getting it
|
||||
emailed to you when ready.
|
||||
</p>
|
||||
<div className="text-sm text-gray-600">
|
||||
Exporting views 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} 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: {displayName}
|
||||
</div>
|
||||
|
||||
{existingExports.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Recent Exports
|
||||
</h4>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{existingExports.map((exportJob) => (
|
||||
<div
|
||||
key={exportJob.id}
|
||||
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
|
||||
>
|
||||
{exportJob.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(exportJob.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{exportJob.groupId && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Group: {exportJob.groupId}
|
||||
</div>
|
||||
)}
|
||||
{exportJob.error && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
{exportJob.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{exportJob.status === "COMPLETED" && exportJob.result && (
|
||||
<button
|
||||
onClick={() =>
|
||||
downloadExport(
|
||||
exportJob.id,
|
||||
exportJob.resourceName || dataroomName,
|
||||
)
|
||||
}
|
||||
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
No previous exports found
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button
|
||||
onClick={sendExportEmail}
|
||||
onClick={startNewExport}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
|
||||
>
|
||||
Email to {session.user.email}
|
||||
Start New Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!viewCount || viewCount <= 10) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Your export will be ready shortly...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
useDataroomFoldersTree,
|
||||
} from "@/lib/swr/use-dataroom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
HIERARCHICAL_DISPLAY_STYLE,
|
||||
useHierarchicalDisplayName,
|
||||
} from "@/lib/utils/hierarchical-display";
|
||||
import { sortByIndexThenName } from "@/lib/utils/sort-items-by-index-name";
|
||||
|
||||
import { FileTree } from "@/components/ui/nextra-filetree";
|
||||
@@ -19,6 +23,31 @@ type MixedItem =
|
||||
| (DataroomFolderWithDocuments & { itemType: "folder" })
|
||||
| (DataroomFolderWithDocuments["documents"][0] & { itemType: "document" });
|
||||
|
||||
const DocumentFileItem = memo(
|
||||
({
|
||||
document,
|
||||
router,
|
||||
}: {
|
||||
document: DataroomFolderWithDocuments["documents"][0] & {
|
||||
itemType: "document";
|
||||
};
|
||||
router: any;
|
||||
}) => {
|
||||
const documentDisplayName = useHierarchicalDisplayName(
|
||||
document.document.name,
|
||||
document.hierarchicalIndex,
|
||||
);
|
||||
|
||||
return (
|
||||
<FileTree.File
|
||||
name={documentDisplayName}
|
||||
onToggle={() => router.push(`/documents/${document.document.id}`)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
DocumentFileItem.displayName = "DocumentFileItem";
|
||||
|
||||
const FolderComponent = memo(
|
||||
({
|
||||
dataroomId,
|
||||
@@ -29,6 +58,12 @@ const FolderComponent = memo(
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
// Get hierarchical display names
|
||||
const folderDisplayName = useHierarchicalDisplayName(
|
||||
folder.name,
|
||||
folder.hierarchicalIndex,
|
||||
);
|
||||
|
||||
const mixedItems = useMemo(() => {
|
||||
const allItems: MixedItem[] = [
|
||||
...(folder.childFolders || []).map((f) => ({
|
||||
@@ -57,11 +92,7 @@ const FolderComponent = memo(
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<FileTree.File
|
||||
key={item.id}
|
||||
name={item.document.name}
|
||||
onToggle={() => router.push(`/documents/${item.document.id}`)}
|
||||
/>
|
||||
<DocumentFileItem key={item.id} document={item} router={router} />
|
||||
);
|
||||
}
|
||||
}),
|
||||
@@ -87,7 +118,7 @@ const FolderComponent = memo(
|
||||
|
||||
return (
|
||||
<FileTree.Folder
|
||||
name={folder.name}
|
||||
name={folderDisplayName}
|
||||
key={folder.id}
|
||||
active={isActive}
|
||||
childActive={isChildActive}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { useUninvitedMembers } from "@/ee/features/dataroom-invitations/lib/swr/use-dataroom-invitations";
|
||||
import {
|
||||
MailCheckIcon,
|
||||
MoreHorizontalIcon,
|
||||
PlusCircleIcon,
|
||||
SendIcon,
|
||||
UserXIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
|
||||
import { useDataroom } from "@/lib/swr/use-dataroom";
|
||||
import { useDataroomGroup } from "@/lib/swr/use-dataroom-groups";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -31,8 +37,10 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { TimestampTooltip } from "@/components/ui/timestamp-tooltip";
|
||||
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
|
||||
|
||||
import { InviteViewersModal } from "../../../ee/features/dataroom-invitations/components/invite-viewers-modal";
|
||||
import { AddGroupMemberModal } from "./add-member-modal";
|
||||
|
||||
export default function GroupMemberTable({
|
||||
@@ -44,13 +52,27 @@ export default function GroupMemberTable({
|
||||
}) {
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const { dataroom } = useDataroom();
|
||||
const { viewerGroupMembers, viewerGroupDomains, viewerGroupAllowAll } =
|
||||
useDataroomGroup();
|
||||
const {
|
||||
uninvitedCount,
|
||||
uninvitedEmails,
|
||||
mutate: mutateUninvited,
|
||||
} = useUninvitedMembers(dataroomId, groupId);
|
||||
const { isFeatureEnabled } = useFeatureFlags();
|
||||
|
||||
const [addMembersOpen, setAddMembersOpen] = useState<boolean>(false);
|
||||
const [inviteOpen, setInviteOpen] = useState<boolean>(false);
|
||||
|
||||
const groupKey = teamId
|
||||
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`
|
||||
: null;
|
||||
|
||||
const handleToggleAllowAll = async () => {
|
||||
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
|
||||
const key =
|
||||
groupKey ??
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`,
|
||||
{
|
||||
@@ -78,7 +100,9 @@ export default function GroupMemberTable({
|
||||
};
|
||||
|
||||
const handleRemoveDomain = async (domain: string) => {
|
||||
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
|
||||
const key =
|
||||
groupKey ??
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`,
|
||||
{
|
||||
@@ -103,7 +127,9 @@ export default function GroupMemberTable({
|
||||
|
||||
const handleRemoveMember = async (id: string) => {
|
||||
// mutate the data optimistically
|
||||
const key = `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
|
||||
const key =
|
||||
groupKey ??
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}`;
|
||||
mutate(
|
||||
key,
|
||||
(currentData: { members: any[] } | undefined) => {
|
||||
@@ -135,131 +161,159 @@ export default function GroupMemberTable({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="l">
|
||||
<div className="mb-2 flex items-center justify-between md:mb-4">
|
||||
<h2>All members</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={viewerGroupAllowAll}
|
||||
onCheckedChange={handleToggleAllowAll}
|
||||
aria-label="Allow all emails"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Allow all emails
|
||||
</span>
|
||||
<>
|
||||
<div className="l">
|
||||
<div className="mb-2 flex items-center justify-between md:mb-4">
|
||||
<h2>All members</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={viewerGroupAllowAll}
|
||||
onCheckedChange={handleToggleAllowAll}
|
||||
aria-label="Allow all emails"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Allow all emails
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAddMembersOpen(true)}
|
||||
className="h-8 gap-1"
|
||||
disabled={viewerGroupAllowAll}
|
||||
>
|
||||
<PlusCircleIcon className="h-4 w-4" />
|
||||
Add members
|
||||
</Button>
|
||||
{isFeatureEnabled("dataroomInvitations") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setInviteOpen(true)}
|
||||
className="relative h-8 gap-1"
|
||||
>
|
||||
<SendIcon className="h-4 w-4" />
|
||||
Share invite
|
||||
{uninvitedCount > 0 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-2 h-5 rounded-full px-2 text-xs font-medium"
|
||||
>
|
||||
{uninvitedCount}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAddMembersOpen(true)}
|
||||
className="h-8 gap-1"
|
||||
disabled={viewerGroupAllowAll}
|
||||
>
|
||||
<PlusCircleIcon className="h-4 w-4" />
|
||||
Add members
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AddGroupMemberModal
|
||||
dataroomId={dataroomId}
|
||||
groupId={groupId}
|
||||
open={addMembersOpen}
|
||||
setOpen={setAddMembersOpen}
|
||||
/>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
|
||||
<TableHead>Name</TableHead>
|
||||
{/* <TableHead>Visit Duration</TableHead> */}
|
||||
{/* <TableHead>Last Viewed Document</TableHead> */}
|
||||
{/* <TableHead>Last Viewed</TableHead> */}
|
||||
<TableHead className="text-center"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{viewerGroupAllowAll ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<div className="flex h-40 w-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All email addresses are allowed to access this group
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="*:whitespace-nowrap *:font-medium hover:bg-transparent">
|
||||
<TableHead>Name</TableHead>
|
||||
{/* <TableHead>Visit Duration</TableHead> */}
|
||||
{/* <TableHead>Last Viewed Document</TableHead> */}
|
||||
{/* <TableHead>Last Viewed</TableHead> */}
|
||||
<TableHead className="text-center"></TableHead>
|
||||
</TableRow>
|
||||
) : viewerGroupMembers.length === 0 &&
|
||||
viewerGroupDomains.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<div className="flex h-40 w-full items-center justify-center">
|
||||
<p>No visits yet. Try sharing a link.</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{viewerGroupDomains.length > 0 &&
|
||||
viewerGroupDomains.map((domain) => (
|
||||
<TableRow key={domain} className="group/row">
|
||||
<TableCell className="">
|
||||
<div className="flex items-center overflow-visible sm:space-x-3">
|
||||
<VisitorAvatar viewerEmail={"@"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="focus:outline-none">
|
||||
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{domain}
|
||||
</p>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{viewerGroupAllowAll ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<div className="flex h-40 w-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All email addresses are allowed to access this group
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : viewerGroupMembers.length === 0 &&
|
||||
viewerGroupDomains.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2}>
|
||||
<div className="flex h-40 w-full items-center justify-center">
|
||||
<p>No views yet. Try sharing a link.</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{viewerGroupDomains.length > 0 &&
|
||||
viewerGroupDomains.map((domain) => (
|
||||
<TableRow key={domain} className="group/row">
|
||||
<TableCell className="">
|
||||
<div className="flex items-center overflow-visible sm:space-x-3">
|
||||
<VisitorAvatar viewerEmail={"@"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="focus:outline-none">
|
||||
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{domain}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
<TableCell className="p-0 text-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
|
||||
onClick={() => handleRemoveDomain(domain)}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Remove Domain
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{viewerGroupMembers ? (
|
||||
viewerGroupMembers.map((viewer) => (
|
||||
<TableRow key={viewer.id} className="group/row">
|
||||
{/* Name */}
|
||||
<TableCell className="">
|
||||
<div className="flex items-center overflow-visible sm:space-x-3">
|
||||
<VisitorAvatar viewerEmail={viewer.viewer.email} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="focus:outline-none">
|
||||
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{viewer.viewer.email}
|
||||
</p>
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
<TableCell className="p-0 text-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
|
||||
onClick={() => handleRemoveDomain(domain)}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
Remove Domain
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{viewerGroupMembers ? (
|
||||
viewerGroupMembers.map((viewer) => {
|
||||
const latestInvitation = viewer.viewer.invitations?.[0];
|
||||
return (
|
||||
<TableRow key={viewer.id} className="group/row">
|
||||
{/* Name */}
|
||||
<TableCell className="">
|
||||
<div className="flex items-center overflow-visible sm:space-x-3">
|
||||
<VisitorAvatar
|
||||
viewerEmail={viewer.viewer.email}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="focus:outline-none">
|
||||
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{viewer.viewer.email}
|
||||
{latestInvitation && (
|
||||
<TimestampTooltip
|
||||
timestamp={latestInvitation.sentAt}
|
||||
side="right"
|
||||
rows={["local", "utc"]}
|
||||
>
|
||||
<MailCheckIcon className="h-4 w-4 text-blue-500 hover:text-blue-600" />
|
||||
</TimestampTooltip>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Last Viewed */}
|
||||
{/* <TableCell className="text-sm text-muted-foreground">
|
||||
</TableCell>
|
||||
{/* Last Viewed */}
|
||||
{/* <TableCell className="text-sm text-muted-foreground">
|
||||
<time
|
||||
dateTime={new Date(viewer.viewer.updatedAt).toISOString()}
|
||||
>
|
||||
@@ -268,48 +322,77 @@ export default function GroupMemberTable({
|
||||
: "-"}
|
||||
</time>
|
||||
</TableCell> */}
|
||||
{/* Actions */}
|
||||
<TableCell className="p-0 text-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
|
||||
onClick={() => handleRemoveMember(viewer.id)}
|
||||
>
|
||||
<UserXIcon className="h-4 w-4" />
|
||||
Remove Member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Actions */}
|
||||
<TableCell className="p-0 text-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 group-hover/row:ring-1 group-hover/row:ring-gray-200 group-hover/row:dark:ring-gray-700"
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontalIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="gap-x-2 text-destructive focus:bg-destructive focus:text-destructive-foreground"
|
||||
onClick={() => handleRemoveMember(viewer.id)}
|
||||
>
|
||||
<UserXIcon className="h-4 w-4" />
|
||||
Remove Member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="min-w-[100px]">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell className="min-w-[100px]">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-24" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddGroupMemberModal
|
||||
dataroomId={dataroomId}
|
||||
groupId={groupId}
|
||||
open={addMembersOpen}
|
||||
setOpen={setAddMembersOpen}
|
||||
/>
|
||||
{isFeatureEnabled("dataroomInvitations") && (
|
||||
<InviteViewersModal
|
||||
open={inviteOpen}
|
||||
setOpen={(next) => {
|
||||
setInviteOpen(next);
|
||||
if (!next) {
|
||||
mutateUninvited();
|
||||
}
|
||||
}}
|
||||
dataroomId={dataroomId}
|
||||
dataroomName={dataroom?.name ?? "this dataroom"}
|
||||
groupId={groupId}
|
||||
defaultEmails={uninvitedEmails}
|
||||
onSuccess={() => {
|
||||
if (groupKey) {
|
||||
mutate(groupKey);
|
||||
}
|
||||
mutateUninvited();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { ItemType, ViewerGroupAccessControls } from "@prisma/client";
|
||||
@@ -19,15 +19,22 @@ import {
|
||||
EyeOffIcon,
|
||||
File,
|
||||
Folder,
|
||||
HomeIcon,
|
||||
} from "lucide-react";
|
||||
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";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -38,10 +45,39 @@ 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,
|
||||
);
|
||||
|
||||
const isRoot = item.id === "__dataroom_root__";
|
||||
|
||||
return (
|
||||
<div className="flex items-center text-foreground">
|
||||
{isRoot ? (
|
||||
<HomeIcon className="mr-2 h-5 w-5" />
|
||||
) : 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;
|
||||
@@ -63,38 +99,35 @@ type ColumnExtra = {
|
||||
};
|
||||
|
||||
const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
return row.getCanExpand() ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={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>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const isRoot = row.original.id === "__dataroom_root__";
|
||||
return (
|
||||
<div className="flex items-center text-foreground">
|
||||
{isRoot ? (
|
||||
<div className="h-6 w-6 shrink-0" />
|
||||
) : row.getCanExpand() ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={row.getToggleExpandedHandler()}
|
||||
className="mr-1 h-6 w-6 shrink-0 p-0"
|
||||
disabled={isRoot}
|
||||
>
|
||||
{row.getIsExpanded() ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="mr-1 h-6 w-6 shrink-0" />
|
||||
)}
|
||||
<PermissionItemName item={row.original} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
@@ -160,7 +193,7 @@ const createColumns = (extra: ColumnExtra): ColumnDef<FileOrFolder>[] => [
|
||||
},
|
||||
];
|
||||
|
||||
// Update the buildTree function to include permissions
|
||||
// Build tree function to include permissions
|
||||
const buildTree = (
|
||||
items: any[],
|
||||
permissions: ViewerGroupAccessControls[],
|
||||
@@ -168,9 +201,13 @@ const buildTree = (
|
||||
): FileOrFolder[] => {
|
||||
const getPermissions = (id: string) => {
|
||||
const permission = permissions.find((p) => p.itemId === id);
|
||||
|
||||
// If we have permission data loaded, use it. Otherwise default to true for consistency.
|
||||
const hasPermissionData = permissions.length > 0;
|
||||
|
||||
return {
|
||||
view: permission?.canView ?? false,
|
||||
download: permission?.canDownload ?? false,
|
||||
view: permission ? permission.canView : hasPermissionData ? false : true,
|
||||
download: permission ? permission.canDownload : false,
|
||||
partialView: false,
|
||||
partialDownload: false,
|
||||
};
|
||||
@@ -189,6 +226,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,
|
||||
}));
|
||||
@@ -197,41 +235,40 @@ const buildTree = (
|
||||
|
||||
const folderPermissions = getPermissions(folder.id);
|
||||
|
||||
// Calculate view and partialView
|
||||
const someSubItemViewable = allSubItems.some(
|
||||
(subItem) => subItem.permissions.view,
|
||||
);
|
||||
const allSubItemsViewable = allSubItems.every(
|
||||
(subItem) => subItem.permissions.view,
|
||||
);
|
||||
const someSubItemDownloadable = allSubItems.some(
|
||||
(subItem) => subItem.permissions.download,
|
||||
);
|
||||
const allSubItemsDownloadable = allSubItems.every(
|
||||
(subItem) => subItem.permissions.download,
|
||||
);
|
||||
// Calculate view and partialView for folders
|
||||
let viewStatus = folderPermissions.view;
|
||||
let partialView = false;
|
||||
let downloadStatus = folderPermissions.download;
|
||||
let partialDownload = false;
|
||||
|
||||
folderPermissions.view = folderPermissions.view || someSubItemViewable;
|
||||
folderPermissions.partialView =
|
||||
someSubItemViewable && !allSubItemsViewable;
|
||||
folderPermissions.download =
|
||||
folderPermissions.download || someSubItemDownloadable;
|
||||
folderPermissions.partialDownload =
|
||||
someSubItemDownloadable && !allSubItemsDownloadable;
|
||||
if (allSubItems.length > 0) {
|
||||
const viewableItems = allSubItems.filter(
|
||||
(item) => item.permissions.view,
|
||||
);
|
||||
const downloadableItems = allSubItems.filter(
|
||||
(item) => item.permissions.download,
|
||||
);
|
||||
|
||||
// Propagate view/download permission up if any subitem has view/download permission
|
||||
folderPermissions.view =
|
||||
folderPermissions.view ||
|
||||
allSubItems.some((subItem) => subItem.permissions.view);
|
||||
folderPermissions.download =
|
||||
folderPermissions.download ||
|
||||
allSubItems.some((subItem) => subItem.permissions.download);
|
||||
viewStatus = viewableItems.length > 0;
|
||||
partialView =
|
||||
viewableItems.length > 0 && viewableItems.length < allSubItems.length;
|
||||
downloadStatus = downloadableItems.length > 0;
|
||||
partialDownload =
|
||||
downloadableItems.length > 0 &&
|
||||
downloadableItems.length < allSubItems.length;
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
hierarchicalIndex: folder.hierarchicalIndex,
|
||||
subItems: allSubItems,
|
||||
permissions: folderPermissions,
|
||||
permissions: {
|
||||
view: viewStatus,
|
||||
download: downloadStatus,
|
||||
partialView,
|
||||
partialDownload,
|
||||
},
|
||||
itemType: ItemType.DATAROOM_FOLDER,
|
||||
});
|
||||
});
|
||||
@@ -248,6 +285,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,
|
||||
});
|
||||
@@ -256,6 +294,57 @@ const buildTree = (
|
||||
return result;
|
||||
};
|
||||
|
||||
// Build tree with virtual root folder
|
||||
const buildTreeWithRoot = (
|
||||
items: any[],
|
||||
permissions: ViewerGroupAccessControls[],
|
||||
dataroomName: string = "Dataroom Home",
|
||||
): FileOrFolder[] => {
|
||||
// Get all items (folders and root documents)
|
||||
const allItems = buildTree(items, permissions, null);
|
||||
|
||||
// Calculate overall permissions for the virtual root
|
||||
const calculateRootPermissions = (items: FileOrFolder[]) => {
|
||||
const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {
|
||||
return items.reduce((acc, item) => {
|
||||
acc.push(item);
|
||||
if (item.subItems) {
|
||||
acc.push(...flattenItems(item.subItems));
|
||||
}
|
||||
return acc;
|
||||
}, [] as FileOrFolder[]);
|
||||
};
|
||||
|
||||
const allFlatItems = flattenItems(items);
|
||||
const viewableItems = allFlatItems.filter((item) => item.permissions.view);
|
||||
const downloadableItems = allFlatItems.filter(
|
||||
(item) => item.permissions.download,
|
||||
);
|
||||
|
||||
return {
|
||||
view: viewableItems.length > 0,
|
||||
download: downloadableItems.length > 0,
|
||||
partialView:
|
||||
viewableItems.length > 0 && viewableItems.length < allFlatItems.length,
|
||||
partialDownload:
|
||||
downloadableItems.length > 0 &&
|
||||
downloadableItems.length < allFlatItems.length,
|
||||
};
|
||||
};
|
||||
|
||||
const rootPermissions = calculateRootPermissions(allItems);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "__dataroom_root__",
|
||||
name: dataroomName,
|
||||
subItems: allItems,
|
||||
permissions: rootPermissions,
|
||||
itemType: ItemType.DATAROOM_FOLDER,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function ExpandableTable({
|
||||
dataroomId,
|
||||
groupId,
|
||||
@@ -275,8 +364,17 @@ export default function ExpandableTable({
|
||||
const [pendingChanges, setPendingChanges] = useState<ItemPermission>({});
|
||||
const [debouncedPendingChanges] = useDebounce(pendingChanges, 2000);
|
||||
|
||||
// Use ref to access current data without dependency
|
||||
const dataRef = useRef<FileOrFolder[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
const updatePermissions = useCallback(
|
||||
(id: string, newPermissions: string[]) => {
|
||||
const isRoot = id === "__dataroom_root__";
|
||||
|
||||
const findItemAndParents = (
|
||||
items: FileOrFolder[],
|
||||
targetId: string,
|
||||
@@ -297,7 +395,7 @@ export default function ExpandableTable({
|
||||
return null;
|
||||
};
|
||||
|
||||
const result = findItemAndParents(data, id);
|
||||
const result = findItemAndParents(dataRef.current, id);
|
||||
if (!result) return;
|
||||
|
||||
const { item, parents } = result;
|
||||
@@ -324,6 +422,80 @@ export default function ExpandableTable({
|
||||
updatedPermissions.view = true;
|
||||
}
|
||||
|
||||
// Handle root-level permissions (affects all items)
|
||||
if (isRoot) {
|
||||
setData((prevData) => {
|
||||
const updateAllItems = (items: FileOrFolder[]): FileOrFolder[] => {
|
||||
return items.map((currentItem) => {
|
||||
if (currentItem.id === "__dataroom_root__") {
|
||||
return {
|
||||
...currentItem,
|
||||
permissions: {
|
||||
view: updatedPermissions.view,
|
||||
download: updatedPermissions.download,
|
||||
partialView: false,
|
||||
partialDownload: false,
|
||||
},
|
||||
subItems: currentItem.subItems
|
||||
? updateAllItems(currentItem.subItems)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedItem = {
|
||||
...currentItem,
|
||||
permissions: {
|
||||
view: updatedPermissions.view,
|
||||
download: updatedPermissions.download,
|
||||
partialView: false,
|
||||
partialDownload: false,
|
||||
},
|
||||
subItems: currentItem.subItems
|
||||
? updateAllItems(currentItem.subItems)
|
||||
: undefined,
|
||||
};
|
||||
|
||||
return updatedItem;
|
||||
});
|
||||
};
|
||||
|
||||
return updateAllItems(prevData);
|
||||
});
|
||||
|
||||
// Collect changes for all items
|
||||
const collectAllChanges = (items: FileOrFolder[]): ItemPermission => {
|
||||
let changes: ItemPermission = {};
|
||||
|
||||
const processItems = (items: FileOrFolder[]) => {
|
||||
items.forEach((item) => {
|
||||
// Don't save the virtual __dataroom_root__ item to database
|
||||
if (item.id !== "__dataroom_root__") {
|
||||
changes[item.id] = {
|
||||
view: updatedPermissions.view,
|
||||
download: updatedPermissions.download,
|
||||
itemType: item.itemType,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.subItems) {
|
||||
processItems(item.subItems);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processItems(items);
|
||||
return changes;
|
||||
};
|
||||
|
||||
const rootChanges = collectAllChanges(dataRef.current);
|
||||
setPendingChanges((prev) => ({
|
||||
...prev,
|
||||
...rootChanges,
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setData((prevData) => {
|
||||
const updateItemInTree = (items: FileOrFolder[]): FileOrFolder[] => {
|
||||
return items.map((currentItem) => {
|
||||
@@ -334,6 +506,7 @@ export default function ExpandableTable({
|
||||
view: updatedPermissions.view,
|
||||
download: updatedPermissions.download,
|
||||
partialView: false,
|
||||
partialDownload: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -392,6 +565,52 @@ export default function ExpandableTable({
|
||||
parent: FileOrFolder,
|
||||
subItems: FileOrFolder[],
|
||||
): FileOrFolder => {
|
||||
const isParentRoot = parent.id === "__dataroom_root__";
|
||||
|
||||
// For root folder, calculate based on all descendants
|
||||
const calculatePermissions = (items: FileOrFolder[]) => {
|
||||
const flattenItems = (items: FileOrFolder[]): FileOrFolder[] => {
|
||||
return items.reduce((acc, item) => {
|
||||
if (item.id !== "__dataroom_root__") {
|
||||
acc.push(item);
|
||||
}
|
||||
if (item.subItems) {
|
||||
acc.push(...flattenItems(item.subItems));
|
||||
}
|
||||
return acc;
|
||||
}, [] as FileOrFolder[]);
|
||||
};
|
||||
|
||||
const allItems = flattenItems(items);
|
||||
const viewableItems = allItems.filter(
|
||||
(item) => item.permissions.view,
|
||||
);
|
||||
const downloadableItems = allItems.filter(
|
||||
(item) => item.permissions.download,
|
||||
);
|
||||
|
||||
return {
|
||||
view: viewableItems.length > 0,
|
||||
partialView:
|
||||
viewableItems.length > 0 &&
|
||||
viewableItems.length < allItems.length,
|
||||
download: downloadableItems.length > 0,
|
||||
partialDownload:
|
||||
downloadableItems.length > 0 &&
|
||||
downloadableItems.length < allItems.length,
|
||||
};
|
||||
};
|
||||
|
||||
if (isParentRoot) {
|
||||
const rootPermissions = calculatePermissions(subItems);
|
||||
return {
|
||||
...parent,
|
||||
permissions: rootPermissions,
|
||||
subItems,
|
||||
};
|
||||
}
|
||||
|
||||
// For regular folders
|
||||
const someSubItemViewable = subItems.some(
|
||||
(subItem) => subItem.permissions.view,
|
||||
);
|
||||
@@ -421,18 +640,21 @@ export default function ExpandableTable({
|
||||
return updateItemInTree(prevData);
|
||||
});
|
||||
|
||||
// database changes
|
||||
// Collect changes for database update
|
||||
const collectChanges = (
|
||||
item: FileOrFolder,
|
||||
parents: FileOrFolder[],
|
||||
): ItemPermission => {
|
||||
let changes: ItemPermission = {
|
||||
[item.id]: {
|
||||
let changes: ItemPermission = {};
|
||||
|
||||
// Don't save the virtual __dataroom_root__ item to database
|
||||
if (item.id !== "__dataroom_root__") {
|
||||
changes[item.id] = {
|
||||
view: updatedPermissions.view,
|
||||
download: updatedPermissions.download,
|
||||
itemType: item.itemType,
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Collect changes for all subitems
|
||||
const collectSubItemChanges = (
|
||||
@@ -440,11 +662,14 @@ export default function ExpandableTable({
|
||||
) => {
|
||||
if (!subItems) return;
|
||||
subItems.forEach((subItem) => {
|
||||
changes[subItem.id] = {
|
||||
view: updatedPermissions.view,
|
||||
download: updatedPermissions.download,
|
||||
itemType: subItem.itemType,
|
||||
};
|
||||
// Don't save the virtual __dataroom_root__ item to database
|
||||
if (subItem.id !== "__dataroom_root__") {
|
||||
changes[subItem.id] = {
|
||||
view: updatedPermissions.view,
|
||||
download: updatedPermissions.download,
|
||||
itemType: subItem.itemType,
|
||||
};
|
||||
}
|
||||
collectSubItemChanges(subItem.subItems);
|
||||
});
|
||||
};
|
||||
@@ -452,35 +677,40 @@ export default function ExpandableTable({
|
||||
collectSubItemChanges(item.subItems);
|
||||
|
||||
// Ensure all parent folders are viewable if the item is being set to viewable
|
||||
// and downloadable if the item is being set to downloadable
|
||||
if (updatedPermissions.view || updatedPermissions.download) {
|
||||
parents.forEach((parent) => {
|
||||
changes[parent.id] = {
|
||||
view: true, // Always enable view for parent folders if child is viewable
|
||||
download: updatedPermissions.download
|
||||
? true
|
||||
: parent.permissions.download, // Always enable download for parent folders if child is downloadable
|
||||
itemType: parent.itemType,
|
||||
};
|
||||
// Don't save the virtual __dataroom_root__ item to database
|
||||
if (parent.id !== "__dataroom_root__") {
|
||||
changes[parent.id] = {
|
||||
view: true,
|
||||
download: updatedPermissions.download
|
||||
? true
|
||||
: parent.permissions.download,
|
||||
itemType: parent.itemType,
|
||||
};
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If turning off view, recalculate parent permissions
|
||||
[...parents].reverse().forEach((parent) => {
|
||||
const otherChildren =
|
||||
parent.subItems?.filter((subItem) => subItem.id !== item.id) ||
|
||||
[];
|
||||
const someSubItemViewable = otherChildren.some(
|
||||
(subItem) => subItem.permissions.view,
|
||||
);
|
||||
const someSubItemDownloadable = otherChildren.some(
|
||||
(subItem) => subItem.permissions.download,
|
||||
);
|
||||
// Don't save the virtual __dataroom_root__ item to database
|
||||
if (parent.id !== "__dataroom_root__") {
|
||||
const otherChildren =
|
||||
parent.subItems?.filter((subItem) => subItem.id !== item.id) ||
|
||||
[];
|
||||
const someSubItemViewable = otherChildren.some(
|
||||
(subItem) => subItem.permissions.view,
|
||||
);
|
||||
const someSubItemDownloadable = otherChildren.some(
|
||||
(subItem) => subItem.permissions.download,
|
||||
);
|
||||
|
||||
changes[parent.id] = {
|
||||
view: someSubItemViewable,
|
||||
download: someSubItemDownloadable,
|
||||
itemType: parent.itemType,
|
||||
};
|
||||
changes[parent.id] = {
|
||||
view: someSubItemViewable,
|
||||
download: someSubItemDownloadable,
|
||||
itemType: parent.itemType,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -492,12 +722,12 @@ export default function ExpandableTable({
|
||||
...collectChanges(item, parents),
|
||||
}));
|
||||
},
|
||||
[data],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (folders && !loading) {
|
||||
const treeData = buildTree(folders, permissions);
|
||||
const treeData = buildTreeWithRoot(folders, permissions, "Dataroom Home");
|
||||
setData(treeData);
|
||||
}
|
||||
}, [folders, loading, permissions]);
|
||||
@@ -554,6 +784,18 @@ export default function ExpandableTable({
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getSubRows: (row) => row.subItems,
|
||||
initialState: {
|
||||
expanded: {
|
||||
"0": true, // Always expand the root folder (first row)
|
||||
},
|
||||
},
|
||||
getRowCanExpand: (row) => {
|
||||
// Root folder is always expanded and cannot be collapsed
|
||||
if (row.original.id === "__dataroom_root__") {
|
||||
return true;
|
||||
}
|
||||
return (row.subRows?.length ?? 0) > 0;
|
||||
},
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
@@ -582,24 +824,35 @@ export default function ExpandableTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
paddingLeft: `${row.depth * 1.25 + 1}rem`,
|
||||
}}
|
||||
className="py-2 last:flex last:justify-end"
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const isRoot = row.original.id === "__dataroom_root__";
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(isRoot && "bg-blue-50/50 dark:bg-blue-950/50")}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, index) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={
|
||||
index === 0
|
||||
? {
|
||||
paddingLeft: `${row.depth * 1.25}rem`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className="py-2 last:flex last:justify-end"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useState } from "react";
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
|
||||
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
|
||||
|
||||
import { SidebarFolderTreeSelection } from "@/components/datarooms/folders";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -16,9 +19,6 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import { moveDataroomDocumentToFolder } from "@/lib/documents/move-dataroom-documents";
|
||||
import { moveDataroomFolderToFolder } from "@/lib/documents/move-dataroom-folders";
|
||||
|
||||
import { TSelectedFolder } from "../documents/move-folder-modal";
|
||||
|
||||
export function MoveToDataroomFolderModal({
|
||||
@@ -110,7 +110,7 @@ export function MoveToDataroomFolderModal({
|
||||
<DialogDescription>Move your item to a folder.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form>
|
||||
<div className="mb-2 max-h-[75vh] overflow-y-scroll">
|
||||
<div className="mb-2 max-h-[75vh] overflow-x-hidden overflow-y-scroll">
|
||||
<SidebarFolderTreeSelection
|
||||
dataroomId={dataroomId}
|
||||
selectedFolder={selectedFolder}
|
||||
@@ -133,7 +133,9 @@ export function MoveToDataroomFolderModal({
|
||||
) : (
|
||||
<>
|
||||
Move to{" "}
|
||||
<span className="font-medium">{selectedFolder.name}</span>
|
||||
<span className="max-w-[200px] truncate font-medium">
|
||||
{selectedFolder.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { fetcher } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface BulkDownloadSettingsProps {
|
||||
dataroomId: string;
|
||||
}
|
||||
|
||||
export default function BulkDownloadSettings({
|
||||
dataroomId,
|
||||
}: BulkDownloadSettingsProps) {
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
|
||||
const { data: dataroomData, mutate: mutateDataroom } = useSWR<{
|
||||
id: string;
|
||||
name: string;
|
||||
pId: string;
|
||||
allowBulkDownload: boolean;
|
||||
}>(
|
||||
dataroomId ? `/api/teams/${teamId}/datarooms/${dataroomId}` : null,
|
||||
fetcher,
|
||||
);
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleBulkDownloadToggle = async (checked: boolean) => {
|
||||
if (!dataroomId || !teamId || isUpdating) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
|
||||
toast.promise(
|
||||
fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
allowBulkDownload: checked,
|
||||
}),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to update bulk download settings");
|
||||
}
|
||||
await mutateDataroom();
|
||||
}),
|
||||
{
|
||||
loading: "Updating bulk download settings...",
|
||||
success: "Bulk download settings updated successfully",
|
||||
error: "Failed to update bulk download settings",
|
||||
},
|
||||
);
|
||||
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">Dataroom Download</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="bulk-download-toggle"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Allow bulk download of entire dataroom
|
||||
</Label>
|
||||
</div>
|
||||
<Switch
|
||||
id="bulk-download-toggle"
|
||||
checked={dataroomData?.allowBulkDownload ?? true}
|
||||
onCheckedChange={handleBulkDownloadToggle}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-6">
|
||||
<p className="text-sm text-muted-foreground transition-colors">
|
||||
When enabled, visitors can download all dataroom contents as a single
|
||||
ZIP file. Individual document and folder downloads will still work
|
||||
regardless of this setting.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Dispatch, SetStateAction, useMemo, useState } from "react";
|
||||
|
||||
import { PlanEnum } from "@/ee/stripe/constants";
|
||||
import { CircleHelpIcon, Tag } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { useTags } from "@/lib/swr/use-tags";
|
||||
import { TagColorProps } from "@/lib/types";
|
||||
|
||||
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { MultiSelect } from "@/components/ui/multi-select-v2";
|
||||
import { BadgeTooltip } from "@/components/ui/tooltip";
|
||||
|
||||
type TagProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
function getTagOption(tag: TagProps) {
|
||||
return {
|
||||
value: tag.id,
|
||||
label: tag.name,
|
||||
icon: (
|
||||
<Tag
|
||||
size={20}
|
||||
className={`rounded-sm border border-gray-200 bg-${tag.color}-100 p-1 dark:text-primary-foreground`}
|
||||
/>
|
||||
),
|
||||
meta: { color: tag.color, description: tag.description },
|
||||
};
|
||||
}
|
||||
|
||||
interface DataroomTagSectionProps {
|
||||
dataroomId: string;
|
||||
teamId: string;
|
||||
initialTags?: {
|
||||
tag: {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export default function DataroomTagSection({
|
||||
dataroomId,
|
||||
teamId,
|
||||
initialTags,
|
||||
}: DataroomTagSectionProps) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>(
|
||||
initialTags?.map((t) => t.tag.id) || [],
|
||||
);
|
||||
const [showUpgradeModal, setShowUpgradeModal] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const { isFree } = usePlan();
|
||||
|
||||
const {
|
||||
tagCount,
|
||||
tags: availableTags,
|
||||
loading: loadingTags,
|
||||
} = useTags({
|
||||
query: {
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
const options = useMemo(
|
||||
() => availableTags?.map((tag) => getTagOption(tag)),
|
||||
[availableTags],
|
||||
);
|
||||
|
||||
// Check if there are unsaved changes
|
||||
const hasChanges = useMemo(() => {
|
||||
const initialTagIds = initialTags?.map((t) => t.tag.id) || [];
|
||||
if (selectedValues.length !== initialTagIds.length) return true;
|
||||
return !selectedValues.every((id) => initialTagIds.includes(id));
|
||||
}, [selectedValues, initialTags]);
|
||||
|
||||
// Callback to handle value change
|
||||
const handleValueChange = (value: string[]) => {
|
||||
setSelectedValues(value);
|
||||
};
|
||||
|
||||
// Save tags to API
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/teams/${teamId}/datarooms/${dataroomId}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ tags: selectedValues }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const { error } = await res.json();
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
mutate(`/api/teams/${teamId}/datarooms`),
|
||||
mutate(`/api/teams/${teamId}/datarooms?simple=true`),
|
||||
mutate(`/api/teams/${teamId}/datarooms/${dataroomId}`),
|
||||
]);
|
||||
|
||||
toast.success("Successfully updated tags!");
|
||||
} catch (error) {
|
||||
toast.error("Failed to update tags");
|
||||
console.error("Error updating tags:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createTag = async (tag: string) => {
|
||||
if (isFree && tagCount && tagCount >= 5) {
|
||||
setShowUpgradeModal(true);
|
||||
toast.error("You have reached the maximum number of tags.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/teams/${teamId}/tags`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: tag }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const { error } = await res.json();
|
||||
toast.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
const newTag = await res.json();
|
||||
await mutate(
|
||||
`/api/teams/${teamId}/tags?${new URLSearchParams({
|
||||
sortBy: "createdAt",
|
||||
sortOrder: "desc",
|
||||
includeLinksCount: false,
|
||||
} as Record<string, any>).toString()}`,
|
||||
);
|
||||
|
||||
// Add to selected values - same pattern as link-sheet
|
||||
setSelectedValues([...selectedValues, newTag.id]);
|
||||
setIsPopoverOpen(false);
|
||||
toast.success("Successfully created tag!");
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
<CardDescription>
|
||||
Organize your dataroom by adding tags for better categorization and
|
||||
filtering
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="dataroom-tags">Tags</Label>
|
||||
<BadgeTooltip content="Organize datarooms by tags to easily filter and find them">
|
||||
<CircleHelpIcon className="h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground" />
|
||||
</BadgeTooltip>
|
||||
</div>
|
||||
<Link
|
||||
href={`/settings/tags`}
|
||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
Manage
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<MultiSelect
|
||||
loading={loadingTags}
|
||||
options={options ?? []}
|
||||
value={selectedValues}
|
||||
setIsPopoverOpen={setIsPopoverOpen}
|
||||
isPopoverOpen={isPopoverOpen}
|
||||
onValueChange={handleValueChange}
|
||||
placeholder="Select tags..."
|
||||
maxCount={3}
|
||||
searchPlaceholder="Search or add tags..."
|
||||
onCreate={(search) => createTag(search)}
|
||||
popoverClassName="sm:w-[400px]"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
loading={isSaving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
size="default"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3">
|
||||
<p className="text-sm text-muted-foreground transition-colors">
|
||||
Tags help you organize and filter datarooms across your workspace.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{showUpgradeModal && (
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger="create_tag"
|
||||
open={showUpgradeModal}
|
||||
setOpen={setShowUpgradeModal}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,10 @@ function DeleteDataroomModal({
|
||||
dataroomName: dataroomName,
|
||||
dataroomId: dataroomId,
|
||||
});
|
||||
await mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`);
|
||||
await Promise.all([
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms`),
|
||||
mutate(`/api/teams/${teamInfo?.currentTeam?.id}/datarooms?simple=true`),
|
||||
]);
|
||||
router.push("/datarooms");
|
||||
resolve(null);
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import useDatarooms from "@/lib/swr/use-datarooms";
|
||||
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
|
||||
|
||||
export default function DuplicateDataroom({
|
||||
dataroomId,
|
||||
@@ -33,7 +33,7 @@ export default function DuplicateDataroom({
|
||||
const [planModalOpen, setPlanModalOpen] = useState<boolean>(false);
|
||||
const { limits } = useLimits();
|
||||
const { isBusiness, isDatarooms, isDataroomsPlus, isTrial } = usePlan();
|
||||
const { datarooms: dataRooms } = useDatarooms();
|
||||
const { datarooms: dataRooms } = useDataroomsSimple();
|
||||
const numDatarooms = dataRooms?.length ?? 0;
|
||||
const limitDatarooms = limits?.datarooms ?? 1;
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function DuplicateDataroom({
|
||||
loading: "Copying dataroom...",
|
||||
success: (dataroom) => {
|
||||
mutate(`/api/teams/${teamId}/datarooms`);
|
||||
mutate(`/api/teams/${teamId}/datarooms?simple=true`);
|
||||
router.push(`/datarooms/${dataroom.id}/documents`);
|
||||
return "Dataroom copied successfully.";
|
||||
},
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function NotificationSettings({
|
||||
<CardTitle>
|
||||
Notifications{" "}
|
||||
{!isDataroomsPlus && !features?.roomChangeNotifications ? (
|
||||
<PlanBadge plan="datarooms-plus" />
|
||||
<PlanBadge plan="data rooms plus" />
|
||||
) : null}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import { BellIcon, CogIcon, ShieldIcon } from "lucide-react";
|
||||
import { BellIcon, CogIcon, DownloadIcon, ShieldIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -39,6 +39,18 @@ export default function SettingsTabs({ dataroomId }: SettingsTabsProps) {
|
||||
<BellIcon className="h-4 w-4" />
|
||||
Notifications
|
||||
</Link>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/settings/downloads`}
|
||||
className={cn(
|
||||
"flex items-center gap-x-2 rounded-md p-2 text-primary hover:bg-muted",
|
||||
{
|
||||
"bg-muted font-medium": router.pathname.includes("downloads"),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
Downloads
|
||||
</Link>
|
||||
<Link
|
||||
href={`/datarooms/${dataroomId}/settings/file-permissions`}
|
||||
className={cn(
|
||||
|
||||
@@ -155,10 +155,10 @@ export function DataroomSortableList({
|
||||
mutate(`${baseKey}/folders`);
|
||||
mutate(`${baseKey}/folders?include_documents=true`);
|
||||
setIsReordering(false);
|
||||
toast.success("Index saved successfully");
|
||||
toast.success("Folder order saved successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to save new order:", error);
|
||||
toast.error("Failed to save index");
|
||||
toast.error("Failed to save order");
|
||||
// Optionally, show an error message to the user
|
||||
} finally {
|
||||
setIsReordering(false);
|
||||
@@ -247,7 +247,7 @@ export function DataroomSortableList({
|
||||
className="gap-x-1"
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
Save index
|
||||
Save order
|
||||
</Button>
|
||||
</Portal>
|
||||
<DeleteFolderModal />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import ErrorPage from "next/error";
|
||||
|
||||
import { useDataroomStats } from "@/lib/swr/use-dataroom-stats";
|
||||
|
||||
import StatsElement from "@/components/documents/stats-element";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { useDataroomStats } from "@/lib/swr/use-dataroom-stats";
|
||||
|
||||
export default function StatsCard() {
|
||||
const { stats, loading, error } = useDataroomStats();
|
||||
|
||||
@@ -30,12 +30,12 @@ export default function StatsCard() {
|
||||
|
||||
const statistics = [
|
||||
{
|
||||
name: "Number of visits",
|
||||
name: "Number of views",
|
||||
value: stats?.dataroomViews.length.toString() ?? "0",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
name: "Number of documents visits",
|
||||
name: "Number of documents views",
|
||||
value: stats?.documentViews.length.toString() ?? "0",
|
||||
active: true,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,11 @@ import { useTheme } from "next-themes";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { SUPPORTED_DOCUMENT_MIME_TYPES } from "@/lib/constants";
|
||||
import {
|
||||
FREE_PLAN_ACCEPTED_FILE_TYPES,
|
||||
FULL_PLAN_ACCEPTED_FILE_TYPES,
|
||||
SUPPORTED_DOCUMENT_MIME_TYPES,
|
||||
} from "@/lib/constants";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import useLimits from "@/lib/swr/use-limits";
|
||||
import { bytesToSize } from "@/lib/utils";
|
||||
@@ -46,49 +50,8 @@ export default function DocumentUpload({
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept:
|
||||
isFree && !isTrial
|
||||
? {
|
||||
"application/pdf": [], // ".pdf"
|
||||
"application/vnd.ms-excel": [], // ".xls"
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
||||
[], // ".xlsx"
|
||||
"text/csv": [], // ".csv"
|
||||
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
|
||||
"image/png": [], // ".png"
|
||||
"image/jpeg": [], // ".jpeg"
|
||||
"image/jpg": [], // ".jpg"
|
||||
}
|
||||
: {
|
||||
"application/pdf": [], // ".pdf"
|
||||
"application/vnd.ms-excel": [], // ".xls"
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
|
||||
[], // ".xlsx"
|
||||
"application/vnd.ms-excel.sheet.macroEnabled.12": [".xlsm"], // ".xlsm"
|
||||
"text/csv": [], // ".csv"
|
||||
"application/vnd.oasis.opendocument.spreadsheet": [], // ".ods"
|
||||
"application/vnd.ms-powerpoint": [], // ".ppt"
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||||
[], // ".pptx"
|
||||
"application/vnd.oasis.opendocument.presentation": [], // ".odp"
|
||||
"application/msword": [], // ".doc"
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
[], // ".docx"
|
||||
"application/vnd.oasis.opendocument.text": [], // ".odt"
|
||||
"image/vnd.dwg": [".dwg"], // ".dwg"
|
||||
"image/vnd.dxf": [".dxf"], // ".dxf"
|
||||
"image/png": [], // ".png"
|
||||
"image/jpeg": [], // ".jpeg"
|
||||
"image/jpg": [], // ".jpg"
|
||||
"application/zip": [], // ".zip"
|
||||
"application/x-zip-compressed": [], // ".zip"
|
||||
"video/mp4": [], // ".mp4"
|
||||
"video/quicktime": [], // ".mov"
|
||||
"video/x-msvideo": [], // ".avi"
|
||||
"video/webm": [], // ".webm"
|
||||
"video/ogg": [], // ".ogg"
|
||||
"application/vnd.google-earth.kml+xml": [".kml"], // ".kml"
|
||||
"application/vnd.google-earth.kmz": [".kmz"], // ".kmz"
|
||||
"application/vnd.ms-outlook": [".msg"], // ".msg"
|
||||
},
|
||||
? FREE_PLAN_ACCEPTED_FILE_TYPES
|
||||
: FULL_PLAN_ACCEPTED_FILE_TYPES,
|
||||
multiple: false,
|
||||
onDropAccepted: (acceptedFiles) => {
|
||||
if (acceptedFiles.length === 0) {
|
||||
@@ -228,8 +191,8 @@ export default function DocumentUpload({
|
||||
{currentFile
|
||||
? "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, *.xls, *.xlsx, *.csv, *.tsv, *.ods, *.png, *.jpeg, *.jpg`
|
||||
: `Only *.pdf, *.pptx, *.docx, *.xlsx, *.xls, *.xlsm, *.csv, *.tsv, *.ods, *.ppt, *.odp, *.doc, *.odt, *.rtf, *txt, *.dwg, *.dxf, *.png, *.jpg, *.jpeg, *.mp4, *.mov, *.avi, *.webm, *.ogg`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,13 +12,13 @@ import { useTeam } from "@/context/team-context";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/ui/modal";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
function DeleteItemsModal({
|
||||
showDeleteItemsModal,
|
||||
setShowDeleteItemsModal,
|
||||
@@ -75,9 +75,7 @@ function DeleteItemsModal({
|
||||
).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(
|
||||
`Failed to delete document ${documentId}: ${error.message}`,
|
||||
);
|
||||
throw new Error(error.message || "Failed to delete document");
|
||||
}
|
||||
analytics.capture("Document Deleted", {
|
||||
team: teamInfo?.currentTeam?.id,
|
||||
@@ -96,9 +94,7 @@ function DeleteItemsModal({
|
||||
).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(
|
||||
`Failed to delete folder ${folderId}: ${error.message}`,
|
||||
);
|
||||
throw new Error(error.message || "Failed to delete folder");
|
||||
}
|
||||
analytics.capture("Folder Deleted", {
|
||||
team: teamInfo?.currentTeam?.id,
|
||||
@@ -319,4 +315,4 @@ export function useDeleteDocumentsAndFoldersModal({
|
||||
}),
|
||||
[setShowDeleteItemsModal, DeleteItemsModalCallback],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,13 @@ export function useDeleteFolderModal(
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
),
|
||||
).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.message || "Failed to delete folder");
|
||||
}
|
||||
return res;
|
||||
}),
|
||||
{
|
||||
loading: isDataroom ? "Removing folder..." : "Deleting folder...",
|
||||
success: () => {
|
||||
@@ -54,9 +60,11 @@ export function useDeleteFolderModal(
|
||||
? "Folder removed successfully."
|
||||
: `Folder deleted successfully with ${folderToDelete?._count.documents} documents and ${folderToDelete?._count.childFolders} folders`;
|
||||
},
|
||||
error: isDataroom
|
||||
? "Failed to remove folder."
|
||||
: "Failed to delete folder. Move documents first.",
|
||||
error: (err) =>
|
||||
err.message ||
|
||||
(isDataroom
|
||||
? "Failed to remove folder."
|
||||
: "Failed to delete folder. Move documents first."),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DefaultPermissionStrategy } from "@prisma/client";
|
||||
import { parsePageId } from "notion-utils";
|
||||
import { toast } from "sonner";
|
||||
import { mutate } from "swr";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "@/lib/documents/create-document";
|
||||
import { putFile } from "@/lib/files/put-file";
|
||||
import { useDataroomPermissions } from "@/lib/hooks/use-dataroom-permissions";
|
||||
import { getNotionPageIdFromSlug } from "@/lib/notion/utils";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { useDataroom } from "@/lib/swr/use-dataroom";
|
||||
import useLimits from "@/lib/swr/use-limits";
|
||||
@@ -72,6 +74,8 @@ export function AddDocumentModal({
|
||||
const [isOpen, setIsOpen] = useState<boolean | undefined>(undefined);
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||
const [notionLink, setNotionLink] = useState<string | null>(null);
|
||||
const [webLink, setWebLink] = useState<string | null>(null);
|
||||
const [uploadMode, setUploadMode] = useState<"file" | "link">("file");
|
||||
const [showGroupPermissions, setShowGroupPermissions] = useState(false);
|
||||
const [uploadedFiles, setUploadedFiles] = useState<
|
||||
{
|
||||
@@ -82,7 +86,7 @@ export function AddDocumentModal({
|
||||
>([]);
|
||||
const teamInfo = useTeam();
|
||||
const { canAddDocuments } = useLimits();
|
||||
const { plan, isFree, isTrial } = usePlan();
|
||||
const { plan, isFree, isTrial, isPaused } = usePlan();
|
||||
const { dataroom } = useDataroom();
|
||||
const teamId = teamInfo?.currentTeam?.id as string;
|
||||
|
||||
@@ -204,6 +208,20 @@ export function AddDocumentModal({
|
||||
): Promise<void> => {
|
||||
event.preventDefault();
|
||||
|
||||
// Check if team is paused
|
||||
if (isPaused) {
|
||||
toast.error(
|
||||
"Your subscription is paused. Resume your subscription to upload documents.",
|
||||
{
|
||||
action: {
|
||||
label: "Go to Billing",
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the file is chosen
|
||||
if (!currentFile) {
|
||||
toast.error("Please select a file to upload.");
|
||||
@@ -382,22 +400,55 @@ export function AddDocumentModal({
|
||||
): Promise<void> => {
|
||||
event.preventDefault();
|
||||
|
||||
// Check if team is paused
|
||||
if (isPaused) {
|
||||
toast.error(
|
||||
"Your subscription is paused. Resume your subscription to upload documents.",
|
||||
{
|
||||
action: {
|
||||
label: "Go to Billing",
|
||||
onClick: () => router.push("/settings/billing"),
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAddDocuments) {
|
||||
toast.error("You have reached the maximum number of documents.");
|
||||
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;
|
||||
}
|
||||
@@ -503,9 +554,147 @@ export function AddDocumentModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleWebLinkUpload = async (
|
||||
event: FormEvent<HTMLFormElement>,
|
||||
): Promise<void> => {
|
||||
event.preventDefault();
|
||||
|
||||
// Check if user is on a free plan (not trial)
|
||||
if (isFree && !isTrial) {
|
||||
toast.error("Web links are available on Pro plan and above.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAddDocuments) {
|
||||
toast.error("You have reached the maximum number of documents.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the field is empty or not
|
||||
if (!webLink) {
|
||||
toast.error("Please enter a website URL to proceed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate URL format with Zod
|
||||
const urlSchema = z.string().url();
|
||||
const urlValidation = urlSchema.safeParse(webLink);
|
||||
|
||||
if (!urlValidation.success) {
|
||||
toast.error("Please enter a valid URL format.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
|
||||
// Extract domain name from URL for the document name
|
||||
let linkName = "Web Link";
|
||||
try {
|
||||
const url = new URL(webLink);
|
||||
linkName = url.hostname.replace("www.", "");
|
||||
} catch (e) {
|
||||
// Use default name if URL parsing fails
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamInfo?.currentTeam?.id}/documents`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: linkName,
|
||||
url: webLink,
|
||||
numPages: 1,
|
||||
type: "link",
|
||||
createLink: false,
|
||||
folderPathName: currentFolderPath?.join("/"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const { error } = await response.json();
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const document = await response.json();
|
||||
|
||||
if (isDataroom && dataroomId) {
|
||||
const dataroomResponse = await addDocumentToDataroom({
|
||||
documentId: document.id,
|
||||
folderPathName: currentFolderPath?.join("/"),
|
||||
});
|
||||
|
||||
if (dataroomResponse?.ok) {
|
||||
const dataroomDocument =
|
||||
(await dataroomResponse.json()) as DataroomDocument & {
|
||||
dataroom: {
|
||||
_count: { viewerGroups: number; permissionGroups: number };
|
||||
};
|
||||
};
|
||||
|
||||
await applyUnifiedPermissionsToDocument(
|
||||
document,
|
||||
dataroomDocument,
|
||||
currentFolderPath,
|
||||
);
|
||||
}
|
||||
|
||||
analytics.capture("Document Added", {
|
||||
documentId: document.id,
|
||||
name: document.name,
|
||||
numPages: document.numPages,
|
||||
path: router.asPath,
|
||||
type: "link",
|
||||
teamId: teamId,
|
||||
dataroomId: dataroomId,
|
||||
$set: {
|
||||
teamId: teamId,
|
||||
teamPlan: plan,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newVersion) {
|
||||
toast.success("Web link added. Redirecting to document page...");
|
||||
|
||||
analytics.capture("Document Added", {
|
||||
documentId: document.id,
|
||||
name: document.name,
|
||||
fileSize: null,
|
||||
path: router.asPath,
|
||||
type: "link",
|
||||
teamId: teamId,
|
||||
$set: {
|
||||
teamId: teamId,
|
||||
teamPlan: plan,
|
||||
},
|
||||
});
|
||||
|
||||
// redirect to the document page
|
||||
router.push("/documents/" + document.id);
|
||||
}
|
||||
} catch (error) {
|
||||
setUploading(false);
|
||||
toast.error("An error occurred while processing the web link.");
|
||||
console.error("An error occurred while processing the web link: ", error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearModelStates = () => {
|
||||
currentFile !== null && setCurrentFile(null);
|
||||
notionLink !== null && setNotionLink(null);
|
||||
webLink !== null && setWebLink(null);
|
||||
setUploadMode("file");
|
||||
setIsOpen(!isOpen);
|
||||
setAddDocumentModalOpen && setAddDocumentModalOpen(!isOpen);
|
||||
};
|
||||
@@ -562,7 +751,7 @@ export function AddDocumentModal({
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{newVersion ? (
|
||||
`After you upload a new version, the existing links will remain the unchanged.`
|
||||
`After you upload a new version, the existing links will remain unchanged.`
|
||||
) : (
|
||||
<span>
|
||||
After you upload the document, create a shareable link.{" "}
|
||||
@@ -584,49 +773,141 @@ export function AddDocumentModal({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<form
|
||||
encType="multipart/form-data"
|
||||
onSubmit={handleFileUpload}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<DocumentUpload
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
/>
|
||||
{uploadMode === "file" ? (
|
||||
<form
|
||||
encType="multipart/form-data"
|
||||
onSubmit={handleFileUpload}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
|
||||
<DocumentUpload
|
||||
currentFile={currentFile}
|
||||
setCurrentFile={setCurrentFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full lg:w-1/2"
|
||||
disabled={uploading || !currentFile}
|
||||
loading={uploading}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload Document"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!newVersion && (
|
||||
<div className="flex justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Want to{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
document
|
||||
.getElementById("upload-multi-files-zone")
|
||||
?.click();
|
||||
clearModelStates();
|
||||
}}
|
||||
>
|
||||
upload multiple files
|
||||
</button>{" "}
|
||||
or{" "}
|
||||
{isFree && !isTrial ? (
|
||||
<UpgradePlanModal
|
||||
clickedPlan={PlanEnum.Pro}
|
||||
trigger={"add_web_link_document"}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
|
||||
>
|
||||
share link as a document
|
||||
</button>
|
||||
</UpgradePlanModal>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setUploadMode("link");
|
||||
}}
|
||||
>
|
||||
share link as a document
|
||||
</button>
|
||||
)}
|
||||
?
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<form
|
||||
encType="multipart/form-data"
|
||||
onSubmit={handleWebLinkUpload}
|
||||
className="flex flex-col space-y-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="web-link">Website URL</Label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
name="web-link"
|
||||
id="web-link"
|
||||
placeholder="https://example.com"
|
||||
className="flex w-full rounded-md border-0 bg-background py-1.5 text-foreground shadow-sm ring-1 ring-inset ring-input placeholder:text-muted-foreground focus:ring-2 focus:ring-inset focus:ring-gray-400 sm:text-sm sm:leading-6"
|
||||
value={webLink || ""}
|
||||
onChange={(e) => setWebLink(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* <small className="text-xs text-muted-foreground">
|
||||
The page will be captured and converted to a document format.
|
||||
</small> */}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full lg:w-1/2"
|
||||
disabled={uploading || !webLink}
|
||||
loading={uploading}
|
||||
>
|
||||
{uploading ? "Saving..." : "Save Web Link"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!newVersion ? (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground underline-offset-4 transition-all hover:text-gray-800 hover:underline hover:dark:text-muted-foreground/80"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
document
|
||||
.getElementById("upload-multi-files-zone")
|
||||
?.click();
|
||||
clearModelStates();
|
||||
e.preventDefault();
|
||||
setUploadMode("file");
|
||||
}}
|
||||
>
|
||||
Want to upload multiple files?
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
Back to file upload
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full lg:w-1/2"
|
||||
disabled={uploading || !currentFile}
|
||||
loading={uploading}
|
||||
>
|
||||
{uploading ? "Uploading..." : "Upload Document"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import useDatarooms from "@/lib/swr/use-datarooms";
|
||||
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
|
||||
|
||||
import {
|
||||
Select,
|
||||
@@ -46,7 +46,7 @@ export function AddToDataroomModal({
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
|
||||
const { datarooms } = useDatarooms();
|
||||
const { datarooms } = useDataroomsSimple();
|
||||
|
||||
const handleSubmit = async (event: any) => {
|
||||
event.preventDefault();
|
||||
@@ -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>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
import useDatarooms from "@/lib/swr/use-datarooms";
|
||||
import useDataroomsSimple from "@/lib/swr/use-datarooms-simple";
|
||||
|
||||
import {
|
||||
Select,
|
||||
@@ -48,7 +48,7 @@ export function AddFolderToDataroomModal({
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
|
||||
const { datarooms } = useDatarooms();
|
||||
const { datarooms } = useDataroomsSimple();
|
||||
|
||||
const handleSubmit = async (event: any) => {
|
||||
event.preventDefault();
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
import { uploadImage } from "@/lib/utils";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form-hook";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
import { RichTextEditor } from "@/components/ui/rich-text-editor";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1, "Title is required")
|
||||
.max(100, "Title must be less than 100 characters"),
|
||||
content: z.any().optional(),
|
||||
pages: z.array(z.number()).min(1, "At least one page must be selected"),
|
||||
isVisible: z.boolean(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface AnnotationFormProps {
|
||||
documentId: string;
|
||||
teamId: string;
|
||||
numPages: number;
|
||||
annotation?: any;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AnnotationForm({
|
||||
documentId,
|
||||
teamId,
|
||||
numPages,
|
||||
annotation,
|
||||
onSuccess,
|
||||
}: AnnotationFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [editorContent, setEditorContent] = useState(
|
||||
annotation?.content || { type: "doc", content: [] },
|
||||
);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
title: annotation?.title || "",
|
||||
content: annotation?.content || null,
|
||||
pages: annotation?.pages || [],
|
||||
isVisible:
|
||||
annotation?.isVisible !== undefined ? annotation.isVisible : true,
|
||||
},
|
||||
});
|
||||
|
||||
const pageOptions = Array.from({ length: numPages }, (_, i) => i + 1);
|
||||
|
||||
const handleImageUpload = async (file: File): Promise<string> => {
|
||||
try {
|
||||
// Upload the image using the existing uploadImage utility
|
||||
const imageUrl = await uploadImage(file, "assets");
|
||||
|
||||
// Don't save to database here - let the form submission handle it
|
||||
// Images will be embedded in the rich text content and parsed when saving
|
||||
|
||||
return imageUrl;
|
||||
} catch (error) {
|
||||
console.error("Failed to upload image:", error);
|
||||
throw new Error("Failed to upload image");
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const url = annotation
|
||||
? `/api/teams/${teamId}/documents/${documentId}/annotations/${annotation.id}`
|
||||
: `/api/teams/${teamId}/documents/${documentId}/annotations`;
|
||||
|
||||
const method = annotation ? "PUT" : "POST";
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...values,
|
||||
content: editorContent,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to save annotation");
|
||||
}
|
||||
|
||||
toast.success(
|
||||
annotation
|
||||
? "Annotation updated successfully"
|
||||
: "Annotation created successfully",
|
||||
);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Error saving annotation:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save annotation",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter annotation title" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A brief title for this annotation that viewers will see.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pages"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pages</FormLabel>
|
||||
<FormDescription>
|
||||
Select which pages this annotation should appear on.
|
||||
</FormDescription>
|
||||
<div className="mt-2 grid grid-cols-10 gap-2">
|
||||
{pageOptions.map((page) => (
|
||||
<div key={page} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`page-${page}`}
|
||||
checked={field.value.includes(page)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
field.onChange([...field.value, page]);
|
||||
} else {
|
||||
field.onChange(field.value.filter((p) => p !== page));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`page-${page}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{page}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>Content</FormLabel>
|
||||
<FormDescription>
|
||||
Add rich text content and images to help explain this part of the
|
||||
document.
|
||||
</FormDescription>
|
||||
<RichTextEditor
|
||||
content={editorContent}
|
||||
onChange={setEditorContent}
|
||||
placeholder="Add your annotation content here..."
|
||||
onImageUpload={handleImageUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isVisible"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">Visible to viewers</FormLabel>
|
||||
<FormDescription>
|
||||
When enabled, viewers will be able to see this annotation when
|
||||
viewing the document.
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading && <LoadingSpinner className="mr-2 h-4 w-4" />}
|
||||
{annotation ? "Update Annotation" : "Create Annotation"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { Edit2, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAnnotations } from "@/lib/swr/use-annotations";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
|
||||
import { AnnotationForm } from "./annotation-form";
|
||||
|
||||
interface AnnotationSheetProps {
|
||||
documentId: string;
|
||||
teamId: string;
|
||||
numPages?: number;
|
||||
trigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AnnotationSheet({
|
||||
documentId,
|
||||
teamId,
|
||||
numPages = 1,
|
||||
trigger,
|
||||
}: AnnotationSheetProps) {
|
||||
const { annotations, mutate } = useAnnotations(documentId, teamId);
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [editingAnnotation, setEditingAnnotation] = useState<any>(null);
|
||||
|
||||
const handleDelete = async (annotationId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/documents/${documentId}/annotations/${annotationId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete annotation");
|
||||
}
|
||||
|
||||
toast.success("Annotation deleted successfully");
|
||||
mutate();
|
||||
} catch (error) {
|
||||
console.error("Error deleting annotation:", error);
|
||||
toast.error("Failed to delete annotation");
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleVisibility = async (
|
||||
annotationId: string,
|
||||
isVisible: boolean,
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/documents/${documentId}/annotations/${annotationId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
isVisible: !isVisible,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update annotation");
|
||||
}
|
||||
|
||||
toast.success(
|
||||
`Annotation ${!isVisible ? "shown" : "hidden"} successfully`,
|
||||
);
|
||||
mutate();
|
||||
} catch (error) {
|
||||
console.error("Error updating annotation:", error);
|
||||
toast.error("Failed to update annotation");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSuccess = () => {
|
||||
setIsCreateOpen(false);
|
||||
setEditingAnnotation(null);
|
||||
mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||
<SheetTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" size="sm">
|
||||
Add Annotations
|
||||
</Button>
|
||||
)}
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[600px] sm:max-w-[600px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Document Annotations</SheetTitle>
|
||||
<SheetDescription>
|
||||
Manage annotations that viewers can see when viewing this document.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{annotations?.length || 0} annotation
|
||||
{annotations?.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Annotation
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Annotation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new annotation that viewers can see when viewing this
|
||||
document.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AnnotationForm
|
||||
documentId={documentId}
|
||||
teamId={teamId}
|
||||
numPages={numPages}
|
||||
onSuccess={handleFormSuccess}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-200px)]">
|
||||
{annotations && annotations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="mb-4 rounded-full bg-muted p-3">
|
||||
<Plus className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-medium">No annotations yet</h3>
|
||||
<p className="mb-4 max-w-sm text-sm text-muted-foreground">
|
||||
Create your first annotation to help viewers understand your
|
||||
document better.
|
||||
</p>
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create First Annotation
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{annotations?.map((annotation) => (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className="space-y-3 rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{annotation.title}</h4>
|
||||
{!annotation.isVisible && (
|
||||
<Badge variant="secondary">Hidden</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Pages: {annotation.pages.join(", ")}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
Created by{" "}
|
||||
{annotation.createdBy?.name ||
|
||||
annotation.createdBy?.email}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{new Date(
|
||||
annotation.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleToggleVisibility(
|
||||
annotation.id,
|
||||
annotation.isVisible,
|
||||
)
|
||||
}
|
||||
>
|
||||
{annotation.isVisible ? (
|
||||
<Eye className="h-4 w-4" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={editingAnnotation?.id === annotation.id}
|
||||
onOpenChange={(open) =>
|
||||
setEditingAnnotation(open ? annotation : null)
|
||||
}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Annotation</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update this annotation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<AnnotationForm
|
||||
documentId={documentId}
|
||||
teamId={teamId}
|
||||
numPages={numPages}
|
||||
annotation={annotation}
|
||||
onSuccess={handleFormSuccess}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Delete Annotation
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete this annotation?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(annotation.id)}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview of annotation content */}
|
||||
{annotation.content && (
|
||||
<div className="rounded bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
<div className="line-clamp-3">
|
||||
{/* Simple text preview */}
|
||||
{typeof annotation.content === "object" &&
|
||||
annotation.content.content
|
||||
? annotation.content.content
|
||||
.filter(
|
||||
(node: any) => node.type === "paragraph",
|
||||
)
|
||||
.map((node: any) =>
|
||||
node.content
|
||||
?.filter(
|
||||
(textNode: any) =>
|
||||
textNode.type === "text",
|
||||
)
|
||||
.map((textNode: any) => textNode.text)
|
||||
.join(" "),
|
||||
)
|
||||
.join(" ")
|
||||
: "No content"}
|
||||
</div>
|
||||
{annotation.images && annotation.images.length > 0 && (
|
||||
<div className="mt-2 flex gap-1">
|
||||
{annotation.images.slice(0, 3).map((image) => (
|
||||
<img
|
||||
key={image.id}
|
||||
src={image.url}
|
||||
alt={image.filename}
|
||||
className="h-8 w-8 rounded border object-cover"
|
||||
/>
|
||||
))}
|
||||
{annotation.images.length > 3 && (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded border bg-muted text-xs">
|
||||
+{annotation.images.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user