mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 14:53:33 -04:00
Compare commits
1280 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b6c30b987 | ||
|
|
8da9bd8883 | ||
|
|
bdbce67423 | ||
|
|
e2cdfc01cd | ||
|
|
d2c9d12b39 | ||
|
|
09736cd49b | ||
|
|
f16bef97dc | ||
|
|
92d4222f96 | ||
|
|
008110b015 | ||
|
|
29ace2d2e5 | ||
|
|
07f927d738 | ||
|
|
993117ba72 | ||
|
|
c8801a0235 | ||
|
|
93cd105871 | ||
|
|
4f98d99dd9 | ||
|
|
b46d39d9b7 | ||
|
|
5ea095662b | ||
|
|
2400d50247 | ||
|
|
8ffd69ff15 | ||
|
|
0b721ec7b9 | ||
|
|
2019c835a0 | ||
|
|
ff23a8c1bc | ||
|
|
df775e9aa3 | ||
|
|
d121c92708 | ||
|
|
2db74bf256 | ||
|
|
0d758eb5b1 | ||
|
|
223387129e | ||
|
|
01944f9a73 | ||
|
|
f9a1dce7b5 | ||
|
|
98271784b0 | ||
|
|
2311fcbf90 | ||
|
|
f6fef171fa | ||
|
|
7565d373e9 | ||
|
|
c4fb694c71 | ||
|
|
bdf8793d4a | ||
|
|
05bd25c9ab | ||
|
|
5dc98fcbcf | ||
|
|
91f11161a2 | ||
|
|
0c60e8a865 | ||
|
|
3c8566a9e8 | ||
|
|
15f0dd5aaf | ||
|
|
fc199a918a | ||
|
|
b936466a9d | ||
|
|
781b5ec674 | ||
|
|
a3dfcb4080 | ||
|
|
0fe1ffe78e | ||
|
|
e280b1f5e0 | ||
|
|
cc43a8f8af | ||
|
|
3caf56247f | ||
|
|
032c70e086 | ||
|
|
903679eaa7 | ||
|
|
2908a1f16d | ||
|
|
b5deefbec1 | ||
|
|
0cb6226643 | ||
|
|
b19f6b81de | ||
|
|
401fd816dd | ||
|
|
a31765b7ae | ||
|
|
a7ae62a14b | ||
|
|
007bc1f7b0 | ||
|
|
bded8cc1fe | ||
|
|
cfaac3e3fa | ||
|
|
9637c80dd7 | ||
|
|
6a9739ab31 | ||
|
|
a51bd344a2 | ||
|
|
50f9a00689 | ||
|
|
54fb8c4b61 | ||
|
|
809a996870 | ||
|
|
85c96ce430 | ||
|
|
b670de9c73 | ||
|
|
626b28f4eb | ||
|
|
9c3dd65fc9 | ||
|
|
1e5c7b07c2 | ||
|
|
686c5777fd | ||
|
|
611e07a02b | ||
|
|
3c514de79d | ||
|
|
8319d21dea | ||
|
|
e4a231b0c5 | ||
|
|
b1efd042cf | ||
|
|
8a7d2120c4 | ||
|
|
e0b3f2d81a | ||
|
|
9f1eaef30c | ||
|
|
0218f4e705 | ||
|
|
3171f32cec | ||
|
|
156d2870cb | ||
|
|
2229d01aa1 | ||
|
|
6e4c6f29b5 | ||
|
|
ad51aea069 | ||
|
|
b8963c99a7 | ||
|
|
b270ce706b | ||
|
|
2c04b2a6aa | ||
|
|
a00b966e8b | ||
|
|
8ac3259f41 | ||
|
|
56f0423d5a | ||
|
|
e83ec6506c | ||
|
|
be22658883 | ||
|
|
bdafa43472 | ||
|
|
891b91d0ba | ||
|
|
b85b9380c3 | ||
|
|
cfeab0278c | ||
|
|
c8735b8b01 | ||
|
|
5ecbd3ba91 | ||
|
|
f83dbf349e | ||
|
|
3d1e91d100 | ||
|
|
0353a530d4 | ||
|
|
0592c7e766 | ||
|
|
5a8c698ed5 | ||
|
|
7ff38bd693 | ||
|
|
6c84926033 | ||
|
|
f35af89f07 | ||
|
|
b1f01fd873 | ||
|
|
96e22136a4 | ||
|
|
f81932e14e | ||
|
|
6b1813e129 | ||
|
|
307076d88e | ||
|
|
a84ad62ea2 | ||
|
|
c3f21058e5 | ||
|
|
d3634d8b1c | ||
|
|
cdbba244d0 | ||
|
|
6daeea908e | ||
|
|
b835a5f137 | ||
|
|
25358a9463 | ||
|
|
11063611bf | ||
|
|
6a03c5cc83 | ||
|
|
37ddf5bab1 | ||
|
|
f741648522 | ||
|
|
57e6395641 | ||
|
|
bc31df7d0a | ||
|
|
1e4b836985 | ||
|
|
5c1b18b4d9 | ||
|
|
f90b2179b5 | ||
|
|
0da158ce54 | ||
|
|
a360b51e12 | ||
|
|
3de6adae2e | ||
|
|
01a6a6e298 | ||
|
|
54ce5891f6 | ||
|
|
8a2bcad190 | ||
|
|
a88ba2dd3c | ||
|
|
3503110c64 | ||
|
|
33436c84a4 | ||
|
|
52f0f04194 | ||
|
|
3d56bb4fe0 | ||
|
|
b0c84687d2 | ||
|
|
0ef5ecea30 | ||
|
|
670e863bae | ||
|
|
3309ed1988 | ||
|
|
3365445538 | ||
|
|
9808b4a2e7 | ||
|
|
f3bd28e29f | ||
|
|
da9e950e5f | ||
|
|
dbf12a2ab2 | ||
|
|
1a2aa16645 | ||
|
|
a1df61edaa | ||
|
|
9e58e12013 | ||
|
|
a71c0beb11 | ||
|
|
93e6b18b49 | ||
|
|
6aae66f0d2 | ||
|
|
46f751b712 | ||
|
|
ccce2cc8b0 | ||
|
|
c38d752dc8 | ||
|
|
6f45c03a29 | ||
|
|
5819f08aec | ||
|
|
a322ea77fc | ||
|
|
9a5f47f905 | ||
|
|
a02ba9767b | ||
|
|
57edf80bfd | ||
|
|
3a2db534ab | ||
|
|
8eac568446 | ||
|
|
5e5015018f | ||
|
|
1fce55aebc | ||
|
|
cae5b96b1e | ||
|
|
3c72bca496 | ||
|
|
ba7e158af8 | ||
|
|
34a13458db | ||
|
|
3200e3cf79 | ||
|
|
de9c28965f | ||
|
|
4e61669361 | ||
|
|
09607ba7c7 | ||
|
|
24d2fe9c32 | ||
|
|
94585b1f1d | ||
|
|
155406935d | ||
|
|
b445db6116 | ||
|
|
d4de56873f | ||
|
|
6c54a9adf0 | ||
|
|
74f948e6ca | ||
|
|
0ea9ee969e | ||
|
|
6ae1af3c1f | ||
|
|
4ac4ab0ba2 | ||
|
|
d29edae5fe | ||
|
|
066a620bd4 | ||
|
|
7ad464150b | ||
|
|
596e70e031 | ||
|
|
13f103a3ca | ||
|
|
a699447e9e | ||
|
|
159e634a1a | ||
|
|
0533fdd2ed | ||
|
|
5119c458d8 | ||
|
|
d8a39f7101 | ||
|
|
3a0712c193 | ||
|
|
cb9dd51afc | ||
|
|
89ce1a248e | ||
|
|
ffc717c86b | ||
|
|
30248fd0be | ||
|
|
c270e6c3d7 | ||
|
|
e8bc9cda1d | ||
|
|
0bc82a3bcf | ||
|
|
a5f7898c37 | ||
|
|
1165927337 | ||
|
|
2d5690cea2 | ||
|
|
ace1b2823a | ||
|
|
a50323cd94 | ||
|
|
008bc11ebf | ||
|
|
3ddf81d935 | ||
|
|
67e95be2d4 | ||
|
|
0bb0a394ae | ||
|
|
cc74945371 | ||
|
|
2ea34189a8 | ||
|
|
66800d949b | ||
|
|
2eb80b97d5 | ||
|
|
f4d8822dbe | ||
|
|
0655266366 | ||
|
|
4b58e72607 | ||
|
|
29445a8aa7 | ||
|
|
c4a498227f | ||
|
|
295fa1dee4 | ||
|
|
ca582f0134 | ||
|
|
43abe21e45 | ||
|
|
c1df4c1307 | ||
|
|
73802c9c1d | ||
|
|
2825ded7c0 | ||
|
|
69161612f6 | ||
|
|
2cc6af1f37 | ||
|
|
7a52f55b05 | ||
|
|
f854476614 | ||
|
|
7165553080 | ||
|
|
fbda1caaf7 | ||
|
|
1c3ed84791 | ||
|
|
6008a5d3a5 | ||
|
|
205354c6c4 | ||
|
|
cb8ef458c2 | ||
|
|
d54f0052df | ||
|
|
d979a81b95 | ||
|
|
bf8ce092af | ||
|
|
c53039d97a | ||
|
|
5f32e5d025 | ||
|
|
b3d203afa2 | ||
|
|
0379c95f9b | ||
|
|
512406837d | ||
|
|
32e8c8570b | ||
|
|
6c86de75ec | ||
|
|
4bc91a5311 | ||
|
|
822f9150b8 | ||
|
|
6117e59226 | ||
|
|
bf9d25731c | ||
|
|
8686ac4090 | ||
|
|
dcacf98ea3 | ||
|
|
cd9fcb8176 | ||
|
|
d158eadf0d | ||
|
|
e115df8e11 | ||
|
|
140ace55bf | ||
|
|
7a44f3d23c | ||
|
|
2f38d54ea5 | ||
|
|
01deda29e7 | ||
|
|
b59b0edd4a | ||
|
|
be358d8517 | ||
|
|
4e3f31dd1c | ||
|
|
ab439bd85d | ||
|
|
aa7e32cb77 | ||
|
|
863fd2c0ae | ||
|
|
a18d308248 | ||
|
|
bfed23769e | ||
|
|
54a8f0e59b | ||
|
|
90d25c7152 | ||
|
|
9ccdeb6997 | ||
|
|
ad1b3e813e | ||
|
|
50949199f4 | ||
|
|
2d477e498f | ||
|
|
f9f9da63a0 | ||
|
|
000daa3021 | ||
|
|
40c30ede11 | ||
|
|
54fdf67bcf | ||
|
|
1bf9b83693 | ||
|
|
32e97f9da5 | ||
|
|
677f8bf207 | ||
|
|
ab92cf2aa9 | ||
|
|
0dff23991a | ||
|
|
04af8966b5 | ||
|
|
dd239f7b30 | ||
|
|
d2195d7c16 | ||
|
|
60269b66a7 | ||
|
|
2cad2fca17 | ||
|
|
a10ae2d253 | ||
|
|
4a4bee658d | ||
|
|
75a689707d | ||
|
|
6f638a22a3 | ||
|
|
18b63f1eb3 | ||
|
|
39689b1bfa | ||
|
|
44bc25f061 | ||
|
|
e2d9c06362 | ||
|
|
165a7e5663 | ||
|
|
8f83011011 | ||
|
|
9924c3debf | ||
|
|
836f8f1bfb | ||
|
|
fae76195ec | ||
|
|
0d8d81e09c | ||
|
|
699890a0be | ||
|
|
8c68312597 | ||
|
|
55bb9919f7 | ||
|
|
f8ca4bcbfc | ||
|
|
7037362b40 | ||
|
|
e29bc0ab82 | ||
|
|
b3cf542fd5 | ||
|
|
59ddb4360e | ||
|
|
4411ef25b0 | ||
|
|
0d57760d25 | ||
|
|
77f3b550d0 | ||
|
|
79e0421281 | ||
|
|
f54e18feb1 | ||
|
|
18d2c59479 | ||
|
|
33d7308cfa | ||
|
|
579ed6de7c | ||
|
|
8a1ee5cdca | ||
|
|
edaf119d9e | ||
|
|
1aad769d93 | ||
|
|
b5b2bafc78 | ||
|
|
8ba8604d83 | ||
|
|
9fdd21e03a | ||
|
|
418b2c7e52 | ||
|
|
49237bbe18 | ||
|
|
049fffe27f | ||
|
|
8d80245adf | ||
|
|
8d235e89f0 | ||
|
|
b030dd1a52 | ||
|
|
17faf2fe26 | ||
|
|
b18a6d7ff3 | ||
|
|
c69a438d08 | ||
|
|
309cbe7f2b | ||
|
|
c0df0028d3 | ||
|
|
4722088fd0 | ||
|
|
27c03330c8 | ||
|
|
740a7cc6f9 | ||
|
|
53ae605e9d | ||
|
|
84da8b7ad3 | ||
|
|
8b283cc5ce | ||
|
|
8548b85d03 | ||
|
|
fbb10344e1 | ||
|
|
615033547c | ||
|
|
8f854995c5 | ||
|
|
f8bb15e376 | ||
|
|
f6a65e875b | ||
|
|
74fc6a744e | ||
|
|
6920b8293d | ||
|
|
6c71022ed6 | ||
|
|
d0176c2b65 | ||
|
|
5ce38fea65 | ||
|
|
19ee048536 | ||
|
|
2531ee6fe4 | ||
|
|
1722cb531f | ||
|
|
f59325cbed | ||
|
|
1c595d3e33 | ||
|
|
4f2df43232 | ||
|
|
b7fe357fb2 | ||
|
|
59d52d4c11 | ||
|
|
8c1a75e26b | ||
|
|
ce8e7d141a | ||
|
|
0a2e4a7df1 | ||
|
|
26bc68e4db | ||
|
|
945f4a0d82 | ||
|
|
41da848714 | ||
|
|
5a92c47c99 | ||
|
|
69f181e8ee | ||
|
|
f374fef4f9 | ||
|
|
263e38f23e | ||
|
|
ddd5cd6823 | ||
|
|
7f8f394d58 | ||
|
|
57d9d2df38 | ||
|
|
b7500ba634 | ||
|
|
615d56131f | ||
|
|
c6f4b05e2a | ||
|
|
366581bcb1 | ||
|
|
35d96fff5d | ||
|
|
24e088b599 | ||
|
|
432d2df449 | ||
|
|
68a79c4b90 | ||
|
|
fa922bdcbe | ||
|
|
1086f6b4e2 | ||
|
|
44d3e7577b | ||
|
|
4015db7fda | ||
|
|
04f41c42c4 | ||
|
|
67fb98c672 | ||
|
|
8c21d2990f | ||
|
|
32201b6dfa | ||
|
|
4326c870a8 | ||
|
|
e03c714555 | ||
|
|
59cb649540 | ||
|
|
77e40c088c | ||
|
|
e5aeda92c8 | ||
|
|
0e99f75b73 | ||
|
|
9ec5fe549b | ||
|
|
47cf7bd3b2 | ||
|
|
c848007874 | ||
|
|
abba1260be | ||
|
|
cfc6b45a5b | ||
|
|
5e8f8167d1 | ||
|
|
dce0821b1a | ||
|
|
129dde8713 | ||
|
|
5d368a61eb | ||
|
|
2d0a869b00 | ||
|
|
88efbe4666 | ||
|
|
46aba5dc8d | ||
|
|
5321dd5690 | ||
|
|
2b0cd7ad3a | ||
|
|
3c08ba3cae | ||
|
|
f850b4ada5 | ||
|
|
1dbe4ecdef | ||
|
|
2b31ed3164 | ||
|
|
df2473929a | ||
|
|
80a877301a | ||
|
|
1aae688f3f | ||
|
|
337ce4523f | ||
|
|
e0b26af2bc | ||
|
|
1e2c2bbb8f | ||
|
|
ab0fd0aea4 | ||
|
|
1310c423bd | ||
|
|
32fadcc194 | ||
|
|
a2959a773e | ||
|
|
cadb4a4fd5 | ||
|
|
8decdab0c3 | ||
|
|
b710fa9f60 | ||
|
|
3cb5d08801 | ||
|
|
aa785c1539 | ||
|
|
f0c645b16d | ||
|
|
9870ac5029 | ||
|
|
cd067cd1a9 | ||
|
|
e1805d9d9e | ||
|
|
23a8aa6712 | ||
|
|
f70a74d20e | ||
|
|
02ea9131e4 | ||
|
|
cd8096f524 | ||
|
|
52819f3259 | ||
|
|
55c1b3983d | ||
|
|
27ac1539cb | ||
|
|
119bd9699d | ||
|
|
607285f25d | ||
|
|
192a944f4b | ||
|
|
c085e8f6dd | ||
|
|
79f855d39a | ||
|
|
f3cb8d51fc | ||
|
|
1bb9ac2006 | ||
|
|
07c8f74de7 | ||
|
|
e305fba93d | ||
|
|
ce680d6c8a | ||
|
|
5362403a04 | ||
|
|
abd77c4c4b | ||
|
|
e4a9337fab | ||
|
|
a9d9f0a190 | ||
|
|
a112350ad4 | ||
|
|
243e602fda | ||
|
|
b6d7955130 | ||
|
|
0277cc390d | ||
|
|
2d85b5c25a | ||
|
|
837a45dd91 | ||
|
|
b941960013 | ||
|
|
650b3f2469 | ||
|
|
3a1273dfac | ||
|
|
b7ab669adc | ||
|
|
affea2ce26 | ||
|
|
85ea90b9c8 | ||
|
|
0bfc714a41 | ||
|
|
ba9b3cfb06 | ||
|
|
1994697fa1 | ||
|
|
1c26b6381e | ||
|
|
f5daa3cdc8 | ||
|
|
8c71f6231a | ||
|
|
2f8e6147f6 | ||
|
|
2ff778a387 | ||
|
|
70c3493057 | ||
|
|
e62dac276a | ||
|
|
1115b4f1b0 | ||
|
|
c05af9ffcb | ||
|
|
2bb84d3992 | ||
|
|
2c90fc6cc8 | ||
|
|
d89bf2d92a | ||
|
|
f591cb13bb | ||
|
|
52720d2520 | ||
|
|
45473e0679 | ||
|
|
5e3c10d26a | ||
|
|
d0c58f753b | ||
|
|
20ba6f7ea9 | ||
|
|
27e93da06a | ||
|
|
c1a204e3be | ||
|
|
6be69b74e8 | ||
|
|
4b894363af | ||
|
|
cf843f66c4 | ||
|
|
cb98314bec | ||
|
|
4046cc19ef | ||
|
|
6787cdffa3 | ||
|
|
d583dbb945 | ||
|
|
bbd395b904 | ||
|
|
e1077e95a2 | ||
|
|
28f33da6d0 | ||
|
|
dbdaa9d7ec | ||
|
|
f4f009dc4a | ||
|
|
aacdd380c4 | ||
|
|
01f774ef7e | ||
|
|
57d7df63c8 | ||
|
|
f41c05a338 | ||
|
|
66526abeef | ||
|
|
3772d28129 | ||
|
|
3cc7222309 | ||
|
|
46082c5f64 | ||
|
|
cad1fd72e3 | ||
|
|
3a2e932628 | ||
|
|
1b3b40917b | ||
|
|
c181c571c4 | ||
|
|
8b094769e4 | ||
|
|
4b2b46d4f7 | ||
|
|
fc6628be08 | ||
|
|
07ce2780c6 | ||
|
|
c8b7b85391 | ||
|
|
0af6b9aff5 | ||
|
|
494e62720d | ||
|
|
8df2be1969 | ||
|
|
3c59be763a | ||
|
|
60a44b2ed1 | ||
|
|
e58a3ecb43 | ||
|
|
20025c825f | ||
|
|
aec30cd5b5 | ||
|
|
771cf887d5 | ||
|
|
78325cddfa | ||
|
|
1428ef1687 | ||
|
|
145f544ff1 | ||
|
|
28a582d9c4 | ||
|
|
19fc2b798b | ||
|
|
922decd252 | ||
|
|
0188a4ab51 | ||
|
|
c47e693e69 | ||
|
|
dd049dfb88 | ||
|
|
e6d64f2668 | ||
|
|
ee1e894e2f | ||
|
|
b3365cd773 | ||
|
|
920e0f37f2 | ||
|
|
fd7b4edc02 | ||
|
|
e689d0253c | ||
|
|
9efd3e4510 | ||
|
|
4fda21c582 | ||
|
|
76abe4ad28 | ||
|
|
e92f8afb46 | ||
|
|
e3907a3e64 | ||
|
|
5c9aa4f9f0 | ||
|
|
ae3b045772 | ||
|
|
6638d2b184 | ||
|
|
6a97045bad | ||
|
|
ba5beea17e | ||
|
|
7df3b301c6 | ||
|
|
ab81949927 | ||
|
|
f7c0d77e9c | ||
|
|
b7d1fd9903 | ||
|
|
e3b743f87b | ||
|
|
54e1106237 | ||
|
|
6a18d482e2 | ||
|
|
c46fc2a5bd | ||
|
|
e28263ff1f | ||
|
|
117b2d22e4 | ||
|
|
d61133ef74 | ||
|
|
ccf68b80c0 | ||
|
|
d2929a94ce | ||
|
|
7fd509b7e4 | ||
|
|
2c3314becc | ||
|
|
6815513ac3 | ||
|
|
e8cfc77902 | ||
|
|
7928e58d3b | ||
|
|
d8743a4b1c | ||
|
|
6626f23ef3 | ||
|
|
a0c1cf964b | ||
|
|
7437a2fb45 | ||
|
|
8ff5b8a577 | ||
|
|
cd434e2fb5 | ||
|
|
7734b689a3 | ||
|
|
ca55a93d5f | ||
|
|
6b7e3acf93 | ||
|
|
e865f75d95 | ||
|
|
af95174929 | ||
|
|
b6598f917c | ||
|
|
702d4ea1a2 | ||
|
|
dbe45d5181 | ||
|
|
4e11cc7ed1 | ||
|
|
2d5184a0ee | ||
|
|
1d2a2beb7a | ||
|
|
60dba612ba | ||
|
|
ea4047bc47 | ||
|
|
fab2316f28 | ||
|
|
ae708313aa | ||
|
|
812f6acac7 | ||
|
|
f56373fd73 | ||
|
|
5d9a1c1bf8 | ||
|
|
a3257cd4c0 | ||
|
|
67f8861d12 | ||
|
|
3fd37b21f3 | ||
|
|
9c1fbbd258 | ||
|
|
32fb79e9a2 | ||
|
|
ebecf18b6c | ||
|
|
403af8106f | ||
|
|
85ceee0b0d | ||
|
|
33f75420a2 | ||
|
|
a20767761b | ||
|
|
b13a40f892 | ||
|
|
a86f841b7e | ||
|
|
41c9da04b1 | ||
|
|
f42a1ad64f | ||
|
|
9c00fffb89 | ||
|
|
7b07610613 | ||
|
|
f1bc2f9dcb | ||
|
|
4a317018cb | ||
|
|
05219b2938 | ||
|
|
32edb4697c | ||
|
|
7e790ca353 | ||
|
|
939767cb42 | ||
|
|
31dc39fad7 | ||
|
|
243d22822a | ||
|
|
17d0fba831 | ||
|
|
05d003535b | ||
|
|
bd39bc290c | ||
|
|
4b203ae686 | ||
|
|
4e306af344 | ||
|
|
46489e25ff | ||
|
|
56d76922dc | ||
|
|
6ad3d3051f | ||
|
|
7598f32c56 | ||
|
|
46fcf042ad | ||
|
|
5962512a83 | ||
|
|
eaaae99dff | ||
|
|
688bbffd5e | ||
|
|
079ec8fc7e | ||
|
|
fba4436a08 | ||
|
|
d7f7e046ed | ||
|
|
e001787e5c | ||
|
|
cc4277a85c | ||
|
|
b49838c49a | ||
|
|
33ce2c9624 | ||
|
|
52d09aedd2 | ||
|
|
cf67ad0d81 | ||
|
|
20006049d0 | ||
|
|
a0ce449abe | ||
|
|
bd8b371c13 | ||
|
|
83433be1c8 | ||
|
|
7596906531 | ||
|
|
cb35813d16 | ||
|
|
55027c790a | ||
|
|
09ff56ad61 | ||
|
|
63e516fb4b | ||
|
|
0b2b60bd49 | ||
|
|
3e8470a216 | ||
|
|
e04746cde3 | ||
|
|
8c293c69eb | ||
|
|
cb96528752 | ||
|
|
8348953510 | ||
|
|
6d25937b56 | ||
|
|
d1f1fe945f | ||
|
|
4155beb7db | ||
|
|
374143172d | ||
|
|
6b4b0a9459 | ||
|
|
21789101fd | ||
|
|
8bfed9a6cc | ||
|
|
c36ceb7ed9 | ||
|
|
e80d8d701a | ||
|
|
f336e61304 | ||
|
|
70194541db | ||
|
|
8570f74a24 | ||
|
|
5b43382fc7 | ||
|
|
237ed0b5bf | ||
|
|
b01dcc6bde | ||
|
|
a433d1e095 | ||
|
|
ccc68f00ae | ||
|
|
756f0036eb | ||
|
|
54b8ab34bd | ||
|
|
59ebd539f1 | ||
|
|
7fad10d203 | ||
|
|
acb29c0600 | ||
|
|
398c7b92b7 | ||
|
|
95a9d8a50a | ||
|
|
95c018786d | ||
|
|
d7053dee14 | ||
|
|
f878521a40 | ||
|
|
40bbdb3fd9 | ||
|
|
7ac933160d | ||
|
|
0ac5ce7b41 | ||
|
|
152ed99727 | ||
|
|
a48633a074 | ||
|
|
b2ebf7e78e | ||
|
|
a5259f4c61 | ||
|
|
542152eadb | ||
|
|
ffc966f3b3 | ||
|
|
e45b24880f | ||
|
|
beac17cecf | ||
|
|
7ca682a4f9 | ||
|
|
07bc32d781 | ||
|
|
d59961f6cc | ||
|
|
945febffb6 | ||
|
|
bda34dddd0 | ||
|
|
ccb2eaaddf | ||
|
|
7880c391f1 | ||
|
|
ad4ea7328f | ||
|
|
763b74ef31 | ||
|
|
851d945545 | ||
|
|
80af2e9aeb | ||
|
|
473734bbd4 | ||
|
|
afe36f98be | ||
|
|
0888eb1a81 | ||
|
|
e2cbb5969d | ||
|
|
026a938e6f | ||
|
|
2da7e6d5d9 | ||
|
|
fd331dfaf9 | ||
|
|
e4a3e7a525 | ||
|
|
71695aba6c | ||
|
|
1aff8c1009 | ||
|
|
61a2e31175 | ||
|
|
c3d547f090 | ||
|
|
33d6dc1455 | ||
|
|
43c3ef591c | ||
|
|
79d56b0b4b | ||
|
|
1d505a2ae3 | ||
|
|
218de89583 | ||
|
|
94298e4609 | ||
|
|
274ea45cb8 | ||
|
|
d5322e1863 | ||
|
|
7131a5edaf | ||
|
|
ae977fbde7 | ||
|
|
f68321690a | ||
|
|
016841d6c2 | ||
|
|
635538b640 | ||
|
|
cf6989fcd7 | ||
|
|
9e324150c4 | ||
|
|
f6bf4b5b23 | ||
|
|
111f8809ca | ||
|
|
548c1d3ad9 | ||
|
|
c39dddf1db | ||
|
|
39d856f368 | ||
|
|
b2c4e46180 | ||
|
|
7622e864cb | ||
|
|
bc66c0334a | ||
|
|
babef5f032 | ||
|
|
f7f67b0ab0 | ||
|
|
9219671430 | ||
|
|
a4675246b2 | ||
|
|
e2d78722d8 | ||
|
|
d5c2f06784 | ||
|
|
26d9851c9e | ||
|
|
fbfb959de2 | ||
|
|
449b32d4bc | ||
|
|
a3c479ff92 | ||
|
|
51c97adce4 | ||
|
|
cde8a0d97f | ||
|
|
e257a10fdb | ||
|
|
6df9c5519a | ||
|
|
33a50b7e7e | ||
|
|
5029af9c5f | ||
|
|
7b61c70f6d | ||
|
|
87059494a3 | ||
|
|
e7f348e34e | ||
|
|
f953b5c10b | ||
|
|
d75f179a46 | ||
|
|
87713a7803 | ||
|
|
ac7091ae06 | ||
|
|
f4f3aa80f1 | ||
|
|
fa82a30907 | ||
|
|
2e81ef25f1 | ||
|
|
12ef6a891b | ||
|
|
fb3f5e5ace | ||
|
|
9537500fe1 | ||
|
|
bb423ba0e4 | ||
|
|
21db5cb481 | ||
|
|
078425f1b2 | ||
|
|
77e3b56b65 | ||
|
|
2ed23e05fa | ||
|
|
486a4a81f7 | ||
|
|
9a0f0c0892 | ||
|
|
4abed529d3 | ||
|
|
4293a8c24b | ||
|
|
60721eefb2 | ||
|
|
e5c8650df0 | ||
|
|
8e6d6584ea | ||
|
|
077f3ac1a3 | ||
|
|
04b8e21769 | ||
|
|
bc672db79a | ||
|
|
dc7be2d334 | ||
|
|
08c9f60010 | ||
|
|
0bb52e72f7 | ||
|
|
593c2ac024 | ||
|
|
8712424b89 | ||
|
|
c408d895b1 | ||
|
|
33c2121c8d | ||
|
|
fa151e79d3 | ||
|
|
58e65baa47 | ||
|
|
7bc57d8380 | ||
|
|
eed3e71113 | ||
|
|
08d98468c5 | ||
|
|
0ee66a264e | ||
|
|
c10f8ceb91 | ||
|
|
e3cb85271c | ||
|
|
2c43f3e1e7 | ||
|
|
29ea3a600a | ||
|
|
3ace0e80e8 | ||
|
|
79f460b9a2 | ||
|
|
7d41846b0a | ||
|
|
d8997f64cb | ||
|
|
8a9139633d | ||
|
|
a5191b440e | ||
|
|
c7d58905b5 | ||
|
|
b6df79b836 | ||
|
|
9975113eff | ||
|
|
e8431845b1 | ||
|
|
a3e51f06e3 | ||
|
|
ce447db6b5 | ||
|
|
8c14212e10 | ||
|
|
9976e085c1 | ||
|
|
6da973dd0c | ||
|
|
1dcf86e5ba | ||
|
|
3232e783e3 | ||
|
|
c66d82a06d | ||
|
|
a46ff44f99 | ||
|
|
669a95bfa9 | ||
|
|
4287fbae85 | ||
|
|
f2845eab91 | ||
|
|
40702e7832 | ||
|
|
9909bd41cb | ||
|
|
0dec7e1987 | ||
|
|
c5965fff89 | ||
|
|
53a1b4c415 | ||
|
|
773551e41d | ||
|
|
2df0e3df88 | ||
|
|
df39c2ee70 | ||
|
|
0bfd4d47b9 | ||
|
|
097617cfa0 | ||
|
|
b2968166df | ||
|
|
9116144de4 | ||
|
|
47e5e8bb28 | ||
|
|
32cae16045 | ||
|
|
4e4c946acf | ||
|
|
3e06b61eba | ||
|
|
04e2376829 | ||
|
|
709ef3f4ad | ||
|
|
2e7de541aa | ||
|
|
dc073ef0f9 | ||
|
|
a677df2fdd | ||
|
|
58c005194c | ||
|
|
16a5c3e408 | ||
|
|
8a4fb040e1 | ||
|
|
eab140e51d | ||
|
|
009d4bf91f | ||
|
|
ed094bbeca | ||
|
|
704bcb4619 | ||
|
|
fbdc594fa9 | ||
|
|
6a511adcf5 | ||
|
|
d55b04d22d | ||
|
|
96d0c90f8c | ||
|
|
2253a734b1 | ||
|
|
dca08a6d81 | ||
|
|
8a4dc44fbc | ||
|
|
e231f016d6 | ||
|
|
05250285c0 | ||
|
|
ce9ac99894 | ||
|
|
f76c39bd1c | ||
|
|
9ccb8d002b | ||
|
|
d112d4e739 | ||
|
|
7dfec75ac0 | ||
|
|
3fd27131e2 | ||
|
|
355fd7e6d1 | ||
|
|
c0b8df4ef9 | ||
|
|
cc3af74676 | ||
|
|
40a1e079a1 | ||
|
|
764769366c | ||
|
|
b433668628 | ||
|
|
cf27794776 | ||
|
|
238fed617f | ||
|
|
0d026d36d1 | ||
|
|
d699ceae38 | ||
|
|
762dd93042 | ||
|
|
1f5ed87363 | ||
|
|
28ce878b34 | ||
|
|
be2e19db8d | ||
|
|
785a633115 | ||
|
|
57beefb894 | ||
|
|
787d35e057 | ||
|
|
a5b1d1abb0 | ||
|
|
daa517a11d | ||
|
|
ef7665c7da | ||
|
|
348a557f5c | ||
|
|
26b36ab1c8 | ||
|
|
94faa30882 | ||
|
|
c579b3ac15 | ||
|
|
82bf346ce5 | ||
|
|
67ed9d0d8e | ||
|
|
c0b241e70a | ||
|
|
808acd8289 | ||
|
|
3d39fe994d | ||
|
|
569db46a8b | ||
|
|
9493ee65cf | ||
|
|
ce962f60ff | ||
|
|
922232e943 | ||
|
|
ca587ce962 | ||
|
|
755be6e0e0 | ||
|
|
4a64e5f0a0 | ||
|
|
c458b42821 | ||
|
|
dc298c4f46 | ||
|
|
91f69ed928 | ||
|
|
64c04e2d23 | ||
|
|
ae8077d700 | ||
|
|
50d8e6388e | ||
|
|
af334c7142 | ||
|
|
f2da0bd58a | ||
|
|
cfdea26599 | ||
|
|
a4a6abec72 | ||
|
|
d8dfd6e093 | ||
|
|
5175b2ca15 | ||
|
|
f734772670 | ||
|
|
5eee62f731 | ||
|
|
4f8d76f797 | ||
|
|
bf4acc09fb | ||
|
|
2a3a39d6f7 | ||
|
|
92b1ffd61c | ||
|
|
c4c9316386 | ||
|
|
0509ff8fac | ||
|
|
1dc85e25de | ||
|
|
fb695bbed1 | ||
|
|
3b0465c65c | ||
|
|
43238ecc44 | ||
|
|
821d280f5c | ||
|
|
1da487a972 | ||
|
|
61c53301bf | ||
|
|
61613af1b7 | ||
|
|
ab612a3eca | ||
|
|
f323034eed | ||
|
|
aa35fa3b2b | ||
|
|
f97d24402e | ||
|
|
036776bde8 | ||
|
|
63e48d99ca | ||
|
|
4dd6f54b37 | ||
|
|
a4e02031c6 | ||
|
|
95bdc37411 | ||
|
|
c90b617ad0 | ||
|
|
aecdaa2875 | ||
|
|
7e924c2741 | ||
|
|
241b851c02 | ||
|
|
2ba9949035 | ||
|
|
3d4b76ffc3 | ||
|
|
f7fb0c6c82 | ||
|
|
e5fc734b90 | ||
|
|
10fa659e52 | ||
|
|
0ac315b017 | ||
|
|
bbfbd2daae | ||
|
|
cd2c4d3314 | ||
|
|
37ee7752c2 | ||
|
|
6b11e6b060 | ||
|
|
f650ab7394 | ||
|
|
fead7459d4 | ||
|
|
bbf3970121 | ||
|
|
26fb810840 | ||
|
|
af9074af6e | ||
|
|
4229c2a4fa | ||
|
|
fd58602e6d | ||
|
|
af26397ad7 | ||
|
|
859d01594a | ||
|
|
09a37058e6 | ||
|
|
edc7366b1d | ||
|
|
7306cb335b | ||
|
|
1e13cc4294 | ||
|
|
a88e5a9ec8 | ||
|
|
09d743c603 | ||
|
|
36c1ec528a | ||
|
|
aec9600036 | ||
|
|
6e1306bdd6 | ||
|
|
37f5c95716 | ||
|
|
0d48c541a0 | ||
|
|
8928cd1667 | ||
|
|
780f935fea | ||
|
|
5bf095178d | ||
|
|
e135293b43 | ||
|
|
be7e009909 | ||
|
|
ada8a713c1 | ||
|
|
eb7391dae0 | ||
|
|
8bdbd5e4fe | ||
|
|
137d15ae71 | ||
|
|
8a2ed6f8ff | ||
|
|
7766b43187 | ||
|
|
b9a637fdac | ||
|
|
3096dbc922 | ||
|
|
d6b0bb4378 | ||
|
|
43ef43ba72 | ||
|
|
314037dd06 | ||
|
|
7c4d66bbf5 | ||
|
|
49d28961ef | ||
|
|
9d649daee5 | ||
|
|
7598a47283 | ||
|
|
c9f7230d27 | ||
|
|
716633c6df | ||
|
|
8a215a765f | ||
|
|
e2ff41a0b1 | ||
|
|
d790fae74a | ||
|
|
1a17862f45 | ||
|
|
acd4c36531 | ||
|
|
f623450f08 | ||
|
|
ce681cfb67 | ||
|
|
a0a6523a25 | ||
|
|
8e2fa36d0e | ||
|
|
c953a84c1f | ||
|
|
2b403f0761 | ||
|
|
f954e3b2d7 | ||
|
|
24ee2a206e | ||
|
|
02d165829f | ||
|
|
b4d996bd14 | ||
|
|
60989faa03 | ||
|
|
4aeda8a1a7 | ||
|
|
023a20f263 | ||
|
|
62821ed803 | ||
|
|
903d62ed57 | ||
|
|
adef626b34 | ||
|
|
11a933cc04 | ||
|
|
4b5ce99bb1 | ||
|
|
f8dff77cee | ||
|
|
0a9b08803e | ||
|
|
39a8568663 | ||
|
|
e1502440eb | ||
|
|
5834d032c3 | ||
|
|
0a28a2af61 | ||
|
|
259f56bd26 | ||
|
|
011ad66a3f | ||
|
|
281c72f88d | ||
|
|
1293523ebf | ||
|
|
67e5d34f39 | ||
|
|
8b3dec6116 | ||
|
|
3359f49f0a | ||
|
|
9715cae587 | ||
|
|
4b122bd907 | ||
|
|
edefa36c8a | ||
|
|
e40a352aed | ||
|
|
70267f0623 | ||
|
|
c19ed7f6d5 | ||
|
|
e2fb0f2ee8 | ||
|
|
f9e208a7e0 | ||
|
|
ee2b8a60a2 | ||
|
|
5651f17e96 | ||
|
|
9679b3722a | ||
|
|
6cb736a10d | ||
|
|
ac5c3b421d | ||
|
|
f01fad5fa6 | ||
|
|
110b727cbc | ||
|
|
1dba2debc5 | ||
|
|
8fec73341f | ||
|
|
33c76de397 | ||
|
|
d409eb1126 | ||
|
|
69d53591c8 | ||
|
|
4cf783f07f | ||
|
|
a971c121cc | ||
|
|
b4abfb6aa4 | ||
|
|
8f760081a5 | ||
|
|
c15934f210 | ||
|
|
c7e1bb4463 | ||
|
|
4ac97fc476 | ||
|
|
a5dda01ffd | ||
|
|
9db4937e54 | ||
|
|
3dc50a384e | ||
|
|
ccc48620b8 | ||
|
|
943c3960e1 | ||
|
|
9a84a67555 | ||
|
|
2241e3c7d3 | ||
|
|
03042dae96 | ||
|
|
3df9d067d6 | ||
|
|
c5f3e8b3c2 | ||
|
|
3a3e8c0e10 | ||
|
|
f010bf7eed | ||
|
|
b22d6a77b0 | ||
|
|
f91d9125e4 | ||
|
|
625eedef1e | ||
|
|
61dd33187a | ||
|
|
817640c7d0 | ||
|
|
aa15d5a6df | ||
|
|
60857007bd | ||
|
|
cfd0c5d846 | ||
|
|
71ec88d235 | ||
|
|
902ea583bd | ||
|
|
fb6b54fba0 | ||
|
|
9dfc56987c | ||
|
|
b982a02717 | ||
|
|
15a0d8ee16 | ||
|
|
9ca03132db | ||
|
|
f908ba3ea3 | ||
|
|
79ea09bd91 | ||
|
|
c743a4f1fe | ||
|
|
ccd1fc5c10 | ||
|
|
2f82a64dbe | ||
|
|
71c6eae9c4 | ||
|
|
28894a08cc | ||
|
|
41e366dcc4 | ||
|
|
6ee11a6b2d | ||
|
|
ab82c66f83 | ||
|
|
f94820f45e | ||
|
|
fe03e1ca68 | ||
|
|
44ee6951c9 | ||
|
|
40194f7204 | ||
|
|
40eefef9a2 | ||
|
|
d8ab44ebb5 | ||
|
|
f875539f2e | ||
|
|
d9c2d142cb | ||
|
|
51d5715f04 | ||
|
|
8ad81b1d50 | ||
|
|
6d4083d8a7 | ||
|
|
bb120afea2 | ||
|
|
cebcc20c26 | ||
|
|
0522e787cd | ||
|
|
81e01f1485 | ||
|
|
9b2b93d56f | ||
|
|
133b402e2b | ||
|
|
9a91eab13f | ||
|
|
5f6722b917 | ||
|
|
fdcd4d46ac | ||
|
|
27a72170b8 | ||
|
|
b3021e60ec | ||
|
|
6d8834bd87 | ||
|
|
ab8a9d95d8 | ||
|
|
563ee3f5df | ||
|
|
c3389a7fcf | ||
|
|
e824bbb533 | ||
|
|
7de0e351f3 | ||
|
|
2df5f55390 | ||
|
|
21038cc5ac | ||
|
|
d6749bcd41 | ||
|
|
005c9ccdef | ||
|
|
d47cb53e59 | ||
|
|
adfb3ca4f0 | ||
|
|
88b7e4edda | ||
|
|
e33b57f0fd | ||
|
|
777825b73f | ||
|
|
04ca9b6f9a | ||
|
|
7f3d3eeb9c | ||
|
|
75ac16d43c | ||
|
|
5f1120c718 | ||
|
|
806a0694c6 | ||
|
|
c506ae3242 | ||
|
|
444fa529fb | ||
|
|
62ced3eb04 | ||
|
|
31718f1c4d | ||
|
|
98ab8971b4 | ||
|
|
870c86f9af | ||
|
|
a4f6aabee9 | ||
|
|
5584ad0a10 | ||
|
|
6b4932b4c5 | ||
|
|
7f1cb0aaf3 | ||
|
|
d42a5a48e9 | ||
|
|
8a1b762c35 | ||
|
|
d2d1de41d2 | ||
|
|
d1366af2a0 | ||
|
|
cae4b15bbb | ||
|
|
e41bc64b0c | ||
|
|
3d03ebe487 | ||
|
|
1fcb431d1b | ||
|
|
4f321490af | ||
|
|
04c7469e68 | ||
|
|
98bc0a9309 | ||
|
|
6e537f4cdf | ||
|
|
b121d61852 | ||
|
|
6ba3c3ffbd | ||
|
|
67b3efad4c | ||
|
|
1282e468e3 | ||
|
|
67b2e78a63 | ||
|
|
213c56f945 | ||
|
|
ccde6e4f4b | ||
|
|
2c1c94d24c | ||
|
|
56edb51f36 | ||
|
|
b6ed2c7dd8 | ||
|
|
74c7c5c423 | ||
|
|
314d774bde | ||
|
|
d94091ae4e | ||
|
|
8bf3c4fcd7 | ||
|
|
4358c15432 | ||
|
|
fdf8d649fe | ||
|
|
a4e22ec4b1 | ||
|
|
783bc43547 | ||
|
|
2e6aec175a | ||
|
|
ee41aaa112 | ||
|
|
32e8a8b1b9 | ||
|
|
6b3def230b | ||
|
|
51c46db106 | ||
|
|
64c18179ac | ||
|
|
5ee0b39e07 | ||
|
|
6470319fd6 | ||
|
|
4ca3f3c8ee | ||
|
|
07e2d2d509 | ||
|
|
07d2a143a2 | ||
|
|
968845492f | ||
|
|
c2106b64f9 | ||
|
|
0d73d1d258 | ||
|
|
423d425950 | ||
|
|
7ab0093fec | ||
|
|
56d2757448 | ||
|
|
f5b6037367 | ||
|
|
2c4ce6c8d1 | ||
|
|
f9ccd3628a | ||
|
|
abef2b394e | ||
|
|
97ff6f1de9 | ||
|
|
7fad4435cb | ||
|
|
92a97209ca | ||
|
|
e7c3f083b4 | ||
|
|
7b5961f941 | ||
|
|
85d4b411b5 | ||
|
|
c9ec32aca7 | ||
|
|
13a3013a8e | ||
|
|
0dd3bac855 | ||
|
|
d7a716a5cb | ||
|
|
0bbc9cbe81 | ||
|
|
df766d8d1f | ||
|
|
20a150a228 | ||
|
|
010292a440 | ||
|
|
394dae18e9 | ||
|
|
f1676c52f0 | ||
|
|
05f6f3c79b | ||
|
|
9942b488ea | ||
|
|
5d83f4bf2d | ||
|
|
d9afd914ff | ||
|
|
990d8160f8 | ||
|
|
80c321b66f | ||
|
|
67b45f3e5c | ||
|
|
ca3cee5673 | ||
|
|
ae0d170244 | ||
|
|
9a31cfa938 | ||
|
|
cdbc9c305e | ||
|
|
cdbce13c49 | ||
|
|
0a41ebbcda | ||
|
|
476fe1602b | ||
|
|
2f482e9afc | ||
|
|
d59e6e0691 | ||
|
|
7ec6866f26 | ||
|
|
3686e7facf | ||
|
|
1aba1894ea | ||
|
|
14503c9b8f | ||
|
|
a315668d31 | ||
|
|
db9ea63210 | ||
|
|
51ecbd5b53 | ||
|
|
45ef0d9809 | ||
|
|
356702b50d | ||
|
|
e72ce5376c | ||
|
|
0d35f3fbd2 | ||
|
|
28c22c1eae | ||
|
|
7cf83f65c3 | ||
|
|
4c4f2e720d | ||
|
|
0fa562e6fd | ||
|
|
bcd0f778cf | ||
|
|
401ab9f706 | ||
|
|
8b09a81d5a | ||
|
|
86718cc406 | ||
|
|
ccb72364e1 | ||
|
|
bfd6a59d87 | ||
|
|
af4caa455a | ||
|
|
d511e4a75c | ||
|
|
8fd906223c | ||
|
|
deadb56aaa | ||
|
|
1ff867879c | ||
|
|
f3630dd868 | ||
|
|
39143525c3 | ||
|
|
e8dd1f8f8b | ||
|
|
28e5d2e3f2 | ||
|
|
21def91427 | ||
|
|
bc5d04c662 | ||
|
|
c736227448 | ||
|
|
168285cb64 | ||
|
|
3411f50d29 | ||
|
|
319c14b0e0 | ||
|
|
64c077396f | ||
|
|
65f0d448a1 | ||
|
|
2fdaa464dd | ||
|
|
f86a6a10ac | ||
|
|
08a2438e79 | ||
|
|
60cc47f7ca | ||
|
|
7e4f606492 | ||
|
|
8ff58534d9 | ||
|
|
a4bb184e95 | ||
|
|
940c1f5692 | ||
|
|
0430733b58 | ||
|
|
33136816af | ||
|
|
469a235799 | ||
|
|
2d71c827b3 |
300
.aiassistant/rules/guidelines.md
Normal file
300
.aiassistant/rules/guidelines.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
---
|
||||||
|
apply: by model decision
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
trigger: always_on
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
|
||||||
|
|
||||||
|
- **Next.js React frontend** `/web`
|
||||||
|
- Pages, components, hooks, lib
|
||||||
|
- **Express Node API server** `/backend/api`
|
||||||
|
- **Shared backend utilities** `/backend/shared`
|
||||||
|
- **Email functions** `/backend/email`
|
||||||
|
- **Database schema** `/backend/supabase`
|
||||||
|
- Supabase-generated types in `/backend/supabase/schema.ts`
|
||||||
|
- **Files shared between frontend and backend** `/common`
|
||||||
|
- Types (User, Profile, etc.) and utilities
|
||||||
|
- Try not to add package dependencies to common
|
||||||
|
- **Android app** `/android`
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Both dev and prod environments
|
||||||
|
- Backend on GCP (Google Cloud Platform)
|
||||||
|
- Frontend on Vercel
|
||||||
|
- Database on Supabase (PostgreSQL)
|
||||||
|
- Firebase for authentication and storage
|
||||||
|
|
||||||
|
## Code Guidelines
|
||||||
|
|
||||||
|
### Component Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import {User} from 'common/user'
|
||||||
|
import {ProfileRow} from 'common/profiles/profile'
|
||||||
|
import {useUser} from 'web/hooks/use-user'
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
user: User
|
||||||
|
profile: ProfileRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({user, profile}: ProfileCardProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
|
||||||
|
<img src={user.avatarUrl} alt={user.name} />
|
||||||
|
<h3>{user.name}</h3>
|
||||||
|
<p>{profile.bio}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We prefer many smaller components that each represent one logical unit, rather than one large component.
|
||||||
|
|
||||||
|
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx` →
|
||||||
|
`ProfileCard`).
|
||||||
|
|
||||||
|
### API Calls
|
||||||
|
|
||||||
|
**Server-side (getStaticProps):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {api} from 'web/lib/api'
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const profiles = await api('get-profiles', {})
|
||||||
|
return {
|
||||||
|
props: {profiles},
|
||||||
|
revalidate: 30 * 60, // 30 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client-side - use hooks:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||||
|
|
||||||
|
function ProfileList() {
|
||||||
|
const {data, refresh} = useAPIGetter('get-profiles', {})
|
||||||
|
|
||||||
|
if (!data) return <Loading / >
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
data.profiles.map((profile) => (
|
||||||
|
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<button onClick = {refresh} > Refresh < /button>
|
||||||
|
< /div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
**Backend (pg-promise):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend (Supabase client):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {db} from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return <h1>{t('welcome', 'Welcome to Compass'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Translation files are in `common/messages/` (en.json, fr.json, de.json).
|
||||||
|
|
||||||
|
### Backend Endpoints
|
||||||
|
|
||||||
|
1. Define schema in `common/src/api/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'get-user-and-profile'
|
||||||
|
:
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
authed
|
||||||
|
:
|
||||||
|
false,
|
||||||
|
rateLimited
|
||||||
|
:
|
||||||
|
true,
|
||||||
|
props
|
||||||
|
:
|
||||||
|
z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
}),
|
||||||
|
returns
|
||||||
|
:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
as
|
||||||
|
{
|
||||||
|
user: User;
|
||||||
|
profile: ProfileRow | null
|
||||||
|
}
|
||||||
|
,
|
||||||
|
summary: 'Get user and profile data by username',
|
||||||
|
tag
|
||||||
|
:
|
||||||
|
'Users',
|
||||||
|
}
|
||||||
|
,
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create handler in `backend/api/src/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
|
||||||
|
const user = await getUserByUsername(username)
|
||||||
|
if (!user) {
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {user, profile}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Register in `backend/api/src/app.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {getUserAndProfile} from './get-user-and-profile'
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
'get-user-and-profile': getUserAndProfile,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile Options (Interests, Causes, Work)
|
||||||
|
|
||||||
|
Options are stored in separate tables with many-to-many relationships:
|
||||||
|
|
||||||
|
- `interests`, `causes`, `work` - option values
|
||||||
|
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
|
||||||
|
|
||||||
|
Fetch in parallel:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [interestsRes, causesRes, workRes] = await Promise.all([
|
||||||
|
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Jest (unit + integration)
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
- Unit tests: `*.unit.test.ts` in `tests/unit/`
|
||||||
|
- Integration tests: `*.integration.test.ts` in `tests/integration/`
|
||||||
|
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
|
||||||
|
|
||||||
|
### Mocking Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
jest.mock('shared/supabase/init')
|
||||||
|
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const mockPg = {
|
||||||
|
oneOrNone: jest.fn(),
|
||||||
|
tx: jest.fn(async (cb) => cb(mockTx)),
|
||||||
|
}
|
||||||
|
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### User Registration
|
||||||
|
|
||||||
|
- Create user + profile + options in single database transaction
|
||||||
|
- Return full profile data from creation API
|
||||||
|
- Don't use sleep() hacks - rely on transactional integrity
|
||||||
|
|
||||||
|
### API Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIErrors} from './helpers/endpoint'
|
||||||
|
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
throw APIErrors.badRequest('Invalid input', {field: 'email'})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Use `debug()` from `common/logger` for development
|
||||||
|
- Use `log` from `shared/utils` for production
|
||||||
|
|
||||||
|
## Things to Avoid
|
||||||
|
|
||||||
|
- Don't use string concatenation for SQL queries
|
||||||
|
- Don't add sleep() delays for "eventual consistency"
|
||||||
|
- Don't create separate API calls when data can be batched in one transaction
|
||||||
|
- Don't use console.log - use `debug()` or `log()`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- React 19
|
||||||
|
- Next.js 16
|
||||||
|
- Supabase (PostgreSQL)
|
||||||
|
- Firebase (Auth, Storage)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Jest (testing)
|
||||||
|
- Playwright (E2E testing)
|
||||||
120
.coderabbit.yaml
Normal file
120
.coderabbit.yaml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Enables IDE autocompletion for this config file
|
||||||
|
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||||
|
|
||||||
|
# Language for CodeRabbit's review comments
|
||||||
|
language: en
|
||||||
|
|
||||||
|
# Enable experimental features (currently not using any specific early_access features)
|
||||||
|
early_access: true
|
||||||
|
|
||||||
|
chat:
|
||||||
|
# CodeRabbit will automatically respond to @coderabbitai mentions in PR comments
|
||||||
|
auto_reply: true
|
||||||
|
|
||||||
|
reviews:
|
||||||
|
auto_review:
|
||||||
|
# Automatically trigger reviews when PRs are opened or updated
|
||||||
|
enabled: true
|
||||||
|
# Skip auto-review if PR title contains these keywords
|
||||||
|
ignore_title_keywords:
|
||||||
|
- "WIP"
|
||||||
|
# Don't auto-review draft PRs
|
||||||
|
drafts: false
|
||||||
|
# Only auto-review PRs targeting these branches
|
||||||
|
base_branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
# Include a high-level summary at the start of each review
|
||||||
|
high_level_summary: true
|
||||||
|
|
||||||
|
# Generate sequence diagrams for complex code flows
|
||||||
|
sequence_diagrams: true
|
||||||
|
|
||||||
|
# Don't include poems in reviews (fun feature, but keeping it professional)
|
||||||
|
poem: false
|
||||||
|
|
||||||
|
# Show review completion status
|
||||||
|
review_status: true
|
||||||
|
|
||||||
|
# Keep the walkthrough section expanded by default
|
||||||
|
collapse_walkthrough: false
|
||||||
|
|
||||||
|
# Include summary of all changed files
|
||||||
|
changed_files_summary: true
|
||||||
|
|
||||||
|
# Don't automatically request changes on the PR (just leave comments)
|
||||||
|
request_changes_workflow: false
|
||||||
|
|
||||||
|
# Pre-merge checks to enforce before merging PRs
|
||||||
|
pre_merge_checks:
|
||||||
|
description:
|
||||||
|
# Validate that PR has a proper description
|
||||||
|
mode: warning # Options: off, warning, error
|
||||||
|
docstrings:
|
||||||
|
# Disable docstring coverage checks (let's assume we don't need them)
|
||||||
|
mode: off
|
||||||
|
|
||||||
|
# Exclude these paths from reviews (build artifacts and dependencies)
|
||||||
|
path_filters:
|
||||||
|
- "!**/node_modules/**" # npm dependencies
|
||||||
|
- "!**/android/**" # Native Android build files
|
||||||
|
- "!**/ios/**" # Native iOS build files
|
||||||
|
- "!**/.expo/**" # Expo build cache
|
||||||
|
- "!**/.expo-shared/**" # Expo shared config
|
||||||
|
- "!**/dist/**" # Build output
|
||||||
|
|
||||||
|
# Custom review instructions for specific file patterns
|
||||||
|
path_instructions:
|
||||||
|
# TypeScript/JavaScript files - main app code
|
||||||
|
- path: "**/*.{ts,tsx,js,jsx}"
|
||||||
|
instructions: |
|
||||||
|
General practices:
|
||||||
|
- Summarize the changes clearly.
|
||||||
|
- Format the summary with bullet points.
|
||||||
|
- Highlight any potential breaking changes for users.
|
||||||
|
- We use early returns to avoid deep nesting.
|
||||||
|
- Ensure all public functions have docstrings.
|
||||||
|
- Flag any hardcoded strings; they should be in the constants file.
|
||||||
|
- Check for edge cases like null values or empty arrays.
|
||||||
|
- Suggest performance optimizations where appropriate.
|
||||||
|
|
||||||
|
Mobile best practices:
|
||||||
|
- Proper use of hooks (useRouter, useFonts, useAssets)
|
||||||
|
- Accessibility: touch targets min 44x44, screen reader support
|
||||||
|
- Safe area handling and platform-specific code (iOS vs Android)
|
||||||
|
- Memory leaks in useEffect and event listeners
|
||||||
|
|
||||||
|
Performance:
|
||||||
|
- Use FlatList/SectionList for lists (never ScrollView with .map)
|
||||||
|
- React.memo, useMemo, useCallback where appropriate
|
||||||
|
|
||||||
|
TypeScript:
|
||||||
|
- Avoid 'any', use explicit types
|
||||||
|
- Prefer 'import type' for type imports
|
||||||
|
|
||||||
|
Security:
|
||||||
|
- No exposed API keys or sensitive data
|
||||||
|
- Use expo-secure-store for sensitive storage
|
||||||
|
- Validate deep linking configurations
|
||||||
|
|
||||||
|
Internationalization:
|
||||||
|
- User-visible strings should be externalized to JSON resource files in common/messages via
|
||||||
|
```
|
||||||
|
const t = useT()
|
||||||
|
const message = t('key', 'english string')
|
||||||
|
```
|
||||||
|
|
||||||
|
- path: "tests/e2e/**/*.ts"
|
||||||
|
instructions: |
|
||||||
|
Playwright E2E test guidelines for this repo:
|
||||||
|
- Page objects live in `tests/e2e/web/pages/`. Each class wraps one page/route, holds only `private readonly` Locators, and exposes action methods.
|
||||||
|
- All tests must use the `app` fixture (type `App`) from `tests/e2e/web/fixtures/base.ts`. Never instantiate page objects directly in a test.
|
||||||
|
- Cross-page flows (actions spanning multiple pages) belong as methods on the `App` class, not as standalone helper functions.
|
||||||
|
- Action methods in page objects must assert `expect(locator).toBeVisible()` before interacting.
|
||||||
|
- Never use `page.waitForTimeout()`. Use Playwright's built-in auto-waiting or `waitForURL` / `waitForSelector`.
|
||||||
|
- No hardcoded credentials in spec files; use `SPEC_CONFIG.ts` or account fixtures.
|
||||||
|
- Test account cleanup must be done in fixture teardown (after `await use(...)`), not in `afterEach` hooks.
|
||||||
|
- File and class names must use PascalCase (e.g., `CompatibilityPage.ts` / `class CompatibilityPage`).
|
||||||
|
- No DB or Firebase calls inside page object classes; those belong in `tests/e2e/utils/`.
|
||||||
|
- Flag any new page object not yet registered in `App`.
|
||||||
5
.coderabbitignore
Normal file
5
.coderabbitignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
**/*.md
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
dist/**
|
||||||
|
test/mocks/**
|
||||||
420
.cursor/rules/dev-rules.mdc
Normal file
420
.cursor/rules/dev-rules.mdc
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- next.js react tailwind frontend `/web`
|
||||||
|
- broken down into pages, components, hooks, lib
|
||||||
|
- express node api server `/backend/api`
|
||||||
|
- one off scripts, like migrations `/backend/scripts`
|
||||||
|
- supabase postgres. schema in `/backend/supabase`
|
||||||
|
- supabase-generated types in `/backend/supabase/schema.ts`
|
||||||
|
- files shared between backend directories `/backend/shared`
|
||||||
|
- anything in `/backend` can import from `shared`, but not vice versa
|
||||||
|
- files shared between the frontend and backend in `/common`
|
||||||
|
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from `/common`, but not vice versa.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- The project has both dev and prod environments.
|
||||||
|
- Backend is on GCP (Google Cloud Platform). Deployment handled by terraform.
|
||||||
|
- Project ID is `compass-130ba`.
|
||||||
|
|
||||||
|
## Code Guidelines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Here's an example component from web in our style:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import { isAdminId, isModId } from 'common/envs/constants'
|
||||||
|
import { type Headline } from 'common/news'
|
||||||
|
import { EditNewsButton } from 'web/components/news/edit-news-button'
|
||||||
|
import { Carousel } from 'web/components/widgets/carousel'
|
||||||
|
import { useUser } from 'web/hooks/use-user'
|
||||||
|
import { track } from 'web/lib/service/analytics'
|
||||||
|
import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page'
|
||||||
|
import { removeEmojis } from 'common/util/string'
|
||||||
|
|
||||||
|
export function HeadlineTabs(props: {
|
||||||
|
headlines: Headline[]
|
||||||
|
currentSlug: string
|
||||||
|
endpoint: DashboardEndpoints
|
||||||
|
hideEmoji?: boolean
|
||||||
|
notSticky?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } =
|
||||||
|
props
|
||||||
|
const user = useUser()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
'bg-canvas-50 w-full',
|
||||||
|
!notSticky && 'sticky top-0 z-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Carousel labelsParentClassName="gap-px">
|
||||||
|
{headlines.map(({ id, slug, title }) => (
|
||||||
|
<Tab
|
||||||
|
key={id}
|
||||||
|
label={hideEmoji ? removeEmojis(title) : title}
|
||||||
|
href={`/${endpoint}/${slug}`}
|
||||||
|
active={slug === currentSlug}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{user && <Tab label="More" href="/dashboard" />}
|
||||||
|
{user && (isAdminId(user.id) || isModId(user.id)) && (
|
||||||
|
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />
|
||||||
|
)}
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components.
|
||||||
|
|
||||||
|
It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find.
|
||||||
|
|
||||||
|
Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { api } from 'web/lib/api/api'
|
||||||
|
// More imports...
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
try {
|
||||||
|
const headlines = await api('headlines', {})
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
headlines,
|
||||||
|
revalidate: 30 * 60, // 30 minutes
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return { props: { headlines: [] }, revalidate: 60 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home(props: { headlines: Headline[] }) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
If we are calling the API on the client, prefer using the `useAPIGetter` hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const YourTopicsSection = (props: {
|
||||||
|
user: User
|
||||||
|
className?: string
|
||||||
|
}) => {
|
||||||
|
const { user, className } = props
|
||||||
|
const { data, refresh } = useAPIGetter('get-followed-groups', {
|
||||||
|
userId: user.id,
|
||||||
|
})
|
||||||
|
const followedGroups = data?.groups ?? []
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
This stores the result in memory, and allows you to call refresh() to get an updated version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in-memory caching so that navigating back to a page will preserve the same state and appear to load instantly.
|
||||||
|
|
||||||
|
Here's the definition of usePersistentInMemoryState:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
|
||||||
|
const [state, setState] = useStateCheckEquality<T>(
|
||||||
|
safeJsonParse(store[key]) ?? initialValue
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storedValue = safeJsonParse(store[key]) ?? initialValue
|
||||||
|
setState(storedValue as T)
|
||||||
|
}, [key])
|
||||||
|
|
||||||
|
const saveState = useEvent((newState: T | ((prevState: T) => T)) => {
|
||||||
|
setState((prevState) => {
|
||||||
|
const updatedState = isFunction(newState) ? newState(prevState) : newState
|
||||||
|
store[key] = JSON.stringify(updatedState)
|
||||||
|
return updatedState
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return [state, saveState] as const
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useApiSubscription(opts: SubscriptionOptions) {
|
||||||
|
useEffect(() => {
|
||||||
|
const ws = client
|
||||||
|
if (ws != null) {
|
||||||
|
if (opts.enabled ?? true) {
|
||||||
|
ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
|
||||||
|
return () => {
|
||||||
|
ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [opts.enabled, JSON.stringify(opts.topics)])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `use-bets`, we have this hook to get live updates with useApiSubscription:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const useContractBets = (
|
||||||
|
contractId: string,
|
||||||
|
opts?: APIParams<'bets'> & { enabled?: boolean }
|
||||||
|
) => {
|
||||||
|
const { enabled = true, ...apiOptions } = {
|
||||||
|
contractId,
|
||||||
|
...opts,
|
||||||
|
}
|
||||||
|
const optionsKey = JSON.stringify(apiOptions)
|
||||||
|
|
||||||
|
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
|
||||||
|
[],
|
||||||
|
`${optionsKey}-bets`
|
||||||
|
)
|
||||||
|
|
||||||
|
const addBets = (bets: Bet[]) => {
|
||||||
|
setNewBets((currentBets) => {
|
||||||
|
const uniqueBets = sortBy(
|
||||||
|
uniqBy([...currentBets, ...bets], 'id'),
|
||||||
|
'createdTime'
|
||||||
|
)
|
||||||
|
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPageVisible = useIsPageVisible()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPageVisible && enabled) {
|
||||||
|
api('bets', apiOptions).then(addBets)
|
||||||
|
}
|
||||||
|
}, [optionsKey, enabled, isPageVisible])
|
||||||
|
|
||||||
|
useApiSubscription({
|
||||||
|
topics: [`contract/${contractId}/new-bet`],
|
||||||
|
onBroadcast: (msg) => {
|
||||||
|
addBets(msg.data.bets as Bet[])
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
return newBets
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function broadcastUpdatedPrivateUser(userId: string) {
|
||||||
|
// don't send private user info because it's private and anyone can listen
|
||||||
|
broadcast(`private-user/${userId}`, {})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
|
||||||
|
broadcast(`user/${user.id}`, { user })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastUpdatedComment(comment: Comment) {
|
||||||
|
broadcast(`user/${comment.onUserId}/comment`, { comment })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We have our scripts in the directory `/backend/scripts`.
|
||||||
|
|
||||||
|
To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env.
|
||||||
|
|
||||||
|
Example from `/backend/scripts/manicode.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { runScript } from 'run-script'
|
||||||
|
|
||||||
|
runScript(async ({ pg }) => {
|
||||||
|
const userPrompt = process.argv[2]
|
||||||
|
await pg.none(...)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Generally scripts should be run by me, especially if they modify backend state or schema.
|
||||||
|
But if you need to run a script, you can use `bun`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun run manicode.ts "Generate a page called cowp, which has cows that make noises!"
|
||||||
|
```
|
||||||
|
|
||||||
|
if that doesn't work, try
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `/common/src/api/schema.ts`.
|
||||||
|
|
||||||
|
E.g. Here is a hypothetical bet schema:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
bet: {
|
||||||
|
method: 'POST',
|
||||||
|
authed: true,
|
||||||
|
returns: {} as CandidateBet & { betId: string },
|
||||||
|
props: z
|
||||||
|
.object({
|
||||||
|
contractId: z.string(),
|
||||||
|
amount: z.number().gte(1),
|
||||||
|
replyToCommentId: z.string().optional(),
|
||||||
|
limitProb: z.number().gte(0.01).lte(0.99).optional(),
|
||||||
|
expiresAt: z.number().optional(),
|
||||||
|
// Used for binary and new multiple choice contracts (cpmm-multi-1).
|
||||||
|
outcome: z.enum(['YES', 'NO']).default('YES'),
|
||||||
|
//Multi
|
||||||
|
answerId: z.string().optional(),
|
||||||
|
dryRun: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, we define the bet endpoint in `backend/api/src/place-bet.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const placeBet: APIHandler<'bet'> = async (props, auth) => {
|
||||||
|
const isApi = auth.creds.kind === 'key'
|
||||||
|
return await betsQueue.enqueueFn(
|
||||||
|
() => placeBetMain(props, auth.uid, isApi),
|
||||||
|
[props.contractId, auth.uid]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
And finally, you need to register the handler in `backend/api/src/routes.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { placeBet } from './place-bet'
|
||||||
|
...
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
bet: placeBet,
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We have two ways to access our postgres database.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db } from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
and
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [userId])
|
||||||
|
```
|
||||||
|
|
||||||
|
The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query and update the database directly from the frontend.
|
||||||
|
|
||||||
|
`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this.
|
||||||
|
|
||||||
|
Another example using the direct client:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const getUniqueBettorIds = async (
|
||||||
|
contractId: string,
|
||||||
|
pg: SupabaseDirectClient
|
||||||
|
) => {
|
||||||
|
const res = await pg.manyOrNone(
|
||||||
|
'select distinct user_id from contract_bets where contract_id = $1',
|
||||||
|
[contractId]
|
||||||
|
)
|
||||||
|
return res.map((r) => r.user_id as string)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(you may notice we write sql in lowercase)
|
||||||
|
|
||||||
|
We have a few helper functions for updating and inserting data into the database.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
buikInsert,
|
||||||
|
bulkUpdate,
|
||||||
|
bulkUpdateData,
|
||||||
|
bulkUpsert,
|
||||||
|
insert,
|
||||||
|
update,
|
||||||
|
updateData,
|
||||||
|
} from 'shared/supabase/utils'
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// you are encouraged to use tryCatch for these
|
||||||
|
const { data, error } = await tryCatch(
|
||||||
|
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw APIError(500, 'Error creating profile: ' + error.message)
|
||||||
|
|
||||||
|
await update(pg, 'profiles', 'user_id', { user_id: auth.uid, age: 99 })
|
||||||
|
|
||||||
|
await updateData(pg, 'private_users', { id: userId, notifications: { ... } })
|
||||||
|
```
|
||||||
|
|
||||||
|
The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it does is sanitize and output sql query strings. It has several helper functions including:
|
||||||
|
|
||||||
|
- `select`: Specifies the columns to select
|
||||||
|
- `from`: Specifies the table to query
|
||||||
|
- `where`: Adds WHERE clauses
|
||||||
|
- `orderBy`: Specifies the order of results
|
||||||
|
- `limit`: Limits the number of results
|
||||||
|
- `renderSql`: Combines all parts into a final SQL string
|
||||||
|
|
||||||
|
Example usage:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const query = renderSql(
|
||||||
|
select('distinct user_id'),
|
||||||
|
from('contract_bets'),
|
||||||
|
where('contract_id = ${id}', { id }),
|
||||||
|
orderBy('created_time desc'),
|
||||||
|
limitValue != null && limit(limitValue)
|
||||||
|
)
|
||||||
|
|
||||||
|
const res = await pg.manyOrNone(query)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use these functions instead of string concatenation.
|
||||||
19
.env.test
Normal file
19
.env.test
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Database
|
||||||
|
#DATABASE_URL=postgresql://test_user:test_password@localhost:5433/test_db
|
||||||
|
#DIRECT_URL=postgresql://test_user:test_password@localhost:5433/test_db
|
||||||
|
|
||||||
|
# Firebase
|
||||||
|
FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
|
||||||
|
FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199
|
||||||
|
FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8088
|
||||||
|
NEXT_PUBLIC_FIREBASE_ENV=TEST
|
||||||
|
NEXT_PUBLIC_FIREBASE_EMULATOR=true
|
||||||
|
|
||||||
|
# API
|
||||||
|
NODE_ENV=test
|
||||||
|
PORT=8088
|
||||||
|
|
||||||
|
ENVIRONMENT=DEV
|
||||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -6,7 +6,7 @@ open_collective: compass-connection # Replace with a single Open Collective user
|
|||||||
ko_fi: compassconnections # Replace with a single Ko-fi username
|
ko_fi: compassconnections # Replace with a single Ko-fi username
|
||||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
liberapay: # Replace with a single Liberapay username
|
liberapay: CompassConnections # Replace with a single Liberapay username
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||||
polar: # Replace with a single Polar username
|
polar: # Replace with a single Polar username
|
||||||
|
|||||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what the bug is.
|
|
||||||
|
|
||||||
**To Reproduce**
|
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
|
||||||
- OS: [e.g. iOS]
|
|
||||||
- Browser [e.g. chrome, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context about the problem here.
|
|
||||||
63
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
63
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
name: Bug report
|
||||||
|
description: Create a report to help us improve
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Bug description
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps to reproduce
|
||||||
|
description: |
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Scroll down to '...'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Expected behavior
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Screenshots
|
||||||
|
description: |
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Info
|
||||||
|
description: |
|
||||||
|
- Browser: [e.g. chrome, safari]
|
||||||
|
- Device (if mobile): [e.g. iPhone6]
|
||||||
|
- Build info
|
||||||
|
placeholder: |
|
||||||
|
Build info from `Settings` -> `About`
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: |
|
||||||
|
Add any other context about the problem here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for contributing!
|
||||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
blank_issues_enabled: true
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
title: ''
|
|
||||||
labels: ''
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
**Describe the solution you'd like**
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
**Describe alternatives you've considered**
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
**Additional context**
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Feature request
|
||||||
|
description: Suggest an idea or improvement for this project
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Problem
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what the problem is.
|
||||||
|
placeholder: I'm always frustrated when [...]
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Solution
|
||||||
|
description: |
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Alternatives
|
||||||
|
description: |
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Additional context
|
||||||
|
description: |
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for contributing!
|
||||||
25
.github/ISSUE_TEMPLATE/other.yml
vendored
Normal file
25
.github/ISSUE_TEMPLATE/other.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Other
|
||||||
|
description: Use this only if no other issue type fits.
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Issue
|
||||||
|
description: |
|
||||||
|
A clear and concise description of the question or issue
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Info
|
||||||
|
description: |
|
||||||
|
- Browser: [e.g. chrome, safari]
|
||||||
|
- Device (if mobile): [e.g. iPhone6]
|
||||||
|
- Build info
|
||||||
|
placeholder: |
|
||||||
|
Build info from `Settings` -> `About`
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for contributing!
|
||||||
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
8
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- Replace [ ] with [X] to check a box -->
|
||||||
|
|
||||||
|
- [ ] Closes #xxxx (Replace xxxx with the GitHub issue number, or delete line).
|
||||||
|
- [ ] Tests added and passed if fixing a bug or adding a new feature.
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
<!-- Describe your changes in detail -->
|
||||||
33
.github/actions/setup/action.yml
vendored
Normal file
33
.github/actions/setup/action.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
name: Setup
|
||||||
|
description: Checkout, cache and install dependencies
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
web/node_modules
|
||||||
|
backend/api/node_modules
|
||||||
|
backend/shared/node_modules
|
||||||
|
backend/email/node_modules
|
||||||
|
common/node_modules
|
||||||
|
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Post-install
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit == 'true'
|
||||||
|
run: yarn postinstall
|
||||||
|
shell: bash
|
||||||
271
.github/copilot-instructions.md
vendored
Normal file
271
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
|
||||||
|
|
||||||
|
- **Next.js React frontend** `/web`
|
||||||
|
- Pages, components, hooks, lib
|
||||||
|
- **Express Node API server** `/backend/api`
|
||||||
|
- **Shared backend utilities** `/backend/shared`
|
||||||
|
- **Email functions** `/backend/email`
|
||||||
|
- **Database schema** `/backend/supabase`
|
||||||
|
- Supabase-generated types in `/backend/supabase/schema.ts`
|
||||||
|
- **Files shared between frontend and backend** `/common`
|
||||||
|
- Types (User, Profile, etc.) and utilities
|
||||||
|
- Try not to add package dependencies to common
|
||||||
|
- **Android app** `/android`
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Both dev and prod environments
|
||||||
|
- Backend on GCP (Google Cloud Platform)
|
||||||
|
- Frontend on Vercel
|
||||||
|
- Database on Supabase (PostgreSQL)
|
||||||
|
- Firebase for authentication and storage
|
||||||
|
|
||||||
|
## Code Guidelines
|
||||||
|
|
||||||
|
### Component Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import {User} from 'common/user'
|
||||||
|
import {ProfileRow} from 'common/profiles/profile'
|
||||||
|
import {useUser} from 'web/hooks/use-user'
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
user: User
|
||||||
|
profile: ProfileRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({user, profile}: ProfileCardProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
|
||||||
|
<img src={user.avatarUrl} alt={user.name} />
|
||||||
|
<h3>{user.name}</h3>
|
||||||
|
<p>{profile.bio}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We prefer many smaller components that each represent one logical unit, rather than one large component.
|
||||||
|
|
||||||
|
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx` →
|
||||||
|
`ProfileCard`).
|
||||||
|
|
||||||
|
### API Calls
|
||||||
|
|
||||||
|
**Server-side (getStaticProps):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {api} from 'web/lib/api'
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const profiles = await api('get-profiles', {})
|
||||||
|
return {
|
||||||
|
props: {profiles},
|
||||||
|
revalidate: 30 * 60, // 30 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client-side - use hooks:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||||
|
|
||||||
|
function ProfileList() {
|
||||||
|
const {data, refresh} = useAPIGetter('get-profiles', {})
|
||||||
|
|
||||||
|
if (!data) return <CompassLoadingIndicator / >
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
data.profiles.map((profile) => (
|
||||||
|
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<button onClick = {refresh} > Refresh < /button>
|
||||||
|
< /div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
**Backend (pg-promise):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend (Supabase client):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {db} from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return <h1>{t('welcome', 'Welcome to Compass')}</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Translation files are in `common/messages/` (en.json, fr.json, de.json).
|
||||||
|
|
||||||
|
### Backend Endpoints
|
||||||
|
|
||||||
|
1. Define schema in `common/src/api/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'get-user-and-profile': {
|
||||||
|
method: 'GET',
|
||||||
|
authed: false,
|
||||||
|
rateLimited: true,
|
||||||
|
props: z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
}),
|
||||||
|
returns: {} as {user: User; profile: ProfileRow | null},
|
||||||
|
summary: 'Get user and profile data by username',
|
||||||
|
tag: 'Users',
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create handler in `backend/api/src/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
|
||||||
|
const user = await getUserByUsername(username)
|
||||||
|
if (!user) {
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {user, profile}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Register in `backend/api/src/app.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {getUserAndProfile} from './get-user-and-profile'
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
'get-user-and-profile': getUserAndProfile,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile Options (Interests, Causes, Work)
|
||||||
|
|
||||||
|
Options are stored in separate tables with many-to-many relationships:
|
||||||
|
|
||||||
|
- `interests`, `causes`, `work` - option values
|
||||||
|
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
|
||||||
|
|
||||||
|
Fetch in parallel:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [interestsRes, causesRes, workRes] = await Promise.all([
|
||||||
|
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Jest (unit + integration)
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
- Unit tests: `*.unit.test.ts` in `tests/unit/`
|
||||||
|
- Integration tests: `*.integration.test.ts` in `tests/integration/`
|
||||||
|
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
|
||||||
|
|
||||||
|
### Mocking Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
jest.mock('shared/supabase/init')
|
||||||
|
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const mockPg = {
|
||||||
|
oneOrNone: jest.fn(),
|
||||||
|
tx: jest.fn(async (cb) => cb(mockTx)),
|
||||||
|
}
|
||||||
|
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### User Registration
|
||||||
|
|
||||||
|
- Create user + profile + options in single database transaction
|
||||||
|
- Return full profile data from creation API
|
||||||
|
- Don't use sleep() hacks - rely on transactional integrity
|
||||||
|
|
||||||
|
### API Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError} from './helpers/endpoint'
|
||||||
|
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
throw APIErrors.badRequest('Invalid input', {field: 'email'})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Use `debug()` from `common/logger` for development
|
||||||
|
- Use `log` from `shared/utils` for production
|
||||||
|
|
||||||
|
## Things to Avoid
|
||||||
|
|
||||||
|
- Don't use string concatenation for SQL queries
|
||||||
|
- Don't add sleep() delays for "eventual consistency"
|
||||||
|
- Don't create separate API calls when data can be batched in one transaction
|
||||||
|
- Don't use console.log - use `debug()` or `log()`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- React 19
|
||||||
|
- Next.js 16
|
||||||
|
- Supabase (PostgreSQL)
|
||||||
|
- Firebase (Auth, Storage)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Jest (testing)
|
||||||
|
- Playwright (E2E testing)
|
||||||
67
.github/workflows/cd-android-live-update.yml
vendored
Normal file
67
.github/workflows/cd-android-live-update.yml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
name: CD Android Live Update
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- 'android/capawesome.json'
|
||||||
|
- '.github/workflows/cd-android-live-update.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
name: Check Version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
changed: ${{ steps.check.outputs.changed }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Read current version
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
current=$(jq -r '.version' android/capawesome.json)
|
||||||
|
echo "version=$current" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Read previous version
|
||||||
|
id: previous
|
||||||
|
run: |
|
||||||
|
if git show HEAD^:android/capawesome.json >/dev/null 2>&1; then
|
||||||
|
previous=$(git show HEAD^:android/capawesome.json | jq -r '.version')
|
||||||
|
else
|
||||||
|
previous="none"
|
||||||
|
fi
|
||||||
|
echo "version=$previous" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Compare versions
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
echo "current=${{ steps.current.outputs.version }}"
|
||||||
|
echo "previous=${{ steps.previous.outputs.version }}"
|
||||||
|
if [ "${{ steps.current.outputs.version }}" = "${{ steps.previous.outputs.version }}" ]; then
|
||||||
|
echo "Version unchanged. Skipping deploy."
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check-version
|
||||||
|
if: needs.check-version.outputs.changed == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
|
- name: Deploy Live Update
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
||||||
|
CAPAWESOME_TOKEN: ${{ secrets.CAPAWESOME_TOKEN }}
|
||||||
|
commitRef: ${{ github.head_ref || github.ref_name }}
|
||||||
|
commitSha: ${{ github.sha }}
|
||||||
|
run: yarn android-live-update
|
||||||
88
.github/workflows/cd-android.yml
vendored
Normal file
88
.github/workflows/cd-android.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
name: Android Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'android/app/build.gradle'
|
||||||
|
- '.github/workflows/cd-android.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
should_build: ${{ steps.version_check.outputs.should_build }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Get previous versionCode
|
||||||
|
id: prev_version
|
||||||
|
run: |
|
||||||
|
git checkout HEAD^
|
||||||
|
PREV=$(grep versionCode android/app/build.gradle | awk '{print $2}')
|
||||||
|
echo "prev_version=$PREV" >> $GITHUB_OUTPUT
|
||||||
|
git checkout -
|
||||||
|
|
||||||
|
- name: Get current versionCode
|
||||||
|
id: curr_version
|
||||||
|
run: |
|
||||||
|
CURR=$(grep versionCode android/app/build.gradle | awk '{print $2}')
|
||||||
|
echo "curr_version=$CURR" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Compare versionCodes
|
||||||
|
id: version_check
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.curr_version.outputs.curr_version }}" -gt "${{ steps.prev_version.outputs.prev_version }}" ]; then
|
||||||
|
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "versionCode not increased. Skipping build."
|
||||||
|
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-and-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check-version
|
||||||
|
if: needs.check-version.outputs.should_build == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
|
- name: Set up JDK
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: temurin
|
||||||
|
java-version: 21
|
||||||
|
cache: gradle
|
||||||
|
|
||||||
|
- name: Compile Web App into Android assets
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||||
|
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
||||||
|
run: yarn build-sync-android
|
||||||
|
|
||||||
|
- name: Build AAB
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
echo "${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }}" | base64 -d > app/google-services.json
|
||||||
|
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > release.keystore
|
||||||
|
cp release.keystore app/release.keystore
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew bundleRelease \
|
||||||
|
-Pandroid.injected.signing.store.file=release.keystore \
|
||||||
|
-Pandroid.injected.signing.store.password=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||||
|
-Pandroid.injected.signing.key.alias=compass \
|
||||||
|
-Pandroid.injected.signing.key.password=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Upload to Play Store (Internal Track)
|
||||||
|
uses: r0adkll/upload-google-play@v1
|
||||||
|
with:
|
||||||
|
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||||
|
packageName: com.compassconnections.app
|
||||||
|
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
|
||||||
|
track: internal
|
||||||
87
.github/workflows/cd-api.yml
vendored
Normal file
87
.github/workflows/cd-api.yml
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
name: API Release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- 'backend/api/package.json'
|
||||||
|
- '.github/workflows/cd-api.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version:
|
||||||
|
name: Check Version
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
changed: ${{ steps.check.outputs.changed }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Read current version
|
||||||
|
id: current
|
||||||
|
run: |
|
||||||
|
current=$(jq -r '.version' backend/api/package.json)
|
||||||
|
echo "version=$current" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Read previous version
|
||||||
|
id: previous
|
||||||
|
run: |
|
||||||
|
if git show HEAD^:backend/api/package.json >/dev/null 2>&1; then
|
||||||
|
previous=$(git show HEAD^:backend/api/package.json | jq -r '.version')
|
||||||
|
else
|
||||||
|
previous="none"
|
||||||
|
fi
|
||||||
|
echo "version=$previous" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Compare versions
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
echo "current=${{ steps.current.outputs.version }}"
|
||||||
|
echo "previous=${{ steps.previous.outputs.version }}"
|
||||||
|
if [ "${{ steps.current.outputs.version }}" = "${{ steps.previous.outputs.version }}" ]; then
|
||||||
|
echo "Version unchanged. Skipping deploy."
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: check-version
|
||||||
|
if: needs.check-version.outputs.changed == 'true'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
|
- name: Authenticate to GCP
|
||||||
|
uses: google-github-actions/auth@v2
|
||||||
|
with:
|
||||||
|
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||||
|
|
||||||
|
- name: Install gcloud CLI
|
||||||
|
uses: google-github-actions/setup-gcloud@v2
|
||||||
|
with:
|
||||||
|
project_id: compass-130ba
|
||||||
|
|
||||||
|
- name: Configure Docker for Artifact Registry
|
||||||
|
run: gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
|
||||||
|
|
||||||
|
- name: Install Tofu (Terraform)
|
||||||
|
run: |
|
||||||
|
LATEST=https://github.com/opentofu/opentofu/releases/download/v1.10.5/tofu_1.10.5_linux_amd64.zip
|
||||||
|
curl -LO "$LATEST"
|
||||||
|
unzip -o tofu_*_linux_amd64.zip
|
||||||
|
sudo mv tofu /usr/local/bin/
|
||||||
|
rm tofu_*_linux_amd64.zip
|
||||||
|
echo "OpenTofu version: $(tofu version)"
|
||||||
|
cd backend/api
|
||||||
|
tofu init
|
||||||
|
|
||||||
|
- name: Run deploy script
|
||||||
|
run: |
|
||||||
|
chmod +x backend/api/deploy-api.sh
|
||||||
|
backend/api/deploy-api.sh
|
||||||
10
.github/workflows/cd.yml
vendored
10
.github/workflows/cd.yml
vendored
@@ -1,11 +1,13 @@
|
|||||||
name: CD
|
name: GitHub Release
|
||||||
|
|
||||||
# Must select "Read and write permissions" in GitHub → Repo → Settings → Actions → General → Workflow permissions
|
# Must select "Read and write permissions" in GitHub → Repo → Settings → Actions → General → Workflow permissions
|
||||||
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [main, master]
|
||||||
|
paths:
|
||||||
|
- 'package.json'
|
||||||
|
- '.github/workflows/cd.yml'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -15,7 +17,7 @@ jobs:
|
|||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@master
|
uses: actions/checkout@master
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0 # To fetch all history for tags
|
fetch-depth: 0 # To fetch all history for tags
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
68
.github/workflows/ci-e2e.yml
vendored
Normal file
68
.github/workflows/ci-e2e.yml
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
name: E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
e2e:
|
||||||
|
name: E2E Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
|
- name: Cache Firebase emulators
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/firebase/emulators
|
||||||
|
key: firebase-emulators-${{ hashFiles('firebase.json') }}
|
||||||
|
restore-keys: firebase-emulators-
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ hashFiles('package.json') }}
|
||||||
|
|
||||||
|
- name: Install Java (for Firebase emulators)
|
||||||
|
uses: actions/setup-java@v4
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: '21' # Required for firebase-tools@15+
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: npx playwright install chromium --with-deps
|
||||||
|
|
||||||
|
# Docker load from cache is actually slower than pulling the images every time with supabase start
|
||||||
|
- name: Start Supabase
|
||||||
|
run: ./scripts/supabase_start.sh
|
||||||
|
|
||||||
|
- name: Run E2E tests
|
||||||
|
env:
|
||||||
|
SKIP_DB_CLEANUP: true # Don't try to stop Docker in CI
|
||||||
|
FIREBASE_TOKEN: 'dummy' # Suppresses auth warning
|
||||||
|
# or
|
||||||
|
run: |
|
||||||
|
yarn test:e2e
|
||||||
|
|
||||||
|
- name: Upload Playwright report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-report
|
||||||
|
path: tests/reports/playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: test-results
|
||||||
|
path: test-results/
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: ignore
|
||||||
81
.github/workflows/ci.yml
vendored
81
.github/workflows/ci.yml
vendored
@@ -2,59 +2,52 @@ name: CI
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
lint:
|
||||||
name: All
|
name: Lint & Typecheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install
|
|
||||||
|
|
||||||
- name: Type check
|
|
||||||
run: echo skipping #npx tsc --noEmit
|
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: yarn typecheck
|
||||||
|
|
||||||
|
test:
|
||||||
|
name: Jest Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: ./.github/actions/setup
|
||||||
|
|
||||||
- name: Run Jest tests
|
- name: Run Jest tests
|
||||||
run: npm run test tests/jest
|
|
||||||
|
|
||||||
# - name: Build app
|
|
||||||
# env:
|
|
||||||
# DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
||||||
# run: npm run build
|
|
||||||
|
|
||||||
# Optional: Playwright E2E tests
|
|
||||||
- name: Install Playwright deps
|
|
||||||
run: npx playwright install --with-deps
|
|
||||||
# npm install @playwright/test
|
|
||||||
# npx playwright install
|
|
||||||
|
|
||||||
- name: Run E2E tests
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_API_URL: localhost:8088
|
NEXT_PUBLIC_FIREBASE_ENV: DEV
|
||||||
NEXT_PUBLIC_FIREBASE_ENV: PROD
|
|
||||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
|
||||||
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
|
||||||
run: |
|
run: |
|
||||||
yarn --cwd=web serve &
|
yarn test:coverage
|
||||||
npx wait-on http://localhost:3000
|
|
||||||
npx playwright test tests/playwright
|
- name: Upload coverage to Codecov
|
||||||
SERVER_PID=$(fuser -k 3000/tcp)
|
uses: codecov/codecov-action@v5
|
||||||
echo $SERVER_PID
|
if: success()
|
||||||
kill $SERVER_PID
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
files: |
|
||||||
|
backend/api/coverage/lcov.info
|
||||||
|
backend/shared/coverage/lcov.info
|
||||||
|
backend/email/coverage/lcov.info
|
||||||
|
common/coverage/lcov.info
|
||||||
|
web/coverage/lcov.info
|
||||||
|
flags: unit
|
||||||
|
fail_ci_if_error: true
|
||||||
|
slug: CompassConnections/Compass
|
||||||
|
|||||||
21
.gitignore
vendored
21
.gitignore
vendored
@@ -13,6 +13,10 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/tests/reports/playwright-report
|
||||||
|
/tests/e2e/web/.auth/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
@@ -35,7 +39,6 @@ yarn-error.log*
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
.envrc
|
.envrc
|
||||||
supabase/*
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -61,7 +64,7 @@ email-preview
|
|||||||
*.last-run.json
|
*.last-run.json
|
||||||
|
|
||||||
*lock.hcl
|
*lock.hcl
|
||||||
/web/pages/test.tsx
|
/web/pages/_test.tsx
|
||||||
|
|
||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
@@ -71,6 +74,7 @@ email-preview
|
|||||||
*.ico
|
*.ico
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
|
*.webp
|
||||||
*.avi
|
*.avi
|
||||||
*.wmv
|
*.wmv
|
||||||
*.mp3
|
*.mp3
|
||||||
@@ -87,3 +91,16 @@ email-preview
|
|||||||
*.terraform
|
*.terraform
|
||||||
/backups/firebase/auth/data/
|
/backups/firebase/auth/data/
|
||||||
/backups/firebase/storage/data/
|
/backups/firebase/storage/data/
|
||||||
|
|
||||||
|
android/app/release*
|
||||||
|
icons/
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
test-results
|
||||||
|
/.nyc_output/
|
||||||
|
|
||||||
|
**/coverage
|
||||||
|
|
||||||
|
*my-release-key.keystore
|
||||||
|
|
||||||
|
.vscode/settings.json
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
1294
.junie/guidelines.md
Normal file
1294
.junie/guidelines.md
Normal file
File diff suppressed because it is too large
Load Diff
28
.lintstagedrc.mjs
Normal file
28
.lintstagedrc.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export default {
|
||||||
|
'web/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config web/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config web/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'common/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config common/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config common/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'backend/api/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/api/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/api/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'backend/shared/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/shared/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/shared/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'backend/email/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||||
|
`prettier --write ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/email/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||||
|
`eslint --config backend/email/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||||
|
],
|
||||||
|
'**/*.{json,css,scss,md}': (files) => [`prettier --write ${files.join(' ')}`],
|
||||||
|
}
|
||||||
35
.prettierignore
Normal file
35
.prettierignore
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
.yarn
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
lib
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
coverage
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
|
||||||
|
# Database / migrations
|
||||||
|
**/*.sql
|
||||||
|
|
||||||
|
# Config / lock files
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# Android / iOS
|
||||||
|
android
|
||||||
|
ios
|
||||||
|
capacitor.config.ts
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
tests/reports
|
||||||
|
playwright-report
|
||||||
|
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"plugins": ["prettier-plugin-sql"],
|
"singleAttributePerLine": false,
|
||||||
|
"bracketSpacing": false,
|
||||||
|
"printWidth": 100,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": ["prettier-plugin-sql", "prettier-plugin-packagejson"],
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "*.sql",
|
"files": "*.sql",
|
||||||
|
|||||||
33
.vscode/launch.json
vendored
Normal file
33
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Current Test",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"--inspect-brk",
|
||||||
|
"${workspaceRoot}/node_modules/.bin/jest",
|
||||||
|
"${fileBasename}",
|
||||||
|
"--runInBand"
|
||||||
|
],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"internalConsoleOptions": "neverOpen"
|
||||||
|
}
|
||||||
|
// {
|
||||||
|
// "type": "node",
|
||||||
|
// "request": "launch",
|
||||||
|
// "name": "Launch Program",
|
||||||
|
// "skipFiles": [
|
||||||
|
// "<node_internals>/**"
|
||||||
|
// ],
|
||||||
|
// "program": "${workspaceFolder}/backend/api/tests/unit/get-profiles.unit.test.ts",
|
||||||
|
// "outFiles": [
|
||||||
|
// "${workspaceFolder}/**/*.js"
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
}
|
||||||
295
.windsurf/rules/compass.md
Normal file
295
.windsurf/rules/compass.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
|
||||||
|
|
||||||
|
- **Next.js React frontend** `/web`
|
||||||
|
- Pages, components, hooks, lib
|
||||||
|
- **Express Node API server** `/backend/api`
|
||||||
|
- **Shared backend utilities** `/backend/shared`
|
||||||
|
- **Email functions** `/backend/email`
|
||||||
|
- **Database schema** `/backend/supabase`
|
||||||
|
- Supabase-generated types in `/backend/supabase/schema.ts`
|
||||||
|
- **Files shared between frontend and backend** `/common`
|
||||||
|
- Types (User, Profile, etc.) and utilities
|
||||||
|
- Try not to add package dependencies to common
|
||||||
|
- **Android app** `/android`
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- Both dev and prod environments
|
||||||
|
- Backend on GCP (Google Cloud Platform)
|
||||||
|
- Frontend on Vercel
|
||||||
|
- Database on Supabase (PostgreSQL)
|
||||||
|
- Firebase for authentication and storage
|
||||||
|
|
||||||
|
## Code Guidelines
|
||||||
|
|
||||||
|
### Component Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
import {User} from 'common/user'
|
||||||
|
import {ProfileRow} from 'common/profiles/profile'
|
||||||
|
import {useUser} from 'web/hooks/use-user'
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
user: User
|
||||||
|
profile: ProfileRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({user, profile}: ProfileCardProps) {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
|
||||||
|
<img src={user.avatarUrl} alt={user.name} />
|
||||||
|
<h3>{user.name}</h3>
|
||||||
|
<p>{profile.bio}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We prefer many smaller components that each represent one logical unit, rather than one large component.
|
||||||
|
|
||||||
|
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx` →
|
||||||
|
`ProfileCard`).
|
||||||
|
|
||||||
|
### API Calls
|
||||||
|
|
||||||
|
**Server-side (getStaticProps):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {api} from 'web/lib/api'
|
||||||
|
|
||||||
|
export async function getStaticProps() {
|
||||||
|
const profiles = await api('get-profiles', {})
|
||||||
|
return {
|
||||||
|
props: {profiles},
|
||||||
|
revalidate: 30 * 60, // 30 minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Client-side - use hooks:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||||
|
|
||||||
|
function ProfileList() {
|
||||||
|
const {data, refresh} = useAPIGetter('get-profiles', {})
|
||||||
|
|
||||||
|
if (!data) return <Loading / >
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
data.profiles.map((profile) => (
|
||||||
|
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<button onClick = {refresh} > Refresh < /button>
|
||||||
|
< /div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
**Backend (pg-promise):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend (Supabase client):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {db} from 'web/lib/supabase/db'
|
||||||
|
|
||||||
|
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Translation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return <h1>{t('welcome', 'Welcome to Compass'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Translation files are in `common/messages/` (en.json, fr.json, de.json).
|
||||||
|
|
||||||
|
### Backend Endpoints
|
||||||
|
|
||||||
|
1. Define schema in `common/src/api/schema.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'get-user-and-profile'
|
||||||
|
:
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
authed
|
||||||
|
:
|
||||||
|
false,
|
||||||
|
rateLimited
|
||||||
|
:
|
||||||
|
true,
|
||||||
|
props
|
||||||
|
:
|
||||||
|
z.object({
|
||||||
|
username: z.string().min(1),
|
||||||
|
}),
|
||||||
|
returns
|
||||||
|
:
|
||||||
|
{
|
||||||
|
}
|
||||||
|
as
|
||||||
|
{
|
||||||
|
user: User;
|
||||||
|
profile: ProfileRow | null
|
||||||
|
}
|
||||||
|
,
|
||||||
|
summary: 'Get user and profile data by username',
|
||||||
|
tag
|
||||||
|
:
|
||||||
|
'Users',
|
||||||
|
}
|
||||||
|
,
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create handler in `backend/api/src/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
|
||||||
|
const user = await getUserByUsername(username)
|
||||||
|
if (!user) {
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {user, profile}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Register in `backend/api/src/app.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {getUserAndProfile} from './get-user-and-profile'
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
'get-user-and-profile': getUserAndProfile,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Profile Options (Interests, Causes, Work)
|
||||||
|
|
||||||
|
Options are stored in separate tables with many-to-many relationships:
|
||||||
|
|
||||||
|
- `interests`, `causes`, `work` - option values
|
||||||
|
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
|
||||||
|
|
||||||
|
Fetch in parallel:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [interestsRes, causesRes, workRes] = await Promise.all([
|
||||||
|
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
|
||||||
|
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Errors
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError} from './helpers/endpoint'
|
||||||
|
|
||||||
|
throw APIErrors.notFound('User not found')
|
||||||
|
throw APIErrors.badRequest('Invalid input', {field: 'email'})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
- Use `debug()` from `common/logger` for development
|
||||||
|
- Use `log` from `shared/utils` for production
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Jest (unit + integration)
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Playwright (E2E)
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Structure
|
||||||
|
|
||||||
|
- Unit tests: `*.unit.test.ts` in `tests/unit/`
|
||||||
|
- Integration tests: `*.integration.test.ts` in `tests/integration/`
|
||||||
|
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
|
||||||
|
|
||||||
|
### Mocking Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
jest.mock('shared/supabase/init')
|
||||||
|
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
const mockPg = {
|
||||||
|
oneOrNone: jest.fn(),
|
||||||
|
tx: jest.fn(async (cb) => cb(mockTx)),
|
||||||
|
}
|
||||||
|
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### User Registration
|
||||||
|
|
||||||
|
- Create user + profile + options in single database transaction
|
||||||
|
- Return full profile data from creation API
|
||||||
|
- Don't use sleep() hacks - rely on transactional integrity
|
||||||
|
|
||||||
|
## Things to Avoid
|
||||||
|
|
||||||
|
- Don't use string concatenation for SQL queries
|
||||||
|
- Don't add sleep() delays for "eventual consistency"
|
||||||
|
- Don't create separate API calls when data can be batched in one transaction
|
||||||
|
- Don't use console.log - use `debug()` or `log()`
|
||||||
|
- Don't remove commented code
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- React 19
|
||||||
|
- Next.js 16
|
||||||
|
- Supabase (PostgreSQL)
|
||||||
|
- Firebase (Auth, Storage)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Jest (testing)
|
||||||
|
- Playwright (E2E testing)
|
||||||
42
.windsurf/rules/next.md
Normal file
42
.windsurf/rules/next.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
trigger: manual
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
---
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
const t = useT()
|
||||||
|
t('common.key', 'English translations')
|
||||||
|
```
|
||||||
|
|
||||||
|
Translations should go to the JSON files in `web/messages` (`de.json` and `fr.json`, as of now).
|
||||||
|
|
||||||
|
### Misc coding tips
|
||||||
|
|
||||||
|
We have many useful hooks that should be reused rather than rewriting them again.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
We prefer using lodash functions instead of reimplementing them with for loops:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {keyBy, uniq} from 'lodash'
|
||||||
|
|
||||||
|
const betsByUserId = keyBy(bets, 'userId')
|
||||||
|
const betIds = uniq(bets, (b) => b.id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Instead of Sets, consider using lodash's uniq function:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const betIds = uniq([])
|
||||||
|
for (const id of betIds) {
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
288
.windsurf/workflows/translate.md
Normal file
288
.windsurf/workflows/translate.md
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
---
|
||||||
|
description: Adding Translations to an Existing File (using useT)
|
||||||
|
---
|
||||||
|
|
||||||
|
## AI Assistant Workflow — Adding Translations to an Existing File (using `useT`)
|
||||||
|
|
||||||
|
This is **not** about adding a new language.
|
||||||
|
This is about correctly adding new translation keys to a feature or component that already exists.
|
||||||
|
|
||||||
|
Follow this strictly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1️⃣ Identify All User-Facing Strings
|
||||||
|
|
||||||
|
Scan the file and list every:
|
||||||
|
|
||||||
|
- Button label
|
||||||
|
- Title
|
||||||
|
- Placeholder
|
||||||
|
- Tooltip
|
||||||
|
- Toast message
|
||||||
|
- Modal text
|
||||||
|
- Validation error
|
||||||
|
- SEO metadata
|
||||||
|
- Empty state message
|
||||||
|
|
||||||
|
If a string is visible to users, it must be translated.
|
||||||
|
|
||||||
|
Do **not** leave inline English in JSX.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button>Delete account</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button>{t('settings.delete_account', 'Delete account')}</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ Import and Initialize `useT`
|
||||||
|
|
||||||
|
At the top of the file:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const t = useT()
|
||||||
|
```
|
||||||
|
|
||||||
|
No exceptions.
|
||||||
|
Do not manually access locale files.
|
||||||
|
|
||||||
|
For the backend, use
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {createT} from 'shared/locale'
|
||||||
|
|
||||||
|
const t = createT(locale)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ Replace Hardcoded Strings
|
||||||
|
|
||||||
|
Wrap every string in:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
t('namespace.key', 'Default English text')
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
t('news.seo.description_general', 'All news and code updates for Compass')
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- First argument = stable translation key
|
||||||
|
- Second argument = default English fallback
|
||||||
|
- The English text must exactly match what you want displayed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ Naming Convention for Keys
|
||||||
|
|
||||||
|
Use structured namespaces.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```
|
||||||
|
profile.delete.confirm_title
|
||||||
|
profile.delete.confirm_body
|
||||||
|
settings.notifications.email_label
|
||||||
|
events.create.submit_button
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```
|
||||||
|
delete1
|
||||||
|
buttonText
|
||||||
|
labelNew
|
||||||
|
```
|
||||||
|
|
||||||
|
Keys must be:
|
||||||
|
|
||||||
|
- Hierarchical
|
||||||
|
- Feature-scoped
|
||||||
|
- Predictable
|
||||||
|
- Stable (never rename casually)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ Add the Keys to All Existing Locale Files
|
||||||
|
|
||||||
|
After updating the component:
|
||||||
|
|
||||||
|
1. Open:
|
||||||
|
|
||||||
|
```
|
||||||
|
common/src/messages/fr.json
|
||||||
|
common/src/messages/de.json
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add the new keys to **every language file**
|
||||||
|
|
||||||
|
Keys must be identical across all files.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"confirm_title": "Delete account?",
|
||||||
|
"confirm_body": "This action cannot be undone."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then translate values for non-English files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6️⃣ Use an LLM for Draft Translation (Correctly)
|
||||||
|
|
||||||
|
When translating large additions:
|
||||||
|
|
||||||
|
- Copy only the new JSON section
|
||||||
|
- Ask:
|
||||||
|
|
||||||
|
```
|
||||||
|
Translate the values of the JSON below to French.
|
||||||
|
Keep all keys unchanged.
|
||||||
|
Return valid JSON only.
|
||||||
|
```
|
||||||
|
|
||||||
|
Never let the model modify keys.
|
||||||
|
|
||||||
|
Then manually review.
|
||||||
|
|
||||||
|
LLMs make mistakes:
|
||||||
|
|
||||||
|
- Wrong tone
|
||||||
|
- Cultural mismatch
|
||||||
|
- Broken JSON
|
||||||
|
- Overly long mobile labels
|
||||||
|
|
||||||
|
You must verify.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7️⃣ Respect Mobile Constraints
|
||||||
|
|
||||||
|
Certain keys must stay short (< 10 characters):
|
||||||
|
|
||||||
|
```
|
||||||
|
nav.home
|
||||||
|
nav.messages
|
||||||
|
nav.more
|
||||||
|
nav.notifs
|
||||||
|
nav.people
|
||||||
|
```
|
||||||
|
|
||||||
|
If you add navigation items, enforce brevity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8️⃣ Handle Variables Properly
|
||||||
|
|
||||||
|
For dynamic values:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
t('events.count', '{count} events', {count})
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure placeholders match across all languages.
|
||||||
|
|
||||||
|
Do not concatenate strings manually.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'Events: ' + count
|
||||||
|
```
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
t('events.count', '{count} events', {count})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9️⃣ SEO & Metadata
|
||||||
|
|
||||||
|
Even SEO descriptions must use translations:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content={t('profile.seo.description', 'View user profiles and connect with like-minded people.')}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not hardcode metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10️⃣ Final Verification Checklist
|
||||||
|
|
||||||
|
Before committing:
|
||||||
|
|
||||||
|
- No visible English strings left
|
||||||
|
- All new keys added to all locale files
|
||||||
|
- No missing translations warnings
|
||||||
|
- JSON is valid
|
||||||
|
- Mobile nav labels are short
|
||||||
|
- Variables work in all languages
|
||||||
|
- No duplicate keys
|
||||||
|
- Namespaces are consistent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Example — Full Pattern
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useT} from 'web/lib/locale'
|
||||||
|
|
||||||
|
export default function DeleteModal() {
|
||||||
|
const t = useT()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h2>{t('profile.delete.confirm_title', 'Delete account?')}</h2>
|
||||||
|
<p>{t('profile.delete.confirm_body', 'This action cannot be undone.')}</p>
|
||||||
|
<button>{t('profile.delete.confirm_button', 'Delete')}</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Summary
|
||||||
|
|
||||||
|
When adding translations to a file:
|
||||||
|
|
||||||
|
1. Replace every user-visible string
|
||||||
|
2. Use `useT()`
|
||||||
|
3. Create structured keys
|
||||||
|
4. Add keys to every locale file
|
||||||
|
5. Translate values carefully
|
||||||
|
6. Validate JSON
|
||||||
|
7. Test UI in multiple languages
|
||||||
|
|
||||||
|
If you skip any of these steps, you create future maintenance debt.
|
||||||
|
|
||||||
|
There is no shortcut.
|
||||||
114
AGENTS.md
Normal file
114
AGENTS.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# AGENTS.md - AI Assistant Guidelines for Compass
|
||||||
|
|
||||||
|
This file provides guidance for AI assistants working on the Compass codebase.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections. Built with Next.js, React, Supabase, Firebase, and Google Cloud.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/web # Next.js frontend (React, Tailwind CSS)
|
||||||
|
/backend/api # Express.js REST API
|
||||||
|
/backend/shared # Shared backend utilities
|
||||||
|
/backend/email # Email functions
|
||||||
|
/common # Shared types and utilities between frontend/backend
|
||||||
|
/supabase # Database schema and migrations
|
||||||
|
/android # Android mobile app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
### Database Access
|
||||||
|
|
||||||
|
- Use `createSupabaseDirectClient()` for backend SQL queries (pg-promise)
|
||||||
|
- Use Supabase JS client (`db.from('table')`) for frontend queries
|
||||||
|
- Never use string concatenation for SQL - use parameterized queries
|
||||||
|
|
||||||
|
### API Development
|
||||||
|
|
||||||
|
1. Add endpoint schema to `common/src/api/schema.ts`
|
||||||
|
2. Create handler in `backend/api/src/`
|
||||||
|
3. Register in `backend/api/src/app.ts`
|
||||||
|
|
||||||
|
### Component Patterns
|
||||||
|
|
||||||
|
- Export main component at top of file
|
||||||
|
- Name component same as file (e.g., `profile-card.tsx` → `ProfileCard`)
|
||||||
|
- Use smaller, composable components over large ones
|
||||||
|
|
||||||
|
### Internationalization
|
||||||
|
|
||||||
|
- Translation files in `common/messages/` (en.json, fr.json, de.json)
|
||||||
|
- Use `useT()` hook: `t('key', 'fallback')`
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Unit tests: `*.unit.test.ts` in package `tests/unit/`
|
||||||
|
- Mock external dependencies (DB, APIs, time)
|
||||||
|
- Use `jest.mock()` at top of test files
|
||||||
|
|
||||||
|
### Profile System
|
||||||
|
|
||||||
|
- Profile fields stored in `profiles` table
|
||||||
|
- Options (interests, causes, work) stored in separate tables with many-to-many relationship
|
||||||
|
- Always fetch profile options in parallel using Promise.all
|
||||||
|
|
||||||
|
### User Registration Flow
|
||||||
|
|
||||||
|
1. Create user + profile + options in single transaction
|
||||||
|
2. Never use sleep() hacks - rely on transactional integrity
|
||||||
|
3. Return full profile data from creation API
|
||||||
|
|
||||||
|
### Important Patterns
|
||||||
|
|
||||||
|
#### Frontend API calls (server-side):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await api('endpoint-name', {props})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend API calls (client-side):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {data} = useAPIGetter('endpoint-name', {props})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Translation:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const t = useT()
|
||||||
|
return <div>{t('key', 'Default text')}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a profile field
|
||||||
|
|
||||||
|
1. Add column to `profiles` table via migration
|
||||||
|
2. Add to schema in `common/src/api/schema.ts`
|
||||||
|
3. Update frontend forms/components
|
||||||
|
|
||||||
|
### Adding translations
|
||||||
|
|
||||||
|
1. Add key to `common/messages/en.json`
|
||||||
|
2. Add translations to `fr.json`, `de.json`, etc.
|
||||||
|
|
||||||
|
## Things to Avoid
|
||||||
|
|
||||||
|
- Don't use string concatenation for SQL queries
|
||||||
|
- Don't add sleep() delays for "eventual consistency" - fix at DB level
|
||||||
|
- Don't create separate API calls when data can be batched in one transaction
|
||||||
|
- Don't use console.log - use `debug()` from `common/logger`
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- React 19
|
||||||
|
- Next.js 16
|
||||||
|
- Supabase (PostgreSQL)
|
||||||
|
- Firebase (Auth, Storage)
|
||||||
|
- Tailwind CSS
|
||||||
|
- Jest (testing)
|
||||||
|
- Playwright (E2E testing)
|
||||||
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
|||||||
Examples of behavior that contributes to a positive environment for our
|
Examples of behavior that contributes to a positive environment for our
|
||||||
community include:
|
community include:
|
||||||
|
|
||||||
* Demonstrating empathy and kindness toward other people
|
- Demonstrating empathy and kindness toward other people
|
||||||
* Being respectful of differing opinions, viewpoints, and experiences
|
- Being respectful of differing opinions, viewpoints, and experiences
|
||||||
* Giving and gracefully accepting constructive feedback
|
- Giving and gracefully accepting constructive feedback
|
||||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
and learning from the experience
|
and learning from the experience
|
||||||
* Focusing on what is best not just for us as individuals, but for the
|
- Focusing on what is best not just for us as individuals, but for the
|
||||||
overall community
|
overall community
|
||||||
|
|
||||||
Examples of unacceptable behavior include:
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
* The use of sexualized language or imagery, and sexual attention or
|
- The use of sexualized language or imagery, and sexual attention or
|
||||||
advances of any kind
|
advances of any kind
|
||||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
* Public or private harassment
|
- Public or private harassment
|
||||||
* Publishing others' private information, such as a physical or email
|
- Publishing others' private information, such as a physical or email
|
||||||
address, without their explicit permission
|
address, without their explicit permission
|
||||||
* Other conduct which could reasonably be considered inappropriate in a
|
- Other conduct which could reasonably be considered inappropriate in a
|
||||||
professional setting
|
professional setting
|
||||||
|
|
||||||
## Enforcement Responsibilities
|
## Enforcement Responsibilities
|
||||||
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
|
|||||||
### 4. Permanent Ban
|
### 4. Permanent Ban
|
||||||
|
|
||||||
**Community Impact**: Demonstrating a pattern of violation of community
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
standards, including sustained inappropriate behavior, harassment of an
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
individual, or aggression toward or disparagement of classes of individuals.
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
**Consequence**: A permanent ban from any sort of public interaction within
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
|||||||
534
CONTRIBUTING.md
534
CONTRIBUTING.md
@@ -1,125 +1,493 @@
|
|||||||
# Contributing to This Repository
|
# Contributing to Compass
|
||||||
|
|
||||||
We welcome pull requests, but only if they meet the project's quality and design standards. Follow the process below precisely to avoid wasting time—yours or ours.
|
Thank you for your interest in contributing to Compass! This document provides comprehensive guidelines for contributing
|
||||||
|
to this open-source project.
|
||||||
|
|
||||||
## Prerequisites
|
## Table of Contents
|
||||||
|
|
||||||
- Familiarity with Git and GitHub (basic commands, branching, forking, etc.)
|
- [Code of Conduct](#code-of-conduct)
|
||||||
- A functioning development environment
|
- [Getting Started](#getting-started)
|
||||||
- Node.js, Python, or other relevant runtime/tools installed (check the `README.md`)
|
- [Development Environment](#development-environment)
|
||||||
|
- [Project Structure](#project-structure)
|
||||||
|
- [Coding Standards](#coding-standards)
|
||||||
|
- [Making Changes](#making-changes)
|
||||||
|
- [Testing](#testing)
|
||||||
|
- [Pull Request Guidelines](#pull-request-guidelines)
|
||||||
|
- [Commit Message Guidelines](#commit-message-guidelines)
|
||||||
|
- [Documentation](#documentation)
|
||||||
|
- [Questions and Support](#questions-and-support)
|
||||||
|
|
||||||
## Fork & Clone
|
## Code of Conduct
|
||||||
|
|
||||||
1. **Fork the repository** using the GitHub UI.
|
Please read and follow our [Code of Conduct](./CODE_OF_CONDUCT.md). We are committed to providing a welcoming and
|
||||||
2. **Clone your fork** locally:
|
inclusive environment for all contributors.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Before contributing, ensure you have the following installed:
|
||||||
|
|
||||||
|
- **Node.js** 20.x or later
|
||||||
|
- **Yarn** 1.x (classic)
|
||||||
|
- **Git**
|
||||||
|
- **Docker** (optional, for isolated development)
|
||||||
|
|
||||||
|
### Fork and Clone
|
||||||
|
|
||||||
|
1. Fork the [repository](https://github.com/CompassConnections/Compass) on GitHub
|
||||||
|
2. Clone your fork:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/your-username/Compass.git
|
git clone https://github.com/<your-username>/Compass.git
|
||||||
cd your-fork
|
cd Compass
|
||||||
|
```
|
||||||
3. **Add the upstream remote**:
|
3. Add the upstream remote:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add upstream https://github.com/CompassConnections/Compass.git
|
git remote add upstream https://github.com/CompassConnections/Compass.git
|
||||||
```
|
```
|
||||||
|
|
||||||
## Create a New Branch
|
### Install Dependencies
|
||||||
|
|
||||||
Never work on `main` or `master`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b fix/brief-but-specific-description
|
yarn install --frozen-lockfile
|
||||||
```
|
```
|
||||||
|
|
||||||
Use a clear, descriptive branch name. Avoid vague names like `patch-1`.
|
## Development Environment
|
||||||
|
|
||||||
## Stay Updated
|
### Running the Development Server
|
||||||
|
|
||||||
Before you start, make sure your fork is up to date:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git fetch upstream
|
yarn dev
|
||||||
git checkout main
|
|
||||||
git merge upstream/main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then rebase your feature branch if needed:
|
Visit http://localhost:3000 to see the application.
|
||||||
|
|
||||||
|
### Isolated Development (Recommended)
|
||||||
|
|
||||||
|
For full isolation with local Supabase and Firebase emulators:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout fix/your-feature
|
yarn dev:isolated
|
||||||
git rebase main
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Make Atomic Commits
|
Benefits:
|
||||||
|
|
||||||
Each commit should represent a single logical change. Follow this format:
|
- No conflicts with other contributors
|
||||||
|
- Works offline
|
||||||
|
- Faster database queries
|
||||||
|
- Free to reset and reseed data
|
||||||
|
|
||||||
```text
|
Requirements:
|
||||||
type(scope): concise description
|
|
||||||
|
|
||||||
body explaining what and why, if necessary
|
- Docker (~500MB)
|
||||||
|
- Supabase CLI
|
||||||
|
- Java 21+ (for Firebase emulators)
|
||||||
|
- Firebase CLI
|
||||||
|
|
||||||
|
See the [README](./README.md) for detailed setup instructions.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
yarn test
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
yarn test:coverage
|
||||||
|
|
||||||
|
# Run tests in watch mode
|
||||||
|
yarn test:watch
|
||||||
|
|
||||||
|
# Run E2E tests
|
||||||
|
yarn test:e2e
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Linting and Type Checking
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Lint all packages
|
||||||
|
yarn lint
|
||||||
|
|
||||||
|
# Fix linting issues
|
||||||
|
yarn lint-fix
|
||||||
|
|
||||||
|
# Type check all packages
|
||||||
|
yarn typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
This is a Yarn workspaces monorepo with the following packages:
|
||||||
|
|
||||||
|
```
|
||||||
|
Compass/
|
||||||
|
├── web/ # Next.js web application
|
||||||
|
│ ├── components/ # React components
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── lib/ # Utilities and services
|
||||||
|
│ ├── pages/ # Next.js pages
|
||||||
|
│ └── messages/ # Internationalization files
|
||||||
|
├── backend/
|
||||||
|
│ ├── api/ # Express API server
|
||||||
|
│ ├── shared/ # Shared backend utilities
|
||||||
|
│ ├── email/ # React email templates
|
||||||
|
│ └── scripts/ # Database migration scripts
|
||||||
|
├── common/ # Shared TypeScript types and utilities
|
||||||
|
├── supabase/ # Database migrations and config
|
||||||
|
├── android/ # Capacitor Android app
|
||||||
|
└── docs/ # Project documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Technologies
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
| -------- | -------------------------------- |
|
||||||
|
| Frontend | Next.js 16, React 19, TypeScript |
|
||||||
|
| Styling | Tailwind CSS |
|
||||||
|
| Backend | Express.js, Node.js |
|
||||||
|
| Database | PostgreSQL (Supabase) |
|
||||||
|
| Auth | Firebase Auth |
|
||||||
|
| Storage | Firebase Storage |
|
||||||
|
| Mobile | Capacitor (Android) |
|
||||||
|
| Testing | Jest, Playwright |
|
||||||
|
|
||||||
|
## Coding Standards
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
|
||||||
|
- Use strict TypeScript typing
|
||||||
|
- Avoid `any` type; use `unknown` when necessary
|
||||||
|
- Prefer interfaces over types for object shapes
|
||||||
|
- Use `const` assertions where appropriate
|
||||||
|
|
||||||
|
### React Components
|
||||||
|
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Name components after their file name
|
||||||
|
- Export primary component at the top of the file
|
||||||
|
- Use composition over inheritance
|
||||||
|
- Keep components small and focused
|
||||||
|
|
||||||
|
Example component structure:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import {useState} from 'react'
|
||||||
|
|
||||||
|
interface ProfileCardProps {
|
||||||
|
name: string
|
||||||
|
age: number
|
||||||
|
onSelect?: (id: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileCard({name, age, onSelect, className}: ProfileCardProps) {
|
||||||
|
const [selected, setSelected] = useState(false)
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setSelected(!selected)
|
||||||
|
onSelect?.(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('card', selected && 'selected', className)}>
|
||||||
|
<h3>
|
||||||
|
{name}, {age}
|
||||||
|
</h3>
|
||||||
|
<button onClick={handleClick}>Select</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Files**: kebab-case (`profile-card.tsx`)
|
||||||
|
- **Components**: PascalCase (`ProfileCard`)
|
||||||
|
- **Hooks**: camelCase with `use` prefix (`useUserProfile`)
|
||||||
|
- **Constants**: SCREAMING_SNAKE_CASE
|
||||||
|
- **Types/Interfaces**: PascalCase
|
||||||
|
|
||||||
|
### Import Order
|
||||||
|
|
||||||
|
Run `yarn lint-fix` to automatically sort imports:
|
||||||
|
|
||||||
|
1. External libraries (React, Next.js, etc.)
|
||||||
|
2. Internal packages (`common/`, `shared/`)
|
||||||
|
3. Relative imports (`../`, `./`)
|
||||||
|
4. Type imports
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Use try-catch for async operations
|
||||||
|
- Create custom error types for API errors
|
||||||
|
- Implement error boundaries for React components
|
||||||
|
- Log errors with appropriate context
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {APIError} from './errors'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('endpoint', params)
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof APIError) {
|
||||||
|
logger.error('API error', {status: err.status, message: err.message})
|
||||||
|
} else {
|
||||||
|
logger.error('Unexpected error', err)
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- Use semantic HTML elements
|
||||||
|
- Include ARIA labels where appropriate
|
||||||
|
- Ensure keyboard navigation works
|
||||||
|
- Use the `SkipLink` component for main content
|
||||||
|
- Announce dynamic content changes with `useLiveRegion`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {useLiveRegion} from 'web/components/live-region'
|
||||||
|
|
||||||
|
function MyComponent() {
|
||||||
|
const {announce} = useLiveRegion()
|
||||||
|
|
||||||
|
const handleAction = () => {
|
||||||
|
// Action completed
|
||||||
|
announce('Action successful', 'polite')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
### Creating a Branch
|
||||||
|
|
||||||
|
Never work directly on `main`. Create a new branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b type/short-description
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch types:
|
||||||
|
|
||||||
|
- `feat/` - New features
|
||||||
|
- `fix/` - Bug fixes
|
||||||
|
- `docs/` - Documentation
|
||||||
|
- `refactor/` - Code refactoring
|
||||||
|
- `test/` - Adding/updating tests
|
||||||
|
- `chore/` - Maintenance tasks
|
||||||
|
|
||||||
|
### Making Commits
|
||||||
|
|
||||||
|
Keep commits atomic and descriptive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat(profiles): add compatibility score display
|
||||||
|
|
||||||
|
- Added compatibility score calculation
|
||||||
|
- Display score on profile cards
|
||||||
|
- Added tests for scoring algorithm"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Commit Message Guidelines](#commit-message-guidelines) for details.
|
||||||
|
|
||||||
|
### Keeping Your Fork Updated
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Fetch latest from upstream
|
||||||
|
git fetch upstream
|
||||||
|
|
||||||
|
# Update main branch
|
||||||
|
git checkout main
|
||||||
|
git merge upstream/main
|
||||||
|
|
||||||
|
# Rebase your feature branch
|
||||||
|
git checkout feat/your-feature
|
||||||
|
git rebase main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
#### Unit Tests
|
||||||
|
|
||||||
|
Place tests in `tests/unit/` within each package:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// web/tests/unit/my-function.test.ts
|
||||||
|
import {myFunction} from '../my-function'
|
||||||
|
|
||||||
|
describe('myFunction', () => {
|
||||||
|
it('should return correct output', () => {
|
||||||
|
expect(myFunction('input')).toBe('expected')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Integration Tests
|
||||||
|
|
||||||
|
Place in `tests/integration/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// web/tests/integration/api.test.ts
|
||||||
|
import {render, screen} from '@testing-library/react'
|
||||||
|
import {MyComponent} from '../MyComponent'
|
||||||
|
|
||||||
|
describe('MyComponent', () => {
|
||||||
|
it('renders correctly', () => {
|
||||||
|
render(<MyComponent / >)
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E2E Tests
|
||||||
|
|
||||||
|
Place in `tests/e2e/` at the root:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// tests/e2e/web/specs/onboarding.spec.ts
|
||||||
|
import {test, expect} from '@playwright/test'
|
||||||
|
|
||||||
|
test('onboarding flow', async ({page}) => {
|
||||||
|
await page.goto('/signup')
|
||||||
|
await page.fill('[name="email"]', 'test@example.com')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
await expect(page).toHaveURL('/onboarding')
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running Specific Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unit tests for web
|
||||||
|
yarn workspace web test
|
||||||
|
|
||||||
|
# Run tests matching pattern
|
||||||
|
yarn test --testPathPattern="profile"
|
||||||
|
|
||||||
|
# Run E2E tests
|
||||||
|
yarn test:e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
Aim for meaningful test coverage. Focus on:
|
||||||
|
|
||||||
|
- Business logic
|
||||||
|
- User interactions
|
||||||
|
- Error handling
|
||||||
|
- Edge cases
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
### Before Submitting
|
||||||
|
|
||||||
|
1. **Run all tests**: `yarn test`
|
||||||
|
2. **Run linter**: `yarn lint`
|
||||||
|
3. **Run type check**: `yarn typecheck`
|
||||||
|
4. **Update documentation** if needed
|
||||||
|
5. **Rebase on main** if necessary
|
||||||
|
|
||||||
|
### Pull Request Format
|
||||||
|
|
||||||
|
**Title**: Clear, descriptive title
|
||||||
|
|
||||||
|
**Description**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Brief description of changes
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added compatibility score to profile cards
|
||||||
|
- Updated search algorithm for better results
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Added unit tests for scoring algorithm
|
||||||
|
- Tested manually with synthetic data
|
||||||
|
|
||||||
|
## Screenshots (if UI changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
### PR Checklist
|
||||||
|
|
||||||
|
- [ ] Code follows style guidelines
|
||||||
|
- [ ] Tests added/updated and passing
|
||||||
|
- [ ] Documentation updated
|
||||||
|
- [ ] No console.log statements (except debugging)
|
||||||
|
- [ ] No debug code left behind
|
||||||
|
|
||||||
|
### Review Process
|
||||||
|
|
||||||
|
1. Maintainers review within 48 hours
|
||||||
|
2. Address feedback promptly
|
||||||
|
3. Do not open new PRs for changes - update existing one
|
||||||
|
4. Squash commits before merging
|
||||||
|
|
||||||
|
## Commit Message Guidelines
|
||||||
|
|
||||||
|
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `feat`: New feature
|
||||||
|
- `fix`: Bug fix
|
||||||
|
- `docs`: Documentation
|
||||||
|
- `style`: Formatting
|
||||||
|
- `refactor`: Code restructuring
|
||||||
|
- `test`: Tests
|
||||||
|
- `chore`: Maintenance
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
```text
|
```text
|
||||||
fix(api): handle 500 error on invalid payload
|
feat(profiles): add compatibility scoring algorithm
|
||||||
|
fix(api): handle rate limiting gracefully
|
||||||
|
docs(readme): update installation instructions
|
||||||
|
refactor(auth): simplify token refresh logic
|
||||||
|
test(profiles): add unit tests for scoring
|
||||||
```
|
```
|
||||||
|
|
||||||
Types include: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`.
|
## Documentation
|
||||||
|
|
||||||
## Test Everything
|
### Updating Documentation
|
||||||
|
|
||||||
If the project has tests, run them. If it doesn’t, write some. Do **not** submit code that hasn't been tested.
|
- Update relevant README files
|
||||||
|
- Add JSDoc comments to complex functions
|
||||||
|
- Update the `/docs` folder for architectural changes
|
||||||
|
|
||||||
```bash
|
### API Documentation
|
||||||
# Example for Node.js
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
No exceptions. If you don't validate your changes, your PR will be closed.
|
API docs are auto-generated and available at:
|
||||||
|
|
||||||
## Lint & Format
|
- Production: https://api.compassmeet.com
|
||||||
|
- Local: http://localhost:8088 (when running locally)
|
||||||
|
|
||||||
Ensure code matches the project style. If the repo uses a linter or formatter, run them:
|
## Questions and Support
|
||||||
|
|
||||||
```bash
|
- **Discord**: https://discord.gg/8Vd7jzqjun
|
||||||
npm run lint
|
- **Email**: hello@compassmeet.com
|
||||||
npm run format
|
- **GitHub Issues**: For bug reports and feature requests
|
||||||
```
|
|
||||||
|
|
||||||
Or whatever command is defined in the repo.
|
---
|
||||||
|
|
||||||
## Write a Good Pull Request
|
|
||||||
|
|
||||||
When opening a pull request:
|
|
||||||
|
|
||||||
* **Title**: Describe what the PR does, clearly and specifically.
|
|
||||||
* **Description**: Explain the context. Link related issues (use `Fixes #123` if applicable).
|
|
||||||
* **Checklist**:
|
|
||||||
|
|
||||||
* [ ] My code is clean and follows the style guide
|
|
||||||
* [ ] I’ve added or updated tests
|
|
||||||
* [ ] I’ve run all tests and they pass
|
|
||||||
* [ ] I’ve documented my changes (if necessary)
|
|
||||||
|
|
||||||
## Code Review Process
|
|
||||||
|
|
||||||
* PRs are reviewed by maintainers or core contributors.
|
|
||||||
* If feedback is given, respond and push updates. Do **not** open new PRs for changes to an existing one.
|
|
||||||
* PRs that are incomplete, sloppy, or violate the above will be closed.
|
|
||||||
|
|
||||||
## Don't Do This
|
|
||||||
|
|
||||||
* Don’t commit directly to `main`
|
|
||||||
* Don’t submit multiple unrelated changes in a single PR
|
|
||||||
* Don’t ignore CI/test failures
|
|
||||||
* Don’t expect hand-holding—read the docs and the source first
|
|
||||||
|
|
||||||
## Security Issues
|
|
||||||
|
|
||||||
Do **not** open public issues for security vulnerabilities. Email the development team instead.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
By contributing, you agree that your code will be licensed under the same license as the rest of the project.
|
|
||||||
|
|
||||||
|
Thank you for contributing to Compass! Together we're building a platform for meaningful connections.
|
||||||
|
|||||||
268
README.md
268
README.md
@@ -1,13 +1,16 @@
|
|||||||
|
|
||||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
|
||||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
|
||||||

|

|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml)
|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/cd-android.yml)
|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/ci-e2e.yml)
|
||||||
|
[](https://codecov.io/gh/CompassConnections/Compass)
|
||||||
|
[](https://www.compassmeet.com/stats)
|
||||||
|
|
||||||
# Compass
|
# Compass
|
||||||
|
|
||||||
This repository contains the source code for [Compass](https://compassmeet.com) — an open platform for forming deep, authentic 1-on-1 connections with clarity and efficiency.
|
This repository contains the source code for [Compass](https://compassmeet.com) — a transparent platform for forming
|
||||||
|
deep, authentic 1-on-1 connections with clarity and efficiency.
|
||||||
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -15,34 +18,50 @@ This repository contains the source code for [Compass](https://compassmeet.com)
|
|||||||
- Radically transparent: user base fully searchable
|
- Radically transparent: user base fully searchable
|
||||||
- Free, ad-free, not for profit (supported by donations)
|
- Free, ad-free, not for profit (supported by donations)
|
||||||
- Created, hosted, maintained, and moderated by volunteers
|
- Created, hosted, maintained, and moderated by volunteers
|
||||||
- Open source
|
- Open-source
|
||||||
- Democratically governed
|
- Democratically governed
|
||||||
|
|
||||||
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
|
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and
|
||||||
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
|
the [FAQ](https://www.compassmeet.com/faq) as well.
|
||||||
|
A detailed description of the early vision is also available in
|
||||||
|
this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass
|
||||||
|
shifted to a more general audience).
|
||||||
|
|
||||||
<p style="text-align: center;">
|
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small
|
||||||
<img src="https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fdemo_compass.gif?alt=media&token=e3ae4334-4e3f-4026-b121-c08b4b724cd1" alt="Compass Demo" width="600">
|
donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help
|
||||||
</p>
|
our community thrive!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## To Do
|
## To Do
|
||||||
|
|
||||||
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording. Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The goal is to make the platform better step by step, and every improvement counts. If you see something that could be clearer, smoother, or more engaging, **please jump in**!
|
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording.
|
||||||
|
Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The
|
||||||
|
goal is to make the platform better step by step, and every improvement counts. If you see something that could be
|
||||||
|
clearer, smoother, or more engaging, **please jump in**!
|
||||||
|
|
||||||
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
|
The complete, official list of tasks is
|
||||||
|
available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are
|
||||||
|
working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue
|
||||||
|
for that task, assign it to yourself as well.
|
||||||
|
|
||||||
|
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do
|
||||||
|
so, use your preferred option:
|
||||||
|
|
||||||
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, use your preferred option:
|
|
||||||
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
|
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
|
||||||
- Email hello@compassmeet.com
|
- Email hello@compassmeet.com
|
||||||
- Raise an issue on GitHub
|
- Raise an issue on GitHub
|
||||||
|
|
||||||
If you want to add tasks without creating an account, you can simply email
|
If you want to add tasks without creating an account, you can simply email
|
||||||
|
|
||||||
```
|
```
|
||||||
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
|
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Put the task title in the email subject and the task description in the email content.
|
Put the task title in the email subject and the task description in the email content.
|
||||||
|
|
||||||
Here is a tailored selection of things that would be very useful. If you want to help but don’t know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
Here is a tailored selection of things that would be very useful. If you want to help but don’t know where to start,
|
||||||
|
just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
||||||
|
|
||||||
- [x] Authentication (user/password and Google Sign In)
|
- [x] Authentication (user/password and Google Sign In)
|
||||||
- [x] Set up PostgreSQL in Production with supabase
|
- [x] Set up PostgreSQL in Production with supabase
|
||||||
@@ -53,35 +72,40 @@ Here is a tailored selection of things that would be very useful. If you want to
|
|||||||
- [x] Search through most profile variables
|
- [x] Search through most profile variables
|
||||||
- [x] Set up chat / direct messaging
|
- [x] Set up chat / direct messaging
|
||||||
- [x] Set up domain name (compassmeet.com)
|
- [x] Set up domain name (compassmeet.com)
|
||||||
- [ ] Add mobile app (React Native on Android and iOS)
|
- [ ] Cover more than 90% with tests (unit, integration, e2e)
|
||||||
- [ ] Add better onboarding (tooltips, modals, etc.)
|
- [x] Add Android mobile app
|
||||||
|
- [ ] Add iOS mobile app
|
||||||
|
- [x] Add better onboarding (tooltips, modals, etc.)
|
||||||
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
|
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
|
||||||
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
|
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
|
||||||
- [ ] Add calendar integration and scheduling
|
- [ ] Add calendar integration and scheduling
|
||||||
- [ ] Add events (group calls, in-person meetups, etc.)
|
- [x] Add events (group calls, in-person meetups, etc.)
|
||||||
|
|
||||||
#### Secondary To Do
|
#### Secondary To Do
|
||||||
|
|
||||||
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time contributors.
|
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time
|
||||||
|
contributors.
|
||||||
|
|
||||||
- [x] Clean up learn more page
|
- [x] Clean up learn more page
|
||||||
- [x] Add dark theme
|
- [x] Add dark theme
|
||||||
- [ ] Add profile fields (intellectual interests, cause areas, personality type, conflict style, timezone, etc.)
|
- [x] Add profile fields (intellectual interests, cause areas, personality type, etc.)
|
||||||
- [ ] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
|
- [ ] Add profile fields: conflict style
|
||||||
- [ ] Cover with tests (crucial, just the test template and framework are ready)
|
- [ ] Add profile fields: timezone
|
||||||
|
- [ ] Add translations: Italian, Dutch, Hindi, Chinese, etc.
|
||||||
|
- [x] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
|
||||||
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
||||||
- [ ] Clean up terms and conditions (convert to Markdown)
|
- [ ] Clean up terms and conditions (convert to Markdown)
|
||||||
- [ ] Clean up privacy notice (convert to Markdown)
|
- [ ] Clean up privacy notice (convert to Markdown)
|
||||||
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
||||||
- [ ] Add email verification
|
- [x] Add email verification
|
||||||
- [ ] Add password reset
|
- [x] Add password reset
|
||||||
- [x] Add automated welcome email
|
- [x] Add automated welcome email
|
||||||
- [ ] Security audit and penetration testing
|
- [ ] Security audit and penetration testing
|
||||||
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
|
- [x] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||||
- [ ] Create settings page (change email, password, delete account, etc.)
|
- [x] Create settings page (change email, password, delete account, etc.)
|
||||||
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
|
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
|
||||||
- [ ] Improve loading sign (e.g., animation of a compass moving around)
|
- [x] Improve loading sign (e.g., animation of a compass moving around)
|
||||||
- [ ] Show compatibility score in profile page
|
- [x] Show compatibility score in profile page
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
|
|
||||||
@@ -94,100 +118,226 @@ The web app is coded in Typescript using React as front-end. It includes:
|
|||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
|
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or
|
||||||
|
contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo and navigating into it:
|
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo
|
||||||
|
and navigating into it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/<your-username>/Compass.git
|
git clone https://github.com/<your-username>/Compass.git
|
||||||
cd Compass
|
cd Compass
|
||||||
```
|
```
|
||||||
|
|
||||||
Install `opentofu`, `docker`, and `yarn`. Try running this on Linux or macOS for a faster install:
|
Install `yarn` (if not already installed):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./setup.sh
|
npm install --global yarn
|
||||||
```
|
```
|
||||||
If it doesn't work, you can install them manually (google how to install `opentofu`, `docker`, and `yarn` for your OS).
|
|
||||||
|
|
||||||
Then, install the dependencies for this project:
|
Then, install the dependencies for this project:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn install
|
yarn install --frozen-lockfile
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the following services: email, geolocation.
|
|
||||||
|
|
||||||
We can't make the following information public, for security and privacy reasons:
|
|
||||||
- Database, otherwise anyone could access all the user data (including private messages)
|
|
||||||
- Firebase, otherwise anyone could remove users or modify the media files
|
|
||||||
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
|
|
||||||
|
|
||||||
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
|
|
||||||
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
|
|
||||||
|
|
||||||
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
Make sure the tests pass:
|
Make sure the Jest tests pass:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn test tests/jest/
|
yarn test
|
||||||
```
|
```
|
||||||
TODO: make `yarn test` run all the tests, not just the ones in `tests/jest/`.
|
|
||||||
|
If they don't and you can't find out why, simply raise an issue! Sometimes it's something on our end that we overlooked.
|
||||||
|
|
||||||
### Running the Development Server
|
### Running the Development Server
|
||||||
|
|
||||||
Start the development server:
|
Start the development server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn dev
|
yarn dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
|
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles;
|
||||||
|
you should see a few synthetic profiles.
|
||||||
|
|
||||||
Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it would be great to improve that though!
|
Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it
|
||||||
|
would be great to improve that though!
|
||||||
|
|
||||||
|
#### Full isolation
|
||||||
|
|
||||||
|
Running `yarn dev:isolated` spins up a local Supabase and Firebase emulator instead of pointing at the shared remote
|
||||||
|
database. This is strongly recommended for day-to-day development:
|
||||||
|
|
||||||
|
- **Freedom** — Reset, wipe, and reseed your local database as many times as you want without affecting other
|
||||||
|
contributors.
|
||||||
|
- **No conflicts** — Multiple contributors can work simultaneously without stepping on each other's data or schema.
|
||||||
|
- **Works offline** — No internet required once services are started locally.
|
||||||
|
- **Faster** — No network latency on every database query.
|
||||||
|
|
||||||
|
However, running in full isolation requires installing several heavy dependencies:
|
||||||
|
|
||||||
|
- **Docker** (~500MB) — runs the Supabase Postgres container
|
||||||
|
- **Supabase CLI** — manages the local Supabase stack (10+ Docker containers, ~1-2GB RAM when running)
|
||||||
|
- **Java 21+** (~300MB) — required by Firebase emulators
|
||||||
|
- **Firebase CLI** — manages the local Firebase emulators
|
||||||
|
|
||||||
|
First startup is slow (30-60s) and the stack uses significant memory. If your machine has less than 8GB RAM, you may
|
||||||
|
notice slowdowns.
|
||||||
|
|
||||||
|
If this feels like too much, you can skip isolation entirely — `yarn dev` works out of the box against the shared remote
|
||||||
|
and is perfectly fine for most contributions, especially UI changes, wording fixes, or anything that doesn't touch the
|
||||||
|
database or authentication.
|
||||||
|
|
||||||
|
###### Setup instructions
|
||||||
|
|
||||||
|
As always, don't hesitate to raise an issue if you run into any problems!
|
||||||
|
|
||||||
|
Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian (native - recommended over snap)
|
||||||
|
curl -fsSL https://get.docker.com | sudo sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
# Log out and back in for group changes to take effect
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install --cask docker
|
||||||
|
# Or download from https://www.docker.com/products/docker-desktop
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
docker --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Supabase CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify it got installed from `yarn install`
|
||||||
|
npx supabase --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Java 21+
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install openjdk-21-jdk
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install openjdk@21
|
||||||
|
|
||||||
|
# Verify (must be 21+)
|
||||||
|
java -version
|
||||||
|
```
|
||||||
|
|
||||||
|
Firebase CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g firebase-tools
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
firebase --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Run in isolation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn dev:isolated
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://localhost:3000` as usual. Your local database comes preloaded with synthetic test profiles so the app
|
||||||
|
looks and feels like the real thing.
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
Now you can start contributing by making changes and submitting pull requests!
|
Now you can start contributing by making changes and submitting pull requests!
|
||||||
|
|
||||||
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
|
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (
|
||||||
- Components tab to see the React component tree and props (you need to install the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
|
||||||
|
|
||||||
|
- Components tab to see the React component tree and props (you need to install
|
||||||
|
the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
||||||
- Console tab for errors and logs
|
- Console tab for errors and logs
|
||||||
- Network tab to see the requests and responses
|
- Network tab to see the requests and responses
|
||||||
- Storage tab to see cookies and local storage
|
- Storage tab to see cookies and local storage
|
||||||
|
|
||||||
You can also add `console.log()` statements in the code.
|
You can also add `console.log()` statements in the code.
|
||||||
|
|
||||||
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
|
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web
|
||||||
|
components or improving wording in some pages. You can find those files in `web/public/md/`.
|
||||||
|
|
||||||
See [development.md](docs/development.md) for additional instructions, such as adding new profile fields.
|
##### Resources
|
||||||
|
|
||||||
|
There is a lot of documentation in the [docs](docs) folder and across the repo, namely:
|
||||||
|
|
||||||
|
- [Next.js.md](docs/Next.js.md) for core fundamentals about our web / page-rendering framework.
|
||||||
|
- [knowledge.md](docs/knowledge.md) for general information about the project structure.
|
||||||
|
- [development.md](docs/development.md) for additional instructions, such as adding new profile fields or languages.
|
||||||
|
- [TESTING.md](docs/TESTING.md) for how to write tests.
|
||||||
|
- [PERFORMANCE_OPTIMIZATION.md](docs/PERFORMANCE_OPTIMIZATION.md) for frontend, backend, and database performance best practices.
|
||||||
|
- [DATABASE_CONNECTION_POOLING.md](docs/DATABASE_CONNECTION_POOLING.md) for database connection management and troubleshooting.
|
||||||
|
- [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for resolving common development issues.
|
||||||
|
- [web](web) for the web.
|
||||||
|
- [backend/api](backend/api) for the backend API.
|
||||||
|
- [android](android) for the Android app.
|
||||||
|
|
||||||
|
There are a lot of useful scripts you can use in the [scripts](scripts) folder.
|
||||||
|
|
||||||
### Submission
|
### Submission
|
||||||
|
|
||||||
Add the original repo as upstream for syncing:
|
Add the original repo as upstream for syncing:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add upstream https://github.com/CompassConnections/Compass.git
|
git remote add upstream https://github.com/CompassConnections/Compass.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Create a new branch for your changes:
|
Create a new branch for your changes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git checkout -b <branch-name>
|
git checkout -b <branch-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Make changes, then stage and commit:
|
Make changes, then stage and commit:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Describe your changes"
|
git commit -m "Describe your changes"
|
||||||
```
|
```
|
||||||
|
|
||||||
Push branch to your fork:
|
Push branch to your fork:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push origin <branch-name>
|
git push origin <branch-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `CompassConnections/Compass` main branch.
|
Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `CompassConnections/Compass` main branch.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the
|
||||||
|
following services: email, geolocation.
|
||||||
|
|
||||||
|
We can't make the following information public, for security and privacy reasons:
|
||||||
|
|
||||||
|
- Database, otherwise anyone could access all the user data (including private messages)
|
||||||
|
- Firebase, otherwise anyone could remove users or modify the media files
|
||||||
|
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the
|
||||||
|
bill.
|
||||||
|
|
||||||
|
That's why we separate all those services between production and development environments, so that you can code freely
|
||||||
|
without impacting the functioning of the deployed platform.
|
||||||
|
Contributors should use the default keys for local development. Production uses a separate environment with stricter
|
||||||
|
rules and private keys that are not shared.
|
||||||
|
|
||||||
|
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment
|
||||||
|
variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have significantly aided in the development of some core features such as direct messaging, prompts, and email notifications. We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.
|
|
||||||
|
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform
|
||||||
|
licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have
|
||||||
|
significantly aided in the development of some core features such as direct messaging, prompts, and email notifications.
|
||||||
|
We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially
|
||||||
|
if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.
|
||||||
|
|||||||
116
SECURITY.md
116
SECURITY.md
@@ -4,9 +4,121 @@
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| 1.0.0 | :white_check_mark: |
|
| 1.10.x | :white_check_mark: |
|
||||||
|
| 1.9.x | :white_check_mark: |
|
||||||
|
| < 1.9.0 | :x: |
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Contact the development team at hello@compassmeet.com to report a vulnerability. You should receive updates within a week.
|
If you discover a security vulnerability within Compass, please send an email to hello@compassmeet.com. All security vulnerabilities will be promptly addressed.
|
||||||
|
|
||||||
|
Please do not publicly disclose the vulnerability until it has been resolved.
|
||||||
|
|
||||||
|
## Security Practices
|
||||||
|
|
||||||
|
Compass takes security seriously and implements several best practices to protect user data and ensure application integrity.
|
||||||
|
|
||||||
|
### Authentication & Authorization
|
||||||
|
|
||||||
|
- **Firebase Authentication**: User authentication is handled by Firebase Auth, which provides industry-standard security for user credentials
|
||||||
|
- **JWT Tokens**: Secure token-based authentication for API access
|
||||||
|
- **Role-Based Access Control**: Different permission levels for users, moderators, and administrators
|
||||||
|
- **Session Management**: Secure session handling with automatic timeout
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
|
||||||
|
- **Encryption at Rest**: Sensitive data is encrypted in the database
|
||||||
|
- **Encryption in Transit**: All communications use HTTPS/TLS encryption
|
||||||
|
- **Environment Variables**: Secrets are managed through secure environment variable configuration
|
||||||
|
- **Data Minimization**: Only necessary data is collected and stored
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
- **Zod Validation**: Strong type checking and validation for all API inputs
|
||||||
|
- **Sanitization**: Input sanitization to prevent injection attacks
|
||||||
|
- **Rate Limiting**: Protection against brute force and denial of service attacks
|
||||||
|
|
||||||
|
### API Security
|
||||||
|
|
||||||
|
- **CORS Configuration**: Restricted cross-origin resource sharing policies
|
||||||
|
- **Rate Limiting**: Per-endpoint rate limiting to prevent abuse
|
||||||
|
- **Authentication Middleware**: All protected endpoints require valid authentication
|
||||||
|
- **Input Validation**: Comprehensive validation of all API inputs
|
||||||
|
|
||||||
|
### Database Security
|
||||||
|
|
||||||
|
- **Row Level Security**: Fine-grained access control at the database level
|
||||||
|
- **Parameterized Queries**: Prevention of SQL injection attacks
|
||||||
|
- **Audit Logging**: Tracking of database access and modifications
|
||||||
|
- **Regular Backups**: Automated database backups for disaster recovery
|
||||||
|
|
||||||
|
### Third-Party Services
|
||||||
|
|
||||||
|
- **Firebase Security Rules**: Strict security rules for Firestore and Storage
|
||||||
|
- **Supabase RLS**: Row-level security policies for PostgreSQL
|
||||||
|
- **Secrets Management**: Secure storage of API keys and credentials
|
||||||
|
|
||||||
|
### Development Practices
|
||||||
|
|
||||||
|
- **Code Reviews**: All changes reviewed by multiple developers
|
||||||
|
- **Automated Testing**: Security-focused tests integrated into CI/CD pipeline
|
||||||
|
- **Dependency Management**: Regular updates and security scanning of dependencies
|
||||||
|
- **Security Audits**: Periodic security assessments and penetration testing
|
||||||
|
|
||||||
|
## Common Security Issues and Resolutions
|
||||||
|
|
||||||
|
### XSS Prevention
|
||||||
|
|
||||||
|
- **Content Security Policy**: Strict CSP headers to prevent cross-site scripting
|
||||||
|
- **Input Sanitization**: All user-generated content is sanitized before display
|
||||||
|
- **Output Encoding**: Proper encoding of user data in HTML contexts
|
||||||
|
|
||||||
|
### CSRF Protection
|
||||||
|
|
||||||
|
- **SameSite Cookies**: CSRF protection through SameSite cookie attributes
|
||||||
|
- **Anti-Forgery Tokens**: Token-based protection for state-changing operations
|
||||||
|
|
||||||
|
### Injection Attacks
|
||||||
|
|
||||||
|
- **SQL Injection**: Parameterized queries and prepared statements
|
||||||
|
- **Command Injection**: Input validation and sanitization
|
||||||
|
- **Script Injection**: Content Security Policy and input filtering
|
||||||
|
|
||||||
|
## Incident Response
|
||||||
|
|
||||||
|
In the event of a security incident:
|
||||||
|
|
||||||
|
1. **Immediate Containment**: Isolate affected systems
|
||||||
|
2. **Investigation**: Determine scope and impact of breach
|
||||||
|
3. **Remediation**: Apply fixes and security patches
|
||||||
|
4. **Notification**: Inform affected users and stakeholders
|
||||||
|
5. **Review**: Post-incident analysis and process improvement
|
||||||
|
|
||||||
|
## Compliance
|
||||||
|
|
||||||
|
Compass aims to comply with relevant data protection regulations:
|
||||||
|
|
||||||
|
- **GDPR**: General Data Protection Regulation compliance
|
||||||
|
- **CCPA**: California Consumer Privacy Act compliance
|
||||||
|
- **Data Retention**: Clear policies for data retention and deletion
|
||||||
|
|
||||||
|
## Third-Party Security
|
||||||
|
|
||||||
|
We regularly audit third-party services for:
|
||||||
|
|
||||||
|
- Security certifications and compliance
|
||||||
|
- Regular security updates and patches
|
||||||
|
- Data handling and privacy practices
|
||||||
|
- Incident response procedures
|
||||||
|
|
||||||
|
## Security Contact
|
||||||
|
|
||||||
|
For security-related inquiries, contact:
|
||||||
|
|
||||||
|
- Email: hello@compassmeet.com
|
||||||
|
- Response Time: Within 24 hours for critical issues
|
||||||
|
- Disclosure Policy: Coordinated disclosure with 90-day timeline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Last Updated: March 2026_
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
// .eslintrc.js
|
|
||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
extends: ['next', 'next/core-web-vitals'],
|
|
||||||
rules: {
|
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
|
||||||
'react/no-unescaped-entities': 'off',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {useEffect, useState} from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {useSession} from "next-auth/react";
|
|
||||||
import ThemeToggle from "@/lib/client/theme";
|
|
||||||
import FavIcon from "@/components/FavIcon";
|
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
const {data: session} = useSession();
|
|
||||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const checkScreenSize = () => {
|
|
||||||
setIsSmallScreen(window.innerWidth < 640); // Tailwind's 'sm' breakpoint is 640px
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial check
|
|
||||||
checkScreenSize();
|
|
||||||
|
|
||||||
// Add event listener for window resize
|
|
||||||
window.addEventListener('resize', checkScreenSize);
|
|
||||||
|
|
||||||
// Clean up the event listener when the component unmounts
|
|
||||||
return () => window.removeEventListener('resize', checkScreenSize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fontStyle = "transition px-2 py-2 text-sm font-medium xs:text-xs"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="w-full
|
|
||||||
{/*shadow-md*/}
|
|
||||||
py-5 px-8 xs:px-4">
|
|
||||||
<nav className="flex justify-between items-center">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-4xl font-bold hover:text-blue-600 transition-colors flex items-center"
|
|
||||||
aria-label={isSmallScreen ? "Home" : "Compass"}
|
|
||||||
>
|
|
||||||
<FavIcon className="dark:invert"/>
|
|
||||||
{!isSmallScreen && (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
Compass
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
|
|
||||||
<ThemeToggle/>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Link
|
|
||||||
href="/about"
|
|
||||||
className={`${fontStyle} bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-white rounded-full hover:bg-gray-300 dark:hover:bg-gray-500`}
|
|
||||||
>
|
|
||||||
About
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{session ? (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Link
|
|
||||||
href="/profile"
|
|
||||||
className={`${fontStyle} text-blue-600 dark:text-blue-100 hover:text-blue-800 dark:hover:text-blue-300`}
|
|
||||||
>
|
|
||||||
My Profile
|
|
||||||
</Link>
|
|
||||||
{/*<Link*/}
|
|
||||||
{/* href="/profiles"*/}
|
|
||||||
{/* className="bg-blue-500 text-white px-4 py-2 rounded-full hover:bg-blue-600 transition"*/}
|
|
||||||
{/*>*/}
|
|
||||||
{/* Dashboard*/}
|
|
||||||
{/*</Link>*/}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link href="/login"
|
|
||||||
className={`${fontStyle} bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-white rounded-full hover:bg-gray-300 dark:hover:bg-gray-500`}>
|
|
||||||
Sign In
|
|
||||||
</Link>
|
|
||||||
{/*<Link href="/register"
|
|
||||||
className={`${fontStyle} bg-blue-500 text-white rounded-full hover:bg-blue-600`}>
|
|
||||||
Sign Up
|
|
||||||
</Link> */}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import NextAuth from "next-auth";
|
|
||||||
import {authOptions} from "@/lib/server/auth";
|
|
||||||
|
|
||||||
const authHandler = NextAuth(authOptions);
|
|
||||||
export {authHandler as GET, authHandler as POST};
|
|
||||||
|
|
||||||
declare module "next-auth" {
|
|
||||||
interface Session {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
image: string;
|
|
||||||
emailVerified?: Date | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface User {
|
|
||||||
emailVerified?: Date | null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "next-auth/jwt" {
|
|
||||||
interface JWT {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import bcrypt from "bcryptjs";
|
|
||||||
import {NextResponse} from "next/server";
|
|
||||||
import {prisma} from "@/lib/server/prisma";
|
|
||||||
import {v4 as uuidv4} from 'uuid';
|
|
||||||
|
|
||||||
// Helper function to generate a verification token
|
|
||||||
const generateVerificationToken = () => {
|
|
||||||
return uuidv4();
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const {email, password, name} = await req.json();
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
return NextResponse.json({error: "Email and password required"}, {status: 400});
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({where: {email}});
|
|
||||||
if (existingUser) {
|
|
||||||
return NextResponse.json({error: "Email already in use"}, {status: 400});
|
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
|
||||||
const verificationToken = generateVerificationToken();
|
|
||||||
const verificationTokenExpires = new Date();
|
|
||||||
verificationTokenExpires.setHours(verificationTokenExpires.getHours() + 24); // Token expires in 24 hours
|
|
||||||
|
|
||||||
// Create user with verification token
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
email,
|
|
||||||
password: hashedPassword,
|
|
||||||
name,
|
|
||||||
emailVerified: null, // Will be set when email is verified
|
|
||||||
verificationToken,
|
|
||||||
verificationTokenExpires,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send verification email. TODO once we have a domain
|
|
||||||
// You can only send testing emails to your own email address.
|
|
||||||
// To send emails to other recipients, please verify a domain at resend.com/domains,
|
|
||||||
// and change the `from` address to an email using this domain.
|
|
||||||
// const verificationUrl = `${process.env.NEXTAUTH_URL}/api/auth/verify-email?token=${verificationToken}`;
|
|
||||||
// const emailHtml = await render(VerificationEmail({ url: verificationUrl }));
|
|
||||||
// try {
|
|
||||||
// let payload = {
|
|
||||||
// from: `Compass <${process.env.EMAIL_FROM!}>`,
|
|
||||||
// to: email,
|
|
||||||
// subject: 'Verify your email',
|
|
||||||
// html: emailHtml,
|
|
||||||
// };
|
|
||||||
// console.log(`Verification email: ${payload}`);
|
|
||||||
// await resend.emails.send(payload);
|
|
||||||
// } catch (emailError) {
|
|
||||||
// console.error('Failed to send verification email:', emailError);
|
|
||||||
// }
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
message: "User created. Please check your email to verify your account.",
|
|
||||||
userId: user.id
|
|
||||||
}, {status: 201});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
return NextResponse.json({error: "Internal Server Error"}, {status: 500});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@/lib/server/prisma";
|
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(req.url);
|
|
||||||
const token = searchParams.get('token');
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return NextResponse.redirect(new URL('/auth/error?error=InvalidToken', req.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find user with this verification token
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
verificationToken: token,
|
|
||||||
verificationTokenExpires: {
|
|
||||||
gt: new Date(), // Check if token is not expired
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return NextResponse.redirect(new URL('/auth/error?error=InvalidOrExpiredToken', req.url));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user as verified
|
|
||||||
await prisma.user.update({
|
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
emailVerified: new Date(),
|
|
||||||
verificationToken: null,
|
|
||||||
verificationTokenExpires: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect to success page
|
|
||||||
return NextResponse.redirect(new URL('/auth/verification-success', req.url));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Email verification error:', error);
|
|
||||||
return NextResponse.redirect(new URL('/auth/error?error=VerificationFailed', req.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import {GetObjectCommand, S3Client} from "@aws-sdk/client-s3";
|
|
||||||
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
|
|
||||||
|
|
||||||
const s3 = new S3Client({
|
|
||||||
region: process.env.AWS_REGION,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
req: Request
|
|
||||||
) {
|
|
||||||
// console.log(req)
|
|
||||||
|
|
||||||
const {searchParams} = new URL(req.url);
|
|
||||||
const key = searchParams.get('key'); // get the key from query params
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
return new Response('S3 download error', {
|
|
||||||
status: 500,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Option 1: Generate a signed URL (client downloads directly from S3)
|
|
||||||
const signedUrl = await getSignedUrl(
|
|
||||||
s3,
|
|
||||||
new GetObjectCommand({
|
|
||||||
Bucket: process.env.AWS_S3_BUCKET_NAME!,
|
|
||||||
Key: key,
|
|
||||||
}),
|
|
||||||
{expiresIn: 300} // 5 minutes
|
|
||||||
);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({url: signedUrl}), {
|
|
||||||
status: 200,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error("S3 download error:", err);
|
|
||||||
return new Response('S3 download error', {
|
|
||||||
status: 500,
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import {prisma} from "@/lib/server/prisma";
|
|
||||||
import {NextResponse} from "next/server";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
// Get all interests from the database
|
|
||||||
// Disable cache for now as it bugs when saving profile with new interest and clicking on "Edit Profile" just after
|
|
||||||
const cacheStrategy = {swr: 0, ttl: 0, tags: ["interests"]};
|
|
||||||
const interests = await prisma.interest.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
name: 'asc'
|
|
||||||
},
|
|
||||||
cacheStrategy: cacheStrategy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const coreValues = await prisma.value.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
name: 'asc'
|
|
||||||
},
|
|
||||||
cacheStrategy: cacheStrategy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const books = await prisma.book.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
name: 'asc'
|
|
||||||
},
|
|
||||||
cacheStrategy: cacheStrategy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const causeAreas = await prisma.causeArea.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
name: 'asc'
|
|
||||||
},
|
|
||||||
cacheStrategy: cacheStrategy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const connections = await prisma.connection.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
name: 'asc'
|
|
||||||
},
|
|
||||||
cacheStrategy: cacheStrategy,
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({interests, coreValues, books, causeAreas, connections});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching interests:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "Failed to fetch interests"},
|
|
||||||
{status: 500}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import {NextResponse} from "next/server";
|
|
||||||
import {getSession} from "@/lib/server/auth";
|
|
||||||
import {retrieveUser} from "@/lib/server/db-utils";
|
|
||||||
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const session = await getSession();
|
|
||||||
console.log(`Session: ${session?.user?.name}`);
|
|
||||||
|
|
||||||
if (!session?.user?.id)
|
|
||||||
return new NextResponse(JSON.stringify({error: "User not found"}), {
|
|
||||||
status: 404,
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
});
|
|
||||||
|
|
||||||
const id = session.user.id;
|
|
||||||
|
|
||||||
const user = await retrieveUser(id);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return new NextResponse(JSON.stringify({error: "User not found"}), {
|
|
||||||
status: 404,
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(user);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { NextResponse } from "next/server";
|
|
||||||
import { prisma } from "@/lib/server/prisma";
|
|
||||||
import { getServerSession } from "next-auth/next";
|
|
||||||
import { authOptions } from "@/lib/server/auth";
|
|
||||||
import {retrieveUser} from "@/lib/server/db-utils";
|
|
||||||
|
|
||||||
// Handler for GET /api/profiles/[id]
|
|
||||||
export async function GET(
|
|
||||||
request: Request,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const params = await context.params;
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
const user = await retrieveUser(id)
|
|
||||||
|
|
||||||
// If user not found, return 404
|
|
||||||
if (!user) {
|
|
||||||
return new NextResponse(JSON.stringify({error: "User not found"}), {
|
|
||||||
status: 404,
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new NextResponse(JSON.stringify(user), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching user profile:", error);
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: "Failed to fetch user profile" }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handler for DELETE /api/profiles/[id]
|
|
||||||
export async function DELETE(
|
|
||||||
request: Request,
|
|
||||||
context: { params: Promise<{ id: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
// Verify authentication
|
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session?.user) {
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'You must be signed in to delete a profile' }),
|
|
||||||
{ status: 401, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = await context.params;
|
|
||||||
const { id } = params;
|
|
||||||
|
|
||||||
// Verify the user is trying to delete their own profile
|
|
||||||
if (session.user.id !== id) {
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'You can only delete your own profile' }),
|
|
||||||
{ status: 403, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete related records first to avoid foreign key constraints
|
|
||||||
await prisma.$transaction([
|
|
||||||
// Delete prompt answers
|
|
||||||
prisma.promptAnswer.deleteMany({
|
|
||||||
where: { profileId: id },
|
|
||||||
}),
|
|
||||||
// Delete intellectual interests
|
|
||||||
prisma.profileInterest.deleteMany({
|
|
||||||
where: { profileId: id },
|
|
||||||
}),
|
|
||||||
prisma.profileValue.deleteMany({
|
|
||||||
where: { profileId: id },
|
|
||||||
}),
|
|
||||||
// Delete cause areas
|
|
||||||
prisma.profileCauseArea.deleteMany({
|
|
||||||
where: { profileId: id },
|
|
||||||
}),
|
|
||||||
// Delete Type of Connection
|
|
||||||
prisma.profileConnection.deleteMany({
|
|
||||||
where: { profileId: id },
|
|
||||||
}),
|
|
||||||
// Delete the profile
|
|
||||||
prisma.profile.deleteMany({
|
|
||||||
where: { id: id },
|
|
||||||
}),
|
|
||||||
// Finally, delete the user
|
|
||||||
prisma.user.delete({
|
|
||||||
where: { id },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ success: true, message: 'Profile deleted successfully' }),
|
|
||||||
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting profile:', error);
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({ error: 'Failed to delete profile' }),
|
|
||||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { prisma } from "@/lib/server/prisma";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
// Get the total count of users from the database
|
|
||||||
const count = await prisma.user.count();
|
|
||||||
|
|
||||||
return NextResponse.json({ count });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching user count:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch user count" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This ensures the route is not cached
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { prisma } from "@/lib/server/prisma";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
let data = await prisma.promptAnswer.findMany({
|
|
||||||
select: {
|
|
||||||
prompt: true,
|
|
||||||
},
|
|
||||||
distinct: ['prompt'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const uniquePrompts = data.map((prompt) => prompt.prompt);
|
|
||||||
|
|
||||||
return NextResponse.json({ uniquePrompts });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching prompts:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Failed to fetch prompts" },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This ensures the route is not cached
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
import {prisma} from "@/lib/server/prisma";
|
|
||||||
import {NextResponse} from "next/server";
|
|
||||||
import {getSession} from "@/lib/server/auth";
|
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const page = parseInt(url.searchParams.get("page") || "1");
|
|
||||||
const gender = url.searchParams.get("gender");
|
|
||||||
const minAge = url.searchParams.get("minAge");
|
|
||||||
const maxAge = url.searchParams.get("maxAge");
|
|
||||||
const minIntroversion = url.searchParams.get("minIntroversion");
|
|
||||||
const maxIntroversion = url.searchParams.get("maxIntroversion");
|
|
||||||
const interests = url.searchParams.get("interests")?.split(",").filter(Boolean) || [];
|
|
||||||
const coreValues = url.searchParams.get("coreValues")?.split(",").filter(Boolean) || [];
|
|
||||||
const books = url.searchParams.get("books")?.split(",").filter(Boolean) || [];
|
|
||||||
const causeAreas = url.searchParams.get("causeAreas")?.split(",").filter(Boolean) || [];
|
|
||||||
const connections = url.searchParams.get("connections")?.split(",").filter(Boolean) || [];
|
|
||||||
const searchQueries = url.searchParams.get("searchQuery")?.split(",").map(q => q.trim()).filter(Boolean) || [];
|
|
||||||
|
|
||||||
const profilesPerPage = 100;
|
|
||||||
const offset = (page - 1) * profilesPerPage;
|
|
||||||
|
|
||||||
const session = await getSession();
|
|
||||||
console.log(`Session: ${session?.user?.name}`);
|
|
||||||
|
|
||||||
// Build the where clause based on filters
|
|
||||||
const where: any = {
|
|
||||||
id: {not: session?.user?.id},
|
|
||||||
};
|
|
||||||
|
|
||||||
where.profile = {};
|
|
||||||
where.profile.AND = [];
|
|
||||||
|
|
||||||
if (gender) {
|
|
||||||
where.profile = {
|
|
||||||
...where.profile,
|
|
||||||
gender: gender,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add age filtering
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
if (minAge || maxAge) {
|
|
||||||
where.profile = {
|
|
||||||
...where.profile,
|
|
||||||
birthYear: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (minAge) {
|
|
||||||
where.profile.birthYear.lte = currentYear - parseInt(minAge);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxAge) {
|
|
||||||
where.profile.birthYear.gte = currentYear - parseInt(maxAge);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add introversion filtering (careful: the query value is actually extroversion
|
|
||||||
if (minIntroversion || maxIntroversion) {
|
|
||||||
where.profile = {
|
|
||||||
...where.profile,
|
|
||||||
introversion: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (minIntroversion) {
|
|
||||||
where.profile.introversion.lte = 100 - parseInt(minIntroversion);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (maxIntroversion) {
|
|
||||||
where.profile.introversion.gte = 100 - parseInt(maxIntroversion);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OR
|
|
||||||
// if (interests.length > 0) {
|
|
||||||
// where.profile = {
|
|
||||||
// ...where.profile,
|
|
||||||
// intellectualInterests: {
|
|
||||||
// some: {
|
|
||||||
// interest: {
|
|
||||||
// name: {in: interests},
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
// AND
|
|
||||||
if (interests.length > 0) {
|
|
||||||
where.profile.AND = [
|
|
||||||
...where.profile.AND,
|
|
||||||
...interests.map((name) => ({
|
|
||||||
intellectualInterests: {
|
|
||||||
some: {
|
|
||||||
interest: {
|
|
||||||
name: name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// AND
|
|
||||||
if (coreValues.length > 0) {
|
|
||||||
where.profile.AND = [
|
|
||||||
...where.profile.AND,
|
|
||||||
...coreValues.map((name) => ({
|
|
||||||
coreValues: {
|
|
||||||
some: {
|
|
||||||
value: {
|
|
||||||
name: name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// AND
|
|
||||||
if (books.length > 0) {
|
|
||||||
where.profile.AND = [
|
|
||||||
...where.profile.AND,
|
|
||||||
...books.map((name) => ({
|
|
||||||
books: {
|
|
||||||
some: {
|
|
||||||
value: {
|
|
||||||
name: name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (causeAreas.length > 0) {
|
|
||||||
where.profile.AND = [
|
|
||||||
...where.profile.AND,
|
|
||||||
...causeAreas.map((name) => ({
|
|
||||||
causeAreas: {
|
|
||||||
some: {
|
|
||||||
causeArea: {
|
|
||||||
name: name,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// OR
|
|
||||||
if (connections.length > 0) {
|
|
||||||
where.profile = {
|
|
||||||
...where.profile,
|
|
||||||
desiredConnections: {
|
|
||||||
some: {
|
|
||||||
connection: {
|
|
||||||
name: {in: connections},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchQueries.length > 0) {
|
|
||||||
where.AND = [
|
|
||||||
...(where.AND ?? []),
|
|
||||||
...searchQueries.map(query => ({
|
|
||||||
OR: [
|
|
||||||
{name: {contains: query, mode: 'insensitive'}},
|
|
||||||
// {email: {contains: searchQuery, mode: 'insensitive'}},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
description: {contains: query, mode: 'insensitive'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
occupation: {contains: query, mode: 'insensitive'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
location: {contains: query, mode: 'insensitive'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
contactInfo: {contains: query, mode: 'insensitive'},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
intellectualInterests: {
|
|
||||||
some: {
|
|
||||||
interest: {
|
|
||||||
name: {contains: query, mode: "insensitive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
coreValues: {
|
|
||||||
some: {
|
|
||||||
value: {
|
|
||||||
name: {contains: query, mode: "insensitive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
books: {
|
|
||||||
some: {
|
|
||||||
value: {
|
|
||||||
name: {contains: query, mode: "insensitive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
causeAreas: {
|
|
||||||
some: {
|
|
||||||
causeArea: {
|
|
||||||
name: {contains: query, mode: "insensitive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
desiredConnections: {
|
|
||||||
some: {
|
|
||||||
connection: {
|
|
||||||
name: {contains: query, mode: "insensitive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
promptAnswers: {
|
|
||||||
some: {
|
|
||||||
answer: {contains: query, mode: "insensitive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
profile: {
|
|
||||||
promptAnswers: {
|
|
||||||
some: {
|
|
||||||
prompt: {contains: query, mode: "insensitive"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}))
|
|
||||||
]
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(where.profile);
|
|
||||||
|
|
||||||
// Fetch paginated and filtered profiles
|
|
||||||
const cacheStrategy = {swr: 60, ttl: 60, tags: ["profiles"]};
|
|
||||||
const profiles = await prisma.user.findMany({
|
|
||||||
skip: offset,
|
|
||||||
take: profilesPerPage,
|
|
||||||
orderBy: {createdAt: "desc"},
|
|
||||||
where,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
// email: true,
|
|
||||||
image: true,
|
|
||||||
createdAt: true,
|
|
||||||
profile: {
|
|
||||||
include: {
|
|
||||||
intellectualInterests: {include: {interest: true}},
|
|
||||||
coreValues: {include: {value: true}},
|
|
||||||
books: {include: {value: true}},
|
|
||||||
causeAreas: {include: {causeArea: true}},
|
|
||||||
desiredConnections: {include: {connection: true}},
|
|
||||||
promptAnswers: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cacheStrategy: cacheStrategy,
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalProfiles = await prisma.user.count();
|
|
||||||
const totalPages = Math.ceil(totalProfiles / profilesPerPage);
|
|
||||||
|
|
||||||
console.log({profiles, totalPages});
|
|
||||||
return NextResponse.json({profiles, totalPages});
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import {NextResponse} from 'next/server';
|
|
||||||
import {getSession} from '@/lib/server/auth';
|
|
||||||
import {v4 as uuidv4} from 'uuid';
|
|
||||||
import {GetObjectCommand, PutObjectCommand, S3Client} from '@aws-sdk/client-s3';
|
|
||||||
import {getSignedUrl} from "@aws-sdk/s3-request-presigner";
|
|
||||||
|
|
||||||
const s3Client = new S3Client({
|
|
||||||
region: process.env.AWS_REGION!,
|
|
||||||
credentials: {
|
|
||||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
|
||||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const session = await getSession();
|
|
||||||
const userId = session?.user?.id;
|
|
||||||
if (!userId) {
|
|
||||||
return NextResponse.json({error: 'Not authenticated'}, {status: 401});
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
|
||||||
console.log('formData', formData);
|
|
||||||
const file = formData.get('file') as File | null;
|
|
||||||
|
|
||||||
if (!file) return NextResponse.json({error: 'No file provided'}, {status: 400});
|
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
if (!file.type.startsWith('image/')) {
|
|
||||||
return NextResponse.json({error: 'Only image files are allowed'}, {status: 400});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (10MB max)
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
return NextResponse.json({error: 'File size must be less than 10MB'}, {status: 400});
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileExtension = file.name.split('.').pop();
|
|
||||||
const fileName = `${uuidv4()}.${fileExtension}`;
|
|
||||||
const fileBuffer = await file.arrayBuffer();
|
|
||||||
const key = `profile-pictures/${userId}/${fileName}`;
|
|
||||||
|
|
||||||
const uploadParams = {
|
|
||||||
Bucket: process.env.AWS_S3_BUCKET_NAME!,
|
|
||||||
Key: key,
|
|
||||||
Body: Buffer.from(fileBuffer),
|
|
||||||
ContentType: file.type,
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await s3Client.send(new PutObjectCommand(uploadParams));
|
|
||||||
console.log(`Response: ${response}`);
|
|
||||||
|
|
||||||
// get signed url
|
|
||||||
const url = await getSignedUrl(
|
|
||||||
s3Client,
|
|
||||||
new GetObjectCommand({
|
|
||||||
Bucket: process.env.AWS_S3_BUCKET_NAME!,
|
|
||||||
Key: key,
|
|
||||||
}),
|
|
||||||
{expiresIn: 300} // 5 minutes
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Signed URL: ${url}`);
|
|
||||||
// const fileUrl = `${process.env.AWS_S3_BUCKET_NAME}/profile-pictures/${fileName}`;
|
|
||||||
|
|
||||||
return NextResponse.json({url: url, key: key});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Upload error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: 'Failed to upload file'},
|
|
||||||
{status: 500}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import {NextResponse} from "next/server";
|
|
||||||
import {prisma} from "@/lib/server/prisma";
|
|
||||||
import {getSession} from "@/lib/server/auth";
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
|
||||||
try {
|
|
||||||
const session = await getSession();
|
|
||||||
|
|
||||||
if (!session?.user?.email) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "Not authenticated"},
|
|
||||||
{status: 401}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await req.json();
|
|
||||||
const {profile, image, name, interests = [], connections = [], coreValues = [], books = [], causeAreas = []} = data;
|
|
||||||
|
|
||||||
console.log('books: ', books)
|
|
||||||
Object.keys(profile).forEach(key => {
|
|
||||||
if (profile[key] === '' || !profile[key]) {
|
|
||||||
delete profile[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('profile', profile);
|
|
||||||
|
|
||||||
// Start a transaction to ensure data consistency
|
|
||||||
const result = await prisma.$transaction(async (prisma) => {
|
|
||||||
|
|
||||||
if (profile.promptAnswers) {
|
|
||||||
const profileData = await prisma.profile.findUnique({
|
|
||||||
where: {
|
|
||||||
userId: session.user.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('profileData:', profileData);
|
|
||||||
|
|
||||||
const profileId = profileData?.id;
|
|
||||||
if (profileId) {
|
|
||||||
const deleted = await prisma.promptAnswer.deleteMany({
|
|
||||||
where: {
|
|
||||||
profileId: profileData?.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
console.log('Deleted prompt answers:', deleted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// First, update/create the profile
|
|
||||||
const updatedUser = await prisma.user.update({
|
|
||||||
where: {email: session.user.email},
|
|
||||||
data: {
|
|
||||||
...(image && {image}),
|
|
||||||
...(name && {name}),
|
|
||||||
profile: {
|
|
||||||
upsert: {
|
|
||||||
create: profile,
|
|
||||||
update: profile,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
profile: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const modelMap: any = {
|
|
||||||
interest: prisma.interest,
|
|
||||||
profileInterest: prisma.profileInterest,
|
|
||||||
connection: prisma.connection,
|
|
||||||
profileConnection: prisma.profileConnection,
|
|
||||||
value: prisma.value,
|
|
||||||
profileValue: prisma.profileValue,
|
|
||||||
book: prisma.book,
|
|
||||||
profileBook: prisma.profileBook,
|
|
||||||
causeArea: prisma.causeArea,
|
|
||||||
profileCauseArea: prisma.profileCauseArea,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type ModelKey = keyof typeof modelMap;
|
|
||||||
|
|
||||||
async function handleFeatures(features: any, attribute: ModelKey, profileAttribute: string, idName: string) {
|
|
||||||
// Add new features
|
|
||||||
if (features !== null && updatedUser.profile) {
|
|
||||||
// First, find or create all features
|
|
||||||
console.log('profile', profileAttribute, profileAttribute);
|
|
||||||
const operations = features.map((feat: { id?: string; name: string }) =>
|
|
||||||
modelMap[attribute].upsert({
|
|
||||||
where: {id: feat.id || ''},
|
|
||||||
update: {name: feat.name},
|
|
||||||
create: {name: feat.name},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const createdFeatures = await Promise.all(operations);
|
|
||||||
|
|
||||||
// Get the IDs of all created/updated features
|
|
||||||
const ids = createdFeatures.map(v => v.id);
|
|
||||||
|
|
||||||
const profileId = updatedUser.profile.id;
|
|
||||||
console.log('profile ID:', profileId);
|
|
||||||
|
|
||||||
// First, remove all existing features for this profile
|
|
||||||
const res = await modelMap[profileAttribute].deleteMany({
|
|
||||||
where: {profileId: profileId},
|
|
||||||
});
|
|
||||||
console.log('deleted profile:', profileAttribute, res);
|
|
||||||
|
|
||||||
// Then, create new features
|
|
||||||
if (ids.length > 0) {
|
|
||||||
const create_res =await modelMap[profileAttribute].createMany({
|
|
||||||
data: ids.map(id => ({
|
|
||||||
profileId: profileId,
|
|
||||||
[idName]: id,
|
|
||||||
})),
|
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
|
||||||
console.log('created many:', profileAttribute, create_res);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await handleFeatures(interests, 'interest', 'profileInterest', 'interestId')
|
|
||||||
await handleFeatures(books, 'book', 'profileBook', 'valueId')
|
|
||||||
await handleFeatures(connections, 'connection', 'profileConnection', 'connectionId')
|
|
||||||
await handleFeatures(coreValues, 'value', 'profileValue', 'valueId')
|
|
||||||
await handleFeatures(causeAreas, 'causeArea', 'profileCauseArea', 'causeAreaId')
|
|
||||||
|
|
||||||
return updatedUser
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Profile update error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{error: "Failed to update profile"},
|
|
||||||
{status: 500}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
|
|
||||||
export default function AuthError() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// import Link from "next/link";
|
|
||||||
//
|
|
||||||
// export default function AuthError(
|
|
||||||
// searchParams?: { [key: string]: string | string[] | undefined }
|
|
||||||
// ) {
|
|
||||||
// const error = searchParams?.error;
|
|
||||||
// const errorMessage = (() => {
|
|
||||||
// switch (error) {
|
|
||||||
// case "InvalidToken":
|
|
||||||
// return "The verification link is invalid.";
|
|
||||||
// case "InvalidOrExpiredToken":
|
|
||||||
// return "The verification link is invalid or has expired. Please request a new one.";
|
|
||||||
// case "VerificationFailed":
|
|
||||||
// return "Email verification failed. Please try again later.";
|
|
||||||
// default:
|
|
||||||
// return "An unexpected error occurred. Please try again.";
|
|
||||||
// }
|
|
||||||
// })();
|
|
||||||
//
|
|
||||||
// return (
|
|
||||||
// <div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
// <div className="max-w-md w-full space-y-8 text-center">
|
|
||||||
// <div className="rounded-full bg-red-100 p-3 inline-flex items-center justify-center">
|
|
||||||
// <svg
|
|
||||||
// className="h-12 w-12 text-red-600"
|
|
||||||
// fill="none"
|
|
||||||
// viewBox="0 0 24 24"
|
|
||||||
// stroke="currentColor"
|
|
||||||
// >
|
|
||||||
// <path
|
|
||||||
// strokeLinecap="round"
|
|
||||||
// strokeLinejoin="round"
|
|
||||||
// strokeWidth={2}
|
|
||||||
// d="M6 18L18 6M6 6l12 12"
|
|
||||||
// />
|
|
||||||
// </svg>
|
|
||||||
// </div>
|
|
||||||
// <h2 className="mt-6 text-3xl font-extrabold ">
|
|
||||||
// Verification Error
|
|
||||||
// </h2>
|
|
||||||
// <p className="mt-2 text-sm text-gray-600">{errorMessage}</p>
|
|
||||||
// <div className="mt-6 space-y-4">
|
|
||||||
// <Link
|
|
||||||
// href="/register"
|
|
||||||
// className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
// >
|
|
||||||
// Back to Registration
|
|
||||||
// </Link>
|
|
||||||
// <Link
|
|
||||||
// href="/login"
|
|
||||||
// className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover: focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
// >
|
|
||||||
// Go to Login
|
|
||||||
// </Link>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export default function VerificationSuccess() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="max-w-md w-full space-y-8 text-center">
|
|
||||||
<div className="rounded-full bg-green-100 p-3 inline-flex items-center justify-center">
|
|
||||||
<svg
|
|
||||||
className="h-12 w-12 text-green-600"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 className="mt-6 text-3xl font-extrabold ">
|
|
||||||
Email Verified Successfully!
|
|
||||||
</h2>
|
|
||||||
<p className="mt-2 text-sm text-gray-600">
|
|
||||||
Your email has been successfully verified. You can now log in to your account.
|
|
||||||
</p>
|
|
||||||
<div className="mt-6">
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
Go to Login
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { DropdownKey } from "@/lib/client/schema";
|
|
||||||
|
|
||||||
|
|
||||||
type DropdownProps = {
|
|
||||||
id: DropdownKey
|
|
||||||
options?: string[]
|
|
||||||
value: string
|
|
||||||
onChange: (id: DropdownKey, value: string) => void
|
|
||||||
onFocus?: (id: DropdownKey) => void
|
|
||||||
onKeyDown?: (id: DropdownKey, key: string) => void
|
|
||||||
onClick: (id: DropdownKey) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dropdown(
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
// options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onFocus,
|
|
||||||
onKeyDown,
|
|
||||||
onClick,
|
|
||||||
}: DropdownProps
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<div className="flex items-center border border-gray-300 rounded-md shadow-sm">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(id, e.target.value)}
|
|
||||||
onFocus={() => onFocus?.(id)}
|
|
||||||
onKeyDown={(e) => onKeyDown?.(id, e.key)}
|
|
||||||
className="flex-1 min-w-0 block w-full px-3 py-2 rounded-l-md border-0 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
|
||||||
placeholder="Type to search"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(_) => onClick?.(id)}
|
|
||||||
className="px-3 py-2 border-l border-gray-300 text-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<svg className="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
|
||||||
fill="currentColor">
|
|
||||||
<path fillRule="evenodd"
|
|
||||||
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
|
|
||||||
clipRule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
/*--background: #0a0a0a;*/
|
|
||||||
/*--foreground: #ffffff;*/
|
|
||||||
}
|
|
||||||
|
|
||||||
/*@media (prefers-color-scheme: dark) {*/
|
|
||||||
/* :root {*/
|
|
||||||
/* --background: #0a0a0a;*/
|
|
||||||
/* --foreground: #ededed;*/
|
|
||||||
/* }*/
|
|
||||||
/*}*/
|
|
||||||
|
|
||||||
body {
|
|
||||||
color: var(--foreground);
|
|
||||||
background: var(--background);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
html, body {
|
|
||||||
background-color: var(--background);
|
|
||||||
color: var(--foreground);
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Style all headings globally */
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
font-family: 'Inter', sans-serif; /* Clean modern font */
|
|
||||||
font-weight: 600; /* Semi-bold for clarity */
|
|
||||||
/*color: #111827; !* Near-black text for readability *!*/
|
|
||||||
line-height: 1.25;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Size scaling */
|
|
||||||
h1 {
|
|
||||||
font-size: 2rem; /* ~32px */
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
font-size: 1.5rem; /* ~24px */
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
font-size: 1.25rem; /* ~20px */
|
|
||||||
}
|
|
||||||
h4 {
|
|
||||||
font-size: 1.125rem; /* ~18px */
|
|
||||||
}
|
|
||||||
h5 {
|
|
||||||
font-size: 1rem; /* ~16px */
|
|
||||||
}
|
|
||||||
h6 {
|
|
||||||
font-size: 0.875rem; /* ~14px */
|
|
||||||
color: #374151; /* Slightly lighter for subheadings */
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: disc;
|
|
||||||
padding-left: 1.25rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
width: 48px; /* 12 * 4px */
|
|
||||||
height: 48px;
|
|
||||||
border: 4px solid #d1d5db; /* gray-300 */
|
|
||||||
border-top-color: #1f2937; /* gray-800 */
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// app/layout.tsx
|
|
||||||
import "./globals.css";
|
|
||||||
import {ThemeProvider} from 'next-themes';
|
|
||||||
import {Metadata} from "next";
|
|
||||||
import Header from "@/app/Header";
|
|
||||||
import Providers from "@/app/providers";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Compass",
|
|
||||||
description: "A social platform to form intentional bonds",
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default function RootLayout(
|
|
||||||
{
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<html lang="en" suppressHydrationWarning className="dark" >
|
|
||||||
<body className="dark:bg-gray-900 dark:text-white">
|
|
||||||
<Providers>
|
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
||||||
<div className="min-h-screen flex flex-col ">
|
|
||||||
<Header/>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="p-6 text-center text-gray-500">
|
|
||||||
<div className="mb-2">
|
|
||||||
<a
|
|
||||||
href="https://github.com/CompassConnections/Compass"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center text-gray-500 hover:text-gray-700 transition"
|
|
||||||
>
|
|
||||||
<svg className="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
|
||||||
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.699 1.028 1.595 1.028 2.688 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
View on GitHub
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>© {new Date().getFullYear()} Compass. All rights reserved.</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
</Providers>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
|
||||||
return (
|
|
||||||
<main className="max-w-4xl mx-auto p-8">
|
|
||||||
{aColor}
|
|
||||||
|
|
||||||
<h1 id="abstract">Abstract</h1>
|
|
||||||
<p>Forming and maintaining close connections is fundamental for most people’s mental health—and hence overall well-being. However, currently available meeting platforms, lacking transparency and searchability, are deeply failing to bring together thoughtful people. This article lays the path for a platform designed to foster close friendships and relationships for people who prioritize learning, curiosity, and critical thinking. The directory of users will be fully transparent and each profile will contain extensive information, allowing searches over all users through powerful filtering and sorting methods. To prevent any value drift from this pro-social mission, the platform will always be free, ad-free, not for profit, donation-supported, open source, and democratically governed. The goal of this article is to better understand the community needs, as well as to gather feedback and collaboration for the suggested implementation.</p>
|
|
||||||
<h1 id="introduction">Introduction</h1>
|
|
||||||
<p>I’ll explain below my rationale for suggesting the implementation of a bonding platform; since it would be run by volunteers, it goes without saying that those reasons are pro-social.</p>
|
|
||||||
<p>The starting point is purely personal; I’m currently looking for a few more close connections and, like many, struggling to find like-minded people—despite trying different approaches like dating apps, forums, and real-life communities. Of course, I’ve made wonderful connections along the way, but they require a lot of effort to find them out of hundreds of other ones, and they may not lead to the emotional and intellectual closeness that is so fulfilling in close relationships.</p>
|
|
||||||
<p>Most of us know that relationships have a major positive impact on overall well-being [1], and most of the happiest people have great close relationships [2]. Additionally, although groups are useful for meeting people, the main value of close connections arises from <a href="https://www.lesswrong.com/posts/pfibDHFZ3waBo6pAc/intentionally-making-close-friends">1-to-1 conversations</a>, as they foster <a href="https://www.lesswrong.com/posts/L2GR6TsB9QDqMhWs7/the-value-proposition-of-romantic-relationships">emotional closeness and vulnerability</a>. With that in mind, I spelled out the type of connections that would bring a lot of value to my life: a close friendship or relationship, with an emotionally stable person around my age, who feels connected to rationality, intellectualism, minimalism, nature, and animal welfare. Of course, this description is much too specific to fit anyone—let alone some of the readers. My values may also slightly vary as my views change; but the description paves the way for attracting and finding people who are much more likely to connect with me.</p>
|
|
||||||
<p>The more values you require in others, the less likely they are to fit them. So, a good approach is to enumerate as many values as possible as long as the whole set still fits a few people out there. I personally love evidence-based learning; this framework has had the most transformative impact on my life so far. Understanding the world through the lenses of rational thinking has deeply stabilizing me, helping me reach some form of internal peace—or robustness to life events—much better than any other practice. It never fails to keep me excited everyday. My goal isn’t to change anyone’s core values, but rather to make the case that there are many people in the world who find a lot of meaning and happiness in rational intellectualism.</p>
|
|
||||||
<p>With that insight, I narrowed down my most important values to rationality and intellectualism—which should still apply to 1-2% of the population. Those are the primary values I’d like to see in the people close to me and, as a consequence, in the platform that I’ll detail below. I imagine a community where people prioritize truth over comfort, depth over instant gratification, and humility over (over)confidence. Such is the essence of epistemological connections—deep bonds rooted in mutual curiosity, love of learning, and evidence-based thinking.</p>
|
|
||||||
<h1 id="core-values">Core Values</h1>
|
|
||||||
<p>By rationality and intellectualism, I certainly don't allude to cold, elitist traits devoid of any emotional awareness. I don't mean that one should have already polished those traits either; one should simply value them enough to have the potential to approach them in the future.</p>
|
|
||||||
<p>All along this article, “the community” should be understood as the set of people who identify with the core values below—whether or not there exists a platform to connect them. Let's carefully explore and define the 4 core values: rationality, intellectualism, relational fulfillment, and interpersonal maturity.</p>
|
|
||||||
<h3 id="rationality">Rationality</h3>
|
|
||||||
<p>Rationality should be understood—very generally—as the systematic process of forming accurate, evidence-based beliefs to achieve some stated goals.</p>
|
|
||||||
<p>Instead of retreating into relativism—everyone’s opinion is equally valid—when disagreements arise, one engages in critical thinking by questioning each other down to the core of their beliefs. This truth-seeking process is a collaboration of the minds where everyone wins, and people find it emotionally grounding—not threatening.</p>
|
|
||||||
<p>Curiosity and depth should rise above comfort; truth requires nuance and thoughtful engagement. Evidence is worth more than status or authority; upon presentation of stronger evidence, one has the intellectual humility to change one’s beliefs without feelings of personal attacks. Awareness of one’s cognitive biases, intending to reduce them, and saying “I don’t know” are fundamental strengths. Likewire, one applauds others when they acknowledge their mistakes, limitations, or change their mind.</p>
|
|
||||||
<p>Rationality may be seen as the exercise of reason at the expense of the impulses—but certainly not of the emotions as a whole.</p>
|
|
||||||
<p>Of course, those are very high epistemic standards which, perhaps, no one wholly holds; but, again, intention and care for them are more than enough.</p>
|
|
||||||
<h3 id="intellectualism">Intellectualism</h3>
|
|
||||||
<p>There is some overlap with rationality, but intellectualism should principally be understood as deriving profound satisfaction from learning, thinking and sharing ideas.</p>
|
|
||||||
<p>The intellectual enjoys gaining knowledge for its own sake—which may or may not prove useful in the far future through higher-order effects—or to fulfil more direct goals. Endowed this epistemic drive, they engage in debate, discussions, and longform content (e.g., books, podcasts and forums) covering diverse topics that require the exchange of ideas: philosophy, science, history, art, etc.</p>
|
|
||||||
<p>Of course, even “truth-seekers” want to be silly and playful from time to time; one just needs to be able to engage in rational intellectualism for important decisions, not all day.</p>
|
|
||||||
<h3 id="relational-fulfillment-agency">Relational Fulfillment & Agency</h3>
|
|
||||||
<p>Relational fulfillment should be understood as experiencing satisfaction and completeness within close relationships—agency being the drive to form and maintain them.</p>
|
|
||||||
<p>Close relationships are relationships endowed with an emotional bond resulting in a profound care (and sometimes love) for each other. Key mechanisms that usually develop close relationships include value alignment, mutual vulnerabilities, proper communication, and the mere exposure effect.</p>
|
|
||||||
<h3 id="interpersonal-maturity">Interpersonal Maturity</h3>
|
|
||||||
<p>Interpersonal maturity is an umbrella term that applies to relationships in general. It involves, among others, self-awareness, accountability, transparency, conflict navigation, and the ability to care simply and sanely.</p>
|
|
||||||
<p>To maintain close connections among rational intellectuals, interpersonal maturity is as fundamental as the three other core values; it is the glue that bonds them together and allows smooth interactions. Indeed, rationality primarily helps determine how to achieve goals, but not which ultimate goals to pursue. So, to understand others and care for them, logic must be paired with self-awareness, emotional intelligence, and an understanding of their deeper influences.</p>
|
|
||||||
<h3 id="audience">Audience</h3>
|
|
||||||
<p>Now that the core values are defined, I’ll describe some other traits in people that partially correlate with them. (Note that the community is defined based on the core values only; anyone who fits the core value, regardless of the traits below, is already part of the community.)</p>
|
|
||||||
<p>So, here are some tendencies that highlight how most potential community members might be.</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>Asking clarifying questions, citing evidence, responding thoughtfully, inviting dialogue before delivering facts</li>
|
|
||||||
<li>Valuing evidence-based practices (e.g., medicine, economics)</li>
|
|
||||||
<li>Attending reading groups, philosophy salons, or academic communities</li>
|
|
||||||
<li>Lifelong learners (e.g., college educated who continues learning on the side)</li>
|
|
||||||
<li>Enjoying online connections (at first)</li>
|
|
||||||
<li>Fostering friendships before romance</li>
|
|
||||||
</ul>
|
|
||||||
<p>Naturally, the people identifying with the core values are quite rare (probably around 1% of the population). Most people have everyday priorities which clashes with the cognitive load required for rational thinking. Also, seeking the truth is usually linked with an initial change of worldview, which is often destabilizing at first, before becoming stabilizing. Social barriers are there as well, especially under social coherentism or revealed foundationalism—the most popular epistemological frameworks.</p>
|
|
||||||
<h3 id="revisions">Revisions</h3>
|
|
||||||
<p>The core values are subject to slight revisions upon receiving feedback from people who mostly align with them. For example, I have no issue making the community more inclusive by adding debate partners (i.e., with no emotional bonding), as long as everyone is transparent about their intentions.</p>
|
|
||||||
<h1 id="market-review">Market Review</h1>
|
|
||||||
<p>I’ll briefly go over some currently available products / methods that connect people and I'll review if they follow the core values.</p>
|
|
||||||
<p>I’ll start with the traditional dating apps (Tinder, Bumble, and the like), as they are the easiest to reject. Almost every aspect in their business model and functionalities goes against the social mission of bringing together rational intellectuals for close connections. The user base is composed of less than 2% of such people and they cannot be searched with the few available filters. Textual profile information fails to bring any valuable insight into their personality and values, which are the most important causes of good connections. Their business model is even less aligned with the pro-social mission than their functionalities. They aim at maximizing profit by maximizing user retention through manipulative neurological and psychological techniques such as instant dopamine gratification and fear of missing out.</p>
|
|
||||||
<p>A dating app which could be praised for its more transparent user base is Feeld. There is no swipe; you can browse a directory and like whoever. Their focus on non-traditional relationships and poor filters make it clearly unsuitable for our stated needs, however.</p>
|
|
||||||
<p>The most popular rationalist communities are LessWrong and its derivatives: Effective Altruism (EA), This Part Of Twitter / Post-Rationalists, Astral Codex Ten, etc.. They are mostly online communities (custom forums, Reddit, Discord, etc.), but most of them organize local meetups. The derivatives mostly embrace the two first core values as well (rationality and intellectualism), and usually add a third one unique to their community. To this day, those communities and their platforms are probably the most likely way to create close relationships between people who follow the core values above. I would love to get some feedback about the number of close connections that emerged from those communities and the proportion of members looking for such connections.</p>
|
|
||||||
<p>It is yet unclear, however, how much they embrace the third core value (actively seeking to form deep, lasting bonds). Those online platforms do permit to write a profile with interests / preferences and contact info, and to search and contact any other member; this is great for general connections. But they don't allow for detailed, standardized profile information or filters in order to find the people most likely to form deep bonds. So, as of now, the best shot for someone looking for close connections is either to travel to local meetups or to find members with shared values / interests (maybe something they posted or on their profile, with some amount of serendipity) and hope that they are also open to closer bonds. In my opinion, what could be improved on those platforms—or created in a new one—is having access to detailed information about plenty of people and being able to quickly filter the most suitable connections.</p>
|
|
||||||
<p>The EA community is probably the one that made the most effort to circumvent those limitations and form close, fulfilling relationships. Besides a few unsuccessful calls for building an <a href="https://forum.effectivealtruism.org/posts/atwsMvS8HXaW4QX2h/what-would-you-desire-in-an-ea-dating-site">EA dating app</a>, they published an interesting <a href="https://docs.google.com/spreadsheets/d/1oL25vc5Feg94flPPCO03ujijRvyHffxt0qnI7rU8rdg/edit?gid=0#gid=0">dating spreadsheet</a> that provides <a href="https://www.lesswrong.com/posts/6yiayg5QWtWme4JN8/anatomy-of-a-dating-document">dating documents</a>—which contain information about who they are, what they are looking for, and how to be contacted. The concept of dating docs is a promising tool to connect people with highly specific preferences and standards—as is the case for most rational intellectuals. But, at this stage, those non-standardized documents are scattered across the web, so there is no easy way to filter them.</p>
|
|
||||||
<p>Other EA platforms for connections include <a href="https://buck-reciprocity.herokuapp.com/">Reprocity</a> and the EA community on Facebook Dating. <a href="https://dateme.directory">DateMe</a>, perhaps appealing to a broader rationalist audience, is a web app with basic filters (gender, age and location) where users can share a dating doc and contact information. These three platforms, however, suffer (again) from poor filtering methods and scarce profile information, making it immensely hard to find close matches.</p>
|
|
||||||
<p>More recently, a few apps have tried to run against the industrial business model of traditional apps. <a href="https://notazombie.net/landing">Not a Zombie</a> (yes, don’t ask me why) is an app that is currently being developed, with a “focus on compatibility first, photos second”. Users would first see a text-based description of other people, and photos would only be revealed after some engagement. Interestingly, they allow any user to message any other user, as they “believe that the act of sending a well-crafted first message can prompt interest even if there wasn’t already interest otherwise”. To prevent spamming, however, they would limit the number of profiles visible per day. Many of the app components are valuable food for thoughts, but the app itself—if it ever gets built—would fall short to embrace the core values of the preceding section. It is for dating only, not open-source, and every user may only see a few profiles per day.</p>
|
|
||||||
<p>Another recent app, <a href="https://datefirefly.com/">Firefly</a>, has reached more popularity while keeping its pro-social mission. Its user base is fully transparent and many interesting prompts bring thoughtful discussions. But, again, profile information and filters are very scarce, so it’s hard to search the user base and spot like-minded people. The code is not open source either.</p>
|
|
||||||
<p>Lastly, <a href="https://www.bewelcome.org/">BeWelcome</a>, a French web app focused on hosting and connections, is worth considering for its extreme pro-social mission and worldwide success. It is proud to be completely free, solely funded by donations, ad-free, non-profit, open-source, and run by volunteers. Its governance is partially democratic; they have three types of position: member, BeVolunteer member or board member. A BeVolunteer member is approved by the board members, and the board members get elected by the BeVolunteer members. All decisions are voted by BeVolunteer members only; so the users have no voting right. Profile information is diverse, although tailored for travelers—such as hobbies, languages, and hosting capabilities. Their member directory is transparent, but only searchable by location (and username…).</p>
|
|
||||||
<p>To conclude, it becomes clear that none of the platforms above fulfill the core values and goals stated in the precedent sections. Please let me know if there is any other platform you may know that better approaches the requires features. Otherwise, I will be happy to build one, as detailed below.</p>
|
|
||||||
<h1 id="platform-values">Platform Values</h1>
|
|
||||||
<h3 id="mission">Mission</h3>
|
|
||||||
<p>The platform’s mission is to maximize the number of close, meaningful connections among the community (i.e., people who align with the core values).</p>
|
|
||||||
<p>To best perform the mission, we need a free, ad-free, transparent, community-driven platform. I describe each aspect in detail below.</p>
|
|
||||||
<h3 id="free">Free</h3>
|
|
||||||
<p>To ensure no manipulation for profit, users must have full, free access to the entire platform.</p>
|
|
||||||
<p>A free platform means freedom from data exploitation and other commercial techniques.</p>
|
|
||||||
<h3 id="ad-free">Ad-Free</h3>
|
|
||||||
<p>To avoid dubious external influences as well as a mission drift toward user retention optimization, the platform must be free of any advertisement.</p>
|
|
||||||
<p>If some mission-aligned institutions sponsor the platform, their acknowledgment may only be shown in textual form outside the main page (e.g., on a “Sponsors” page).</p>
|
|
||||||
<h3 id="transparent">Transparent</h3>
|
|
||||||
<p>To foster decentralized trust in the platform and other users, the platform must promote radical transparency. The entire source code must be public on GitHub. In addition to having their implementation open source, all matching, filtering and sorting algorithms must be documented in English on the platform. To helps curious thinkers find each other without endless swiping, all profiles must be searchable.</p>
|
|
||||||
<p>Data privacy must be promoted as much as code transparency. Users must be made aware of the visibility of their content. Direct messages, login information and other content marked as private must be kept private to the user. Whether some user data are public (for the world wide web) or semi-public (for logged-in users only) must be clearly spelled out.</p>
|
|
||||||
<h3 id="mission-aligned">Mission-Aligned</h3>
|
|
||||||
<p>To align with the core values, the platform must promote slowness, reflection, vulnerability, truth-seeking and openness. Tools must be designed for signal, not addiction.</p>
|
|
||||||
<h3 id="community-driven-governance">Community-Driven Governance</h3>
|
|
||||||
<p>To prevent the platform from drifting toward exploitative practices, it must be created and maintained by the community, for the community. Decisions must follow a democratic process. Everyone may suggest and implement features.</p>
|
|
||||||
<h1 id="implementation">Implementation</h1>
|
|
||||||
<p>I’ll describe in more technical details how to implement the platform in order to promote close connections in a very efficient fashion, while staying aligned with the platform values—and hence the core values. It will be very heavy on data and algorithms. The back end will be extensive and accessible to the user, while the design will be clean but minimalist (a drastic paradigm shift from traditional meeting apps).</p>
|
|
||||||
<h3 id="profile-information">Profile Information</h3>
|
|
||||||
<p>The profile page should front-load intellectual and psychological alignment, while still being human and approachable. It should contain as much information as possible—this requires a lot of self-awareness. Naturally, many people would be uncomfortable sharing so many personal things about themselves, especially since everyone in the app could view the profile. One easy way to circumvent this concern is to set different levels of privacy for different parts of the profile; the main screen would be open to everyone, and the other parts would be revealed whenever the profile owner feels like the connection has matured enough.</p>
|
|
||||||
<p>The first screen could thus include:</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>Name and small headshot (20% of the screen max)</li>
|
|
||||||
<li>Intellectual topics currently being explored</li>
|
|
||||||
<li>Favorite intellectual topics</li>
|
|
||||||
<li>Least favorite intellectual topics</li>
|
|
||||||
<li>Thinking style</li>
|
|
||||||
<li>Results from evidence-based personality tests (e.g., Big 5)</li>
|
|
||||||
<li>Conflict style</li>
|
|
||||||
<li>Desires: type of connection, activities to do, etc.</li>
|
|
||||||
</ul>
|
|
||||||
<p>Many traditional features remain important, such as the level of education, job or studies, hobbies, pets, habits, subcultures, diet, emotional sensitivity, sense of humor, ambition, organization, pet peeves, non-negotiables.</p>
|
|
||||||
<p>They could mention their physical and mental health—especially some traits that rub people the wrong way, insecurities, biases, triggers, therapy, or the things they are trying to improve about themselves.</p>
|
|
||||||
<p>Their values must also be clearly stated, as value alignment is a strong indicator of good connections. Moral aspects include community engagement, social justice, and other cause areas.</p>
|
|
||||||
<p>For people interested in romantic relationships, they would benefit from talking about their love languages (giving and receiving), timeline, romantic orientation, family projects, work-life balance, financial goals / habits, career goals, housing situation (renting vs owning), location, and whether they would date someone who already has kids.</p>
|
|
||||||
<p>I think it would also be valuable to add a profile variable that tracks when a user was last connected, in order to let any user filter out inactive profiles.</p>
|
|
||||||
<p>What they are looking for is also paramount for a good match. The user could go through all the features above one more time and write what they would like in their ideal person.</p>
|
|
||||||
<p>The points above are important to define tags (necessary for filtering), but the user could in addition complete their profile with as much well-formatted text as they want—including a few pictures, link to a blog, contact information, or even some reviews from close people.</p>
|
|
||||||
<p>To avoid empty profiles, we would set a minimum number of characters to write on their profile. Also, we’ll make some features mandatory (personality traits, intellectual interests, location, desired connection, etc.). An interesting nudge to incite people to fill in their profile would be to add a profile variable for the level of profile completion (in percent); like any other feature, one would be able to filter or sort by profile completion in order to interact with the most completed profiles.</p>
|
|
||||||
<h5 id="modules">Modules</h5>
|
|
||||||
<p>Ultimately, we could add modules to raise self-awareness about disagreement styles, thinking styles, intellectual interests, philosophies, etc. I suspect this would add very useful profile information.</p>
|
|
||||||
<h5 id="prompt-based-messaging">Prompt-based Messaging</h5>
|
|
||||||
<p>Deep open-ended questions are a great way to deepen connections; they can be answered individually, used as openings, or as tags for filtering. Here are some that I find particularly appealing in light of the core values, but they should probably span many more topics (most of them are cherry-picked from ChatGPT).</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>How has understanding X helped you suffer less when Y happened?</li>
|
|
||||||
<li>How have some ideas changed the way you respond to stress?</li>
|
|
||||||
<li>What’s something you recently changed your mind about after seeing stronger evidence or reasoning?</li>
|
|
||||||
<li>What do you think counts as a good reason to believe something?</li>
|
|
||||||
<li>What’s a topic you find endlessly fascinating but rarely get to talk about?</li>
|
|
||||||
<li>Have you found that understanding Bayesian thinking changed how you handle personal conflict or relationships?</li>
|
|
||||||
<li>When someone disagrees with you on a topic that matters, how do you approach the conversation?</li>
|
|
||||||
<li>Has understanding the truth about something ever brought you peace, even if it was difficult at first?</li>
|
|
||||||
<li>Is there a belief you hold that you wish were false—but still think is probably true?</li>
|
|
||||||
<li>If we were to spend an afternoon talking, what topic would you bring up first—and why?</li>
|
|
||||||
<li>When you're in conflict with someone close to you, what happens in your body? What do you notice first—emotions, thoughts, or physical sensations?</li>
|
|
||||||
<li>Think of a time when you and someone important to you disagreed, but came out stronger after. What do you think made that possible?</li>
|
|
||||||
</ul>
|
|
||||||
<p>I also like the idea of user-generated prompts voted by the community.</p>
|
|
||||||
<h3 id="searching">Searching</h3>
|
|
||||||
<p>The principal method to find like-minded users will be through advance filters and sorting algorithms across most of the variables defined in each profile.</p>
|
|
||||||
<p>The difference between filtering and sorting may be subtle but very useful. Filtering simply means finding a subset of the user base that matches some hard criteria. For instance, one could filter for openness (in the Big 5) higher than 50% AND interested in a relationship, OR favorite intellectual topic is neuroscience. With pure filtering, the resulting profiles are presented in no specific order.</p>
|
|
||||||
<p>Sorting means assigning a number to each profile and ordering them according to that number. The formula which maps each profile onto a number will be fully defined by the user. For instance, it can be "agreeableness in percent + 10 for each currently explored topic belonging to biology”.</p>
|
|
||||||
<p>So, filtering is for hard desires and sorting is for soft desires. Mixing the two methods provides immense capabilities to find the connections that matter to us the most.</p>
|
|
||||||
<p>Alternatively, some open users with less specific preferences could enjoy a secondary tool that balances discovery with serendipity (aka the exploration / exploitation trade-off). If desired, it would be easy to provide profiles that fall slightly outside the mentioned preferences. Or one could use filters with no sorting to randomly sample profiles from the filtered subset. At some point, people could use some algorithms (transparent and curated by the community) to sort their filtered selection; but this would always be an opt-in feature that would never override a sorting method implemented by the user.</p>
|
|
||||||
<h3 id="matching">Matching</h3>
|
|
||||||
<p>Once users find great people, one needs to configure if they can start a connection.</p>
|
|
||||||
<p>In the ideal world where everyone is good and caring, one would let all users start a connection with anyone else by allowing direct messaging. In a world full of spams and harassment, one would only be able to “like” a profile; and the conversation would start only when both people like each other. This process would of course be catalyzed by making the list of who likes you fully transparent.</p>
|
|
||||||
<p>Although I’m eager to receive suggestions in the comments or in the suggestion form, my belief in the goodness of humanity makes me advocate for a hybrid model closer to the ideal one: people can direct message anyone, but this opening would be received more quietly than a message from an already connected user. For instance, those new messages could first arrive in a list separate from the one with connected users, and they would not trigger a notification by default. If early evidence shows that this hybrid model is still subject to immense spams, I—as a good rationalist—will be happy to update my prior.</p>
|
|
||||||
<p>Of course, people are free to share their contact info on their profile (unlike—you guessed it—traditional apps) if they prefer. Likewise, people exchanging messages on the app would be free to share any information at any stage of the connection—including contact info to connect outside the app.</p>
|
|
||||||
<p>It always feels painful when someone cuts connections without any context. When a person leaves a chat, the platform could provide general guidelines for ethical endings and invite them to fill in a quick form with the reasons why (for the other user).</p>
|
|
||||||
<p>Now that I presented how all the good things can happen, from joining to meaningful connections, I’ll dive more into the technical details and precautions to make this pro-social tool viable under the hood.</p>
|
|
||||||
<h3 id="tech">Tech</h3>
|
|
||||||
<p>First of all, building free and open source software (FOSS) is a drastic paradigm shift from current meeting apps, which brings its pros and cons. One major positive side effect of FOSS is the attraction of technically skilled contributors.</p>
|
|
||||||
<p>The minimum viable product will contain these features:</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>Authentication</li>
|
|
||||||
<li>Page listing all the profiles</li>
|
|
||||||
<li>Search through all the profile variables (intellectual interests, location, cause areas, personality type, conflict style, desired type of connection, prompt answers, gender, etc.)</li>
|
|
||||||
<li>Direct messaging</li>
|
|
||||||
<li>(Prompts or Modules for self-awareness)</li>
|
|
||||||
</ul>
|
|
||||||
<p>I propose to develop the platform as a web app first, as it’s much faster (to develop) and works on all devices. For that purpose, using a framework like React for the front-end and a service like Google Firebase for the back-end seems appropriate. I would consider wrapping it around a progressive web app at some point to make it easier to transfer to a mobile app later. Hosting could be under Firebase Hosting, Vercel, Netlify, or even AWS (seems cost-effective at scale, and I know how to use a few things); let’s discuss about the best one. I personally have reasonable full-stack skills and don’t mind learning on the field, but it would be awesome to get contributions or feedback from developers with specific experience in any of the above frameworks / services.</p>
|
|
||||||
<p>The software will be under a permissive license; deciding which one is still open.</p>
|
|
||||||
<p>For optimal user privacy, end-to-end encrypted messaging may be implemented at some point.</p>
|
|
||||||
<h3 id="branding">Branding</h3>
|
|
||||||
<p>I suggest the branding to be intellectual: high-contrast design, thought-provoking (but humble) and philosophical tone in a clean / bookish font.</p>
|
|
||||||
<h3 id="moderation">Moderation</h3>
|
|
||||||
<p>Strong moderation should be done to filter out spams, harassment, conspiracy theorists and contrarians for the sake of it.</p>
|
|
||||||
<p>Keeping in mind the advantages of decentralized governance and the limited number of volunteers, I suggest multiple layers of moderation. The first layer will be moderated by the users themselves; they can report and review anyone else—e.g., someone who wrote an inappropriate messages (sexual advances to someone who wrote on their profile that they are not interested, etc.) or a profile with misaligned content (inappropriate images, hate speech, etc.). A profile would be suspended after a few reports.</p>
|
|
||||||
<p>The second layer will be run by volunteering moderators. They’ll review profiles that got suspended, remove inactive account, and perform other essential tasks.</p>
|
|
||||||
<p>If a user notices that someone’s profile isn’t accurate, they would be able to softly report it as (anonymous) feedback. This feature would ideally be framed as a helpful “nudge” between two users, as a way to improve profiles and hence successful connections. A profile that keeps receiving soft reports could be scrutinized by moderators.</p>
|
|
||||||
<p>If too many newcomers are misaligned with the core values, vetting—examining interested users before joining—may be considered at some point. An epistemic and interpersonal onboarding that checks for core principles of rationalism and healthy relationships before signup may help.</p>
|
|
||||||
<h3 id="governance">Governance</h3>
|
|
||||||
<p>There are at least two possible models for governance, and I am yet unsure which one would best achieve the platform’s end goals.</p>
|
|
||||||
<p>The first model is cooperative: users or contributors collectively own and manage the platform—they all have voting power and share in decisions.</p>
|
|
||||||
<p>The second model is a stewardship council: a small group of deeply aligned users would enforce the value by voting on major decisions. This raises questions about how someone would be granted access to that council; elections and rotations would help, I suppose. Regardless, all rationales and votes of that council would be fully transparent / public.</p>
|
|
||||||
<p>A hybrid model may also work. The co-op would vote on features, and the council would make sure the features are aligned with the core values—e.g., referring to a written constitution.</p>
|
|
||||||
<p>A constitution would extent the Platform Values section by including, for instance, a mission statement, some core values, a user bill of rights, and governance principles.</p>
|
|
||||||
<h3 id="finance">Finance</h3>
|
|
||||||
<p>Finance is a critical part of the platform which may severely (and indirectly) impact the mission. Indeed, this platform will have inescapable expenses, but it doesn’t have any clear, easy source of revenue.</p>
|
|
||||||
<p>On the expense side, since everyone will contribute for free, the main cost related to the app will be hosting servers. Expenses should be minimal, if carefully developed, but I don’t have an estimate right now as there are too many uncertainties. Hopefully, we’ll have an estimate per month and per daily user in the next weeks.</p>
|
|
||||||
<p>On the revenue side, donations (from users, institutions, etc.) will be the source most aligned with our mission. If donations continuously fall short, a donation campaign would be the best band aid. We send emails and add banners to the platform with a very transparent messaging about the platform’s financial health and how much it needs to be viable—not unlike those Wikipedia campaigns. As a last resort, optional memberships (e.g., providing voting rights on major decisions or little perks) or one-time lifetime purchase may be considered.</p>
|
|
||||||
<p>Ideally, we will use <a href="https://opencollective.com">OpenCollective</a> (or similar) as long-term funding platform, as they provide massive transparency; but we can’t use them before the community and platform exist, So, we will gather initial funding throughout my personal accounts (see top of the article); please mention “meeting app” if through PayPal. Obviously, I pledge to use the donation solely for the development of the platform and, in the event that the platform fails, to donate the remaining capital to a charity voted by the community. Finally, expenses and revenue will be fully transparent and available in real time on the platform.</p>
|
|
||||||
<h3 id="extensions">Extensions</h3>
|
|
||||||
<p>The question of selectivity vs openness is particularly interesting from a utilitarian point of view: how selective should the platform be to maximize overall well-being?</p>
|
|
||||||
<p>If the platform is very selective, there will be only a couple people with very aligned values. Their well-being will increase a lot, but there are only a few of them. So there is not much overall benefit for society.</p>
|
|
||||||
<p>If the platform is fully open (everyone can join), there are two possibilities depending on the connection mechanism. If the list of members is opaque and poorly searchable, as in traditional dating apps, it becomes very unlikely to find value-aligned people. Hence, each individual’s increase in well-being is negligible, even though there are plenty of people, and there is not much overall benefit for society either.</p>
|
|
||||||
<p>Qualitatively, the figure below illustrates this trade-off between selectivity and openness for poorly searchable (i.e., Tinder-type) platforms. The quality is the individual increase in well-being, which increases as the platform selects for people strictly following the core values. The quantity is simply the number of users. The platform’s overall benefit (read, increase in total well-being) is then the product of quantity and quality—reaching a maximum with a non-extreme selectivity.</p>
|
|
||||||
<p><Image src="https://martinbraquet.com/wp-content/uploads/rational_qualitative.png" alt="" /></p>
|
|
||||||
<p>The second possibility appears when the members are fully visible and searchable by anyone. In that case, each member can filter and meaningfully engage with the few people aligning with their values. Each individual’s increase in well-being becomes important, and there are plenty of people. So, a fully open and searchable platform may bring a lot of overall benefit for society.</p>
|
|
||||||
<p>Of course, in practice, there will always be interferences within big communities. And, more importantly, a larger platform requires more resources (i.e., funding) and moderation. That’s why the focus of this article is on a specific community for now. Not only do I identify with the rational / intellectual community, but it is also composed of members who are much more likely to contribute (especially on the tech side), making it a very convenient community. But if it creates much greater good, I think it would be worth considering extending the platform at some point in the far future—provided that it doesn’t negatively dilute the community or create brand identity confusion. More than just creating a higher good, a larger user base means economy of scale: donations may scale proportionally while expense per user would diminish—making the platform more likely to survive financially.</p>
|
|
||||||
<h3 id="viability">Viability</h3>
|
|
||||||
<p>Let’s think quantitatively about what would be required to make the app useful and viable.</p>
|
|
||||||
<p>By useful, I mean that we consider it a win (for the mission) when every user makes at least one meaningful connection through the app. Let’s make a few assumptions:</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>Users will only connect with people within their 20-year range, and people are uniformly aged between 20 and 50. So the probability that two users are aged at most 10 years apart is around 50% (20% for the youngest and oldest people, and 67% for most people in the middle).</li>
|
|
||||||
<li>Users will only connect with people in the same geographical area, and they are uniformly spread across 5 of them. That’s a 20% probability to find someone in the same area.</li>
|
|
||||||
<li>Users may form a connection with people of any gender (it’s not a dating-only app, after all).</li>
|
|
||||||
<li>Since most users would share rational and intellectual interests, their chance of sharing similar interests and personality traits with another user is higher than usual: 5%.</li>
|
|
||||||
</ul>
|
|
||||||
<p>So, the probabilities for any two users to form a meaningful connection is 0.5%. That means a minimum viable user base of 200. Once the app reaches this critical mass, I suspect that its value will be tangible—through testimonies, etc.</p>
|
|
||||||
<p>Another critical aspect that requires people is moderation. I would estimate that one moderator for 100-200 users is enough—most of the users should in principle be less spamming or violent than the average person. That means 2-5 moderators, up to 1000 users.</p>
|
|
||||||
<p>The number of maintainers can be minimal once the MVP is out; 1 or 2 people who periodically look at issues (especially critical bugs or vulnerabilities) should be enough. If feature requests come out, that’s great; but that’s a job for kind contributors (not maintainers).</p>
|
|
||||||
<p>Now, though to a lesser interest, we can estimate the maximum number of users that could use the app. As the app will be in English at first, we narrow down the population to 1.5 billion people. It’s a niche topic, so probably only 1% of the population would fit the core values. Let’s be gracious and assume that 50% of them are fulfilled and don’t more connections. That brings the total addressable market to 7.5 million people.</p>
|
|
||||||
<h1 id="critiques">Critiques</h1>
|
|
||||||
<p>I’ll list some expected critiques and provide potential solutions.</p>
|
|
||||||
<h3 id="choice-cognitive-overload">Choice & Cognitive Overload</h3>
|
|
||||||
<p>When one can see everyone else, it’s easy to spend more time browsing than engaging. Also, users must actively filter profiles and go through a lot of information, creating much friction. This is a key difference with traditional apps—and it’s clearly a feature, not a bug. I suspect these concerns may only concern to the more casual, less data-savvy users or the ones with less specific preferences. For those people, I would suggest a discovery mode that resembles more traditional apps: a limited number of profiles shown one at a time by a curated (but transparent, of course) algorithm. This casual mode, however, will never reduce the functionalities and efficiency of the explore mode dedicated to power users.</p>
|
|
||||||
<h3 id="spam-and-harassment">Spam and Harassment</h3>
|
|
||||||
<p>On a platform where everyone can message each other, it’s so easy to be spammed and harassed. The Moderation section suggests different strategies. Let’s carefully monitor that aspect as the product goes live.</p>
|
|
||||||
<p>Capping the number of daily messages may be useful as a last resort. I am somewhat reticent to the idea, however, as I believe the app should be fully usable by every “good” user—which is the rationale behind making it free. Maybe there is value in capping the number of first / opening messages, instead.</p>
|
|
||||||
<h3 id="unequal-gender-ratio">Unequal Gender Ratio</h3>
|
|
||||||
<p>Men are more likely to outnumber women with those niche values, but this should not be an issue as the app won’t be focused on dating.</p>
|
|
||||||
<p>In this situation, women will be at much higher risk of spamming and harassment. If the strategies in the Moderation section fall short, we’ll tackle the issue more aggressively.</p>
|
|
||||||
<h3 id="self-awareness-dread">Self-awareness Dread</h3>
|
|
||||||
<p>People usually don't like to write descriptions about themselves. They get descriptions from others and follow self-awareness modules, however. People can also give feedback and suggest modifications.</p>
|
|
||||||
<h3 id="online-connections">Online Connections</h3>
|
|
||||||
<p>The platform doesn’t target a specific location; until we get thousands of users, it’s very likely that connected people live far apart. It’s a real concern, although the rational / intellectual community has slightly lower needs for in-person connections than the average person, as a lot of their activities pertain to the mind.</p>
|
|
||||||
<h1 id="premortem">Premortem</h1>
|
|
||||||
<p>Here I’ll identify a few potential problems that may terminate the project and address whether, in my humble opinion, they should be concerning.</p>
|
|
||||||
<h3 id="what-if-i-don-t-need-the-app-anymore-">What if I don’t need the app anymore?</h3>
|
|
||||||
<p>There will likely a time when I don’t intend to use or develop the app anymore. When this happens, the app will likely survive on its own, per the robust mechanisms detailed above (open source, democratic governance, etc.). So, if contributions of all types (donations, moderation, and code maintenance / improvement) are decentralized, my presence, or the lack thereof, should not significantly impact the well functioning of the app.</p>
|
|
||||||
<h3 id="what-if-donations-can-t-meet-expenses-">What if donations can’t meet expenses?</h3>
|
|
||||||
<p>This is a more critical issue. If donation-based models fail, even for such an app that would require so little financial help, then it simply means that the value of the product is severely below its maintenance cost. And a mission-driven product with net-negative value and no potential for improvement is not fulfilling its mission; so it should be shut down.</p>
|
|
||||||
<h1 id="roadmap">Roadmap</h1>
|
|
||||||
<p>If the app attracts enough traction and contributors, a minimum viable product should be available within 1-2 months. If it further gains popularity, features would be released whenever the community implements them.</p>
|
|
||||||
<h1 id="conclusion">Conclusion</h1>
|
|
||||||
<p>I hope the reading was valuable. Let’s build a better way to form meaningful relationships—one designed for people who think deeply, value evidence, and want connections that last. If you believe conversations should go deeper than swipes and small talk, join us.<a href="https://martinbraquet.com/meeting-rational/#abstract"></a></p>
|
|
||||||
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {Suspense} from "react";
|
|
||||||
import OnboardingForm from "@/components/onboarding";
|
|
||||||
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
|
||||||
return (
|
|
||||||
<Suspense fallback={<div></div>}>
|
|
||||||
<RegisterComponent/>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RegisterComponent() {
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
<OnboardingForm/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import ProfilePage from "@/app/profiles/page";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {useEffect} from 'react';
|
|
||||||
import {useSession} from "next-auth/react"; // ← Add this line here
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"; // This disables SSG and ISR
|
|
||||||
|
|
||||||
|
|
||||||
export default function HomePage() {
|
|
||||||
const {data: session} = useSession();
|
|
||||||
const userId = session?.user?.id
|
|
||||||
console.log("session:", userId)
|
|
||||||
|
|
||||||
const fontStyle = "transition px-5 py-3 text-3xl font-medium xs:text-sm"
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const text = "Search.";
|
|
||||||
const typewriter = document.getElementById("typewriter");
|
|
||||||
let i = 0;
|
|
||||||
let timeoutId: any;
|
|
||||||
let intervalId;
|
|
||||||
|
|
||||||
// Clear any existing content
|
|
||||||
if (typewriter) typewriter.textContent = ""
|
|
||||||
|
|
||||||
function typeWriter() {
|
|
||||||
if (i < text.length && typewriter) {
|
|
||||||
typewriter.textContent = text.substring(0, i + 1);
|
|
||||||
i++;
|
|
||||||
timeoutId = setTimeout(typeWriter, 150);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start typing after delay
|
|
||||||
intervalId = setTimeout(() => typeWriter(), 500);
|
|
||||||
|
|
||||||
// Cleanup function - this runs when component unmounts
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
clearTimeout(intervalId);
|
|
||||||
if (typewriter) typewriter.textContent = "Search."
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen flex flex-col">
|
|
||||||
{/* Header */}
|
|
||||||
{/*<header className="flex justify-between items-center p-2 max-w-6xl mx-auto w-full">*/}
|
|
||||||
{/* <a */}
|
|
||||||
{/* href="https://github.com/CompassConnections/Compass" */}
|
|
||||||
{/* target="_blank" */}
|
|
||||||
{/* rel="noopener noreferrer"*/}
|
|
||||||
{/* className="text-gray-700 hover: transition"*/}
|
|
||||||
{/* >*/}
|
|
||||||
{/* <svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">*/}
|
|
||||||
{/* <path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.699 1.028 1.595 1.028 2.688 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />*/}
|
|
||||||
{/* </svg>*/}
|
|
||||||
{/* </a>*/}
|
|
||||||
{/*</header>*/}
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="flex flex-col items-center justify-start flex-1 text-center px-4">
|
|
||||||
<div className="h-10"></div>
|
|
||||||
<h1
|
|
||||||
className="pt-12 pb-2 text-7xl md:text-8xl xs:text-6xl font-extrabold max-w-4xl leading-tight xl:whitespace-nowrap md:whitespace-nowrap ">
|
|
||||||
Don't Swipe. <span id="typewriter"></span><span id="cursor" className="animate-pulse">|</span>
|
|
||||||
</h1>
|
|
||||||
{/*<p className="mt-6 text-lg md:text-xl text-gray-400 max-w-2xl">*/}
|
|
||||||
{/* {"Tired of swiping? Search what you're looking for!"}*/}
|
|
||||||
{/*</p>*/}
|
|
||||||
{/* Spacer */}
|
|
||||||
<div className="h-10"></div>
|
|
||||||
{!userId && <div className="py-8">
|
|
||||||
<Link href="/register"
|
|
||||||
className={`${fontStyle} bg-gradient-to-r from-red-600 to-red-800 text-white rounded-full hover:from-red-700 hover:to-red-900`}>
|
|
||||||
Join Compass
|
|
||||||
</Link>
|
|
||||||
{/* Spacer */}
|
|
||||||
{/*<div className="h-16"></div>*/}
|
|
||||||
</div>}
|
|
||||||
{/* Why Compass Bar */}
|
|
||||||
<div className="w-full bg-gray-50 dark:bg-gray-900 py-8 mt-20">
|
|
||||||
<div className="max-w-6xl mx-auto px-4">
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 text-center">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-lg font-bold">Radically Transparent</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
No algorithms. Every profile searchable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-lg font-bold">Built for Depth</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Filter by any keyword and what matters most.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="text-lg font-bold">Community Owned</h3>
|
|
||||||
<p className="text-gray-600 dark:text-gray-400">
|
|
||||||
Free forever. Built by users, for users.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Spacer */}
|
|
||||||
{userId &&
|
|
||||||
<>
|
|
||||||
{/*<div className="h-20"></div>*/}
|
|
||||||
<div className=" w-full py-10">
|
|
||||||
<main className="min-h-screen flex flex-col">
|
|
||||||
<ProfilePage/>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from 'next/link';
|
|
||||||
import {usePathname, useRouter} from "next/navigation";
|
|
||||||
import {Profile} from "@/lib/client/profile";
|
|
||||||
import {useEffect} from "react";
|
|
||||||
import {signOut, useSession} from "next-auth/react";
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
|
||||||
const pathname = usePathname(); // Get the current route
|
|
||||||
const router = useRouter();
|
|
||||||
const {data: session} = useSession();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
async function asyncRun() {
|
|
||||||
if (!session?.user?.id)
|
|
||||||
router.push('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
asyncRun();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
const header = (
|
|
||||||
<div className="px-4 py-5 sm:px-6 flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg leading-6 font-medium ">My Profile</h3>
|
|
||||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">View and update your profile information</p>
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/complete-profile?redirect=${encodeURIComponent(pathname)}`}
|
|
||||||
className="mx-1 transition px-2 py-2 text-sm font-medium xs:text-xs bg-blue-500 text-white rounded-full hover:bg-blue-600 min-w-20"
|
|
||||||
>
|
|
||||||
Edit Profile
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
onClick={() => signOut({callbackUrl: "/"})}
|
|
||||||
className="mx-1 transition px-2 py-2 text-sm font-medium xs:text-xs bg-red-500 text-white rounded-full hover:bg-red-600 min-w-20"
|
|
||||||
>
|
|
||||||
Sign Out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
{Profile('/api/profile', header)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
;
|
|
||||||
} catch
|
|
||||||
(error) {
|
|
||||||
console.error('Error fetching user data:', error);
|
|
||||||
return <div className="text-center py-10">Error loading profile. Please try again later.</div>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import React, {useEffect, useRef, useState} from 'react';
|
|
||||||
import {Gender} from "@prisma/client";
|
|
||||||
import Dropdown from "@/app/components/dropdown";
|
|
||||||
import Slider from '@mui/material/Slider';
|
|
||||||
import {DropdownKey, Item, RangeKey} from "@/lib/client/schema";
|
|
||||||
import {capitalize} from "@/lib/format";
|
|
||||||
import {fetchFeatures} from "@/lib/client/fetching";
|
|
||||||
|
|
||||||
interface FilterProps {
|
|
||||||
filters: {
|
|
||||||
gender: string;
|
|
||||||
interests: string[];
|
|
||||||
coreValues: string[];
|
|
||||||
connections: string[];
|
|
||||||
causeAreas: string[];
|
|
||||||
searchQuery: string;
|
|
||||||
minAge?: number | null;
|
|
||||||
maxAge?: number | null;
|
|
||||||
minIntroversion?: number | null;
|
|
||||||
maxIntroversion?: number | null;
|
|
||||||
};
|
|
||||||
onFilterChange: (key: string, value: any) => void;
|
|
||||||
onShowFilters: (value: boolean) => void;
|
|
||||||
onToggleFilter: (key: DropdownKey, value: string) => void;
|
|
||||||
onReset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dropdownConfig: { id: DropdownKey, name: string }[] = [
|
|
||||||
{id: "connections", name: "Connection Type"},
|
|
||||||
{id: "coreValues", name: "Values"},
|
|
||||||
{id: "interests", name: "Interests"},
|
|
||||||
{id: "books", name: "Works"},
|
|
||||||
// {id: "causeAreas", name: "Cause Areas"},
|
|
||||||
]
|
|
||||||
|
|
||||||
export const rangeConfig: { id: RangeKey, name: string, min: number, max: number }[] = [
|
|
||||||
{id: "age", name: "Age", min: 15, max: 60},
|
|
||||||
{id: "introversion", name: "Introversion - Extroversion", min: 0, max: 100},
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ProfileFilters({filters, onFilterChange, onShowFilters, onToggleFilter, onReset}: FilterProps) {
|
|
||||||
|
|
||||||
const [showFilters, setShowFilters] = useState(true);
|
|
||||||
|
|
||||||
// Initialize state for all dropdowns as an object with keys from dropdownConfig ids
|
|
||||||
const [optionsDropdown, setOptionsDropdown] = useState(() =>
|
|
||||||
Object.fromEntries(dropdownConfig.map(({id}) => [id, [] as Item[]]))
|
|
||||||
);
|
|
||||||
const setOptionsDropdownId = (id: string, value: any) => {
|
|
||||||
setOptionsDropdown((prev) => ({...prev, [id]: value}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const [selectedDropdown, setSelectedDropdown] = useState(() =>
|
|
||||||
Object.fromEntries(dropdownConfig.map(({id}) => [id, new Set<string>()]))
|
|
||||||
);
|
|
||||||
|
|
||||||
const [newDropdown, setNewDropdown] = useState(() =>
|
|
||||||
Object.fromEntries(dropdownConfig.map(({id}) => [id, '']))
|
|
||||||
);
|
|
||||||
|
|
||||||
const [showDropdown, setShowDropdown] = useState(() =>
|
|
||||||
Object.fromEntries(dropdownConfig.map(({id}) => [id, false]))
|
|
||||||
);
|
|
||||||
|
|
||||||
// refs cannot be in state; create refs map outside state
|
|
||||||
const refDropdown = useRef<any>(
|
|
||||||
Object.fromEntries(dropdownConfig.map(({id}) => [id, React.createRef<HTMLDivElement>()]))
|
|
||||||
);
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchFeatures(setOptionsDropdownId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('selectedDropdown changed:', selectedDropdown);
|
|
||||||
}, [selectedDropdown]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
|
|
||||||
console.log('optionsDropdown changed:', optionsDropdown, params);
|
|
||||||
|
|
||||||
for (const [key, value] of params.entries()) {
|
|
||||||
let v: any = value
|
|
||||||
if (key === 'minAge') {
|
|
||||||
setMinRange({...minRange, age: v});
|
|
||||||
} else if (key === 'maxAge') {
|
|
||||||
setMaxRange({...maxRange, age: v});
|
|
||||||
} else if (key === 'minIntroversion') {
|
|
||||||
setMinRange({...minRange, introversion: v});
|
|
||||||
} else if (key === 'maxIntroversion') {
|
|
||||||
setMaxRange({...maxRange, introversion: v});
|
|
||||||
} else if (['interests', 'coreValues', 'causeAreas', 'connections'].includes(key)) {
|
|
||||||
v = v.split(",").filter(Boolean) || []
|
|
||||||
console.log(v)
|
|
||||||
for (const n of v) {
|
|
||||||
const option = optionsDropdown[key].find(i => i.name === n);
|
|
||||||
if (option) {
|
|
||||||
console.log(option);
|
|
||||||
setSelectedDropdown(prev => {
|
|
||||||
const newSet = new Set(prev[key]);
|
|
||||||
newSet.add(option.id);
|
|
||||||
return {...prev, [key]: newSet};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [optionsDropdown]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
for (const id in showDropdown) {
|
|
||||||
const ref = refDropdown.current[id];
|
|
||||||
if (
|
|
||||||
ref?.current &&
|
|
||||||
!ref.current.contains(event.target as Node)
|
|
||||||
) {
|
|
||||||
setShowDropdown(prev => ({...prev, [id]: false}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [showDropdown]);
|
|
||||||
|
|
||||||
|
|
||||||
const toggle = (id: DropdownKey, optionId: string) => {
|
|
||||||
setSelectedDropdown(prev => {
|
|
||||||
const newSet = new Set(prev[id]);
|
|
||||||
if (newSet.has(optionId)) {
|
|
||||||
newSet.delete(optionId);
|
|
||||||
} else {
|
|
||||||
newSet.add(optionId);
|
|
||||||
}
|
|
||||||
// console.log(newSet);
|
|
||||||
return {...prev, [id]: newSet};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (id: DropdownKey, key: string) => {
|
|
||||||
if (key === 'Escape') setShowDropdown(prev => ({...prev, [id]: false}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChange = (id: DropdownKey, e: string) => {
|
|
||||||
setNewDropdown(prev => ({...prev, [id]: e}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFocus = (id: DropdownKey) => {
|
|
||||||
setShowDropdown(prev => ({...prev, [id]: true}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClick = (id: DropdownKey) => {
|
|
||||||
setShowDropdown(prev => ({...prev, [id]: !showDropdown[id]}))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDrowDown(id: DropdownKey, name: string) {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={id + '.div'}>
|
|
||||||
<div className="relative" ref={refDropdown.current[id]}>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-white mb-2">
|
|
||||||
{name}
|
|
||||||
</label>
|
|
||||||
<Dropdown
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
value={newDropdown[id]}
|
|
||||||
onChange={handleChange}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onClick={handleClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(showDropdown[id]) && (
|
|
||||||
<div
|
|
||||||
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-900 shadow-lg max-h-60 rounded-md py-1 text-base ring-1 ring-black dark:ring-white ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
|
||||||
{optionsDropdown[id]
|
|
||||||
.filter(v => v.name.toLowerCase().includes(newDropdown[id].toLowerCase()))
|
|
||||||
.map((v) => (
|
|
||||||
<div
|
|
||||||
key={v.id}
|
|
||||||
className=" dark:text-white cursor-default select-none relative py-2 pl-3 pr-9 hover:bg-blue-50 dark:hover:bg-gray-700"
|
|
||||||
onClick={() => {
|
|
||||||
onToggleFilter(id, v.name);
|
|
||||||
toggle(id, v.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
||||||
checked={selectedDropdown[id].has(v.id)}
|
|
||||||
onChange={() => {
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<span className="font-normal ml-3 block truncate">
|
|
||||||
{v.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
|
||||||
{Array.from(selectedDropdown[id]).map(vId => {
|
|
||||||
const value = optionsDropdown[id].find(i => i.id === vId);
|
|
||||||
if (!value) return null;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={vId}
|
|
||||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:text-white dark:bg-gray-700"
|
|
||||||
>
|
|
||||||
{value.name}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggle(id, vId);
|
|
||||||
onToggleFilter(id, value.name);
|
|
||||||
}}
|
|
||||||
className="ml-1.5 inline-flex items-center justify-center h-4 w-4 rounded-full bg-blue-200 hover:bg-blue-300 dark:text-white dark:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Remove {value.name}</span>
|
|
||||||
<svg className="h-2 w-2" fill="currentColor" viewBox="0 0 8 8">
|
|
||||||
<path
|
|
||||||
d="M4 3.293L6.646.646a.5.5 0 01.708.708L4.707 4l2.647 2.646a.5.5 0 01-.708.708L4 4.707l-2.646 2.647a.5.5 0 01-.708-.708L3.293 4 .646 1.354a.5.5 0 01.708-.708L4 3.293z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Range {
|
|
||||||
id: RangeKey;
|
|
||||||
name: string;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [minRange, setMinRange] = useState(() =>
|
|
||||||
Object.fromEntries(rangeConfig.map(({id}) => [id, undefined]))
|
|
||||||
);
|
|
||||||
const [maxRange, setMaxRange] = useState(() =>
|
|
||||||
Object.fromEntries(rangeConfig.map(({id}) => [id, undefined]))
|
|
||||||
);
|
|
||||||
|
|
||||||
function getSlider({id, name, min, max}: Range, showSlider: boolean = true) {
|
|
||||||
const minStr = 'min' + capitalize(id);
|
|
||||||
const maxStr = 'max' + capitalize(id);
|
|
||||||
const minVal = minRange[id];
|
|
||||||
const maxVal = maxRange[id];
|
|
||||||
const setMinVal = (v: any) => setMinRange({...minRange, [id]: v});
|
|
||||||
const setMaxVal = (v: any) => setMaxRange({...maxRange, [id]: v});
|
|
||||||
return (
|
|
||||||
<div key={id + '.div'}>
|
|
||||||
|
|
||||||
{showSlider &&
|
|
||||||
<div className="w-full px-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-white mb-2">{name}</label>
|
|
||||||
<Slider
|
|
||||||
value={[minVal || min, maxVal || max]}
|
|
||||||
onChange={(e, value) => {
|
|
||||||
let [_min, _max] = value;
|
|
||||||
setMinVal((_min || min) > min ? _min : undefined);
|
|
||||||
setMaxVal((_max || max) < max ? _max : undefined);
|
|
||||||
}}
|
|
||||||
onChangeCommitted={(e, value) => {
|
|
||||||
let [_min, _max] = value;
|
|
||||||
onFilterChange(minStr, (_min || min) > min ? _min : undefined);
|
|
||||||
onFilterChange(maxStr, (_max || max) < max ? _max : undefined);
|
|
||||||
}}
|
|
||||||
valueLabelDisplay="auto"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
sx={{
|
|
||||||
color: '#3B82F6',
|
|
||||||
'& .MuiSlider-valueLabel': {
|
|
||||||
backgroundColor: '#3B82F6',
|
|
||||||
color: '#fff',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
{/*<label className="block text-sm font-medium text-gray-700 dark:text-white mb-1">Min Age</label>*/}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
value={minVal || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value ? parseInt(e.target.value) : undefined;
|
|
||||||
onFilterChange(minStr, value);
|
|
||||||
setMinVal(value);
|
|
||||||
}}
|
|
||||||
placeholder="Min"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{/*<label className="block text-sm font-medium text-gray-700 dark:text-white mb-1">Max Age</label>*/}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
value={maxVal || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value ? parseInt(e.target.value) : undefined;
|
|
||||||
onFilterChange(maxStr, value);
|
|
||||||
setMaxVal(value);
|
|
||||||
}}
|
|
||||||
placeholder="Max"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full mb-8">
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-4">
|
|
||||||
{/*{showFilters && ()}*/}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowFilters(!showFilters);
|
|
||||||
onShowFilters(!showFilters);
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 border rounded-lg items-center gap-2 whitespace-nowrap hidden"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
|
|
||||||
</svg>
|
|
||||||
{showFilters ? 'Hide' : 'Show'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showFilters && (
|
|
||||||
<div className="p-4 rounded-lg shadow-sm border space-y-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-white mb-1">Gender</label>
|
|
||||||
<select
|
|
||||||
className="w-full p-2 border rounded-lg"
|
|
||||||
value={filters.gender}
|
|
||||||
onChange={(e) => onFilterChange('gender', e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Any Gender</option>
|
|
||||||
{Object.keys(Gender).map((option) => (
|
|
||||||
<option key={option} value={option}>
|
|
||||||
{option}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{getSlider(rangeConfig[0], false)}
|
|
||||||
{dropdownConfig.map(({id, name}) => getDrowDown(id, name))}
|
|
||||||
{getSlider(rangeConfig[1])}
|
|
||||||
|
|
||||||
{/*<div>*/}
|
|
||||||
{/* <label className="block text-sm font-medium text-gray-700 dark:text-white mb-1">Cause Areas</label>*/}
|
|
||||||
{/* <div className="flex flex-wrap gap-2">*/}
|
|
||||||
{/* {allCauseAreas.map((cause) => (*/}
|
|
||||||
{/* <button*/}
|
|
||||||
{/* key={cause.name}*/}
|
|
||||||
{/* onClick={() => onToggleFilter('causeAreas', cause.name)}*/}
|
|
||||||
{/* className={`px-3 py-1 text-sm rounded-full ${*/}
|
|
||||||
{/* filters.causeAreas.includes(cause.name)*/}
|
|
||||||
{/* ? 'bg-green-100 dark:text-white dark:bg-green-900 text-green-800 border border-green-200'*/}
|
|
||||||
{/* : 'bg-gray-100 dark:text-white dark:bg-gray-700 text-gray-800 border border-gray-200 hover:bg-gray-200'*/}
|
|
||||||
{/* }`}*/}
|
|
||||||
{/* >*/}
|
|
||||||
{/* {cause.name}*/}
|
|
||||||
{/* </button>*/}
|
|
||||||
{/* ))}*/}
|
|
||||||
{/* </div>*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onReset();
|
|
||||||
setSelectedDropdown(() =>
|
|
||||||
Object.fromEntries(dropdownConfig.map(({id}) => [id, new Set<string>()]))
|
|
||||||
);
|
|
||||||
setMinRange(() =>
|
|
||||||
Object.fromEntries(rangeConfig.map(({id}) => [id, undefined]))
|
|
||||||
);
|
|
||||||
setMaxRange(() =>
|
|
||||||
Object.fromEntries(rangeConfig.map(({id}) => [id, undefined]))
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 text-sm text-gray-600 dark:text-white hover:text-gray-800"
|
|
||||||
>
|
|
||||||
Reset Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useParams} from "next/navigation";
|
|
||||||
import {Profile} from "@/lib/client/profile";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic"; // This disables SSG and ISR
|
|
||||||
|
|
||||||
export default function Post() {
|
|
||||||
const {id} = useParams();
|
|
||||||
|
|
||||||
return (
|
|
||||||
|
|
||||||
<div className="min-h-screen py-12 px-4 sm:px-6 lg:px-8">
|
|
||||||
{Profile(`/api/profiles/${id}`)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import React, {useCallback, useEffect, useState} from "react";
|
|
||||||
import {DropdownKey, ProfileData} from "@/lib/client/schema";
|
|
||||||
import {dropdownConfig, ProfileFilters} from "./ProfileFilters";
|
|
||||||
import Image from "next/image";
|
|
||||||
import {useSession} from "next-auth/react";
|
|
||||||
|
|
||||||
// Disable static generation
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
const renderImages = false;
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
gender: '',
|
|
||||||
minAge: null as number | null,
|
|
||||||
maxAge: null as number | null,
|
|
||||||
minIntroversion: null as number | null,
|
|
||||||
maxIntroversion: null as number | null,
|
|
||||||
interests: [] as string[],
|
|
||||||
coreValues: [] as string[],
|
|
||||||
books: [] as string[],
|
|
||||||
causeAreas: [] as string[],
|
|
||||||
connections: [] as string[],
|
|
||||||
searchQuery: '',
|
|
||||||
forceRun: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProfileFilters = {
|
|
||||||
gender: string;
|
|
||||||
minAge: number | null;
|
|
||||||
maxAge: number | null;
|
|
||||||
minIntroversion: number | null;
|
|
||||||
maxIntroversion: number | null;
|
|
||||||
interests: string[];
|
|
||||||
books: string[];
|
|
||||||
coreValues: string[];
|
|
||||||
causeAreas: string[];
|
|
||||||
connections: string[];
|
|
||||||
searchQuery: string;
|
|
||||||
forceRun: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
|
||||||
const {data: session} = useSession();
|
|
||||||
const userId = session?.user?.id
|
|
||||||
console.log("session:", userId)
|
|
||||||
|
|
||||||
// if (!userId) return <div/>
|
|
||||||
|
|
||||||
const [profiles, setProfiles] = useState<ProfileData[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [_, setShowFilters] = useState(true);
|
|
||||||
const [images, setImages] = useState<string[]>([]);
|
|
||||||
|
|
||||||
const [text, setText] = useState<string>('');
|
|
||||||
|
|
||||||
const [filters, setFilters] = useState(initialState);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
const newFilters = {...initialState};
|
|
||||||
|
|
||||||
for (const [key, value] of params.entries()) {
|
|
||||||
// Type guard to check if the key is a valid filter key
|
|
||||||
if (key in newFilters) {
|
|
||||||
const filterKey = key as keyof ProfileFilters;
|
|
||||||
|
|
||||||
if (key === 'searchQuery') {
|
|
||||||
setText(value);
|
|
||||||
newFilters[filterKey] = value as never;
|
|
||||||
} else if (['interests', 'coreValues', 'causeAreas', 'connections'].includes(key)) {
|
|
||||||
const arrayKey = filterKey as 'interests' | 'coreValues' | 'causeAreas' | 'connections';
|
|
||||||
newFilters[arrayKey] = [...newFilters[arrayKey], value];
|
|
||||||
} else {
|
|
||||||
newFilters[filterKey] = value as never;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(newFilters);
|
|
||||||
|
|
||||||
setFilters(newFilters);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [isStart, setIsStart] = useState(true);
|
|
||||||
|
|
||||||
const fetchProfiles = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
let params = new URLSearchParams();
|
|
||||||
if (isStart) {
|
|
||||||
params = new URLSearchParams(window.location.search);
|
|
||||||
setIsStart(false);
|
|
||||||
}
|
|
||||||
console.log('fetchProfiles', params);
|
|
||||||
|
|
||||||
if (filters.gender) params.append('gender', filters.gender);
|
|
||||||
if (filters.minAge) params.append('minAge', filters.minAge.toString());
|
|
||||||
if (filters.maxAge) params.append('maxAge', filters.maxAge.toString());
|
|
||||||
if (filters.minIntroversion) params.append('minIntroversion', filters.minIntroversion.toString());
|
|
||||||
if (filters.maxIntroversion) params.append('maxIntroversion', filters.maxIntroversion.toString());
|
|
||||||
|
|
||||||
for (let i = 0; i < dropdownConfig.length; i++) {
|
|
||||||
const v = dropdownConfig[i];
|
|
||||||
const filterKey = v.id as DropdownKey;
|
|
||||||
if (filters[filterKey] && filters[filterKey].length > 0) {
|
|
||||||
params.append(v.id, filters[filterKey].join(','));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filters.searchQuery) params.append('searchQuery', filters.searchQuery);
|
|
||||||
|
|
||||||
let s = params.toString();
|
|
||||||
window.history.pushState({}, '', `?${s}`);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/profiles?${s}`);
|
|
||||||
const data = await response.json();
|
|
||||||
if (!response.ok) {
|
|
||||||
console.log(response);
|
|
||||||
throw Error(data?.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(data.error || 'Failed to fetch profiles');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setProfiles(data.profiles || []);
|
|
||||||
console.log(data.profiles);
|
|
||||||
|
|
||||||
if (renderImages) {
|
|
||||||
for (const u of data.profiles) {
|
|
||||||
console.log(u);
|
|
||||||
const img = u.image;
|
|
||||||
let url = img;
|
|
||||||
if (img && !img.startsWith('http')) {
|
|
||||||
const imageResponse = await fetch(`/api/download?key=${img}`);
|
|
||||||
console.log(`imageResponse: ${imageResponse}`)
|
|
||||||
if (imageResponse.ok) {
|
|
||||||
const imageBlob = await imageResponse.json();
|
|
||||||
url = imageBlob['url'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setImages(prev => [...(prev || []), url]);
|
|
||||||
}
|
|
||||||
console.log(images);
|
|
||||||
}
|
|
||||||
} catch
|
|
||||||
(error: any) {
|
|
||||||
console.error('Error fetching profiles:', error);
|
|
||||||
setError('Error: ' + error.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [filters, images]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchProfiles();
|
|
||||||
}, [fetchProfiles]);
|
|
||||||
|
|
||||||
const handleFilterChange = (key: string, value: any) => {
|
|
||||||
setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const showFilterChange = (value: boolean) => {
|
|
||||||
setShowFilters(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleFilter = (key: DropdownKey, value: string) => {
|
|
||||||
setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: prev[key].includes(value)
|
|
||||||
? prev[key].filter((item: string) => item !== value)
|
|
||||||
: [...prev[key], value]
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
|
||||||
setFilters(initialState);
|
|
||||||
setText('');
|
|
||||||
// window.history.pushState({}, '', '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFilterChange = handleFilterChange
|
|
||||||
|
|
||||||
if (!userId) return <div/>
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="w-full max-w-7xl mx-auto">
|
|
||||||
{/*<div className="flex justify-between items-end mb-4">*/}
|
|
||||||
{/* /!*<h1 className="text-4xl sm:text-5xl font-extrabold">People</h1>*!/*/}
|
|
||||||
{/* <div className="text-lg pb-1">*/}
|
|
||||||
{/* /!*Users: <span className="font-bold">{totalUsers}</span>*!/*/}
|
|
||||||
{/* {totalUsers} users*/}
|
|
||||||
{/* </div>*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
|
|
||||||
{/*<div className="py-6">*/}
|
|
||||||
{/* All the profiles are searchable, simply filter them below to find your best connections!*/}
|
|
||||||
{/*</div>*/}
|
|
||||||
|
|
||||||
<div className="relative flex-grow py-6 w-2/4 xs:w-full mx-auto">
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder='Try "meditation", "hiking", or multiple words like "writing, nature"'
|
|
||||||
className="w-full pl-10 pr-10 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
const input = e.target as HTMLInputElement;
|
|
||||||
onFilterChange('searchQuery', input.value);
|
|
||||||
input.blur(); // This dismisses the keyboard
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{filters.searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
onFilterChange('searchQuery', '');
|
|
||||||
setText('');
|
|
||||||
}}
|
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-8 xs:gap-0">
|
|
||||||
{/* Filters Sidebar */}
|
|
||||||
<div className={`w-full md:w-80 flex-shrink-0`}>
|
|
||||||
{/*// md:${showFilters ? 'w-80' : 'w-20'}*/}
|
|
||||||
<div className="top-24">
|
|
||||||
<ProfileFilters
|
|
||||||
filters={filters}
|
|
||||||
onFilterChange={handleFilterChange}
|
|
||||||
onShowFilters={showFilterChange}
|
|
||||||
onToggleFilter={toggleFilter}
|
|
||||||
onReset={resetFilters}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profiles Grid */}
|
|
||||||
<div className="flex-1">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex justify-center py-8">
|
|
||||||
<div className="flex justify-center min-h-screen py-8">
|
|
||||||
<div data-testid="spinner"
|
|
||||||
className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex justify-center py-2">
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
) : profiles.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-1 lg:grid-cols-1 xl:grid-cols-2 gap-6 py-4">
|
|
||||||
{profiles.map((
|
|
||||||
user,
|
|
||||||
idx
|
|
||||||
) => (
|
|
||||||
<Link
|
|
||||||
key={user.id}
|
|
||||||
href={`/profiles/${user.id}`}
|
|
||||||
className="group block bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-md transition-shadow duration-200 h-full"
|
|
||||||
>
|
|
||||||
<div className="p-4 h-full flex flex-col">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
{renderImages && (<div className="flex-shrink-0">
|
|
||||||
<Image
|
|
||||||
className="h-16 w-16 rounded-full object-cover"
|
|
||||||
src={images[idx]}
|
|
||||||
alt={``}
|
|
||||||
/>
|
|
||||||
</div>)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white truncate">
|
|
||||||
{user.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-3">
|
|
||||||
{user.profile?.description || ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-2 flex-grow">
|
|
||||||
{user.profile?.coreValues && user.profile.coreValues.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{user.profile.coreValues.slice(0, 6).map(({value}) => (
|
|
||||||
<span key={value?.id}
|
|
||||||
className="inline-block text-xs px-2 py-1 bg-blue-50 text-blue-700 dark:text-white dark:bg-gray-700 rounded-full">
|
|
||||||
{value?.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-2 flex-grow">
|
|
||||||
{user.profile?.intellectualInterests && user.profile.intellectualInterests.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{user.profile.intellectualInterests.slice(0, 10).map(({interest}) => (
|
|
||||||
<span key={interest?.id}
|
|
||||||
className="inline-block text-xs px-2 py-1 bg-blue-50 text-blue-700 dark:text-white dark:bg-gray-700 rounded-full">
|
|
||||||
{interest?.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-2 flex-grow">
|
|
||||||
{user.profile.books?.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{user.profile.books.slice(0, 6).map(({value}) => (
|
|
||||||
<span key={value?.id}
|
|
||||||
className="inline-block text-xs px-2 py-1 bg-blue-50 text-blue-700 dark:text-white dark:bg-gray-700 rounded-full">
|
|
||||||
{value?.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
{/*<p className="text-gray-500 dark:text-gray-400">No profiles found matching your criteria.</p>*/}
|
|
||||||
<svg className="mx-auto h-12 w-12 mt-4 text-gray-400" fill="none" viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
||||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
||||||
</svg>
|
|
||||||
<h3 className="mt-2 text-sm font-medium">No profiles found</h3>
|
|
||||||
<p className="mt-1 text-sm">
|
|
||||||
{"Try adjusting your search or filter to find what you're looking for."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {SessionProvider} from "next-auth/react";
|
|
||||||
import {ThemeProvider} from 'next-themes';
|
|
||||||
|
|
||||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
|
||||||
return <SessionProvider><ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
|
||||||
{children}
|
|
||||||
</ThemeProvider>;</SessionProvider>;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { ClipboardCopy } from "lucide-react";
|
|
||||||
|
|
||||||
interface CodeBlockProps {
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodeBlock({ code }: CodeBlockProps) {
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(code);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy text: ", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative bg-gray-900 rounded-md overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between px-4 py-2 bg-gray-800">
|
|
||||||
<span className="text-xs text-gray-400">Terminal</span>
|
|
||||||
<button
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
className="text-gray-400 hover:text-white transition-colors"
|
|
||||||
aria-label="Copy to clipboard"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<span className="text-green-400 text-xs">Copied!</span>
|
|
||||||
) : (
|
|
||||||
<ClipboardCopy size={16} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<pre className="p-4 overflow-x-auto text-gray-300 text-sm">
|
|
||||||
<code>{code}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import SetupInstructions from "./setup-instructions";
|
|
||||||
|
|
||||||
export default function SetupPage() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center p-8">
|
|
||||||
<div className="max-w-3xl w-full rounded-lg shadow-lg p-8">
|
|
||||||
<h1 className="text-3xl font-bold text-center mb-6 ">
|
|
||||||
Welcome to Compass
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mb-8 text-center">
|
|
||||||
It looks like your database isn't set up yet. Follow the
|
|
||||||
instructions below to get started.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<SetupInstructions />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import SetupSteps from "./setup-steps";
|
|
||||||
|
|
||||||
export default function SetupInstructions() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<SetupSteps />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import { CodeBlock } from "./code-block";
|
|
||||||
|
|
||||||
export default function SetupSteps() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<section>
|
|
||||||
<h2 className="text-2xl font-semibold mb-4 text-gray-800">
|
|
||||||
Getting Started
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Follow these steps to set up your Next.js & Prisma Postgres Auth
|
|
||||||
Starter:
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="text-xl font-semibold mb-3 text-gray-800">
|
|
||||||
1. Install Dependencies
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-3">
|
|
||||||
After cloning the repo and navigating into it, install dependencies:
|
|
||||||
</p>
|
|
||||||
<CodeBlock code="npm install" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="text-xl font-semibold mb-3 text-gray-800">
|
|
||||||
2. Create a Prisma Postgres Instance
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-3">
|
|
||||||
Create a Prisma Postgres instance by running the following command:
|
|
||||||
</p>
|
|
||||||
<CodeBlock code="npx prisma init --db" />
|
|
||||||
<p className="text-gray-600 mt-3">
|
|
||||||
This command is interactive and will prompt you to:
|
|
||||||
</p>
|
|
||||||
<ol className="list-decimal list-inside mt-2 space-y-1 text-gray-600">
|
|
||||||
<li>Log in to the Prisma Console</li>
|
|
||||||
<li>
|
|
||||||
Select a <strong>region</strong> for your Prisma Postgres instance
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Give a <strong>name</strong> to your Prisma project
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p className="text-gray-600 mt-3">
|
|
||||||
Once the command has terminated, copy the{" "}
|
|
||||||
<strong>Database URL</strong> from the terminal output. You'll
|
|
||||||
need it in the next step.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="text-xl font-semibold mb-3 text-gray-800">
|
|
||||||
3. Set Up Your .env File
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-3">
|
|
||||||
You need to configure your database connection via an environment
|
|
||||||
variable.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-600 mb-3">
|
|
||||||
First, create an <code>.env</code> file:
|
|
||||||
</p>
|
|
||||||
<CodeBlock code="touch .env" />
|
|
||||||
<p className="text-gray-600 mb-3 mt-3">
|
|
||||||
Then update the <code>.env</code> file by replacing the existing{" "}
|
|
||||||
<code>DATABASE_URL</code> value with the one you previously copied:
|
|
||||||
</p>
|
|
||||||
<CodeBlock
|
|
||||||
code={`DATABASE_URL="prisma+postgres://accelerate.prisma-data.net/?api_key=PRISMA_POSTGRES_API_KEY"`}
|
|
||||||
/>
|
|
||||||
<p className="text-gray-600 mb-3 mt-3">
|
|
||||||
To ensure your authentication works properly, you'll also need to
|
|
||||||
set env vars for NextAuth.js:
|
|
||||||
</p>
|
|
||||||
<CodeBlock code={`AUTH_SECRET="RANDOM_32_CHARACTER_STRING"`} />
|
|
||||||
<p className="text-gray-600 mb-3 mt-3">
|
|
||||||
You can generate a random 32 character string for the{" "}
|
|
||||||
<code>AUTH_SECRET</code> with this command:
|
|
||||||
</p>
|
|
||||||
<CodeBlock code="npx auth secret" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="text-xl font-semibold mb-3 text-gray-800">
|
|
||||||
4. Migrate the Database
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-3">
|
|
||||||
Run the following command to set up your database and Prisma schema:
|
|
||||||
</p>
|
|
||||||
<CodeBlock code="npx prisma migrate dev --name init" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="text-xl font-semibold mb-3 text-gray-800">
|
|
||||||
5. Seed the Database
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-3">Add initial data to your database:</p>
|
|
||||||
<CodeBlock code="npx prisma db seed" />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="text-xl font-semibold mb-3 text-gray-800">
|
|
||||||
6. Run the App
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 mb-3">Start the development server:</p>
|
|
||||||
<CodeBlock code="npm run dev" />
|
|
||||||
<p className="text-gray-600 mt-3">
|
|
||||||
Once the server is running, visit <code>http://localhost:3000</code>{" "}
|
|
||||||
to start using the app.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,695 +0,0 @@
|
|||||||
import React, {useEffect, useState} from "react";
|
|
||||||
import {SubmitHandler, useForm} from "react-hook-form";
|
|
||||||
import {Gender} from "@prisma/client";
|
|
||||||
import {useSession} from "next-auth/react";
|
|
||||||
import {useRouter, useSearchParams} from "next/navigation";
|
|
||||||
import Slider from "@mui/material/Slider";
|
|
||||||
import {errorBlock} from "@/lib/client/errors";
|
|
||||||
import {Item} from "@/lib/client/schema";
|
|
||||||
import {fetchFeatures} from "@/lib/client/fetching";
|
|
||||||
|
|
||||||
|
|
||||||
// Updated Question type to support more field types and conditions
|
|
||||||
type Question = {
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
type?: string;
|
|
||||||
options?: string[];
|
|
||||||
range?: string[];
|
|
||||||
optional?: boolean;
|
|
||||||
group?: boolean;
|
|
||||||
fields?: Question[];
|
|
||||||
condition?: (values: any) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Onboarding questions with conditional logic and grouping
|
|
||||||
const questions: Question[] = [
|
|
||||||
{
|
|
||||||
name: "name",
|
|
||||||
label: "Hi! What's your first name?",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "location",
|
|
||||||
label: "Where are you located?",
|
|
||||||
group: true,
|
|
||||||
fields: [
|
|
||||||
{name: "country", label: "Country"},
|
|
||||||
// { name: "zipCode", label: "Zip Code" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "gender",
|
|
||||||
label: "What's your gender?",
|
|
||||||
type: "select",
|
|
||||||
options: ["Man", "Woman", "Other"],
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// name: "genderOther",
|
|
||||||
// label: "How do you identify?",
|
|
||||||
// type: "select",
|
|
||||||
// options: [
|
|
||||||
// "Agender",
|
|
||||||
// "Androgynous",
|
|
||||||
// "Bigender",
|
|
||||||
// "Cis Man",
|
|
||||||
// "Cis Woman",
|
|
||||||
// "Genderfluid",
|
|
||||||
// "Genderqueer",
|
|
||||||
// "Gender Nonconforming",
|
|
||||||
// "Hijra",
|
|
||||||
// "Intersex",
|
|
||||||
// "Non-binary",
|
|
||||||
// "Other gender",
|
|
||||||
// "Pangender",
|
|
||||||
// "Transfeminine",
|
|
||||||
// "Transgender",
|
|
||||||
// "Trans Man",
|
|
||||||
// "Transmasculine",
|
|
||||||
// "Transsexual",
|
|
||||||
// "Trans Woman",
|
|
||||||
// "Two Spirit"
|
|
||||||
// ],
|
|
||||||
// optional: true,
|
|
||||||
// condition: (values) => values.gender === "See all",
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
name: "birthday",
|
|
||||||
label: "When's your birthday?",
|
|
||||||
type: "date",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "connections",
|
|
||||||
label: "What kind of relationship are you looking for?",
|
|
||||||
type: "multiselect",
|
|
||||||
options: [
|
|
||||||
// "Debate Partner",
|
|
||||||
"Friendship",
|
|
||||||
"Short-term relationship",
|
|
||||||
"Long-term Relationship",
|
|
||||||
// "Other",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// name: "kids",
|
|
||||||
// label: "What are your ideal plans for children? (optional)",
|
|
||||||
// type: "select",
|
|
||||||
// options: [
|
|
||||||
// "Skip",
|
|
||||||
// "Want someday",
|
|
||||||
// "Don't want",
|
|
||||||
// "Have and want more",
|
|
||||||
// "Have and don't want more",
|
|
||||||
// "Not sure yet",
|
|
||||||
// "Have kids",
|
|
||||||
// "Open to kids",
|
|
||||||
// ],
|
|
||||||
// optional: true,
|
|
||||||
// condition: (values) =>
|
|
||||||
// ["Short-term dating", "Hookups", "Long-term dating"].includes(
|
|
||||||
// values.relationshipType
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: "nonMonogamy",
|
|
||||||
// label: "Non-Monogamy Options",
|
|
||||||
// type: "select",
|
|
||||||
// options: ["Monogamous", "Non-monogamous", "Open to either"],
|
|
||||||
// condition: (values) =>
|
|
||||||
// ["Short-term dating", "Hookups", "Long-term dating"].includes(
|
|
||||||
// values.relationshipType
|
|
||||||
// ),
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: "photos",
|
|
||||||
// label: "Add photos (optional)",
|
|
||||||
// type: "file",
|
|
||||||
// optional: true,
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
name: "description",
|
|
||||||
label: "Tell us about yourself",
|
|
||||||
type: "textarea",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "contactInfo",
|
|
||||||
label: "How can people contact you?",
|
|
||||||
type: "textarea",
|
|
||||||
},
|
|
||||||
// Personality questions
|
|
||||||
// {
|
|
||||||
// name: "intenseOrCarefree",
|
|
||||||
// label: "Which word describes you better?",
|
|
||||||
// type: "select",
|
|
||||||
// options: ["Intense", "Carefree"],
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: "religion",
|
|
||||||
// label: "How important is religion/God in your life?",
|
|
||||||
// type: "select",
|
|
||||||
// options: [
|
|
||||||
// "Not at all important",
|
|
||||||
// "Slightly important",
|
|
||||||
// "Moderately important",
|
|
||||||
// "Very important",
|
|
||||||
// "Extremely important"
|
|
||||||
// ]
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// name: "politics",
|
|
||||||
// label: "Which best describes your political beliefs?",
|
|
||||||
// type: "select",
|
|
||||||
// options: [
|
|
||||||
// "Very liberal",
|
|
||||||
// "Liberal",
|
|
||||||
// "Moderate",
|
|
||||||
// "Conservative",
|
|
||||||
// "Very conservative",
|
|
||||||
// "Other"
|
|
||||||
// ]
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
name: "introversion",
|
|
||||||
label: "How would you describe your social style?",
|
|
||||||
type: "slider",
|
|
||||||
range: ['Introverted', 'Extroverted'],
|
|
||||||
},
|
|
||||||
// {
|
|
||||||
// name: "introversion",
|
|
||||||
// label: "How would you describe your social style?",
|
|
||||||
// type: "select",
|
|
||||||
// options: [
|
|
||||||
// "Very introverted",
|
|
||||||
// "Somewhat introverted",
|
|
||||||
// "In the middle",
|
|
||||||
// "Somewhat extroverted",
|
|
||||||
// "Very extroverted"
|
|
||||||
// ]
|
|
||||||
// },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
// List of valid countries (shortened for brevity, add more as needed)
|
|
||||||
const countryOptions = [
|
|
||||||
"United States", "Canada", "United Kingdom", "Australia", "Germany", "France", "India", "China", "Japan", "Brazil", "Mexico", "Italy", "Spain", "Netherlands", "Sweden", "Norway", "Denmark", "Finland", "Ireland", "New Zealand"
|
|
||||||
// ...add more countries as needed
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// Helper to get visible questions based on current form values
|
|
||||||
const getVisibleQuestions = (values: FormValues) =>
|
|
||||||
questions.filter((q) => !q.condition || q.condition(values));
|
|
||||||
|
|
||||||
|
|
||||||
const OnboardingForm: React.FC = () => {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const redirect = searchParams.get('redirect') || '/';
|
|
||||||
const router = useRouter();
|
|
||||||
const {update} = useSession();
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const [step, setStep] = useState(0);
|
|
||||||
const {register, handleSubmit, getValues, formState: {errors}} = useForm<FormValues>();
|
|
||||||
const [formValues, setFormValues] = useState<FormValues>({});
|
|
||||||
const [showGenderDefs, setShowGenderDefs] = useState(false);
|
|
||||||
const [sliderValue, setSliderValue] = useState(0);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const featureNames = ['connections'];
|
|
||||||
const [allFeatures, _setAllFeatures] = useState(() =>
|
|
||||||
Object.fromEntries(featureNames.map((id) => [id, [] as Item[]]))
|
|
||||||
);
|
|
||||||
const setAllFeatures = (id: string, value: any) => {
|
|
||||||
_setAllFeatures((prev) => ({...prev, [id]: value}));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('Fetching features...');
|
|
||||||
fetchFeatures(setAllFeatures);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
|
||||||
// Helper to calculate age
|
|
||||||
const getAge = (month: string, day: string, year: string) => {
|
|
||||||
if (!month || !day || !year) return null;
|
|
||||||
const mm = parseInt(month, 10);
|
|
||||||
const dd = parseInt(day, 10);
|
|
||||||
const yyyy = parseInt(year, 10);
|
|
||||||
if (isNaN(mm) || isNaN(dd) || isNaN(yyyy)) return null;
|
|
||||||
const today = new Date();
|
|
||||||
// const birthDate = new Date(yyyy, mm - 1, dd);
|
|
||||||
let age = today.getFullYear() - yyyy;
|
|
||||||
const m = today.getMonth() - (mm - 1);
|
|
||||||
if (m < 0 || (m === 0 && today.getDate() < dd)) {
|
|
||||||
age--;
|
|
||||||
}
|
|
||||||
return age;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const visibleQuestions = getVisibleQuestions(formValues);
|
|
||||||
const isLastStep = step === visibleQuestions.length - 1;
|
|
||||||
const currentQuestion = visibleQuestions[step];
|
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<FormValues> = async () => {
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError('');
|
|
||||||
const allValues = {...formValues, ...getValues()};
|
|
||||||
console.log(JSON.stringify(allValues, null, 2));
|
|
||||||
try {
|
|
||||||
const data: any = {
|
|
||||||
profile: {
|
|
||||||
description: allValues.description,
|
|
||||||
contactInfo: allValues.contactInfo,
|
|
||||||
location: allValues.country,
|
|
||||||
gender: allValues.gender as Gender,
|
|
||||||
birthYear: parseInt(allValues.birthYear),
|
|
||||||
introversion: 100 - allValues.introversion,
|
|
||||||
// images: keys,
|
|
||||||
},
|
|
||||||
// ...(key && {image: key}),
|
|
||||||
name: allValues.name,
|
|
||||||
};
|
|
||||||
for (const t of ['connections']) {
|
|
||||||
if (!allValues[t]) continue;
|
|
||||||
data[t] = Array.from(allValues[t]).map(name => ({
|
|
||||||
id: allFeatures[t].find(i => i.name === name)?.id,
|
|
||||||
name: name
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
console.log('data:', data)
|
|
||||||
const response = await fetch('/api/user/update-profile', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
setError(errorData.error || 'Failed to update profile');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await update();
|
|
||||||
router.push(redirect);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Profile update error:', error);
|
|
||||||
setError(error instanceof Error ? error.message : 'Failed to update profile');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const nextStep = () => {
|
|
||||||
const values = getValues();
|
|
||||||
setFormValues((prev) => ({...prev, ...values}));
|
|
||||||
setStep((s) => Math.min(s + 1, visibleQuestions.length - 1));
|
|
||||||
};
|
|
||||||
const prevStep = () => setStep((s) => Math.max(s - 1, 0));
|
|
||||||
|
|
||||||
|
|
||||||
// Questions where skip is allowed (from 'relationshipDuration' onward)
|
|
||||||
const skipFrom = visibleQuestions.findIndex(q => q.name === 'relationshipDuration');
|
|
||||||
const canSkip = step >= skipFrom && skipFrom !== -1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{error && errorBlock(error)}
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="mt-0 space-y-6 flex flex-col items-center">
|
|
||||||
<div className="w-full flex flex-col items-center">
|
|
||||||
<label className="px-4 text-center block w-full text-3xl font-bold mb-8 mt-0"
|
|
||||||
style={{marginTop: '0rem'}}>{currentQuestion.label}</label>
|
|
||||||
{currentQuestion.group && currentQuestion.fields ? (
|
|
||||||
currentQuestion.fields.map((field) => {
|
|
||||||
let fieldInput;
|
|
||||||
if (field.name === "country") {
|
|
||||||
fieldInput = (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
list="country-list"
|
|
||||||
{...register(field.name, {
|
|
||||||
required: !field.optional,
|
|
||||||
// validate: value => countryOptions.includes(value) || "Please select a valid country." // Skip until all countries are listed
|
|
||||||
})}
|
|
||||||
defaultValue={getValues(field.name) || ""}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
nextStep();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-center text-lg py-3 px-5 h-14 rounded-full border w-72"
|
|
||||||
/>
|
|
||||||
<datalist id="country-list">
|
|
||||||
{countryOptions.map((country) => (
|
|
||||||
<option key={country} value={country}/>
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
} else if (field.name === "zipCode") {
|
|
||||||
fieldInput = (
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
{...register(field.name, {
|
|
||||||
required: !field.optional,
|
|
||||||
pattern: {value: /^\d{5}(-\d{4})?$/, message: "Please enter a valid US zip code."}
|
|
||||||
})}
|
|
||||||
defaultValue={getValues(field.name) || ""}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
nextStep();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-center text-lg py-3 px-5 h-14 rounded-full border w-48"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
fieldInput = (
|
|
||||||
<input
|
|
||||||
type={field.type || "text"}
|
|
||||||
{...register(field.name, {required: !field.optional})}
|
|
||||||
defaultValue={getValues(field.name) || ""}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
nextStep();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-center text-lg py-3 px-5 h-14 rounded-full border"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div key={field.name} className="w-full flex flex-col items-center">
|
|
||||||
<label className="text-center w-full">{field.label}</label>
|
|
||||||
{fieldInput}
|
|
||||||
{errors[field.name] && (
|
|
||||||
<span className="block text-center mt-4">
|
|
||||||
{(errors[field.name] as any)?.message || "This field is required"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : currentQuestion.name === "birthday" ? (
|
|
||||||
<div className="flex flex-row gap-2 justify-center items-center mt-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
maxLength={2}
|
|
||||||
placeholder="MM"
|
|
||||||
{...register("birthMonth", {required: true, pattern: /^(0?[1-9]|1[0-2])$/})}
|
|
||||||
className="rounded-full border px-4 py-2 text-lg text-center w-20"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
maxLength={2}
|
|
||||||
placeholder="DD"
|
|
||||||
{...register("birthDay", {required: true, pattern: /^(0?[1-9]|[12][0-9]|3[01])$/})}
|
|
||||||
className="rounded-full border px-4 py-2 text-lg text-center w-20"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
maxLength={4}
|
|
||||||
placeholder="YYYY"
|
|
||||||
{...register("birthYear", {required: true, pattern: /^[0-9]{4}$/})}
|
|
||||||
className="rounded-full border px-4 py-2 text-lg text-center w-28"
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
const month = getValues("birthMonth");
|
|
||||||
const day = getValues("birthDay");
|
|
||||||
const year = getValues("birthYear");
|
|
||||||
const age = getAge(month, day, year);
|
|
||||||
if (age && age > 0 && age < 120) {
|
|
||||||
return <span className="ml-4 text-lg font-semibold text-blue-700">You are {age}!</span>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
) : (currentQuestion.type === "select" && currentQuestion.options && currentQuestion.options.length <= 6) ? (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2 justify-center">
|
|
||||||
{currentQuestion.options.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt}
|
|
||||||
type="button"
|
|
||||||
className={`px-4 py-2 rounded-full border ${formValues[currentQuestion.name] === opt ? 'bg-red-700 text-white' : 'bg-gray-100 dark:bg-gray-700'} hover:bg-red-100 dark:hover:bg-red-800 transition-colors`}
|
|
||||||
onClick={() => {
|
|
||||||
setFormValues({...formValues, [currentQuestion.name]: opt});
|
|
||||||
setStep((s) => Math.min(s + 1, visibleQuestions.length - 1));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (currentQuestion.type === "multiselect" && currentQuestion.options && currentQuestion.options.length <= 6) ? (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2 justify-center">
|
|
||||||
{currentQuestion.options.map((opt) => {
|
|
||||||
const selected = (formValues[currentQuestion.name] || []).includes(opt);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt}
|
|
||||||
type="button"
|
|
||||||
className={`px-4 py-2 rounded-full border ${selected ? 'bg-red-700 text-white' : 'bg-gray-100 dark:bg-gray-700'} hover:bg-red-100 dark:hover:bg-red-800 transition-colors`}
|
|
||||||
onClick={() => {
|
|
||||||
const prev = formValues[currentQuestion.name] || [];
|
|
||||||
const next = selected ? prev.filter((v: string) => v !== opt) : [...prev, opt];
|
|
||||||
setFormValues({...formValues, [currentQuestion.name]: next});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : currentQuestion.type === "slider" ? (
|
|
||||||
<div className="flex items-center w-full max-w-xl gap-4">
|
|
||||||
{currentQuestion.range && (
|
|
||||||
<span className="min-w-[80px] text-gray-500 text-left">
|
|
||||||
{currentQuestion.range[0]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Slider
|
|
||||||
value={sliderValue}
|
|
||||||
onChange={(e, value) => {
|
|
||||||
setSliderValue(value);
|
|
||||||
setFormValues({...formValues, [currentQuestion.name]: value});
|
|
||||||
}}
|
|
||||||
valueLabelDisplay="auto"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
sx={{
|
|
||||||
color: '#3B82F6',
|
|
||||||
'& .MuiSlider-valueLabel': {
|
|
||||||
backgroundColor: '#3B82F6',
|
|
||||||
color: '#fff',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
{currentQuestion.range && (
|
|
||||||
<span className="min-w-[80px] text-gray-500 text-right">
|
|
||||||
{currentQuestion.range[1]}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : currentQuestion.type === "select" ? (
|
|
||||||
<select
|
|
||||||
{...register(currentQuestion.name, {required: !currentQuestion.optional})}
|
|
||||||
className="px-4 text-center text-lg py-3 h-14 rounded-full border"
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
nextStep();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<option value="">Select...</option>
|
|
||||||
{currentQuestion.options?.map((opt) => (
|
|
||||||
<option key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : currentQuestion.type === "multiselect" ? (
|
|
||||||
<select
|
|
||||||
multiple
|
|
||||||
{...register(currentQuestion.name, {required: !currentQuestion.optional})}
|
|
||||||
className="px-4 border rounded-full w-full min-h-[44px] focus:outline-none focus:ring-2 focus:ring-blue-400 text-center text-lg py-3 h-14"
|
|
||||||
style={{minHeight: 44, boxSizing: 'border-box', cursor: 'pointer'}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
nextStep();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{currentQuestion.options?.map((opt) => (
|
|
||||||
<option key={opt} value={opt}>
|
|
||||||
{opt}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
// For a more advanced UI, consider using react-select's MultiSelect component
|
|
||||||
) : currentQuestion.type === "textarea" ? (
|
|
||||||
<textarea
|
|
||||||
{...register(currentQuestion.name, {required: !currentQuestion.optional})}
|
|
||||||
defaultValue={getValues(currentQuestion.name) || ""}
|
|
||||||
className="text-center text-lg py-3 px-5 h-28 rounded-lg border"
|
|
||||||
/>
|
|
||||||
) : currentQuestion.type === "file" ? (
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
{...register(currentQuestion.name)}
|
|
||||||
className="text-center text-lg py-3 px-5 h-14 rounded-full border"
|
|
||||||
/>
|
|
||||||
) : (currentQuestion.name === "genderOther") ? (
|
|
||||||
<div className="flex flex-col items-center w-full">
|
|
||||||
<select
|
|
||||||
{...register(currentQuestion.name, {required: !currentQuestion.optional})}
|
|
||||||
className="px-4 text-center text-lg py-3 h-14 rounded-full border w-full max-w-xl"
|
|
||||||
>
|
|
||||||
<option value="">Select...</option>
|
|
||||||
{questions.find(q => q.name === "genderOther")?.options?.map((opt) => (
|
|
||||||
<option key={opt} value={opt}>{opt}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mt-4 mb-2 px-4 py-2 rounded-full border bg-gray-100 hover:bg-gray-200 text-gray-700 transition-colors"
|
|
||||||
onClick={() => setShowGenderDefs((v) => !v)}
|
|
||||||
>
|
|
||||||
{showGenderDefs ? "Hide definitions" : "Show definitions"}
|
|
||||||
</button>
|
|
||||||
{showGenderDefs && (
|
|
||||||
<div
|
|
||||||
className="mt-2 p-4 bg-white border rounded-lg shadow max-w-xl text-left text-sm overflow-y-auto max-h-96">
|
|
||||||
<ul className="list-disc pl-5">
|
|
||||||
<li><b>Agender</b>: Individuals with no gender identity or a neutral gender identity.</li>
|
|
||||||
<li><b>Androgynous</b>: Individuals with both male & female presentation or nature.</li>
|
|
||||||
<li><b>Bigender</b>: Individuals who identify as multiple genders/identities, either simultaneously
|
|
||||||
or
|
|
||||||
at different times.
|
|
||||||
</li>
|
|
||||||
<li><b>Cis Man</b>: Individuals whose gender identity matches the male sex they were assigned at
|
|
||||||
birth.
|
|
||||||
</li>
|
|
||||||
<li><b>Cis Woman</b>: Individuals whose gender identity matches the female sex they were assigned at
|
|
||||||
birth.
|
|
||||||
</li>
|
|
||||||
<li><b>Genderfluid</b>: Individuals who don’t have a fixed gender identity.</li>
|
|
||||||
<li><b>Genderqueer</b>: Individuals who don’t identify with binary gender identity norms.</li>
|
|
||||||
<li><b>Gender Nonconforming</b>: Individuals whose gender expressions don’t match masculine &
|
|
||||||
feminine
|
|
||||||
gender norms.
|
|
||||||
</li>
|
|
||||||
<li><b>Hijra</b>: A third gender identity, largely used in the Indian subcontinent, which typically
|
|
||||||
reflects people who were assigned male at birth, with feminine gender expression, who identify as
|
|
||||||
neither male nor female.
|
|
||||||
</li>
|
|
||||||
<li><b>Intersex</b>: Individuals born with a reproductive or sexual anatomy that doesn’t fit the
|
|
||||||
typical definitions of female or male.
|
|
||||||
</li>
|
|
||||||
<li><b>Non-binary</b>: A term covering any gender identity or expression that doesn’t fit within the
|
|
||||||
gender binary.
|
|
||||||
</li>
|
|
||||||
<li><b>Other gender</b>: Individuals who identify with any other gender expressions.</li>
|
|
||||||
<li><b>Pangender</b>: Individuals who identify with a wide multiplicity of gender identities.</li>
|
|
||||||
<li><b>Transfeminine</b>: Transgender individuals whose gender expression is more feminine
|
|
||||||
presenting.
|
|
||||||
</li>
|
|
||||||
<li><b>Transgender</b>: Individuals whose gender identity differs from the sex they were assigned at
|
|
||||||
birth.
|
|
||||||
</li>
|
|
||||||
<li><b>Trans Man</b>: Individuals who were assigned female at birth (AFAB) but have a male gender
|
|
||||||
identity.
|
|
||||||
</li>
|
|
||||||
<li><b>Transmasculine</b>: Transgender individuals whose gender expression is more masculine
|
|
||||||
presenting.
|
|
||||||
</li>
|
|
||||||
<li><b>Transsexual</b>: This term is sometimes used to describe trans individuals (who do not
|
|
||||||
identify
|
|
||||||
with the sex they were assigned at birth) who wish to align their gender identity & sex through
|
|
||||||
medical intervention.
|
|
||||||
</li>
|
|
||||||
<li><b>Trans Woman</b>: Individuals who were assigned male at birth (AMAB) but have a female gender
|
|
||||||
identity.
|
|
||||||
</li>
|
|
||||||
<li><b>Two Spirit</b>: Term largely used in Indigenous, Native American, and First Nation cultures,
|
|
||||||
reflecting individuals who identify with multiple genders/gender identities that are neither male
|
|
||||||
nor female.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
type={currentQuestion.type || "text"}
|
|
||||||
{...register(currentQuestion.name, {required: !currentQuestion.optional})}
|
|
||||||
defaultValue={getValues(currentQuestion.name) || ""}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
nextStep();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-center text-lg py-3 px-5 h-14 rounded-full border"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{errors[currentQuestion.name] && <span className="block text-center mt-4">This field is required</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row justify-center items-center w-full mt-16 gap-4">
|
|
||||||
{step > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={prevStep}
|
|
||||||
className="rounded-full px-6 py-2 font-semibold bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
|
||||||
>
|
|
||||||
Back
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canSkip && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setFormValues((prev) => ({...prev, [currentQuestion.name]: ""}));
|
|
||||||
setStep((s) => Math.min(s + 1, visibleQuestions.length - 1));
|
|
||||||
}}
|
|
||||||
className="rounded-full px-6 py-2 font-semibold bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
Skip
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!isLastStep ? (
|
|
||||||
<button
|
|
||||||
key={currentQuestion.name}
|
|
||||||
type="button"
|
|
||||||
onClick={nextStep}
|
|
||||||
className="rounded-full px-6 py-2 font-semibold bg-red-700 text-white hover:bg-red-800 transition-colors"
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded-full px-6 py-2 font-semibold bg-red-700 text-white hover:bg-red-800 transition-colors"
|
|
||||||
>
|
|
||||||
{isSubmitting ? 'Saving...' : 'Submit'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export default OnboardingForm;
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
import {Textarea} from '@/components/ui/textarea';
|
|
||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
|
||||||
// import {cons} from "effect/List";
|
|
||||||
|
|
||||||
type Prompt = {
|
|
||||||
id: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Answer = {
|
|
||||||
promptId: string;
|
|
||||||
prompt: string;
|
|
||||||
text: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PromptAnswerProps {
|
|
||||||
prompts: string[];
|
|
||||||
onAnswerChange: (answer: Answer) => void;
|
|
||||||
initialAnswer?: string;
|
|
||||||
initialValues?: any;
|
|
||||||
initialPromptId?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PromptAnswer(
|
|
||||||
{
|
|
||||||
prompts,
|
|
||||||
onAnswerChange,
|
|
||||||
initialValues = null,
|
|
||||||
initialAnswer = '',
|
|
||||||
initialPromptId = '',
|
|
||||||
className = '',
|
|
||||||
}: PromptAnswerProps
|
|
||||||
) {
|
|
||||||
// const [selectedPromptId, setSelectedPromptId] = useState(initialPromptId);
|
|
||||||
const [answer, setAnswer] = useState(initialAnswer);
|
|
||||||
const [isCustomPrompt, setIsCustomPrompt] = useState(false);
|
|
||||||
const [selectedPrompt, setSelectedPrompt] = useState('');
|
|
||||||
const idToPrompt = Object.fromEntries(prompts.map((item, idx) => [idx, item]));
|
|
||||||
// console.log('dictPrompts', idToPrompt)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialPromptId === 'custom') {
|
|
||||||
setIsCustomPrompt(true);
|
|
||||||
setSelectedPrompt(initialAnswer);
|
|
||||||
}
|
|
||||||
}, [initialPromptId, initialAnswer]);
|
|
||||||
|
|
||||||
const handlePromptChange = (prompt: string) => {
|
|
||||||
console.log('handlePromptChange', prompt)
|
|
||||||
if (prompt === 'custom') {
|
|
||||||
setIsCustomPrompt(true);
|
|
||||||
setSelectedPrompt('');
|
|
||||||
// onAnswerChange({promptId: 'custom', prompt: selectedPrompt, text: selectedPrompt});
|
|
||||||
} else {
|
|
||||||
setIsCustomPrompt(false);
|
|
||||||
setSelectedPrompt(prompt);
|
|
||||||
// onAnswerChange({promptId: id, prompt: idToPrompt[id], text: answer});
|
|
||||||
}
|
|
||||||
setAnswer(initialValues[prompt] || '')
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAnswerChange = (text: string) => {
|
|
||||||
setAnswer(text);
|
|
||||||
console.log('handleAnswerChange', text)
|
|
||||||
onAnswerChange({
|
|
||||||
promptId: '...',
|
|
||||||
prompt: selectedPrompt,
|
|
||||||
text: text,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCustomPromptChange = (text: string) => {
|
|
||||||
setSelectedPrompt(text);
|
|
||||||
// onAnswerChange({
|
|
||||||
// promptId: 'custom',
|
|
||||||
// prompt: text,
|
|
||||||
// text,
|
|
||||||
// });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`space-y-4 ${className}`}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Select a prompt
|
|
||||||
</label>
|
|
||||||
<Select value={isCustomPrompt ? 'custom' : selectedPrompt} onValueChange={handlePromptChange}>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder="Choose a prompt..."/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-60 overflow-auto">
|
|
||||||
{prompts.map((prompt, idx) => (
|
|
||||||
<SelectItem key={idx} value={prompt}>
|
|
||||||
{prompt}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
<SelectItem value="custom">Write your own prompt</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isCustomPrompt && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Your custom prompt
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={selectedPrompt}
|
|
||||||
onChange={(e) => handleCustomPromptChange(e.target.value)}
|
|
||||||
placeholder="Write your own prompt..."
|
|
||||||
className="min-h-[100px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2 ">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Your answer
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={answer}
|
|
||||||
onChange={(e) => handleAnswerChange(e.target.value)}
|
|
||||||
placeholder="Type your answer here..."
|
|
||||||
className="min-h-[150px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default PromptAnswer;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
|
||||||
outline:
|
|
||||||
"border border-input hover:bg-accent hover:text-accent-foreground",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-10 px-4 py-2",
|
|
||||||
sm: "h-9 rounded-md px-3",
|
|
||||||
lg: "h-11 rounded-md px-8",
|
|
||||||
icon: "h-10 w-10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
export interface ButtonProps
|
|
||||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
||||||
VariantProps<typeof buttonVariants> {
|
|
||||||
asChild?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
||||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Button.displayName = "Button"
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
|
||||||
import { Check, ChevronDown } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const Select = SelectPrimitive.Root
|
|
||||||
|
|
||||||
const SelectGroup = SelectPrimitive.Group
|
|
||||||
|
|
||||||
const SelectValue = SelectPrimitive.Value
|
|
||||||
|
|
||||||
const SelectTrigger = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-full items-center justify-between rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
))
|
|
||||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
|
||||||
|
|
||||||
const SelectContent = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
|
||||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"relative z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
||||||
position === "popper" &&
|
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
position={position}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<SelectPrimitive.Viewport
|
|
||||||
className={cn(
|
|
||||||
"p-1",
|
|
||||||
position === "popper" &&
|
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
))
|
|
||||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
|
||||||
|
|
||||||
const SelectLabel = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
ref={ref}
|
|
||||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
|
||||||
|
|
||||||
const SelectItem = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
|
||||||
>(({ className, children, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
|
||||||
"bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center ">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
))
|
|
||||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
|
||||||
|
|
||||||
const SelectSeparator = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
|
||||||
>(({ className, ...props }, ref) => (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
ref={ref}
|
|
||||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectGroup,
|
|
||||||
SelectValue,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectContent,
|
|
||||||
SelectLabel,
|
|
||||||
SelectItem,
|
|
||||||
SelectSeparator,
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
export interface TextareaProps
|
|
||||||
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
|
||||||
|
|
||||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
||||||
({ className, ...props }, ref) => {
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
className={cn(
|
|
||||||
"flex min-h-[80px] w-full rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
ref={ref}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Textarea.displayName = "Textarea"
|
|
||||||
|
|
||||||
export { Textarea }
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
interface VerificationEmailProps {
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VerificationEmail({ url }: VerificationEmailProps) {
|
|
||||||
return (
|
|
||||||
<div style={container}>
|
|
||||||
<div style={content}>
|
|
||||||
<h1 style={heading}>Verify your email</h1>
|
|
||||||
<p style={text}>
|
|
||||||
Thanks for signing up! Please verify your email address by clicking the button below:
|
|
||||||
</p>
|
|
||||||
<a href={url} style={button}>
|
|
||||||
Verify Email
|
|
||||||
</a>
|
|
||||||
<p style={text}>
|
|
||||||
If you didn't create an account, you can safely ignore this email.
|
|
||||||
</p>
|
|
||||||
<p style={text}>
|
|
||||||
<small style={smallText}>
|
|
||||||
Or copy and paste this link into your browser:<br />
|
|
||||||
<a href={url} style={link}>
|
|
||||||
{url}
|
|
||||||
</a>
|
|
||||||
</small>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email styles
|
|
||||||
const container = {
|
|
||||||
backgroundColor: '#f6f9fc',
|
|
||||||
padding: '20px 0',
|
|
||||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = {
|
|
||||||
maxWidth: '600px',
|
|
||||||
margin: '0 auto',
|
|
||||||
backgroundColor: '#ffffff',
|
|
||||||
padding: '30px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const heading = {
|
|
||||||
color: '#2d3748',
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
margin: '0 0 20px 0',
|
|
||||||
padding: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const text = {
|
|
||||||
color: '#4a5568',
|
|
||||||
fontSize: '16px',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
margin: '0 0 20px 0',
|
|
||||||
};
|
|
||||||
|
|
||||||
const smallText = {
|
|
||||||
fontSize: '14px',
|
|
||||||
color: '#718096',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
};
|
|
||||||
|
|
||||||
const button = {
|
|
||||||
display: 'inline-block',
|
|
||||||
backgroundColor: '#3182ce',
|
|
||||||
color: '#ffffff',
|
|
||||||
textDecoration: 'none',
|
|
||||||
padding: '12px 24px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontWeight: '600',
|
|
||||||
marginBottom: '20px',
|
|
||||||
};
|
|
||||||
|
|
||||||
const link = {
|
|
||||||
color: '#3182ce',
|
|
||||||
wordBreak: 'break-all' as const,
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// For som unknown reasons, the spinner does not render when using LoadingSpinner(), so I copy paste the div block everywhere (TODO)
|
|
||||||
|
|
||||||
export default function LoadingSpinner() {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center min-h-screen py-8">
|
|
||||||
<div data-testid="spinner" className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
|
|
||||||
export function errorBlock(error: string = '') {
|
|
||||||
return <div className="bg-red-50 border-l-4 border-red-400 p-4">
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
|
||||||
fill="currentColor">
|
|
||||||
<path fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
|
||||||
clipRule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="ml-3">
|
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export async function fetchFeatures(setAllFeatures: any) {
|
|
||||||
// results = []
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/interests');
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
for (const [id, values] of Object.entries(data)) {
|
|
||||||
setAllFeatures(id, values || []);
|
|
||||||
// results.push({id, values});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching feature options:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
export async function parseImage(img: string, setImage: any, batch = false) {
|
|
||||||
if (!img) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let url = img;
|
|
||||||
if (!img.startsWith('http')) {
|
|
||||||
const imageResponse = await fetch(`/api/download?key=${img}`);
|
|
||||||
console.log(`imageResponse: ${imageResponse}`)
|
|
||||||
if (imageResponse.ok) {
|
|
||||||
const imageBlob = await imageResponse.json();
|
|
||||||
url = imageBlob['url'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (url) {
|
|
||||||
if (batch) {
|
|
||||||
setImage((prev: any) => [...prev, url]);
|
|
||||||
} else {
|
|
||||||
setImage(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,379 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import Image from "next/image";
|
|
||||||
import {pStyle} from "@/lib/client/constants";
|
|
||||||
import React, {useEffect, useState} from "react";
|
|
||||||
import {parseImage} from "@/lib/client/media";
|
|
||||||
import {useRouter} from 'next/navigation';
|
|
||||||
|
|
||||||
interface DeleteProfileButtonProps {
|
|
||||||
profileId: string;
|
|
||||||
onDelete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DeleteProfileButton({profileId, onDelete}: DeleteProfileButtonProps) {
|
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!confirm('Are you sure you want to delete this profile? This action cannot be undone.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsDeleting(true);
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/profiles/${profileId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to delete profile');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call the onDelete callback if provided
|
|
||||||
if (onDelete) {
|
|
||||||
onDelete();
|
|
||||||
} else {
|
|
||||||
router.push('/');
|
|
||||||
}
|
|
||||||
console.log('Done deleting')
|
|
||||||
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting profile:', error);
|
|
||||||
alert('Failed to delete profile. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
disabled={isDeleting}
|
|
||||||
className=" items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md shadow-sm text-gray-900 dark:text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed group relative w-full justify-center "
|
|
||||||
>
|
|
||||||
{isDeleting ? 'Deleting...' : 'Delete Profile'}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Profile(url: string, header: any = null) {
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [userData, setUserData] = useState<any>(null);
|
|
||||||
const [image, setImage] = useState<string | null>(null);
|
|
||||||
const [images, setImages] = useState<string[]>([]);
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchImage() {
|
|
||||||
const res = await fetch(url);
|
|
||||||
const data = await res.json();
|
|
||||||
setUserData(data);
|
|
||||||
// console.log('userData', data);
|
|
||||||
document.title = data.name;
|
|
||||||
if (data?.image) {
|
|
||||||
await parseImage(data.image, setImage);
|
|
||||||
|
|
||||||
// const link: HTMLLinkElement =
|
|
||||||
// document.querySelector("link[rel~='icon']") || document.createElement("link");
|
|
||||||
// link.rel = "icon";
|
|
||||||
// console.log('image for cover', image);
|
|
||||||
// link.href = image || "";
|
|
||||||
// link.type = "image/png"; // Or adjust based on actual image type
|
|
||||||
// document.head.appendChild(link);
|
|
||||||
}
|
|
||||||
|
|
||||||
setImages([]);
|
|
||||||
await Promise.all(
|
|
||||||
(data?.profile?.images || []).map(async (img: string) => {
|
|
||||||
await parseImage(img, setImages, true);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
console.log('images', data?.profile?.images);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchImage();
|
|
||||||
}, [url]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center min-h-screen py-8">
|
|
||||||
<div data-testid="spinner"
|
|
||||||
className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userData) {
|
|
||||||
return <div>
|
|
||||||
<h1 className="text-center">Profile not found</h1>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('userData', userData);
|
|
||||||
|
|
||||||
interface Tags {
|
|
||||||
profileAttribute: string;
|
|
||||||
attribute?: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagsConfig: Tags[] = [
|
|
||||||
{profileAttribute: 'desiredConnections', attribute: 'connection', title: 'Connection Type'},
|
|
||||||
{profileAttribute: 'coreValues', title: 'Values'},
|
|
||||||
{profileAttribute: 'intellectualInterests', attribute: 'interest', title: 'Interests'},
|
|
||||||
{profileAttribute: 'books', title: 'Works to Discuss'},
|
|
||||||
// {profileAttribute: 'causeAreas', attribute: 'causeArea', title: 'Cause Areas'},
|
|
||||||
]
|
|
||||||
|
|
||||||
function getTags({profileAttribute, attribute = 'value', title}: Tags) {
|
|
||||||
const values = userData?.profile?.[profileAttribute];
|
|
||||||
console.log('values', values);
|
|
||||||
return <div key={profileAttribute + '.div'}>
|
|
||||||
{values?.length > 0 && (
|
|
||||||
<div className="mt-3"><
|
|
||||||
h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> {title} </h2>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
|
||||||
{values.map((a: any) => (
|
|
||||||
<span
|
|
||||||
key={a?.[attribute]?.id}
|
|
||||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-800 dark:text-white dark:bg-gray-700 rounded-full hover:bg-gray-200 dark:hover:bg-gray-500 transition"
|
|
||||||
>
|
|
||||||
{a?.[attribute]?.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article className="max-w-3xl mx-auto shadow-lg rounded-lg overflow-hidden">
|
|
||||||
{header}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="pt-20 px-8 pb-8 flex-1">
|
|
||||||
<h1 className="text-3xl font-bold mb-2">
|
|
||||||
{userData.name}
|
|
||||||
</h1>
|
|
||||||
<div className="space-y-6 pt-4 border-t border-gray-200"></div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gradient-to-r relative float-right h-auto flex items-start pt-20 px-8 pb-8">
|
|
||||||
{
|
|
||||||
image ? (
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-32 w-32 rounded-full border-4 border-white overflow-hidden ">
|
|
||||||
<a href={image} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Image
|
|
||||||
src={image}
|
|
||||||
alt={userData.name || 'Profile picture'}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
width={200}
|
|
||||||
height={200}
|
|
||||||
// onError={(e) => {
|
|
||||||
// const target = e.target as HTMLImageElement;
|
|
||||||
// target.onerror = null;
|
|
||||||
// target.src = `https://ui-avatars.com/api/?name=${encodeURIComponent(profile.name || 'U')}&background=random`;
|
|
||||||
// }}
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) :
|
|
||||||
(
|
|
||||||
<div
|
|
||||||
className="-bottom-16 left-8 h-32 w-32 rounded-full border-4 border-white bg-gray-200 flex items-center justify-center">
|
|
||||||
<span className="text-4xl font-bold text-gray-600">
|
|
||||||
{userData.name ? userData.name.charAt(0).toUpperCase() : 'U'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pt-20 px-8 pb-8 flex-1">
|
|
||||||
<div className="space-y-6 pt-4">
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.gender && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Gender </h2>
|
|
||||||
< p
|
|
||||||
className="mt-1 capitalize"> {userData.profile.gender} </p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.birthYear && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Age </h2>
|
|
||||||
< p
|
|
||||||
className="mt-1 capitalize"> {new Date().getFullYear() - userData.profile.birthYear} </p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.location && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Location </h2>
|
|
||||||
< p
|
|
||||||
className={pStyle}> {userData.profile.location} </p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.occupation && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Occupation </h2>
|
|
||||||
< p
|
|
||||||
className={pStyle}> {userData.profile.occupation} </p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.introversion && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Social Style
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center w-full max-w-xl gap-4">
|
|
||||||
<span className={pStyle}>Introverted</span>
|
|
||||||
<div className="mt-1 flex items-center gap-4 w-32">
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-blue-600 h-2 rounded-full transition-all duration-200"
|
|
||||||
style={{width: `${100 - userData.profile.introversion}%`}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={pStyle}>Extroverted</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.personalityType && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Personality
|
|
||||||
Type </h2>
|
|
||||||
< p
|
|
||||||
className={pStyle}> {userData.profile.personalityType} </p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{/*{*/}
|
|
||||||
{/* userData?.profile?.conflictStyle && (*/}
|
|
||||||
{/* <div>*/}
|
|
||||||
{/* <h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Conflict Style </h2>*/}
|
|
||||||
{/* < p*/}
|
|
||||||
{/* className={pStyle}> {userData.profile.conflictStyle} </p>*/}
|
|
||||||
{/* </div>*/}
|
|
||||||
{/* )*/}
|
|
||||||
{/*}*/}
|
|
||||||
|
|
||||||
{tagsConfig.map((tag: any) => getTags(tag))}
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.description && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> About </h2>
|
|
||||||
<p className={pStyle} style={{whiteSpace: 'pre-line'}}>{userData.profile.description}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.contactInfo && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Contact </h2>
|
|
||||||
<p className={pStyle} style={{whiteSpace: 'pre-line'}}>{userData.profile.contactInfo}</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
userData?.profile?.promptAnswers?.length > 0 && (
|
|
||||||
<div className="mt-3"><
|
|
||||||
h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Prompt Answers </h2>
|
|
||||||
|
|
||||||
< div
|
|
||||||
className="gap-2 mt-1">
|
|
||||||
{
|
|
||||||
userData.profile.promptAnswers.map((value: any, idx: any) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="py-2"
|
|
||||||
>
|
|
||||||
<i>{value.prompt}</i><br/>{value.answer}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
images &&
|
|
||||||
<div className="mb-8">
|
|
||||||
{/*<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider"> Photos </h2>*/}
|
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
||||||
{Array.from(new Set(images)).map((img, index) => ( // Set is a hack to avoid a bug where duplicates fill in images when we navigate different pages
|
|
||||||
<div key={index}
|
|
||||||
className="relative group aspect-square rounded-lg overflow-hidden border border-gray-200 ">
|
|
||||||
<a href={img} target="_blank" rel="noopener noreferrer">
|
|
||||||
<Image
|
|
||||||
src={img}
|
|
||||||
alt={`Uploaded image ${index + 1}`}
|
|
||||||
width={150}
|
|
||||||
height={150}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
{/*<div>*/
|
|
||||||
}
|
|
||||||
{/* <h2 className="text-sm font-medium text-gray-500 uppercase tracking-wider">Creation Date</h2>*/
|
|
||||||
}
|
|
||||||
{/* <p className={pStyle}>*/
|
|
||||||
}
|
|
||||||
{/* {user.profile.createdAt}*/
|
|
||||||
}
|
|
||||||
{/* {new Date(user.profile.createdAt).toLocaleDateString("en-US", {*/
|
|
||||||
}
|
|
||||||
{/* year: "numeric",*/
|
|
||||||
}
|
|
||||||
{/* month: "long",*/
|
|
||||||
}
|
|
||||||
{/* day: "numeric",*/
|
|
||||||
}
|
|
||||||
{/* })}*/
|
|
||||||
}
|
|
||||||
{/* </p>*/
|
|
||||||
}
|
|
||||||
{/*</div>*/
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
export interface ProfileData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
image: string;
|
|
||||||
profile: {
|
|
||||||
location: string;
|
|
||||||
gender: string;
|
|
||||||
birthYear: number;
|
|
||||||
introversion: number;
|
|
||||||
occupation: string;
|
|
||||||
personalityType: string;
|
|
||||||
conflictStyle: string;
|
|
||||||
description: string;
|
|
||||||
contactInfo: string;
|
|
||||||
intellectualInterests: { interest?: { name?: string, id?: string } }[];
|
|
||||||
coreValues: { value?: { name?: string, id?: string } }[];
|
|
||||||
books: { value?: { name?: string, id?: string } }[];
|
|
||||||
causeAreas: { causeArea?: { name?: string, id?: string } }[];
|
|
||||||
desiredConnections: { connection?: { name?: string, id?: string } }[];
|
|
||||||
promptAnswers: { prompt?: string; answer?: string, id?: string }[];
|
|
||||||
images: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DropdownKey = 'interests' | 'causeAreas' | 'connections' | 'coreValues' | 'books';
|
|
||||||
export type RangeKey = 'age' | 'introversion';
|
|
||||||
|
|
||||||
// type OtherKey = 'gender' | 'searchQuery';
|
|
||||||
|
|
||||||
export interface Item {
|
|
||||||
id: DropdownKey;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import {useTheme} from 'next-themes';
|
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
import { SunIcon, MoonIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
|
||||||
const {theme, setTheme} = useTheme();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
// Fix hydration mismatch
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
if (!mounted) return null;
|
|
||||||
|
|
||||||
const isDark = theme === 'dark';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => setTheme(isDark ? 'light' : 'dark')}
|
|
||||||
className={`relative inline-flex items-center rounded-full border-4 transition-colors duration-300`}
|
|
||||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-2 w-16">
|
|
||||||
<div className={`p-1 rounded-md`}>
|
|
||||||
<SunIcon className={`h-4 w-4 text-yellow-500 ${isDark ? 'hidden' : ''}`} />
|
|
||||||
</div>
|
|
||||||
<div className={`p-1 rounded-md`}>
|
|
||||||
<MoonIcon className={`h-4 w-4 text-yellow-500 ${isDark ? '' : 'hidden'}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
function capitalizeOne(str: string) {
|
|
||||||
if (!str) return "";
|
|
||||||
return str[0].toUpperCase() + str.slice(1).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function capitalize(str: string) {
|
|
||||||
return str
|
|
||||||
.split(" ")
|
|
||||||
.map(word => capitalizeOne(word))
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import 'server-only';
|
|
||||||
|
|
||||||
import type {NextAuthOptions} from "next-auth";
|
|
||||||
import {getServerSession} from "next-auth";
|
|
||||||
import {PrismaAdapter} from "@auth/prisma-adapter";
|
|
||||||
import {prisma} from "@/lib/server/prisma";
|
|
||||||
import GoogleProvider from "next-auth/providers/google";
|
|
||||||
import CredentialsProvider from "next-auth/providers/credentials";
|
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
|
|
||||||
export const authOptions: NextAuthOptions = {
|
|
||||||
adapter: PrismaAdapter(prisma),
|
|
||||||
session: {
|
|
||||||
strategy: "jwt",
|
|
||||||
},
|
|
||||||
providers: [
|
|
||||||
GoogleProvider({
|
|
||||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
||||||
}),
|
|
||||||
CredentialsProvider({
|
|
||||||
name: "credentials",
|
|
||||||
credentials: {
|
|
||||||
email: {label: "Email", type: "email"},
|
|
||||||
password: {label: "Password", type: "password"},
|
|
||||||
},
|
|
||||||
async authorize(credentials) {
|
|
||||||
if (!credentials?.email || !credentials?.password) {
|
|
||||||
throw new Error("Email and password are required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {email: credentials.email},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !user.password) {
|
|
||||||
throw new Error("Invalid email or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCorrectPassword = await bcrypt.compare(
|
|
||||||
credentials.password,
|
|
||||||
user.password
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isCorrectPassword) {
|
|
||||||
throw new Error("Invalid email or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
image: user.image,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
pages: {
|
|
||||||
signIn: "/login",
|
|
||||||
error: "/login",
|
|
||||||
},
|
|
||||||
callbacks: {
|
|
||||||
async jwt({token, user}) {
|
|
||||||
if (user) {
|
|
||||||
token.id = user.id;
|
|
||||||
token.email = user.email;
|
|
||||||
token.name = user.name;
|
|
||||||
token.picture = user.image;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
async session({session, token}) {
|
|
||||||
if (token && session.user) {
|
|
||||||
session.user.id = token.id as string;
|
|
||||||
session.user.name = token.name as string;
|
|
||||||
session.user.email = token.email as string;
|
|
||||||
session.user.image = token.picture as string;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
async redirect({url, baseUrl}) {
|
|
||||||
if (url.startsWith("/")) return `${baseUrl}${url}`;
|
|
||||||
else if (new URL(url).origin === baseUrl) return url;
|
|
||||||
return baseUrl;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
secret: process.env.NEXTAUTH_SECRET,
|
|
||||||
debug: process.env.NODE_ENV === "development",
|
|
||||||
} satisfies NextAuthOptions;
|
|
||||||
|
|
||||||
export const getSession = () => getServerSession(authOptions);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
"use server";
|
|
||||||
import 'server-only';
|
|
||||||
|
|
||||||
import {prisma} from "@/lib/server/prisma";
|
|
||||||
|
|
||||||
|
|
||||||
export async function checkUserTableExists(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await prisma.user.findFirst();
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
// If there's an error, the table likely doesn't exist
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function retrieveUser(id: string) {
|
|
||||||
const cacheStrategy = {swr: 60, ttl: 60, tags: ["profiles_id"]};
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: {id},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
// email: true,
|
|
||||||
image: true,
|
|
||||||
createdAt: true,
|
|
||||||
profile: {
|
|
||||||
include: {
|
|
||||||
intellectualInterests: {include: {interest: true}},
|
|
||||||
causeAreas: {include: {causeArea: true}},
|
|
||||||
coreValues: {include: {value: true}},
|
|
||||||
books: {include: {value: true}},
|
|
||||||
desiredConnections: {include: {connection: true}},
|
|
||||||
promptAnswers: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// cacheStrategy: cacheStrategy, TODO
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log("Fetched user profile:", user);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import 'server-only';
|
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { withAccelerate } from '@prisma/extension-accelerate'
|
|
||||||
|
|
||||||
// Create a typed extended client first
|
|
||||||
const prismaClient = new PrismaClient({ log: ['query'] }).$extends(withAccelerate())
|
|
||||||
|
|
||||||
// Use `typeof` to capture the correct extended type
|
|
||||||
type AcceleratedPrismaClient = typeof prismaClient
|
|
||||||
|
|
||||||
const globalForPrisma = global as unknown as { prisma: AcceleratedPrismaClient | undefined }
|
|
||||||
|
|
||||||
export const prisma = globalForPrisma.prisma || prismaClient;
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import 'server-only';
|
|
||||||
|
|
||||||
// import { createClient } from '@supabase/supabase-js';
|
|
||||||
//
|
|
||||||
// export const supabase = createClient(
|
|
||||||
// process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
// process.env.SUPABASE_KEY!
|
|
||||||
// );
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import {NextResponse} from 'next/server';
|
|
||||||
import type {NextRequest} from 'next/server';
|
|
||||||
import {Ratelimit} from '@upstash/ratelimit';
|
|
||||||
import {Redis} from '@upstash/redis';
|
|
||||||
|
|
||||||
// Initialize Redis connection
|
|
||||||
const redis = Redis.fromEnv();
|
|
||||||
|
|
||||||
// Create a rate limiter that allows 5 requests per 60 seconds
|
|
||||||
const rateLimit = new Ratelimit({
|
|
||||||
redis,
|
|
||||||
limiter: Ratelimit.fixedWindow(100, '60 s'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Define which routes to apply rate limiting to
|
|
||||||
const RATE_LIMITED_PATHS = [
|
|
||||||
'/api/', // All API routes
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
|
||||||
const path = request.nextUrl.pathname;
|
|
||||||
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'anonymous';
|
|
||||||
|
|
||||||
// console.log('middleware', path, ip)
|
|
||||||
|
|
||||||
|
|
||||||
// Only apply rate limiting to specified paths
|
|
||||||
if (RATE_LIMITED_PATHS.some(prefix => path.startsWith(prefix))) {
|
|
||||||
const {success, limit, remaining, reset} = await rateLimit.limit(ip);
|
|
||||||
if (!success) {
|
|
||||||
return new NextResponse(
|
|
||||||
JSON.stringify({message: 'Too many requests. Try again in 60 seconds.'}),
|
|
||||||
{
|
|
||||||
status: 429,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-RateLimit-Limit': limit.toString(),
|
|
||||||
'X-RateLimit-Remaining': remaining.toString(),
|
|
||||||
'X-RateLimit-Reset': reset.toString(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure which routes to run the middleware on
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
/*
|
|
||||||
* Match all request paths except for the ones starting with:
|
|
||||||
* - _next/static (static files)
|
|
||||||
* - _next/image (image optimization files)
|
|
||||||
* - favicon.ico (favicon file)
|
|
||||||
* - public folder
|
|
||||||
*/
|
|
||||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|css|js|woff|woff2|ttf|eot)$).*)',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import type { NextConfig } from "next";
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
|
||||||
images: {
|
|
||||||
remotePatterns: [
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'bayesbond.s3.eu-north-1.amazonaws.com',
|
|
||||||
pathname: '/**', // allow all paths
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: 'https',
|
|
||||||
hostname: 'lh3.googleusercontent.com',
|
|
||||||
pathname: '/**', // allow all paths
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "Compass",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev --turbopack",
|
|
||||||
"postinstall": "npx prisma generate --no-engine",
|
|
||||||
"build": "npx prisma migrate deploy && next build",
|
|
||||||
"start": "next start",
|
|
||||||
"lint": "next lint",
|
|
||||||
"test": "jest",
|
|
||||||
"test:watch": "jest --watch",
|
|
||||||
"test:coverage": "jest --coverage",
|
|
||||||
"test:update": "jest --updateSnapshot"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@auth/prisma-adapter": "^2.10.0",
|
|
||||||
"@aws-sdk/client-s3": "^3.855.0",
|
|
||||||
"@aws-sdk/s3-request-presigner": "^3.855.0",
|
|
||||||
"@emotion/react": "^11.14.0",
|
|
||||||
"@emotion/styled": "^11.14.1",
|
|
||||||
"@heroicons/react": "^2.2.0",
|
|
||||||
"@mui/material": "^7.2.0",
|
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
|
||||||
"@playwright/test": "^1.54.2",
|
|
||||||
"@prisma/client": "^6.12.0",
|
|
||||||
"@prisma/extension-accelerate": "^2.0.2",
|
|
||||||
"@radix-ui/react-select": "^2.2.5",
|
|
||||||
"@react-email/render": "^1.1.3",
|
|
||||||
"@supabase/supabase-js": "^2.53.0",
|
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@upstash/ratelimit": "^2.0.6",
|
|
||||||
"@upstash/redis": "^1.35.3",
|
|
||||||
"bcryptjs": "^3.0.2",
|
|
||||||
"browser-image-compression": "^2.0.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"express-rate-limit": "^8.0.1",
|
|
||||||
"heroicons": "^2.2.0",
|
|
||||||
"jsonwebtoken": "^9.0.2",
|
|
||||||
"lucide-react": "^0.503.0",
|
|
||||||
"next": "^15.4.4",
|
|
||||||
"next-auth": "^4.24.11",
|
|
||||||
"next-rate-limit": "^0.0.3",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.0.0",
|
|
||||||
"react-hook-form": "^7.62.0",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"resend": "^4.7.0",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
|
||||||
"use-debounce": "^10.0.5",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"wait-on": "^8.0.4"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/eslintrc": "^3",
|
|
||||||
"@testing-library/jest-dom": "^6.6.4",
|
|
||||||
"@testing-library/react": "^16.3.0",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@types/jest": "^30.0.0",
|
|
||||||
"@types/node": "^20",
|
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "15.1.7",
|
|
||||||
"jest": "^30.0.5",
|
|
||||||
"jest-environment-jsdom": "^30.0.5",
|
|
||||||
"postcss": "^8",
|
|
||||||
"prisma": "^6.13.0",
|
|
||||||
"tailwindcss": "^3.4.1",
|
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^5"
|
|
||||||
},
|
|
||||||
"prisma": {
|
|
||||||
"seed": "npx tsx prisma/seed.ts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import 'dotenv/config'; // <-- This loads your .env file before Prisma runs
|
|
||||||
import type {PrismaConfig} from "prisma";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
schema: path.join("prisma", "schema.prisma"),
|
|
||||||
migrations: {
|
|
||||||
path: path.join("prisma", "migrations"),
|
|
||||||
},
|
|
||||||
// views: {
|
|
||||||
// path: path.join("prisma", "views"),
|
|
||||||
// },
|
|
||||||
// typedSql: {
|
|
||||||
// path: path.join("prisma", "queries"),
|
|
||||||
// }
|
|
||||||
} satisfies PrismaConfig;
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
-- CreateSchema
|
|
||||||
CREATE SCHEMA IF NOT EXISTS "public";
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "public"."Gender" AS ENUM ('Male', 'Female', 'Other');
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "public"."PersonalityType" AS ENUM ('Introvert', 'Extrovert', 'Ambivert');
|
|
||||||
|
|
||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "public"."ConflictStyle" AS ENUM ('Competing', 'Avoidant', 'Compromising', 'Accommodating', 'Collaborating');
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."User" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT,
|
|
||||||
"email" TEXT,
|
|
||||||
"password" TEXT,
|
|
||||||
"emailVerified" TIMESTAMP(3),
|
|
||||||
"verificationToken" TEXT,
|
|
||||||
"verificationTokenExpires" TIMESTAMP(3),
|
|
||||||
"image" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."Profile" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"location" TEXT,
|
|
||||||
"description" TEXT,
|
|
||||||
"contactInfo" TEXT,
|
|
||||||
"birthYear" INTEGER,
|
|
||||||
"occupation" TEXT,
|
|
||||||
"gender" "public"."Gender",
|
|
||||||
"personalityType" "public"."PersonalityType",
|
|
||||||
"introversion" INTEGER,
|
|
||||||
"conflictStyle" "public"."ConflictStyle",
|
|
||||||
"images" TEXT[],
|
|
||||||
|
|
||||||
CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."Connection" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Connection_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."Interest" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Interest_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."Value" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Value_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."CauseArea" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "CauseArea_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."ProfileConnection" (
|
|
||||||
"profileId" TEXT NOT NULL,
|
|
||||||
"connectionId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ProfileConnection_pkey" PRIMARY KEY ("profileId","connectionId")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."ProfileInterest" (
|
|
||||||
"profileId" TEXT NOT NULL,
|
|
||||||
"interestId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ProfileInterest_pkey" PRIMARY KEY ("profileId","interestId")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."ProfileValue" (
|
|
||||||
"profileId" TEXT NOT NULL,
|
|
||||||
"valueId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ProfileValue_pkey" PRIMARY KEY ("profileId","valueId")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."ProfileCauseArea" (
|
|
||||||
"profileId" TEXT NOT NULL,
|
|
||||||
"causeAreaId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ProfileCauseArea_pkey" PRIMARY KEY ("profileId","causeAreaId")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."PromptAnswer" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"profileId" TEXT NOT NULL,
|
|
||||||
"prompt" TEXT NOT NULL,
|
|
||||||
"answer" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "PromptAnswer_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."Account" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"type" TEXT NOT NULL,
|
|
||||||
"provider" TEXT NOT NULL,
|
|
||||||
"providerAccountId" TEXT NOT NULL,
|
|
||||||
"refresh_token" TEXT,
|
|
||||||
"access_token" TEXT,
|
|
||||||
"expires_at" INTEGER,
|
|
||||||
"token_type" TEXT,
|
|
||||||
"scope" TEXT,
|
|
||||||
"id_token" TEXT,
|
|
||||||
"session_state" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."Session" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"sessionToken" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"expires" TIMESTAMP(3) NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."VerificationToken" (
|
|
||||||
"identifier" TEXT NOT NULL,
|
|
||||||
"token" TEXT NOT NULL,
|
|
||||||
"expires" TIMESTAMP(3) NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."Authenticator" (
|
|
||||||
"credentialID" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
"providerAccountId" TEXT NOT NULL,
|
|
||||||
"credentialPublicKey" TEXT NOT NULL,
|
|
||||||
"counter" INTEGER NOT NULL,
|
|
||||||
"credentialDeviceType" TEXT NOT NULL,
|
|
||||||
"credentialBackedUp" BOOLEAN NOT NULL,
|
|
||||||
"transports" TEXT,
|
|
||||||
|
|
||||||
CONSTRAINT "Authenticator_pkey" PRIMARY KEY ("userId","credentialID")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "public"."User"("email");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_verificationToken_key" ON "public"."User"("verificationToken");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Profile_userId_key" ON "public"."Profile"("userId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Connection_name_key" ON "public"."Connection"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Interest_name_key" ON "public"."Interest"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Value_name_key" ON "public"."Value"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "CauseArea_name_key" ON "public"."CauseArea"("name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "public"."Account"("provider", "providerAccountId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "public"."Session"("sessionToken");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "public"."VerificationToken"("identifier", "token");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Authenticator_credentialID_key" ON "public"."Authenticator"("credentialID");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."Profile" ADD CONSTRAINT "Profile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileConnection" ADD CONSTRAINT "ProfileConnection_connectionId_fkey" FOREIGN KEY ("connectionId") REFERENCES "public"."Connection"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileConnection" ADD CONSTRAINT "ProfileConnection_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileInterest" ADD CONSTRAINT "ProfileInterest_interestId_fkey" FOREIGN KEY ("interestId") REFERENCES "public"."Interest"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileInterest" ADD CONSTRAINT "ProfileInterest_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileValue" ADD CONSTRAINT "ProfileValue_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileValue" ADD CONSTRAINT "ProfileValue_valueId_fkey" FOREIGN KEY ("valueId") REFERENCES "public"."Value"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileCauseArea" ADD CONSTRAINT "ProfileCauseArea_causeAreaId_fkey" FOREIGN KEY ("causeAreaId") REFERENCES "public"."CauseArea"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileCauseArea" ADD CONSTRAINT "ProfileCauseArea_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."PromptAnswer" ADD CONSTRAINT "PromptAnswer_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."Authenticator" ADD CONSTRAINT "Authenticator_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- The values [Male,Female] on the enum `Gender` will be removed. If these variants are still used in the database, this will fail.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterEnum
|
|
||||||
BEGIN;
|
|
||||||
CREATE TYPE "public"."Gender_new" AS ENUM ('Man', 'Woman', 'Other');
|
|
||||||
ALTER TABLE "public"."Profile" ALTER COLUMN "gender" TYPE "public"."Gender_new" USING ("gender"::text::"public"."Gender_new");
|
|
||||||
ALTER TYPE "public"."Gender" RENAME TO "Gender_old";
|
|
||||||
ALTER TYPE "public"."Gender_new" RENAME TO "Gender";
|
|
||||||
DROP TYPE "public"."Gender_old";
|
|
||||||
COMMIT;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."Book" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Book_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "public"."ProfileBook" (
|
|
||||||
"profileId" TEXT NOT NULL,
|
|
||||||
"valueId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "ProfileBook_pkey" PRIMARY KEY ("profileId","valueId")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Book_name_key" ON "public"."Book"("name");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileBook" ADD CONSTRAINT "ProfileBook_valueId_fkey" FOREIGN KEY ("valueId") REFERENCES "public"."Book"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "public"."ProfileBook" ADD CONSTRAINT "ProfileBook_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
generator client {
|
|
||||||
provider = "prisma-client-js"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
url = env("DATABASE_URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
model User {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String?
|
|
||||||
email String? @unique
|
|
||||||
password String?
|
|
||||||
emailVerified DateTime?
|
|
||||||
verificationToken String? @unique
|
|
||||||
verificationTokenExpires DateTime?
|
|
||||||
image String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
accounts Account[]
|
|
||||||
Authenticator Authenticator[]
|
|
||||||
profile Profile?
|
|
||||||
sessions Session[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Profile {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String @unique
|
|
||||||
location String?
|
|
||||||
description String?
|
|
||||||
contactInfo String?
|
|
||||||
birthYear Int?
|
|
||||||
occupation String?
|
|
||||||
gender Gender?
|
|
||||||
personalityType PersonalityType?
|
|
||||||
introversion Int?
|
|
||||||
conflictStyle ConflictStyle?
|
|
||||||
images String[]
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
causeAreas ProfileCauseArea[]
|
|
||||||
desiredConnections ProfileConnection[]
|
|
||||||
intellectualInterests ProfileInterest[]
|
|
||||||
coreValues ProfileValue[]
|
|
||||||
books ProfileBook[]
|
|
||||||
promptAnswers PromptAnswer[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Connection {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique
|
|
||||||
users ProfileConnection[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Interest {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique
|
|
||||||
users ProfileInterest[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Value {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique
|
|
||||||
users ProfileValue[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model Book {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique
|
|
||||||
users ProfileBook[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model CauseArea {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
name String @unique
|
|
||||||
users ProfileCauseArea[]
|
|
||||||
}
|
|
||||||
|
|
||||||
model ProfileConnection {
|
|
||||||
profileId String
|
|
||||||
connectionId String
|
|
||||||
connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
|
|
||||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([profileId, connectionId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ProfileInterest {
|
|
||||||
profileId String
|
|
||||||
interestId String
|
|
||||||
interest Interest @relation(fields: [interestId], references: [id], onDelete: Cascade)
|
|
||||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([profileId, interestId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ProfileValue {
|
|
||||||
profileId String
|
|
||||||
valueId String
|
|
||||||
value Value @relation(fields: [valueId], references: [id], onDelete: Cascade)
|
|
||||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([profileId, valueId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ProfileBook {
|
|
||||||
profileId String
|
|
||||||
valueId String
|
|
||||||
value Book @relation(fields: [valueId], references: [id], onDelete: Cascade)
|
|
||||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([profileId, valueId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ProfileCauseArea {
|
|
||||||
profileId String
|
|
||||||
causeAreaId String
|
|
||||||
causeArea CauseArea @relation(fields: [causeAreaId], references: [id], onDelete: Cascade)
|
|
||||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([profileId, causeAreaId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model PromptAnswer {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
profileId String
|
|
||||||
prompt String
|
|
||||||
answer String
|
|
||||||
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model Account {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
userId String
|
|
||||||
type String
|
|
||||||
provider String
|
|
||||||
providerAccountId String
|
|
||||||
refresh_token String?
|
|
||||||
access_token String?
|
|
||||||
expires_at Int?
|
|
||||||
token_type String?
|
|
||||||
scope String?
|
|
||||||
id_token String?
|
|
||||||
session_state String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([provider, providerAccountId])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Session {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
sessionToken String @unique
|
|
||||||
userId String
|
|
||||||
expires DateTime
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
}
|
|
||||||
|
|
||||||
model VerificationToken {
|
|
||||||
identifier String
|
|
||||||
token String
|
|
||||||
expires DateTime
|
|
||||||
|
|
||||||
@@unique([identifier, token])
|
|
||||||
}
|
|
||||||
|
|
||||||
model Authenticator {
|
|
||||||
credentialID String @unique
|
|
||||||
userId String
|
|
||||||
providerAccountId String
|
|
||||||
credentialPublicKey String
|
|
||||||
counter Int
|
|
||||||
credentialDeviceType String
|
|
||||||
credentialBackedUp Boolean
|
|
||||||
transports String?
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@id([userId, credentialID])
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Gender {
|
|
||||||
Man
|
|
||||||
Woman
|
|
||||||
Other
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PersonalityType {
|
|
||||||
Introvert
|
|
||||||
Extrovert
|
|
||||||
Ambivert
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ConflictStyle {
|
|
||||||
Competing
|
|
||||||
Avoidant
|
|
||||||
Compromising
|
|
||||||
Accommodating
|
|
||||||
Collaborating
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user