555 Commits
1.4.0 ... 1.8.0

Author SHA1 Message Date
MartinBraquet
d699ceae38 Release 2025-12-13 10:55:28 +02:00
MartinBraquet
762dd93042 Merge remote-tracking branch 'origin/main' 2025-12-12 10:50:39 +02:00
MartinBraquet
1f5ed87363 Add issue and PR template 2025-12-12 10:50:21 +02:00
Okechi Jones-Williams
28ce878b34 Add API unit tests for create-* endpoints (#23)
* setting up test structure

* .

* added playwright config file, deleted original playwright folder and moved "some.test" file

* continued test structure setup

* Updating test folder structure

* Added database seeding script and backend testing folder structure

* removed the database test

* Replaced db seeding script

* Updated userInformation.ts to use values from choices.tsx

* merge prep

* removing extra unit test, moving api test to correct folder

* Pushing to get help with sql Unit test

* Updating get-profiles unit tests

* Added more unit tests

* .

* Added more unit tests

* Added getSupabaseToken unit test

* .

* excluding supabase token test so ci can pass

* .

* Seperated the seedDatabase func into its own file so it can be accessed seperatly

* Fixed failing test

* .

* .

* Fix tests

* Fix lint

* Clean

* Fixed module paths in compute-score unit test

* Updated root tsconfig to recognise backend/shared

* Added create comment unit test

* Added some unit tests

* Working on createProfile return issue

* .

* Fixes

---------

Co-authored-by: MartinBraquet <martin.braquet@gmail.com>
2025-12-12 10:34:07 +02:00
MartinBraquet
be2e19db8d Cover cleanDocs with tests 2025-12-11 20:42:39 +01:00
MartinBraquet
785a633115 Fix: trim hardbreak only at start and end 2025-12-11 20:15:53 +01:00
MartinBraquet
57beefb894 Fix query collision on i variable 2025-12-09 19:08:16 +01:00
MartinBraquet
787d35e057 Merge remote-tracking branch 'origin/main' 2025-12-08 12:16:11 +01:00
MartinBraquet
a5b1d1abb0 Improve RelationshipStatus icon 2025-12-08 12:16:04 +01:00
Martin Braquet
daa517a11d Update dev-rules.mdc to include project structure 2025-12-07 12:33:25 +01:00
Nicholas Chamberlain
ef7665c7da Add firebase emulator, Add registration script, Add signup spec (#22)
* add firebase emulator, add registration script, add signup spec

* Upgrade firebase emulator and make it pass the E2E tests

---------

Co-authored-by: MartinBraquet <martin.braquet@gmail.com>
2025-12-06 23:43:46 +01:00
MartinBraquet
348a557f5c Clean 2025-12-05 16:53:33 +01:00
MartinBraquet
26b36ab1c8 Test 2025-12-05 16:47:14 +01:00
MartinBraquet
94faa30882 Fix 2025-12-05 00:53:53 +01:00
MartinBraquet
c579b3ac15 Upgrade 2025-12-05 00:53:18 +01:00
MartinBraquet
82bf346ce5 Improve readme 2025-12-05 00:50:03 +01:00
MartinBraquet
67ed9d0d8e Restore 2025-12-05 00:46:34 +01:00
MartinBraquet
c0b241e70a Test 2025-12-05 00:35:54 +01:00
MartinBraquet
808acd8289 Todo done 2025-12-04 21:52:09 +01:00
MartinBraquet
3d39fe994d Add 2025-12-04 21:45:53 +01:00
MartinBraquet
569db46a8b Remove add-iam-policy-binding to roles/artifactregistry.reader post API deploy
May not be needed
2025-12-04 21:44:55 +01:00
MartinBraquet
9493ee65cf Fix 2025-12-04 21:20:01 +01:00
MartinBraquet
ce962f60ff Fix 2025-12-04 21:19:06 +01:00
MartinBraquet
922232e943 Fix 2025-12-04 21:17:47 +01:00
MartinBraquet
ca587ce962 Fix 2025-12-04 21:17:01 +01:00
MartinBraquet
755be6e0e0 Fix 2025-12-04 21:16:11 +01:00
MartinBraquet
4a64e5f0a0 Fix 2025-12-04 21:12:12 +01:00
MartinBraquet
c458b42821 Fix 2025-12-04 21:09:00 +01:00
MartinBraquet
dc298c4f46 Fix 2025-12-04 21:04:01 +01:00
MartinBraquet
91f69ed928 Fix 2025-12-04 20:51:09 +01:00
MartinBraquet
64c04e2d23 Fix 2025-12-04 20:51:02 +01:00
MartinBraquet
ae8077d700 Increment API version 2025-12-04 20:46:47 +01:00
MartinBraquet
50d8e6388e Add auto API deploy 2025-12-04 20:45:54 +01:00
MartinBraquet
af334c7142 Undo 2025-12-04 20:39:20 +01:00
MartinBraquet
f2da0bd58a Undo 2025-12-04 20:39:12 +01:00
MartinBraquet
cfdea26599 git commit -m "Stop tracking metadata.json" 2025-12-04 20:38:14 +01:00
MartinBraquet
a4a6abec72 Fix 2025-12-04 20:36:32 +01:00
MartinBraquet
d8dfd6e093 Stop tracking metadata.json 2025-12-04 20:34:52 +01:00
MartinBraquet
5175b2ca15 Add git info to backend API 2025-12-04 20:31:45 +01:00
MartinBraquet
f734772670 Add 2025-12-04 20:18:25 +01:00
MartinBraquet
5eee62f731 Fix 2025-12-04 19:58:39 +01:00
MartinBraquet
4f8d76f797 Add description below profile images 2025-12-04 19:51:04 +01:00
MartinBraquet
bf4acc09fb Clean todo 2025-12-03 23:55:03 +01:00
MartinBraquet
2a3a39d6f7 Move to config folder 2025-12-03 23:51:41 +01:00
MartinBraquet
92b1ffd61c Remove old folder 2025-12-03 23:44:42 +01:00
MartinBraquet
c4c9316386 Fix tests 2025-12-03 23:32:52 +01:00
MartinBraquet
0509ff8fac Add new badge 2025-12-03 23:23:53 +01:00
MartinBraquet
1dc85e25de Fix 2025-12-03 17:00:48 +01:00
MartinBraquet
fb695bbed1 Fix 2025-12-03 16:59:25 +01:00
MartinBraquet
3b0465c65c Add profile fields: work areas, moral causes, and interests 2025-12-03 16:56:02 +01:00
MartinBraquet
43238ecc44 Clean comment 2025-12-01 19:19:01 +01:00
MartinBraquet
821d280f5c Refresh profile pics and make them square 2025-12-01 16:09:35 +01:00
MartinBraquet
1da487a972 Fix 2025-11-30 16:21:53 +01:00
MartinBraquet
61c53301bf Load compat questions progressively to avoid long page load 2025-11-30 13:15:29 +01:00
MartinBraquet
61613af1b7 Hide logs 2025-11-30 13:13:53 +01:00
Okechi Jones-Williams
ab612a3eca Add backend API unit tests (#21)
* setting up test structure

* .

* added playwright config file, deleted original playwright folder and moved "some.test" file

* continued test structure setup

* Updating test folder structure

* Added database seeding script and backend testing folder structure

* removed the database test

* Replaced db seeding script

* Updated userInformation.ts to use values from choices.tsx

* merge prep

* removing extra unit test, moving api test to correct folder

* Pushing to get help with sql Unit test

* Updating get-profiles unit tests

* Added more unit tests

* .

* Added more unit tests

* Added getSupabaseToken unit test

* .

* excluding supabase token test so ci can pass

* .

* Seperated the seedDatabase func into its own file so it can be accessed seperatly

* Fixed failing test

* .

* .

* Fix tests

* Fix lint

* Clean

---------

Co-authored-by: MartinBraquet <martin.braquet@gmail.com>
2025-11-30 00:03:16 +01:00
MartinBraquet
f323034eed Clean 2025-11-26 23:11:55 +01:00
MartinBraquet
aa35fa3b2b Pre compute compatibility scores for faster profile lookup 2025-11-26 22:49:33 +01:00
MartinBraquet
f97d24402e Update todo 2025-11-26 16:33:10 +01:00
MartinBraquet
036776bde8 Add search results count in profiles grid 2025-11-26 16:15:39 +01:00
MartinBraquet
63e48d99ca Do not show compat score if one hasn't answered any question 2025-11-26 15:19:28 +01:00
MartinBraquet
4dd6f54b37 Add compat questions if none answered 2025-11-25 20:44:05 +01:00
MartinBraquet
a4e02031c6 Add prompts category 2025-11-25 15:03:34 +01:00
MartinBraquet
95bdc37411 Clean 2025-11-25 14:34:06 +01:00
MartinBraquet
c90b617ad0 Fix typo 2025-11-23 17:09:18 +01:00
MartinBraquet
aecdaa2875 Clean home 2025-11-23 16:09:30 +01:00
MartinBraquet
7e924c2741 Release 2025-11-23 14:22:54 +01:00
MartinBraquet
241b851c02 Fix 2025-11-20 21:13:38 +01:00
MartinBraquet
2ba9949035 Add get it on google play 2025-11-20 21:09:42 +01:00
MartinBraquet
3d4b76ffc3 Fix message fetching after sending 2025-11-19 21:04:08 +01:00
Nicholas Chamberlain
f7fb0c6c82 Test/e2e login auth (#19)
* Add basic test for login and auth state

* Remove test file

* Change login setup

* Apply suggestions from code review

Co-authored-by: Martin Braquet <martin.braquet@gmail.com>

* Change signin structure to use UI

* Fix URL loading

* Spin up backend server as well for E2E

---------

Co-authored-by: Martin Braquet <martin.braquet@gmail.com>
2025-11-18 23:17:00 +01:00
MartinBraquet
e5fc734b90 Fix question refresh too quick 2025-11-15 16:32:55 +01:00
MartinBraquet
10fa659e52 Add favicon.svg 2025-11-15 16:18:27 +01:00
MartinBraquet
0ac315b017 Merge remote-tracking branch 'origin/main' 2025-11-15 16:12:36 +01:00
MartinBraquet
bbfbd2daae Remove 2025-11-15 16:04:16 +01:00
MartinBraquet
cd2c4d3314 Load favicon from local webview assets 2025-11-15 15:55:01 +01:00
MartinBraquet
37ee7752c2 Fix build-web-view 2025-11-15 15:53:07 +01:00
Okechi Jones-Williams
6b11e6b060 Add minor changes to tests (#20)
* setting up test structure

* .

* added playwright config file, deleted original playwright folder and moved "some.test" file

* continued test structure setup

* Updating test folder structure

* Added database seeding script and backend testing folder structure

* removed the database test

* Replaced db seeding script

* Updated userInformation.ts to use values from choices.tsx

* merge prep

* removing extra unit test, moving api test to correct folder
2025-11-15 15:35:45 +01:00
MartinBraquet
f650ab7394 Clean 2025-11-15 14:20:14 +01:00
MartinBraquet
fead7459d4 Clean 2025-11-15 14:12:06 +01:00
MartinBraquet
bbf3970121 Update todo 2025-11-15 14:06:15 +01:00
MartinBraquet
26fb810840 Add docs links 2025-11-15 14:01:45 +01:00
MartinBraquet
af9074af6e Add users link to stats 2025-11-15 13:53:35 +01:00
MartinBraquet
4229c2a4fa Merge remote-tracking branch 'origin/main' 2025-11-15 13:52:14 +01:00
MartinBraquet
fd58602e6d Add "core" 2025-11-15 13:52:05 +01:00
MartinBraquet
af26397ad7 Rounded dropdown 2025-11-15 13:51:58 +01:00
MartinBraquet
859d01594a Allow for skipping unanswered questions in /compatibility 2025-11-15 13:51:48 +01:00
MartinBraquet
09a37058e6 Fix tabs index caching 2025-11-15 13:22:48 +01:00
MartinBraquet
edc7366b1d Improve docs 2025-11-15 13:05:26 +01:00
MartinBraquet
7306cb335b Add chalk 2025-11-15 12:24:06 +01:00
Martin Braquet
1e13cc4294 Update README.md 2025-11-15 10:16:44 +01:00
Martin Braquet
a88e5a9ec8 Update README.md 2025-11-15 09:20:46 +01:00
MartinBraquet
09d743c603 Fix 2025-11-15 01:04:41 +01:00
MartinBraquet
36c1ec528a Include all ts/tsx files in coverage 2025-11-15 00:52:37 +01:00
MartinBraquet
aec9600036 Clean 2025-11-15 00:48:37 +01:00
MartinBraquet
6e1306bdd6 Merge coverage files 2025-11-15 00:41:48 +01:00
MartinBraquet
37f5c95716 Fix coverage 2025-11-15 00:33:25 +01:00
MartinBraquet
0d48c541a0 Add tests runs to all packages 2025-11-15 00:27:30 +01:00
MartinBraquet
8928cd1667 Update test structure 2025-11-15 00:14:12 +01:00
MartinBraquet
780f935fea Fix webview live update 2025-11-14 18:53:25 +01:00
MartinBraquet
5bf095178d Add live updates for the webview app 2025-11-14 18:12:29 +01:00
MartinBraquet
e135293b43 Lowercase new 2025-11-14 18:12:05 +01:00
MartinBraquet
be7e009909 Fix 2025-11-14 16:03:01 +01:00
MartinBraquet
ada8a713c1 Fix 2025-11-14 16:01:21 +01:00
MartinBraquet
eb7391dae0 Skip for now 2025-11-14 15:24:19 +01:00
MartinBraquet
8bdbd5e4fe Add code coverage 2025-11-14 15:23:56 +01:00
MartinBraquet
137d15ae71 Clean 2025-11-14 14:37:01 +01:00
MartinBraquet
8a2ed6f8ff Improve docs 2025-11-14 14:32:30 +01:00
MartinBraquet
7766b43187 Fix 2025-11-14 14:14:52 +01:00
MartinBraquet
b9a637fdac Fix 2025-11-14 14:13:34 +01:00
MartinBraquet
3096dbc922 Add Next.js.md 2025-11-14 14:12:07 +01:00
MartinBraquet
d6b0bb4378 Ignore getServerSideProps on mobile 2025-11-14 12:26:37 +01:00
MartinBraquet
43ef43ba72 Little fixes 2025-11-13 20:38:16 +01:00
MartinBraquet
314037dd06 Add /compatibility page to browse all the questions 2025-11-13 20:10:53 +01:00
MartinBraquet
7c4d66bbf5 Fix 2025-11-13 16:27:58 +01:00
MartinBraquet
49d28961ef Improve UI for filters 2025-11-13 15:53:39 +01:00
MartinBraquet
9d649daee5 Add profile field and filter: MBTI 2025-11-13 15:12:11 +01:00
MartinBraquet
7598a47283 Add local logging 2025-11-13 14:11:50 +01:00
MartinBraquet
c9f7230d27 Fix 2025-11-13 13:54:23 +01:00
MartinBraquet
716633c6df Remove trailing empty paragraphs 2025-11-13 13:53:11 +01:00
MartinBraquet
8a215a765f Add AI assistant rules 2025-11-13 13:13:17 +01:00
MartinBraquet
e2ff41a0b1 Ignore 2025-11-13 13:12:57 +01:00
MartinBraquet
d790fae74a Add e2e script for local development 2025-11-13 13:11:53 +01:00
MartinBraquet
1a17862f45 Clean choice constants 2025-11-12 18:39:56 +01:00
MartinBraquet
acd4c36531 Add profile field and filter: relationship status 2025-11-12 18:29:28 +01:00
MartinBraquet
f623450f08 Clean 2025-11-11 22:13:07 +01:00
MartinBraquet
ce681cfb67 Add profile field and filter: languages 2025-11-11 22:02:16 +01:00
MartinBraquet
a0a6523a25 Move stats up 2025-11-11 21:07:51 +01:00
MartinBraquet
8e2fa36d0e Fix 2025-11-11 21:07:40 +01:00
MartinBraquet
c953a84c1f Clean choices 2025-11-11 21:06:56 +01:00
MartinBraquet
2b403f0761 Move log 2025-11-11 21:06:44 +01:00
Okechi Jones-Williams
f954e3b2d7 Add database seeding script and backend testing folder structure (#18)
* setting up test structure

* added playwright config file, deleted original playwright folder and moved "some.test" file

* continued test structure setup

* Updating test folder structure

* Added database seeding script and backend testing folder structure

* removed the database test

* Replaced db seeding script

* Updated userInformation.ts to use values from choices.tsx
2025-11-11 19:00:07 +01:00
MartinBraquet
24ee2a206e Back to up 2025-11-10 18:59:15 +01:00
MartinBraquet
02d165829f Move to / 2025-11-10 18:54:09 +01:00
MartinBraquet
b4d996bd14 Fix 2025-11-10 18:49:22 +01:00
MartinBraquet
60989faa03 Add TEST_DOWNTIME 2025-11-10 18:45:07 +01:00
MartinBraquet
4aeda8a1a7 Clean 2025-11-09 22:13:01 +01:00
MartinBraquet
023a20f263 Add script to add test user to db 2025-11-09 20:20:22 +01:00
MartinBraquet
62821ed803 Release 2025-11-08 20:52:41 +01:00
MartinBraquet
903d62ed57 Add error comment 2025-11-08 19:55:13 +01:00
MartinBraquet
adef626b34 Cache build ID 2025-11-08 19:46:42 +01:00
MartinBraquet
11a933cc04 Comment 2025-11-08 19:46:21 +01:00
MartinBraquet
4b5ce99bb1 Fix avatar link not working on mobile 2025-11-08 19:46:13 +01:00
MartinBraquet
f8dff77cee Do not use ID filtering anymore as loaded messages may be deleted or edited 2025-11-08 18:50:23 +01:00
MartinBraquet
0a9b08803e Add comment 2025-11-08 18:48:47 +01:00
MartinBraquet
39a8568663 Fix orderBy that must be filtered out 2025-11-08 18:12:13 +01:00
MartinBraquet
e1502440eb Increase debounce since moved back to free plan (1 / sec) 2025-11-07 23:31:18 +01:00
MartinBraquet
5834d032c3 Android release 2025-11-07 23:26:46 +01:00
MartinBraquet
0a28a2af61 Allow editing old messages 2025-11-07 23:26:38 +01:00
MartinBraquet
259f56bd26 Fix typescript / linting warnings 2025-11-07 23:17:16 +01:00
MartinBraquet
011ad66a3f Fix emoji location (2) 2025-11-07 23:13:32 +01:00
MartinBraquet
281c72f88d Merge remote-tracking branch 'origin/main' 2025-11-07 22:53:07 +01:00
Okechi Jones-Williams
1293523ebf Added further playwright structure (#17)
* setting up test structure

* .

* added playwright config file, deleted original playwright folder and moved "some.test" file

* continued test structure setup

* Updating test folder structure
2025-11-07 22:52:19 +01:00
MartinBraquet
67e5d34f39 Fix emoji location 2025-11-07 22:48:38 +01:00
MartinBraquet
8b3dec6116 Merge remote-tracking branch 'origin/main' 2025-11-07 21:52:25 +01:00
MartinBraquet
3359f49f0a Add edit, delete and emojis on messages 2025-11-07 21:51:56 +01:00
Okechi Jones-Williams
9715cae587 Setting up test file structure (#16)
* setting up test structure

* .
2025-11-07 14:28:07 +01:00
MartinBraquet
4b122bd907 Increase version 2025-11-05 20:52:19 +01:00
MartinBraquet
edefa36c8a Comments 2025-11-05 20:46:56 +01:00
MartinBraquet
e40a352aed When clicking on the DM notif, go to the DM chat 2025-11-05 20:46:41 +01:00
MartinBraquet
70267f0623 Add demo fix
Hope it works finally
2025-11-04 17:51:38 +01:00
MartinBraquet
c19ed7f6d5 Add demo
Hope it works finally
2025-11-04 17:37:49 +01:00
MartinBraquet
e2fb0f2ee8 Line clamp notif to 5 2025-11-04 13:07:57 +01:00
MartinBraquet
f9e208a7e0 Add createAndroidTestNotifications 2025-11-04 13:04:06 +01:00
MartinBraquet
ee2b8a60a2 Increase notif lines 2025-11-04 13:03:43 +01:00
MartinBraquet
5651f17e96 Increase android version 2025-11-04 12:46:17 +01:00
MartinBraquet
9679b3722a Show error 2025-11-04 01:25:19 +01:00
MartinBraquet
6cb736a10d Upgrade 2025-11-03 23:55:37 +01:00
MartinBraquet
ac5c3b421d Upgrade 2025-11-03 23:45:53 +01:00
MartinBraquet
f01fad5fa6 Improve message rendering on webview (fix 2) 2025-11-03 23:45:03 +01:00
MartinBraquet
110b727cbc Improve message rendering on webview (fix) 2025-11-03 23:13:56 +01:00
MartinBraquet
1dba2debc5 Clean 2025-11-03 20:27:32 +01:00
MartinBraquet
8fec73341f Comment 2025-11-03 18:39:56 +01:00
MartinBraquet
33c76de397 Improve bio tips 2025-11-03 17:19:37 +01:00
MartinBraquet
d409eb1126 Refactor news 2025-11-03 16:53:50 +01:00
MartinBraquet
69d53591c8 Load cached remote page data from webview 2025-11-03 16:44:41 +01:00
MartinBraquet
4cf783f07f Add proxy 2025-11-03 16:19:10 +01:00
MartinBraquet
a971c121cc Allow any origin 2025-11-03 16:04:50 +01:00
MartinBraquet
b4abfb6aa4 Simplify build-id 2025-11-03 15:53:44 +01:00
MartinBraquet
8f760081a5 Add build-id/route.ts 2025-11-03 15:25:11 +01:00
MartinBraquet
c15934f210 Render home icon if logged out 2025-11-03 15:17:14 +01:00
MartinBraquet
c7e1bb4463 People is more human than profiles 2025-11-03 12:15:18 +01:00
MartinBraquet
4ac97fc476 Make webview go back one page instead of closing the app 2025-11-02 22:26:22 +01:00
MartinBraquet
a5dda01ffd Clean 2025-11-02 22:20:49 +01:00
MartinBraquet
9db4937e54 Fix layout 2025-11-02 22:07:56 +01:00
MartinBraquet
3dc50a384e Add title when loading 2025-11-02 22:05:36 +01:00
MartinBraquet
ccc48620b8 Fix profile page not switching between users 2025-11-02 21:41:23 +01:00
MartinBraquet
943c3960e1 Comment logs 2025-11-02 21:39:17 +01:00
MartinBraquet
9a84a67555 Improve margins for mobile 2025-11-02 21:29:51 +01:00
MartinBraquet
2241e3c7d3 Log pixel top and bottom 2025-11-02 21:27:07 +01:00
MartinBraquet
03042dae96 Do not disclose exact last online time for privacy reasons 2025-11-02 21:26:17 +01:00
MartinBraquet
3df9d067d6 Clean posthog 2025-11-02 21:15:09 +01:00
MartinBraquet
c5f3e8b3c2 Clean css 2025-11-02 20:26:39 +01:00
MartinBraquet
3a3e8c0e10 Add get pixel height 2025-11-02 20:26:29 +01:00
MartinBraquet
f010bf7eed Fix 2025-11-02 13:14:20 +01:00
MartinBraquet
b22d6a77b0 Update readme 2025-11-02 13:09:14 +01:00
MartinBraquet
f91d9125e4 Ignore getStaticProps and getStaticPaths for mobile webview build 2025-11-02 13:09:00 +01:00
MartinBraquet
625eedef1e Add middleware template 2025-11-02 13:04:10 +01:00
MartinBraquet
61dd33187a Use SSR/ISR only for web app (load client side on mobile app) 2025-11-02 13:03:22 +01:00
MartinBraquet
817640c7d0 Update reserved path 2025-11-02 00:21:25 +01:00
MartinBraquet
aa15d5a6df Comment 2025-11-02 00:21:15 +01:00
MartinBraquet
60857007bd Fix 2025-11-01 22:11:50 +01:00
MartinBraquet
cfd0c5d846 versionCode 4 2025-11-01 22:05:43 +01:00
MartinBraquet
71ec88d235 Add mobile icons 2025-11-01 22:05:28 +01:00
MartinBraquet
902ea583bd Remove AD_ID 2025-11-01 21:27:36 +01:00
MartinBraquet
fb6b54fba0 Delete mobile subscriptions if not set up 2025-11-01 21:26:43 +01:00
MartinBraquet
9dfc56987c Add Child Sexual Abuse and Exploitation (CSAE) 2025-11-01 20:30:42 +01:00
MartinBraquet
b982a02717 V2 2025-11-01 18:47:55 +01:00
MartinBraquet
15a0d8ee16 Add /delete-account 2025-11-01 17:20:31 +01:00
MartinBraquet
9ca03132db Rename to com.compassconnections.com 2025-11-01 17:14:08 +01:00
MartinBraquet
f908ba3ea3 Update terms 2025-11-01 17:09:15 +01:00
MartinBraquet
79ea09bd91 Skip web service-worker.js on native mobile 2025-11-01 16:25:55 +01:00
MartinBraquet
c743a4f1fe Comment unused google auth 2025-11-01 16:22:09 +01:00
MartinBraquet
ccd1fc5c10 Fix data not in notif 2025-11-01 16:06:22 +01:00
MartinBraquet
2f82a64dbe Add isNativeMobile 2025-11-01 16:06:07 +01:00
MartinBraquet
71c6eae9c4 Fix googleNativeLogin 2025-11-01 16:05:58 +01:00
MartinBraquet
28894a08cc Add capgo-capacitor-social-login to gradle 2025-11-01 16:05:42 +01:00
MartinBraquet
41e366dcc4 Output export for webview 2025-11-01 16:05:26 +01:00
MartinBraquet
6ee11a6b2d Skip getStaticProps in webview 2025-11-01 16:05:03 +01:00
MartinBraquet
ab82c66f83 Add bridge for social 2025-11-01 16:04:36 +01:00
MartinBraquet
f94820f45e Fix favicon not showing in webview 2025-11-01 16:03:39 +01:00
MartinBraquet
fe03e1ca68 Use local assets for prod 2025-11-01 16:03:26 +01:00
MartinBraquet
44ee6951c9 Comment usused deep link 2025-11-01 16:02:52 +01:00
MartinBraquet
40194f7204 Update readme 2025-11-01 16:02:37 +01:00
MartinBraquet
40eefef9a2 Use full ico path for webview 2025-11-01 16:02:25 +01:00
MartinBraquet
d8ab44ebb5 Install @capgo/capacitor-social-login 2025-11-01 16:01:47 +01:00
MartinBraquet
f875539f2e Move to unused 2025-11-01 16:01:36 +01:00
MartinBraquet
d9c2d142cb Format 2025-11-01 16:00:11 +01:00
MartinBraquet
51d5715f04 Clean 404 2025-11-01 12:38:01 +01:00
MartinBraquet
8ad81b1d50 Add bottom margin 2025-11-01 12:37:55 +01:00
MartinBraquet
6d4083d8a7 Move hosting constants to new file 2025-11-01 11:19:36 +01:00
MartinBraquet
bb120afea2 Clean web build 2025-11-01 11:17:20 +01:00
MartinBraquet
cebcc20c26 Add email verif link in welcome email 2025-11-01 10:53:10 +01:00
MartinBraquet
0522e787cd Add Forgot Password? 2025-10-31 20:41:57 +01:00
MartinBraquet
81e01f1485 Change tag 2025-10-31 20:32:09 +01:00
MartinBraquet
9b2b93d56f Update faq 2025-10-31 20:31:40 +01:00
MartinBraquet
133b402e2b Add IS_WEBVIEW_BUILD 2025-10-31 13:36:41 +01:00
MartinBraquet
9a91eab13f Catch user not found 2025-10-31 11:52:32 +01:00
MartinBraquet
5f6722b917 Add message notif 2025-10-31 11:52:24 +01:00
MartinBraquet
fdcd4d46ac Add allowNavigation 2025-10-31 11:52:06 +01:00
MartinBraquet
27a72170b8 Url redirect 2025-10-31 11:51:58 +01:00
MartinBraquet
b3021e60ec Add sync_android.sh 2025-10-31 10:41:22 +01:00
MartinBraquet
6d8834bd87 Add little emoji 2025-10-31 10:41:12 +01:00
MartinBraquet
ab8a9d95d8 Add notif perms 2025-10-31 00:15:42 +01:00
MartinBraquet
563ee3f5df Remove unused social login 2025-10-31 00:15:34 +01:00
MartinBraquet
c3389a7fcf Uncomment 2025-10-31 00:14:57 +01:00
MartinBraquet
e824bbb533 Add webview push notif 2025-10-30 23:45:04 +01:00
MartinBraquet
7de0e351f3 Skip notif verif in client 2025-10-30 23:44:41 +01:00
MartinBraquet
2df5f55390 Add androidpush check 2025-10-30 23:31:57 +01:00
MartinBraquet
21038cc5ac Clean and refactor oauth redirect 2025-10-30 23:28:18 +01:00
MartinBraquet
d6749bcd41 Add way to access profiles without log in during dev 2025-10-30 23:18:50 +01:00
MartinBraquet
005c9ccdef Add info 2025-10-30 23:18:31 +01:00
MartinBraquet
d47cb53e59 Fix filter top touching top nav bar 2025-10-30 23:18:18 +01:00
MartinBraquet
adfb3ca4f0 Add build_sync_android.sh 2025-10-30 23:03:16 +01:00
MartinBraquet
88b7e4edda Update android readmes 2025-10-30 22:59:09 +01:00
MartinBraquet
e33b57f0fd Comment sensitive logs 2025-10-30 22:52:33 +01:00
MartinBraquet
777825b73f Better log 2025-10-30 22:50:16 +01:00
MartinBraquet
04ca9b6f9a Remove PKCE as using google client secret 2025-10-30 22:39:10 +01:00
MartinBraquet
7f3d3eeb9c Hide background in bottom nav bar 2025-10-30 22:24:47 +01:00
MartinBraquet
75ac16d43c Rename 2025-10-30 21:59:33 +01:00
MartinBraquet
5f1120c718 Fix 2025-10-30 21:55:21 +01:00
MartinBraquet
806a0694c6 Add status bar 2025-10-30 21:50:36 +01:00
MartinBraquet
c506ae3242 Fix statusbard 2025-10-30 21:48:31 +01:00
MartinBraquet
444fa529fb Fix margin 2025-10-30 21:47:17 +01:00
MartinBraquet
62ced3eb04 Add top and bottom margin for mobile 2025-10-30 21:40:36 +01:00
MartinBraquet
31718f1c4d Refactor redirect url 2025-10-30 20:37:12 +01:00
MartinBraquet
98ab8971b4 Clean 2025-10-30 19:52:55 +01:00
MartinBraquet
870c86f9af Cleanup 2025-10-30 19:39:03 +01:00
MartinBraquet
a4f6aabee9 Cleanup 2025-10-30 19:37:11 +01:00
MartinBraquet
5584ad0a10 Remove unused cst 2025-10-30 19:26:26 +01:00
MartinBraquet
6b4932b4c5 Add error handling 2025-10-30 19:26:19 +01:00
MartinBraquet
7f1cb0aaf3 Add firebase login after receiving tokens 2025-10-30 19:26:08 +01:00
MartinBraquet
d42a5a48e9 Send code verifier to backend 2025-10-30 18:44:22 +01:00
MartinBraquet
8a1b762c35 Fix readme 2025-10-30 18:44:12 +01:00
MartinBraquet
d2d1de41d2 Fix 2025-10-30 18:08:22 +01:00
MartinBraquet
d1366af2a0 Fix 2025-10-30 18:06:14 +01:00
MartinBraquet
cae4b15bbb Fix 2025-10-30 18:01:49 +01:00
MartinBraquet
e41bc64b0c Fix 2025-10-30 17:21:19 +01:00
MartinBraquet
3d03ebe487 Fix 2025-10-30 17:18:00 +01:00
MartinBraquet
1fcb431d1b Fix bridge 2025-10-30 17:15:49 +01:00
MartinBraquet
4f321490af Fix 2025-10-30 17:11:53 +01:00
MartinBraquet
04c7469e68 Fix 2025-10-30 17:06:19 +01:00
MartinBraquet
98bc0a9309 Fix 2025-10-30 17:01:21 +01:00
MartinBraquet
6e537f4cdf Fix 2025-10-30 16:48:52 +01:00
MartinBraquet
b121d61852 Fix 2025-10-30 16:24:22 +01:00
MartinBraquet
6ba3c3ffbd Fix 2025-10-30 13:53:59 +01:00
MartinBraquet
67b3efad4c Fix 2025-10-30 13:50:05 +01:00
MartinBraquet
1282e468e3 Fix 2025-10-30 13:47:12 +01:00
MartinBraquet
67b2e78a63 Fix oauthRedirect 2025-10-30 13:44:40 +01:00
MartinBraquet
213c56f945 Fix 2025-10-30 13:20:27 +01:00
MartinBraquet
ccde6e4f4b Fix 2025-10-30 12:46:25 +01:00
MartinBraquet
2c1c94d24c Update android webview 2025-10-30 12:40:20 +01:00
MartinBraquet
56edb51f36 Add bridge 2025-10-30 12:40:00 +01:00
MartinBraquet
b6ed2c7dd8 Change GOOGLE_CLIENT_ID 2025-10-30 12:25:46 +01:00
MartinBraquet
74c7c5c423 Fix GOOGLE_CLIENT_ID 2025-10-30 12:00:04 +01:00
MartinBraquet
314d774bde Fix 2025-10-30 11:51:56 +01:00
MartinBraquet
d94091ae4e Add webviewGoogleSignin 2025-10-30 11:33:23 +01:00
MartinBraquet
8bf3c4fcd7 Remove sign up button if logged in 2025-10-29 20:43:36 +01:00
MartinBraquet
4358c15432 Update TODOS 2025-10-29 20:30:56 +01:00
MartinBraquet
fdf8d649fe Link logo to /home page 2025-10-29 20:04:50 +01:00
MartinBraquet
a4e22ec4b1 Add email change 2025-10-29 20:01:42 +01:00
MartinBraquet
783bc43547 Move delete pinned photo 2025-10-29 19:23:33 +01:00
MartinBraquet
2e6aec175a Comment 2025-10-29 17:51:22 +01:00
MartinBraquet
ee41aaa112 Use WithPrivateUser 2025-10-29 17:51:12 +01:00
MartinBraquet
32e8a8b1b9 Add donation name 2025-10-29 17:30:57 +01:00
MartinBraquet
6b3def230b Add liberapay donation link 2025-10-29 17:20:47 +01:00
MartinBraquet
51c46db106 Fix 2025-10-29 17:06:39 +01:00
MartinBraquet
64c18179ac Fix 2025-10-29 16:50:06 +01:00
MartinBraquet
5ee0b39e07 Hide settings when logged out 2025-10-29 16:29:14 +01:00
MartinBraquet
6470319fd6 Add settings page 2025-10-29 16:25:03 +01:00
MartinBraquet
4ca3f3c8ee Delete auth user right away 2025-10-29 16:24:49 +01:00
MartinBraquet
07e2d2d509 Fix red color 2025-10-29 16:21:58 +01:00
MartinBraquet
07d2a143a2 Create base settings page 2025-10-29 12:24:08 +01:00
MartinBraquet
968845492f Comment unused function 2025-10-29 12:23:53 +01:00
MartinBraquet
c2106b64f9 Add webview_oauth_signin.md 2025-10-29 12:08:20 +01:00
MartinBraquet
0d73d1d258 Delete 2025-10-28 18:43:39 +01:00
MartinBraquet
423d425950 Roolback 2025-10-28 18:42:37 +01:00
MartinBraquet
7ab0093fec Sign in failing for android 2025-10-28 18:41:54 +01:00
MartinBraquet
56d2757448 Add styles 2025-10-28 18:32:21 +01:00
MartinBraquet
f5b6037367 Move log 2025-10-28 18:02:45 +01:00
MartinBraquet
2c4ce6c8d1 Add isAndroidWebView 2025-10-28 17:33:25 +01:00
MartinBraquet
f9ccd3628a Add logs 2025-10-28 16:56:45 +01:00
MartinBraquet
abef2b394e Add aiexclude 2025-10-28 16:51:00 +01:00
MartinBraquet
97ff6f1de9 Add base android webview app 2025-10-28 16:05:22 +01:00
MartinBraquet
7fad4435cb Add android readme 2025-10-28 15:16:19 +01:00
MartinBraquet
92a97209ca Add yarn build-web 2025-10-28 15:16:13 +01:00
MartinBraquet
e7c3f083b4 Update NEXT_PUBLIC_LOCAL_ANDROID check 2025-10-28 15:16:02 +01:00
MartinBraquet
7b5961f941 Add log 2025-10-28 01:34:29 +01:00
MartinBraquet
85d4b411b5 Add log 2025-10-28 01:10:51 +01:00
MartinBraquet
c9ec32aca7 Fix APK discrimination 2025-10-28 00:37:02 +01:00
MartinBraquet
13a3013a8e Add @capgo/capacitor-social-login 2025-10-28 00:16:28 +01:00
MartinBraquet
0dd3bac855 Add googleNativeLogin 2025-10-28 00:10:44 +01:00
MartinBraquet
d7a716a5cb Add -H 0.0.0.0 2025-10-28 00:04:02 +01:00
MartinBraquet
0bbc9cbe81 Add IS_LOCAL_ANDROID 2025-10-28 00:03:54 +01:00
MartinBraquet
df766d8d1f Fix typo 2025-10-28 00:02:28 +01:00
MartinBraquet
20a150a228 Remove ; 2025-10-27 21:45:17 +01:00
MartinBraquet
010292a440 Remove cached dataa 2025-10-27 21:44:29 +01:00
MartinBraquet
394dae18e9 Move FCM inside 2025-10-27 21:44:19 +01:00
MartinBraquet
f1676c52f0 Add mobile push notifications 2025-10-27 16:12:51 +01:00
MartinBraquet
05f6f3c79b Install capacitor android 2025-10-27 13:13:52 +01:00
MartinBraquet
9942b488ea Serve mobile app from url 2025-10-27 13:13:41 +01:00
MartinBraquet
5d83f4bf2d Add build web script 2025-10-27 13:13:25 +01:00
MartinBraquet
d9afd914ff Make SEA description dynamic 2025-10-27 12:56:06 +01:00
MartinBraquet
990d8160f8 Use SSR for news 2025-10-27 12:49:08 +01:00
MartinBraquet
80c321b66f Remove log 2025-10-27 12:48:12 +01:00
MartinBraquet
67b45f3e5c Add capacitor 2025-10-27 11:49:39 +01:00
MartinBraquet
ca3cee5673 Move paragraph 2025-10-27 01:47:23 +01:00
MartinBraquet
ae0d170244 Improve wording 2025-10-27 01:46:10 +01:00
MartinBraquet
9a31cfa938 Fixes 2025-10-27 01:34:30 +01:00
MartinBraquet
cdbc9c305e Allow null for profiles 2025-10-27 01:23:20 +01:00
MartinBraquet
cdbce13c49 Sort zod types 2025-10-27 00:39:48 +01:00
MartinBraquet
0a41ebbcda Add an option to disable your profile 2025-10-27 00:37:54 +01:00
MartinBraquet
476fe1602b Clean 2025-10-27 00:37:18 +01:00
MartinBraquet
2f482e9afc Fix links 2025-10-26 18:50:14 +01:00
MartinBraquet
d59e6e0691 Fix news link (open in no tab) 2025-10-26 18:37:44 +01:00
MartinBraquet
7ec6866f26 Add news to FAQ 2025-10-26 16:37:16 +01:00
MartinBraquet
3686e7facf Remove changelog 2025-10-26 16:32:00 +01:00
MartinBraquet
1aba1894ea Remove unused react 2025-10-26 16:24:38 +01:00
MartinBraquet
14503c9b8f Fix header color 2025-10-26 16:24:12 +01:00
MartinBraquet
a315668d31 Fix format 2025-10-26 16:19:42 +01:00
MartinBraquet
db9ea63210 Add what's new page 2025-10-26 16:07:57 +01:00
MartinBraquet
51ecbd5b53 Add dark swagger docs 2025-10-26 15:45:47 +01:00
MartinBraquet
45ef0d9809 Add /donate to /support 2025-10-26 14:37:05 +01:00
MartinBraquet
356702b50d Hide local endpoints when deployed 2025-10-26 14:34:09 +01:00
MartinBraquet
e72ce5376c Remove unused pages 2025-10-26 14:24:48 +01:00
MartinBraquet
0d35f3fbd2 Add bottom margin 2025-10-26 14:24:17 +01:00
MartinBraquet
28c22c1eae Add qr code to FAQ 2025-10-26 14:24:04 +01:00
MartinBraquet
7cf83f65c3 Cache vote creators 2025-10-26 13:53:46 +01:00
MartinBraquet
4c4f2e720d Add status on proposals 2025-10-26 10:51:52 +01:00
MartinBraquet
0fa562e6fd Rename order by 2025-10-26 10:01:36 +01:00
MartinBraquet
bcd0f778cf Rename vote title 2025-10-26 10:01:20 +01:00
MartinBraquet
401ab9f706 Add bottom margin 2025-10-26 09:42:20 +01:00
MartinBraquet
8b09a81d5a Add keyword example 2025-10-25 23:54:50 +02:00
MartinBraquet
86718cc406 Sleep more more more 2025-10-25 23:20:17 +02:00
MartinBraquet
ccb72364e1 Sleep more 2025-10-25 23:08:55 +02:00
MartinBraquet
bfd6a59d87 Increase cache check 2025-10-25 23:08:48 +02:00
MartinBraquet
af4caa455a Fix typos 2025-10-25 23:00:54 +02:00
MartinBraquet
d511e4a75c Comment log 2025-10-25 23:00:46 +02:00
MartinBraquet
8fd906223c Re enable dev cache 2025-10-25 23:00:40 +02:00
MartinBraquet
deadb56aaa Try to load profiles times if not found 2025-10-25 23:00:25 +02:00
MartinBraquet
1ff867879c Throttle discord message 2025-10-25 23:00:02 +02:00
MartinBraquet
f3630dd868 Send generic error message instead of error log 2025-10-25 22:30:08 +02:00
MartinBraquet
39143525c3 Revalidates ISR pages 2025-10-25 22:18:48 +02:00
MartinBraquet
e8dd1f8f8b Add log 2025-10-25 21:48:25 +02:00
MartinBraquet
28e5d2e3f2 Fix profile not found due to cache 2025-10-25 21:40:29 +02:00
MartinBraquet
21def91427 Fix plus icon color 2025-10-25 21:22:29 +02:00
MartinBraquet
bc5d04c662 Set user_id?: string 2025-10-25 16:57:51 +02:00
MartinBraquet
c736227448 Add response to send test email 2025-10-25 16:52:11 +02:00
MartinBraquet
168285cb64 Add contact info to /contact 2025-10-25 16:37:15 +02:00
MartinBraquet
3411f50d29 Add /local/send-test-email endpoint 2025-10-25 16:33:30 +02:00
MartinBraquet
319c14b0e0 Add react import in email
Required since using tsx watch src/serve.ts and not nodemon
2025-10-25 16:32:55 +02:00
MartinBraquet
64c077396f Rename fromEmail 2025-10-25 16:32:07 +02:00
MartinBraquet
65f0d448a1 Fix messages not sent 2025-10-25 15:48:42 +02:00
MartinBraquet
2fdaa464dd Remove import 2025-10-25 15:33:23 +02:00
MartinBraquet
f86a6a10ac Add political_details 2025-10-25 15:32:16 +02:00
MartinBraquet
08a2438e79 Add Animist 2025-10-25 15:31:53 +02:00
MartinBraquet
60cc47f7ca Improve rendering of religion in about me 2025-10-25 14:45:53 +02:00
MartinBraquet
7e4f606492 Improve filter formatting for politics and religion 2025-10-25 14:36:55 +02:00
MartinBraquet
8ff58534d9 Update docs 2025-10-25 14:24:14 +02:00
MartinBraquet
a4bb184e95 Add filter: religion 2025-10-25 14:22:11 +02:00
MartinBraquet
940c1f5692 Add profile field: religion 2025-10-25 14:21:58 +02:00
MartinBraquet
0430733b58 Add internal endpoints to API docs 2025-10-25 13:44:00 +02:00
MartinBraquet
33136816af Improve API docs 2025-10-25 13:31:44 +02:00
MartinBraquet
469a235799 Remove logs 2025-10-25 04:40:11 +02:00
MartinBraquet
2d71c827b3 Fix 2025-10-25 04:30:21 +02:00
MartinBraquet
17f9e72a9f Release 2025-10-25 04:19:29 +02:00
MartinBraquet
120aeed56f Add API to FAQ 2025-10-25 04:19:19 +02:00
MartinBraquet
8128c3b2d7 Update docs 2025-10-25 04:10:37 +02:00
MartinBraquet
4581a33cae Fix package import 2025-10-25 04:10:32 +02:00
MartinBraquet
d43e2af3ae Remove unused openapi 2025-10-25 04:07:46 +02:00
MartinBraquet
0283eb4d85 Massive upgrade to the API Swagger UI @ api.compassmeet.com 2025-10-25 03:42:23 +02:00
MartinBraquet
f483ae42a8 Add smoker to your filters 2025-10-25 01:31:43 +02:00
MartinBraquet
f974eba465 Move Reset filter to top 2025-10-25 01:25:24 +02:00
MartinBraquet
7d7969fe0f Reset filter 2025-10-25 01:22:01 +02:00
MartinBraquet
2a3d7e8362 Fix you filter 2025-10-25 01:21:46 +02:00
MartinBraquet
a38c03c4e0 Fix 2025-10-25 01:02:21 +02:00
MartinBraquet
342a0c612a Add filter for smoking 2025-10-25 00:58:53 +02:00
MartinBraquet
f1f9970407 Either instead of Any Kids 2025-10-24 23:03:41 +02:00
MartinBraquet
c83a3e6315 Log error 2025-10-24 23:03:17 +02:00
MartinBraquet
fbc65e7e2a Update helpers for local run 2025-10-24 23:03:04 +02:00
MartinBraquet
d9e9407cab Update doc 2025-10-24 23:02:36 +02:00
MartinBraquet
d0881b76e0 Update migrate 2025-10-24 23:02:28 +02:00
MartinBraquet
61c867b49c Add info for add new profile field 2025-10-24 23:02:17 +02:00
MartinBraquet
87de30d257 Create share notif 2025-10-24 23:02:03 +02:00
MartinBraquet
817605417c Default to DEV in local 2025-10-24 23:01:50 +02:00
MartinBraquet
65b018db2a Add contact links 2025-10-24 20:48:45 +02:00
MartinBraquet
addb52e3fa Add /donate page with OC widget 2025-10-24 19:24:58 +02:00
MartinBraquet
c3124ec7c3 Clean 2025-10-24 18:52:00 +02:00
MartinBraquet
b1caa6dfdc Remove log 2025-10-24 18:34:55 +02:00
MartinBraquet
26f28d55d9 Add filters for drinks 2025-10-24 18:31:43 +02:00
MartinBraquet
cb66688529 Make left nav bar scrollable 2025-10-24 17:20:52 +02:00
MartinBraquet
40c61f11be Speed update local run 2025-10-24 17:10:47 +02:00
MartinBraquet
9b45c75a5b Remove none 2025-10-24 16:25:16 +02:00
MartinBraquet
09425c1910 Rename college 2025-10-24 16:24:01 +02:00
MartinBraquet
591798e98c Add filter for education level 2025-10-24 16:19:37 +02:00
MartinBraquet
acdd82a680 Clean and factor out get-private-messages 2025-10-24 15:18:04 +02:00
MartinBraquet
5719ac3209 Fix age input 2025-10-24 14:20:55 +02:00
MartinBraquet
2ac687b0c2 Fix 2025-10-24 02:41:10 +02:00
MartinBraquet
a86a249f05 Add height in cm 2025-10-24 02:36:30 +02:00
MartinBraquet
e49a7b0bb4 Add backend support for setting compatibility answers 2025-10-24 02:09:46 +02:00
MartinBraquet
e904a7949c Fix RLS 2025-10-24 01:57:01 +02:00
MartinBraquet
080d8110df Delete bookmarked searches in the backend 2025-10-24 01:35:16 +02:00
MartinBraquet
d90826e851 Create bookmarked searches in the backend 2025-10-24 01:30:03 +02:00
MartinBraquet
e495da692b Improve wording in saved searches 2025-10-24 01:29:32 +02:00
MartinBraquet
52970ef93e Add fkeys to schema 2025-10-24 01:26:50 +02:00
MartinBraquet
8f641d117a Cancel deletions automated by cascade delete 2025-10-24 01:08:39 +02:00
MartinBraquet
d164ebc7da Add foreign keys 2025-10-24 01:04:18 +02:00
MartinBraquet
632cc5810d Add icons 2025-10-23 20:15:21 +02:00
MartinBraquet
e565a6c77f Set last online upon user creation 2025-10-23 18:45:37 +02:00
MartinBraquet
c1fe700d7a Add lib clean 2025-10-23 18:18:01 +02:00
MartinBraquet
06ee267804 Remove log 2025-10-23 18:00:11 +02:00
MartinBraquet
aad722c723 Add chat messages AES encryption 2025-10-23 17:57:19 +02:00
MartinBraquet
aefc58b636 Make mobile filter modal scrollable 2025-10-23 15:52:18 +02:00
MartinBraquet
fdd96507b8 Add politics filter 2025-10-23 15:41:46 +02:00
MartinBraquet
2ad87a5ec5 Update SQL schema 2025-10-23 15:25:06 +02:00
MartinBraquet
b94cdba5af Add diet 2025-10-23 14:58:57 +02:00
MartinBraquet
725261335c Reorder about 2025-10-23 14:16:50 +02:00
MartinBraquet
5fb0051fc6 Update README.md for clarity and minor corrections 2025-10-23 02:25:47 +02:00
MartinBraquet
1247847739 Update FAQ 2025-10-23 02:25:34 +02:00
MartinBraquet
18cb4e74d6 Refactor notification process 2025-10-23 02:07:45 +02:00
MartinBraquet
e07cb7fca9 Show all notifs types 2025-10-23 01:46:57 +02:00
MartinBraquet
dc54ed46f8 Add notif for New Vote Page 2025-10-23 01:38:34 +02:00
MartinBraquet
0415d86d71 Replace DROP INDEX with IF NOT EXISTS 2025-10-23 00:34:34 +02:00
MartinBraquet
b8b95be5ce Add on delete cascade 2025-10-23 00:29:14 +02:00
MartinBraquet
46820f0986 Clean 2025-10-23 00:08:54 +02:00
MartinBraquet
dcc022ac7f Add open Collective 2025-10-23 00:05:27 +02:00
MartinBraquet
9142f0d633 Autofocus 2025-10-22 23:49:28 +02:00
MartinBraquet
181c72befe Format 2025-10-22 23:49:18 +02:00
MartinBraquet
99f3459978 Remove user followers in search-users 2025-10-22 23:33:25 +02:00
MartinBraquet
75fbc9679c Fix page props 2025-10-22 23:33:12 +02:00
MartinBraquet
700b7774b1 Margin 404 2025-10-22 23:32:37 +02:00
MartinBraquet
d9f0a9b1ca Fix "younger than 99" 2025-10-22 22:59:25 +02:00
MartinBraquet
70644ff26d Prevent editor focus on mobile devices 2025-10-22 16:22:50 +02:00
MartinBraquet
bbefcc3bc8 Add indexes to push_subscriptions 2025-10-22 15:33:55 +02:00
MartinBraquet
09767dbae3 Add WPA to faq 2025-10-22 15:33:42 +02:00
MartinBraquet
57eafa95ba Fire and forget other user notif 2025-10-22 15:00:41 +02:00
MartinBraquet
f4f28a411e Typo 2025-10-22 15:00:27 +02:00
MartinBraquet
f6059ef5c7 Focus on editor upon page loading 2025-10-22 14:47:07 +02:00
MartinBraquet
e3fa4efa95 Remove expired subscriptions 2025-10-22 14:28:59 +02:00
MartinBraquet
6884a91eb8 Ignore ico 2025-10-22 14:28:23 +02:00
MartinBraquet
71ba018a42 Remove misplace favicon 2025-10-22 13:57:24 +02:00
MartinBraquet
10f5232ac3 Clean 2025-10-22 13:54:56 +02:00
MartinBraquet
78d707484d Update badge 2 2025-10-22 13:54:47 +02:00
MartinBraquet
69db66fbbb Update badge 2025-10-22 13:32:53 +02:00
MartinBraquet
99691cd7ee Factor out vapid key 2025-10-22 13:23:14 +02:00
MartinBraquet
47cef359ca Put back icons 2025-10-22 03:57:57 +02:00
MartinBraquet
046105498f Hide icons 2025-10-22 03:53:07 +02:00
MartinBraquet
4d3ef5dd2a Log payload 2025-10-22 03:43:56 +02:00
MartinBraquet
8bcd5623bf Add service worker info 2025-10-22 03:43:46 +02:00
MartinBraquet
a29b4a3a8e Remove badge 2025-10-22 03:40:15 +02:00
MartinBraquet
dee0fb396b Remove cache 2025-10-22 03:39:35 +02:00
MartinBraquet
b5c707e07f Send notif for every single private message 2025-10-22 03:25:04 +02:00
MartinBraquet
8fe35bd1d7 Add VAPID secrets 2025-10-22 03:02:34 +02:00
MartinBraquet
6c864c35cd Implement subscriptions for mobile notifications 2025-10-22 02:52:41 +02:00
MartinBraquet
f00acf6af1 Improve link accessibility 2025-10-22 01:18:45 +02:00
MartinBraquet
49e1599bc4 Fix react error 2025-10-22 00:44:22 +02:00
MartinBraquet
7311d4b724 Remove cache 2025-10-22 00:38:56 +02:00
MartinBraquet
fa44e348a2 Fix WPA theme color 2025-10-22 00:22:28 +02:00
MartinBraquet
8cba02741c Fix WPA 2025-10-22 00:01:58 +02:00
MartinBraquet
48d04d5e72 Add WPA 2025-10-21 23:37:20 +02:00
MartinBraquet
7cac25c0e2 Remove react import (2) 2025-10-21 20:50:56 +02:00
MartinBraquet
88b0fa0163 Remove react import 2025-10-21 20:32:35 +02:00
MartinBraquet
3fcef24cc9 Add SEO (including better tab titles) 2025-10-21 20:29:10 +02:00
MartinBraquet
d9fba6ce6b Fix link 2025-10-21 12:40:28 +02:00
MartinBraquet
8bc2f0c40e Fix flashing submit button 2025-10-21 12:36:54 +02:00
MartinBraquet
21254695d5 Remove unused import 2025-10-21 12:32:39 +02:00
MartinBraquet
f063f0a6f4 Add donate links 2025-10-21 12:32:00 +02:00
MartinBraquet
2d847cbcdb Add Github sponsor 2025-10-21 12:15:27 +02:00
MartinBraquet
547e99f526 Add vercel config for backup info 2025-10-20 17:36:55 +02:00
MartinBraquet
a9794cd2ee Add packages 2025-10-20 17:24:42 +02:00
MartinBraquet
c651abd8ae Add unmet deps 2025-10-20 16:45:02 +02:00
MartinBraquet
15781475b6 Rename Love to Profile 2025-10-20 16:35:59 +02:00
MartinBraquet
26a28175fd Rename LovePage 2025-10-20 16:24:22 +02:00
MartinBraquet
aa3680934b Rename lover to profile 2025-10-20 16:18:49 +02:00
MartinBraquet
0b36586ddf Rename love folder in backend 2025-10-20 16:14:23 +02:00
MartinBraquet
7b58acac0d Rename love folder 2025-10-20 16:13:43 +02:00
MartinBraquet
27bf4eadf9 Rename compatibility_answers_free 2025-10-20 16:10:41 +02:00
MartinBraquet
c8d4353888 Rename love_waitlist 2025-10-20 16:05:14 +02:00
MartinBraquet
4876ca2643 Rename love_stars 2025-10-20 16:03:28 +02:00
MartinBraquet
e06a382c94 Rename love_ships 2025-10-20 16:01:33 +02:00
MartinBraquet
d1a421ca15 Rename compatibility_prompts 2025-10-20 15:59:44 +02:00
MartinBraquet
cd3c8d89d0 Rename love_likes 2025-10-20 15:48:22 +02:00
MartinBraquet
1f943ccead Rename love_compatibility_answers 2025-10-20 15:46:16 +02:00
MartinBraquet
753776fa9a Fix 2025-10-20 15:37:33 +02:00
MartinBraquet
9787a2446e Fix 2025-10-20 15:31:39 +02:00
MartinBraquet
4cb29d274b Add floating info box 2025-10-20 15:28:59 +02:00
MartinBraquet
df55d63f99 Add security and help pages 2025-10-20 13:31:58 +02:00
MartinBraquet
236e2d48c5 Update security email 2025-10-20 13:20:31 +02:00
MartinBraquet
30d45d834f Add compat prompts number to stats 2025-10-20 13:03:26 +02:00
MartinBraquet
edf30897f2 Fix effect 2025-10-20 12:56:42 +02:00
MartinBraquet
3d31ebb576 Fix hooks 2025-10-20 12:55:16 +02:00
MartinBraquet
d3bac8bcc0 Fix vercel badge 2025-10-20 12:53:37 +02:00
MartinBraquet
a360f80cdf Add warning message about short bio 2025-10-20 12:51:28 +02:00
MartinBraquet
0cc7549546 remove log 2025-10-20 12:51:10 +02:00
MartinBraquet
283d2743e0 Fix mod 2025-10-19 22:50:09 +02:00
MartinBraquet
b431fa11fa Fix pref_gender filtering 2025-10-19 21:42:37 +02:00
MartinBraquet
648e00867f Reduce v space 2025-10-19 21:30:44 +02:00
MartinBraquet
552af7bb6b Add romantic type in filters 2025-10-19 21:25:17 +02:00
MartinBraquet
92980f7c79 Increase contrast 2025-10-19 20:19:52 +02:00
MartinBraquet
09a563bf73 Clean 2025-10-19 20:19:45 +02:00
MartinBraquet
141fa12a20 Add kids strength and other relationship filters 2025-10-19 12:36:48 +02:00
MartinBraquet
6e0035d4f3 Show profiles that don't set kids strength 2025-10-19 12:36:09 +02:00
MartinBraquet
97bac4132c Fix loc filter clearing 2025-10-19 11:11:51 +02:00
MartinBraquet
b23b0280cd Fix mobile nav contrast 2025-10-19 11:00:49 +02:00
MartinBraquet
7ac093a8d0 Fix socials contrast 2025-10-19 10:58:12 +02:00
MartinBraquet
dfc524b957 Fix 2025-10-19 00:32:48 +02:00
MartinBraquet
65ba0d348b Increase contrast for better accessibility 2025-10-19 00:11:59 +02:00
MartinBraquet
ed07031539 Fix primary key 2025-10-18 23:31:32 +02:00
MartinBraquet
93f3690344 Speed up init theme 2025-10-18 22:47:43 +02:00
MartinBraquet
1341d1356a Fix warning 2025-10-18 22:37:46 +02:00
MartinBraquet
38dcf16c03 customlink -> custom-link 2025-10-18 22:36:37 +02:00
MartinBraquet
8696a42959 SSH and view logs in one click 2025-10-18 22:36:12 +02:00
MartinBraquet
c6fc7db1e9 Move up 2025-10-18 13:17:44 +02:00
MartinBraquet
58540aca57 Add proposals and votes number to stats 2025-10-18 13:16:05 +02:00
MartinBraquet
b7b75279c2 Add warning message upon console opening 2025-10-18 12:49:36 +02:00
554 changed files with 23470 additions and 11066 deletions

420
.cursor/rules/dev-rules.mdc Normal file
View 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.

View File

@@ -1,3 +1,9 @@
# use firebase emulator for running e2e tests
NEXT_PUBLIC_FIREBASE_EMULATOR=false
FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199
# You already have access to basic local functionality (UI, authentication, database read access).
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc

6
.github/FUNDING.yml vendored
View File

@@ -1,12 +1,12 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: [CompassConnections] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: CompassMeet # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
open_collective: compass-connection # Replace with a single Open Collective 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
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
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1 @@
blank_issues_enabled: true

15
.github/ISSUE_TEMPLATE/other.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
name: Other
description: Any other question or issue
body:
- type: textarea
attributes:
label: Issue
description: >
A clear and concise description of the question or issue
validations:
required: true
- type: markdown
attributes:
value: >
Thanks for contributing!

8
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View 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 -->

96
.github/workflows/cd-api.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: CD API
on:
push:
branches: [ main, master ]
paths:
- "backend/api/package.json"
- ".github/workflows/cd-api.yml"
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # we need full history for git log
- name: Install jq
run: sudo apt-get install -y jq
- 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: |
# Get previous commits package.json (if it existed)
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: Check version change
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 "changed=false" >> $GITHUB_OUTPUT
else
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
if: steps.check.outputs.changed == 'true'
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
if: steps.check.outputs.changed == 'true'
run: yarn install
- name: Authenticate to GCP
if: steps.check.outputs.changed == 'true'
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- name: Install gcloud CLI
if: steps.check.outputs.changed == 'true'
uses: google-github-actions/setup-gcloud@v2
with:
project_id: compass-130ba
- name: Configure Docker for Artifact Registry
if: steps.check.outputs.changed == 'true'
run: |
gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
- name: Install Tofu (Terraform)
if: steps.check.outputs.changed == 'true'
run: |
sudo apt-get update
sudo apt-get install -y wget unzip
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 || exit 1
tofu init
- name: Run deploy script
if: steps.check.outputs.changed == 'true'
run: |
chmod +x backend/api/deploy-api.sh
backend/api/deploy-api.sh

View File

@@ -6,6 +6,9 @@ name: CD
on:
push:
branches: [ main, master ]
paths:
- "package.json"
- ".github/workflows/cd.yml"
jobs:
release:

View File

@@ -10,7 +10,7 @@ on:
jobs:
ci:
name: All
name: Tests
runs-on: ubuntu-latest
steps:
@@ -32,29 +32,44 @@ jobs:
run: npm run lint
- name: Run Jest tests
run: npm run test tests/jest
# - name: Build app
# env:
# DATABASE_URL: ${{ secrets.DATABASE_URL }}
# run: npm run build
env:
NEXT_PUBLIC_FIREBASE_ENV: DEV
run: |
yarn test:coverage
# npm install -g lcov-result-merger
# mkdir coverage
# lcov-result-merger \
# "backend/api/coverage/lcov.info" \
# "backend/shared/coverage/lcov.info" \
# "backend/email/coverage/lcov.info" \
# "common/coverage/lcov.info" \
# "web/coverage/lcov.info" \
# > coverage/lcov.info
# Optional: Playwright E2E tests
- name: Install Playwright deps
run: npx playwright install --with-deps
# npm install @playwright/test
# npx playwright install
run: |
npx playwright install chromium
# npx playwright install --with-deps
# npm install @playwright/test
- name: Run E2E tests
env:
NEXT_PUBLIC_API_URL: localhost:8088
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: |
yarn --cwd=web serve &
npx wait-on http://localhost:3000
npx playwright test tests/playwright
SERVER_PID=$(fuser -k 3000/tcp)
echo $SERVER_PID
kill $SERVER_PID
chmod +x scripts/e2e.sh
./scripts/e2e.sh
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
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
env:
CI: true

17
.gitignore vendored
View File

@@ -13,6 +13,10 @@
# testing
/coverage
# Playwright
/tests/reports/playwright-report
/tests/e2e/web/.auth/
# next.js
/.next/
/out/
@@ -61,15 +65,17 @@ email-preview
*.last-run.json
*lock.hcl
/web/pages/test.tsx
/web/pages/_test.tsx
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.ico
*.mp4
*.mov
*.webp
*.avi
*.wmv
*.mp3
@@ -86,3 +92,12 @@ email-preview
*.terraform
/backups/firebase/auth/data/
/backups/firebase/storage/data/
android/app/release*
icons/
*.bak
test-results
/.nyc_output/
**/coverage

32
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,32 @@
{
// 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 Jest Tests",
"type": "node",
"request": "launch",
"runtimeArgs": [
"--inspect-brk",
"${workspaceRoot}/node_modules/.bin/jest",
"--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"
// ]
// }
]
}

422
.windsurf/rules/compass.md Normal file
View File

@@ -0,0 +1,422 @@
---
trigger: always_on
description:
globs:
---
## 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.

View File

@@ -1,13 +1,14 @@
[![CI](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
![Vercel](https://deploy-badge.vercel.app/vercel/compass)
[![CD](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
![Vercel](https://deploy-badge.vercel.app/vercel/bayesbond)
[![CD API](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml)
[![CI](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/CompassConnections/Compass/branch/main/graph/badge.svg)](https://codecov.io/gh/CompassConnections/Compass)
[![Users](https://img.shields.io/badge/Users-300%2B-blue?logo=myspace)](https://www.compassmeet.com/stats)
# 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.
**We cant 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!
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.
## Features
@@ -21,9 +22,9 @@ This repository contains the source code for [Compass](https://compassmeet.com)
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.
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;">
<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">
</p>
**We cant 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!
![Demo](https://raw.githubusercontent.com/CompassConnections/assets/refs/heads/main/assets/demo-2x.gif)
## To Do
@@ -31,8 +32,8 @@ No contribution is too small—whether its changing a color, resizing a butto
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, just use your preferred option:
- Ask or DM an admin on Discord
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)
- Email hello@compassmeet.com
- Raise an issue on GitHub
@@ -53,7 +54,9 @@ Here is a tailored selection of things that would be very useful. If you want to
- [x] Search through most profile variables
- [x] Set up chat / direct messaging
- [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)
- [x] Add Android mobile app
- [ ] Add iOS mobile app
- [ ] Add better onboarding (tooltips, modals, 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.)
@@ -66,22 +69,23 @@ Everything is open to anyone for collaboration, but the following ones are parti
- [x] Clean up learn more page
- [x] Add dark theme
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
- [ ] Cover with tests (very important, just the test template and framework are ready)
- [x] Add profile fields (intellectual interests, cause areas, personality type, etc.)
- [ ] Add profile fields: conflict style
- [ ] Add profile fields: timezone
- [x] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
- [ ] Make the app more user-friendly and appealing (UI/UX)
- [ ] Clean up terms and conditions (convert to Markdown)
- [ ] Clean up privacy notice (convert to Markdown)
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
- [ ] Add email verification
- [ ] Add password reset
- [x] Add email verification
- [x] Add password reset
- [x] Add automated welcome email
- [ ] Security audit and penetration testing
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
- [ ] Create settings page (change email, password, delete account, etc.)
- [x] Make `deploy-api.sh` run automatically on push to `main` branch
- [x] Create settings page (change email, password, delete account, etc.)
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
- [ ] Improve loading sign (e.g., animation of a compass moving around)
- [ ] Show compatibility score in profile page
- [x] Improve loading sign (e.g., animation of a compass moving around)
- [x] Show compatibility score in profile page
## Implementation
@@ -104,38 +108,23 @@ git clone https://github.com/<your-username>/Compass.git
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
./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:
```bash
yarn install
```
### 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
Make sure the tests pass:
```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
@@ -162,7 +151,17 @@ 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/`.
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
##### Resources
There is a lof 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.
- [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
@@ -189,5 +188,19 @@ git push origin <branch-name>
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
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.

View File

@@ -8,5 +8,5 @@
## Reporting a Vulnerability
Contact the development team at compass.meet.info@gmail.com to report a vulnerability. You should receive updates within a week.
Contact the development team at hello@compassmeet.com to report a vulnerability. You should receive updates within a week.

View File

@@ -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',
},
}

View File

@@ -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>
);
}

View File

@@ -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;
}
}

View File

@@ -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});
}
}

View File

@@ -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));
}
}

View File

@@ -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'},
});
}
}

View File

@@ -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}
);
}
}

View File

@@ -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);
}

View File

@@ -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' } }
);
}
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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});
}

View File

@@ -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}
);
}
}

View File

@@ -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}
);
}
}

View File

@@ -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>
// );
// }

View File

@@ -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>
);
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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>
)
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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 peoples mental healthand 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>Ill 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; Im currently looking for a few more close connections and, like many, struggling to find like-minded peopledespite trying different approaches like dating apps, forums, and real-life communities. Of course, Ive 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 anyonelet 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 peaceor robustness to life eventsmuch better than any other practice. It never fails to keep me excited everyday. My goal isnt to change anyones 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 intellectualismwhich should still apply to 1-2% of the population. Those are the primary values Id like to see in the people close to me and, as a consequence, in the platform that Ill 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 connectionsdeep 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 belowwhether 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—everyones 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 ones beliefs without feelings of personal attacks. Awareness of ones cognitive biases, intending to reduce them, and saying “I dont 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 &amp; 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, Ill 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>Ill briefly go over some currently available products / methods that connect people and I'll review if they follow the core values.</p>
<p>Ill 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, dont 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 wasnt 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 its 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 platforms 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>Ill 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, well 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>Whats 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>Whats 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 firstemotions, 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 Im 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, Ill 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 its 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); lets discuss about the best one. I personally have reasonable full-stack skills and dont 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. Theyll review profiles that got suspended, remove inactive account, and perform other essential tasks.</p>
<p>If a user notices that someones profile isnt 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 platforms 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 doesnt 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 dont have an estimate right now as there are too many uncertainties. Hopefully, well 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 platforms 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 cant 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 individuals 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 platforms overall benefit (read, increase in total well-being) is then the product of quantity and qualityreaching 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 individuals 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. Thats 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 futureprovided that it doesnt 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 diminishmaking the platform more likely to survive financially.</p>
<h3 id="viability">Viability</h3>
<p>Lets 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. Lets 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. Thats a 20% probability to find someone in the same area.</li>
<li>Users may form a connection with people of any gender (its 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 tangiblethrough testimonies, etc.</p>
<p>Another critical aspect that requires people is moderation. I would estimate that one moderator for 100-200 users is enoughmost 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, thats great; but thats 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. Its a niche topic, so probably only 1% of the population would fit the core values. Lets be gracious and assume that 50% of them are fulfilled and dont more connections. That brings the total addressable market to 7.5 million people.</p>
<h1 id="critiques">Critiques</h1>
<p>Ill list some expected critiques and provide potential solutions.</p>
<h3 id="choice-cognitive-overload">Choice &amp; Cognitive Overload</h3>
<p>When one can see everyone else, its 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 appsand its 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, its so easy to be spammed and harassed. The Moderation section suggests different strategies. Lets 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 userwhich 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 wont 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, well 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 doesnt target a specific location; until we get thousands of users, its very likely that connected people live far apart. Its 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 Ill 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 dont need the app anymore?</h3>
<p>There will likely a time when I dont 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 cant 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. Lets build a better way to form meaningful relationshipsone 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>;
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>;
}

View File

@@ -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>
);
}

View File

@@ -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&apos;t set up yet. Follow the
instructions below to get started.
</p>
<SetupInstructions />
</div>
</div>
);
}

View File

@@ -1,11 +0,0 @@
"use client";
import SetupSteps from "./setup-steps";
export default function SetupInstructions() {
return (
<div className="space-y-8">
<SetupSteps />
</div>
);
}

View File

@@ -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&apos;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&apos;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>
);
}

View File

@@ -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 dont have a fixed gender identity.</li>
<li><b>Genderqueer</b>: Individuals who dont identify with binary gender identity norms.</li>
<li><b>Gender Nonconforming</b>: Individuals whose gender expressions dont 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 doesnt fit the
typical definitions of female or male.
</li>
<li><b>Non-binary</b>: A term covering any gender identity or expression that doesnt 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;

View File

@@ -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;

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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 }

View File

@@ -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,
};

View File

@@ -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>
)
}

View File

@@ -1,2 +0,0 @@
'use client';

View File

@@ -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>
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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>
)
;
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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(" ");
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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!
// );

View File

@@ -1,6 +0,0 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -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)$).*)',
],
};

View File

@@ -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;

View File

@@ -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"
}
}

View File

@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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"

View File

@@ -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
}

View File

@@ -1,213 +0,0 @@
import {PrismaClient} from "@prisma/client";
// Cannot import from prisma.ts as we are outside the server
const prisma = new PrismaClient({log: ['query']})
async function main() {
type ProfileBio = {
name: string;
age: number;
introversion: number;
occupation: string;
location: string;
bio: string;
interests: string[];
values: string[];
books: string[];
};
const profiles: ProfileBio[] = [
{
name: "Elena",
age: 29,
introversion: 75,
occupation: "Cognitive Science Researcher",
location: "Berlin, Germany",
bio: "Im passionate about understanding the limits and mechanics of human reasoning. I spend weekends dissecting papers on decision theory and evenings debating moral uncertainty. If you know your way around LessWrong and thought experiments, well get along.",
interests: ["Bayesian epistemology", "AI alignment", "Effective Altruism", "Meditation", "Game Theory"],
values: ["Intellectualism", "Rationality", "Autonomy"],
books: ["Daniel Kahneman - Thinking, Fast and Slow"]
},
{
name: "Marcus",
age: 34,
introversion: 34,
occupation: "Software Engineer",
location: "San Francisco, USA",
bio: "Practicing instrumental rationality one well-calibrated belief at a time. Stoicism and startup life have taught me a lot about tradeoffs. Looking for someone who can argue in good faith and loves truth-seeking as much as I do.",
interests: ["Stoicism", "Predictive processing", "Rational fiction", "Startups", "Causal inference"],
values: ["Diplomacy", "Rationality", "Community"],
books: ["Daniel Kahneman - Thinking, Fast and Slow"]
},
{
name: "Aya",
age: 26,
introversion: 56,
occupation: "Philosophy PhD Candidate",
location: "Oxford, UK",
bio: "My research focuses on metaethics and formal logic, but my heart belongs to moral philosophy. I think a lot about personhood, consciousness, and the ethics of future civilizations. Let's talk about Rawls or Parfit over tea.",
interests: ["Metaethics", "Consciousness", "Transhumanism", "Moral realism", "Formal logic"],
values: ["Radical Honesty", "Structure", "Sufficiency"],
books: ["Daniel Kahneman - Thinking, Fast and Slow"]
},
{
name: "David",
age: 41,
introversion: 71,
occupation: "Data Scientist",
location: "Toronto, Canada",
bio: "Former humanities major turned quant. Still fascinated by existential risk, the philosophy of science, and how to stay sane in an uncertain world. I'm here to meet people who think weird is a compliment.",
interests: ["Probability theory", "Longtermism", "Epistemic humility", "Futurology", "Meditation"],
values: ["Conservatism", "Ambition", "Idealism"],
books: ["Daniel Kahneman - Thinking, Fast and Slow"]
},
{
name: "Mei",
age: 31,
introversion: 12,
occupation: "Independent Writer",
location: "Singapore",
bio: "Writing essays on intellectual humility, the philosophy of language, and how thinking styles shape our lives. I appreciate calm reasoning, rigorous curiosity, and the beauty of well-defined concepts. Let's try to model each other's minds.",
interests: ["Philosophy of language", "Bayesian reasoning", "Writing", "Dialectics", "Systems thinking"],
values: ["Emotional Merging", "Sufficiency", "Pragmatism"],
books: ["Daniel Kahneman - Thinking, Fast and Slow"]
}
];
const interests = new Set<string>();
const values = new Set<string>();
const books = new Set<string>();
profiles.forEach(profile => {
profile.interests.forEach(v => interests.add(v));
});
profiles.forEach(profile => {
profile.values.forEach(v => values.add(v));
});
profiles.forEach(profile => {
profile.books.forEach(v => books.add(v));
});
console.log('Interests:', [...interests]);
console.log('Values:', [...values]);
console.log('Books:', [...books]);
await prisma.interest.createMany({
data: [...interests].map(v => ({name: v})),
skipDuplicates: true,
});
await prisma.value.createMany({
data: [...values].map(v => ({name: v})),
skipDuplicates: true,
});
await prisma.book.createMany({
data: [...books].map(v => ({name: v})),
skipDuplicates: true,
});
await prisma.causeArea.createMany({
data: [
{name: 'Climate Change'},
{name: 'AI Alignment'},
{name: 'Animal Welfare'},
],
skipDuplicates: true,
});
await prisma.connection.createMany({
data: [
// {name: 'Debate Partner'},
{name: 'Friendship'},
{name: 'Short-term relationship'},
{name: 'Long-term relationship'},
],
skipDuplicates: true,
});
// Get actual Interest & CauseArea objects
const allInterests = await prisma.interest.findMany();
const allValues = await prisma.value.findMany();
const allBooks = await prisma.book.findMany();
const allCauseAreas = await prisma.causeArea.findMany();
const allConnections = await prisma.connection.findMany();
// Create mock users
for (let i = 0; i < 5; i++) {
const profile = profiles[i % profiles.length];
const user = await prisma.user.create({
data: {
email: `user${i + 1}@Compassmeet.com`,
name: profile.name,
image: 'profile-pictures/57a821c0-cda0-4797-8654-f54f26fed414.jpg',
profile: {
create: {
location: profile.location,
birthYear: 2025 - profile.age,
introversion: profile.introversion,
description: `[Dummy profile for demo purposes]\n${profile.bio}`,
gender: i % 2 === 0 ? 'Man' : 'Woman',
personalityType: i % 3 === 0 ? 'Extrovert' : 'Introvert',
conflictStyle: 'Avoidant',
contactInfo: `Email: user${i}@Compassmeet.com\nPhone: +1 (123) 456-7890`,
occupation: profile.occupation,
desiredConnections: {
create: [
{connectionId: allConnections[i % allConnections.length].id},
],
},
intellectualInterests: {
create: allInterests
.filter(e => (new Set(profile.interests)).has(e.name))
.map(e => ({interestId: e.id}))
},
coreValues: {
create: allValues
.filter(e => (new Set(profile.values)).has(e.name))
.map(e => ({valueId: e.id}))
},
books: {
create: allBooks
.filter(e => (new Set(profile.books)).has(e.name))
.map(e => ({valueId: e.id}))
},
causeAreas: {
create: [
{causeAreaId: allCauseAreas[i % allCauseAreas.length].id},
],
},
promptAnswers: {
create: [
{prompt: 'Whats a belief youve changed your mind about after encountering strong evidence?', answer: 'I used to think willpower was the key to habit change. But research on environment design and cue-based behavior shifted my perspective. Now I optimize context, not just grit.'},
{prompt: 'What does thinking rationally mean to you in practice?', answer: 'It means being more committed to updating my beliefs than defending them — even if its uncomfortable. Especially when Im wrong.'},
{prompt: 'Whats a concept or topic that recently captivated you?', answer: 'Emergent complexity in ant colonies — how simple rules lead to intelligent systems. It made me rethink centralized control.'},
{prompt: 'If you could master any discipline outside your current field, what would it be and why?', answer: 'Philosophy of science. I love questioning the assumptions behind how we know what we know.'},
{prompt: 'What do you most value in a deep connection with someone?', answer: 'Shared intellectual honesty — the ability to explore truth collaboratively, without ego or defensiveness.'},
{prompt: 'When have you felt most deeply understood by someone?', answer: 'During a multi-hour conversation where we dissected a disagreement, not to win but to understand each others frameworks'},
{prompt: 'How do rationality and emotional closeness coexist for you?', answer: 'They reinforce each other. Being clear-headed helps me show up honestly and empathetically. I want to be close to people who care about truth — including emotional truth.'},
],
},
},
},
},
});
console.log(`Created user ${user.email}`);
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -1,3 +0,0 @@
a {
color: cornflowerblue;
}

View File

@@ -1,25 +0,0 @@
import type { Config } from "tailwindcss";
export default {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
background: "var(--background)",
foreground: "var(--foreground)",
},
screens: {
'xs': {'max': '500px'},
},
},
},
corePlugins: {
animation: true,
},
darkMode: 'class', // required for next-themes
plugins: [ ],
} satisfies Config;

View File

@@ -1,31 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"types": ["jest", "@testing-library/jest-dom"],
"typeRoots": ["./node_modules/@types", "./types"],
"jsx": "react-jsx",
"sourceMap": true
}

0
android/.aiexclude Normal file
View File

102
android/.gitignore vendored Normal file
View File

@@ -0,0 +1,102 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
/app/release/

413
android/README.md Normal file
View File

@@ -0,0 +1,413 @@
# Compass Android WebView App
This folder contains the source code for the Android application of Compass.
A hybrid mobile app built with **Next.js (TypeScript)** frontend, **Firebase backend**, and wrapped as a **Capacitor WebView** for Android. In the future it may contain native code as well.
This document describes how to:
1. Build and run the web frontend and backend locally
2. Sync and build the Android WebView wrapper
3. Debug, sign, and publish the APK
4. Enable Google Sign-In and push notifications
---
## 1. Project Overview
The app is a Capacitor Android project that loads the local Next.js assets inside a WebView.
During development, it can instead load the local frontend (`http://10.0.2.2:3000`) and backend (`http://10.0.2.2:8088`).
Firebase handles authentication and push notifications.
Google Sign-In is supported natively in the WebView via the Capacitor Social Login plugin.
Project Structure
- `app/src/main/java/com/compass/app`: Contains the Java/Kotlin source code for the Android application.
- `app/src/main/res`: Contains the resources for the application, such as layouts, strings, and images.
- `app/build.gradle`: The Gradle build file for the Android application module.
- `build.gradle`: The top-level Gradle build file for the project.
- `AndroidManifest.xml`: The manifest file that describes essential information about the application.
### **Why Local Is the Default**
- **Performance:** Local assets load instantly, without network latency.
- **Reliability:** Works offline or in poor connectivity environments.
- **App Store policy compliance:** Apple and Google generally prefer that the main experience doesnt depend on a remote site (for security, review, and performance reasons).
- **Version consistency:** The web bundle is versioned with the app, ensuring no breaking updates outside your control.
When Remote (No Local Assets) Is sometimes Used
Loading from a **remote URL** (e.g. `https://compassmeet.com`) is **less common**, but seen in a few cases:
- **Internal enterprise apps** where the WebView just wraps an existing web portal.
- **Dynamic content** or **frequent updates** where pushing a new web build every time through app stores would be too slow.
- To leverage the low latency of ISR and SSR.
However, this approach requires:
- Careful handling of **CORS**, **SSL**, and **login/session** persistence.
- Compliance with **Google Play policies** (they may reject apps that are “just a webview of a website” unless theres meaningful native integration).
**A middle ground we use:**
- The app ships with **local assets** for core functionality.
- The app **fetches remote content or updates** (e.g., via Capacitor Live Updates, Ionic Appflow).
## 2. Prerequisites
### Required Software
| Tool | Version | Purpose |
| -------------- | ------- | ---------------------------------- |
| Node.js | 22+ | For building frontend/backend |
| yarn | latest | Package manager |
| Java | 21 | Required for Android Gradle plugin |
| Android Studio | latest | For building and signing APKs |
| Capacitor CLI | latest | Android bridge |
| OpenJDK | 21 | JDK for Gradle |
### Environment Setup
```bash
sudo apt install openjdk-21-jdk
sudo update-alternatives --config java
# Select Java 21
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
java -version
javac -version
````
---
## 3. Build and Run the Web App
```
yarn install
yarn build-web-view
```
### Local mode
If you want the webview to load from your local web version of Compass, run the web app.
In root directory:
```bash
export NEXT_PUBLIC_LOCAL_ANDROID=1
yarn dev # or prod
```
* Runs Next.js frontend at `http://localhost:3000`
* Runs backend at `http://10.0.2.2:8088`
### Deployed mode
If you want the webview to load from the deployed web version of Compass (like at www.compassmeet.com), no web app to run.
---
## 5. Android WebView App Setup
### Install dependencies
```
cd android
./gradlew clean
```
Sync web files and native plugins with Android, for offline fallback. In root:
```
export NEXT_PUBLIC_LOCAL_ANDROID=1 # if running your local web Compass
yarn build-web-view # if you made changes to web app
npx cap sync android
```
### Load from site
During local development, open Android Studio project and run the app on an emulator or your physical device.
To use an emulator:
```
npx cap open android
```
To use a physical device for the local web version, you need your mobile and computer to be on the same network / Wi-Fi and point the URL (`LOCAL_BACKEND_DOMAIN` in the code) to your computer IP address (for example, `192.168.1.3:3000`). You also need to set
```
export NEXT_PUBLIC_WEBVIEW_DEV_PHONE=1
```
Then adb install the app your phone (or simply run it from Android Studio on your phone) and the app should be loading content directly from the local code on your computer. You can make changes in the code and it will refresh instantly on the phone.
Building the Application:
1. Open Android Studio.
2. Click on "Open an existing Android Studio project".
3. Navigate to the `android` folder in this repository and select it.
4. Wait for Android Studio to index the project and download any necessary dependencies.
5. Connect your Android device via USB or set up an Android emulator.
6. Click on the "Run" button (green play button) in Android Studio to build and run the application.
7. Select your device or emulator and click "OK".
8. The application should now build and launch on your device or emulator.
---
## 6. Building the APK
### From Android Studio
- If you want to generate a signed APK for release, go to "Build" > "Generate Signed Bundle / APK..." and follow the prompts.
- Make sure to test the application thoroughly on different devices and Android versions to ensure compatibility.
### Debug build
```bash
cd android
./gradlew assembleDebug
```
Outputs:
```
android/app/build/outputs/apk/debug/app-debug.apk
```
### Install on emulator
```bash
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
### Release build (signed)
1. Generate a release keystore:
```bash
keytool -genkey -v -keystore release-key.keystore -alias compass \
-keyalg RSA -keysize 2048 -validity 10000
```
2. Add signing config to `android/app/build.gradle`
3. Build:
```bash
./gradlew assembleRelease
```
### Release on App Stores
To release on the app stores, you need to submit the .aab files, which are not signed, instead of APK. Google or Apple will then sign it with their own key.
---
## 9. Debugging
Client logs from the emulator on Chrome can be accessed at:
```
chrome://inspect/#devices
```
Backend logs can be accessed from the output of `yarn prod / dev` like in the web application.
Java/Kotlin logs can be accessed via Android Studio's Logcat.
```
adb logcat | grep CompassApp
adb logcat | grep com.compassconnections.app
adb logcat | grep Capacitor
```
You can also add this inside `MainActivity.java`:
```java
webView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
Log.d("WebView", consoleMessage.message());
return true;
}
});
```
---
## 10. Deploy to Play Store
1. Sign the release APK or AAB.
2. Verify package name matches Firebase settings (`com.compassconnections.app`).
3. Upload to Google Play Console.
4. Add Privacy Policy and content rating.
5. Submit for review.
---
## 11. Common Issues
| Problem | Cause | Fix |
| -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------- |
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Old APK signed with different key | Uninstall old app first |
| `Account reauth failed [16]` | Missing or incorrect SHA-1 in Firebase | Re-add SHA-1 of keystore |
| App opens in Firefox | Missing `WebViewClient` override | Fix `shouldOverrideUrlLoading` |
| APK > 1 GB | Cached webpack artifacts included | Add `.next/` and `/public/cache` to `.gitignore` and build excludes |
---
## 13. Local Development Workflow
```bash
# Terminal 1
export NEXT_PUBLIC_LOCAL_ANDROID=1
yarn dev # or prod
# Terminal 2: start frontend
export NEXT_PUBLIC_LOCAL_ANDROID=1
yarn build-web-view # if you made changes to web app
npx cap sync android
# Run on emulator or device
```
---
## 14. Deployment Workflow
```bash
# Build web app for production and Sync assets to Android
yarn build-sync-android
# Build signed release APK in Android Studio
```
---
## Live Updates
To avoid releasing to the app stores after every code update in the web pages, we build the new bundle and store it in Capawesome Cloud (an alternative to Ionic).
First, you need to do this one-time setup:
```
npm install -g @capawesome/cli@latest
npx @capawesome/cli login
```
Then, run this to build your local assets and push them to Capawesome. Once done, each mobile app user will receive a notice that there is a new update available, which they can approve to download.
```
yarn build-web-view
npx @capawesome/cli apps:bundles:create --path web/out
```
That's all. So you should run the lines above every time you want your web updates pushed to main (which essentially updates the web app) to update the mobile app as well.
Maybe we should add it to our CD. For example we set a file with `{liveUpdateVersion: 1}` and run the live update each time a push to main increments that counter.
There is a limit of 100 monthly active user per month, though. So we may need to pay or create our custom limit as we scale. Next plan is $9 / month and allows 1000 MAUs.
- ∞ Live Updates
- 100 Monthly Active Users
- 500 MB of Storage (around 10 MB per update, but we just delete the previous ones)
- 5 GB of Bandwidth
---
## 15. Resources
* [Capacitor Docs](https://capacitorjs.com/docs)
* [Firebase Android Setup](https://firebase.google.com/docs/android/setup)
* [FCM HTTP API](https://firebase.google.com/docs/cloud-messaging/send-message)
* [Next.js Deployment](https://nextjs.org/docs/deployment)
# Useful Commands
- To build the project: `./gradlew assembleDebug`
- To run unit tests: `./gradlew test`
- To run instrumentation tests: `./gradlew connectedAndroidTest`
- To clean the project: `./gradlew clean`
- To install dependencies: Open Android Studio and it will handle dependencies automatically.
- To update dependencies: Modify the `build.gradle` files and sync the project in Android Studio.
- To generate a signed APK: Use the "Generate Signed Bundle / APK..." option in Android Studio.
- To lint the project: `./gradlew lint`
- To check for updates to the Android Gradle Plugin: `./gradlew dependencyUpdates`
- To run the application on a connected device or emulator: `./gradlew installDebug`
- To view the project structure: Use the "Project" view in Android Studio.
- To analyze the APK: `./gradlew analyzeRelease`
- To run ProGuard/R8: `./gradlew minifyRelease`
- To generate documentation: `./gradlew javadoc`
# One time setups
Was already done for Compass, so you only need to do the steps below if you create a project separated from Compass.
## Configure Firebase
### In Firebase Console
1. Add a **Web app** → obtain `firebaseConfig`
2. Add an **Android app**
* Package name: `com.compassconnections.app`
* Add your SHA-1 and SHA-256 fingerprints (see below)
* Download `google-services.json` and put it in:
```
android/app/google-services.json
```
### To get SHA-1 for debug keystore
```bash
keytool -list -v \
-keystore ~/.android/debug.keystore \
-alias androiddebugkey \
-storepass android \
-keypass android
```
Add both SHA-1 and SHA-256 to Firebase.
## 7. Google Sign-In (Web + Native)
In Firebase Console:
* Enable **Google** provider under *Authentication → Sign-in method*
* Add your **Android SHA-1**
* Add your **Web OAuth client ID**
In your code:
```ts
import { googleNativeLogin } from 'web/lib/service/android-push'
```
## 8. Push Notifications (FCM)
### Setup FCM
* Add Firebase Cloud Messaging to your project
* Include `google-services.json` under `android/app/`
* Add in `android/build.gradle`:
```gradle
classpath 'com.google.gms:google-services:4.3.15'
```
* Add at the bottom of `android/app/build.gradle`:
```gradle
apply plugin: 'com.google.gms.google-services'
```
### Test notification
```ts
const message = {
notification: {
title: "Test Notification",
body: "Hello from Firebase Admin SDK"
},
token: "..."
};
initAdmin()
await admin.messaging().send(message)
.then(response => console.log("Successfully sent message:", response))
.catch(error => console.error("Error sending message:", error));
```
---
## Deep link / custom scheme
A **custom scheme** is a URL protocol that your app owns.
Example:
```
com.compassconnections.app://auth
```
When Android (or iOS) sees a redirect to one of these URLs, it **launches your app** and passes it the URL data. It's useful to open links in the app instead of the browser. For example, if there's a link to Compass on Discord and we click on it on a mobile device that has the app, we want the link to open in the app instead of the browser.
You register this scheme in your `AndroidManifest.xml` so Android knows which app handles it.

3
android/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/build/*
!/build/.npmkeep
/google-services.json

66
android/app/build.gradle Normal file
View File

@@ -0,0 +1,66 @@
apply plugin: 'com.android.application'
apply plugin: 'com.google.gms.google-services'
android {
namespace "com.compassconnections.app"
compileSdk 36
defaultConfig {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 14
versionName "1.1.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
// Import the Firebase BoM
implementation platform('com.google.firebase:firebase-bom:34.4.0')
// TODO: Add the dependencies for Firebase products you want to use
// When using the BoM, don't specify versions in Firebase dependencies
implementation 'com.google.firebase:firebase-analytics'
// Add the dependencies for any other desired Firebase products
// https://firebase.google.com/docs/android/setup#available-libraries
implementation 'com.google.android.gms:play-services-auth:21.4.0'
implementation 'com.google.firebase:firebase-auth:24.0.1'
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}

View File

@@ -0,0 +1,24 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-status-bar')
implementation project(':capawesome-capacitor-live-update')
implementation project(':capgo-capacitor-social-login')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}

21
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}

View File

@@ -0,0 +1,76 @@
<?xml version="1.1" encoding="utf-8" ?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:fitsSystemWindows="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- <intent-filter>-->
<!-- <action android:name="openapp" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
<!-- <data android:scheme="com.compassconnections.app" android:host="details"/>-->
<!-- </intent-filter>-->
<!-- <intent-filter android:autoVerify="true">-->
<!-- <action android:name="android.intent.action.VIEW" />-->
<!-- <category android:name="android.intent.category.DEFAULT" />-->
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
<!-- <data android:scheme="com.compassconnections.app" android:host="auth" />-->
<!-- </intent-filter>-->
</activity>
<!-- <service-->
<!-- android:name=".MyMessagingService"-->
<!-- android:exported="false">-->
<!-- <intent-filter>-->
<!-- <action android:name="com.google.firebase.MESSAGING_EVENT" />-->
<!-- </intent-filter>-->
<!--&lt;!&ndash; <meta-data&ndash;&gt;-->
<!--&lt;!&ndash; android:name="com.google.firebase.messaging.default_notification_channel_id"&ndash;&gt;-->
<!--&lt;!&ndash; android:value="@string/default_notification_channel_id" />&ndash;&gt;-->
<!-- </service>-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
<!-- Firebase Cloud Messaging -->
<permission android:name="${applicationId}.permission.C2D_MESSAGE" android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.C2D_MESSAGE" />
<!-- Old, can be removed ?-->
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
</manifest>

View File

@@ -0,0 +1,173 @@
package com.compassconnections.app;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebSettings;
import android.webkit.WebView;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.core.content.ContextCompat;
import com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin;
import com.getcapacitor.BridgeActivity;
import com.getcapacitor.BridgeWebViewClient;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginHandle;
import org.json.JSONException;
import org.json.JSONObject;
import ee.forgr.capacitor.social.login.GoogleProvider;
import ee.forgr.capacitor.social.login.ModifiedMainActivityForSocialLoginPlugin;
import ee.forgr.capacitor.social.login.SocialLoginPlugin;
//import android.app.NotificationChannel;
//import android.app.NotificationManager;
//import android.os.Build;
//import com.google.firebase.messaging.RemoteMessage;
//import com.capacitorjs.plugins.pushnotifications.MessagingService;
//public class MyMessagingService extends MessagingService {
//
// @Override
// public void onMessageReceived(RemoteMessage remoteMessage) {
// // TODO(developer): Handle FCM messages here.
// // Not getting messages here? See why this may be: https://goo.gl/39bRNJ
// Log.d(TAG, "From: " + remoteMessage.getFrom());
//
// // Check if message contains a data payload.
// if (remoteMessage.getData().size() > 0) {
// Log.d(TAG, "Message data payload: " + remoteMessage.getData());
//
// if (/* Check if data needs to be processed by long running job */ true) {
// // For long-running tasks (10 seconds or more) use WorkManager.
// scheduleJob();
// } else {
// // Handle message within 10 seconds
// handleNow();
// }
//
// }
//
// // Check if message contains a notification payload.
// if (remoteMessage.getNotification() != null) {
// Log.d(TAG, "Message Notification Body: " + remoteMessage.getNotification().getBody());
// }
//
// // Also if you intend on generating your own notifications as a result of a received FCM
// // message, here is where that should be initiated. See sendNotification method below.
// }
//}
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
// Declare this at class level
private final ActivityResultLauncher<String> requestPermissionLauncher =
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
if (isGranted) {
Log.i("CompassApp", "Permission granted");
// Permission granted you can show notifications
} else {
Log.i("CompassApp", "Permission denied");
// Permission denied handle gracefully
}
});
private void askNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // API 33
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
// Permission not yet granted; request it
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
}
}
}
public static class NativeBridge {
@JavascriptInterface
public boolean isNativeApp() {
return true;
}
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
// String data = intent.getDataString();
String endpoint = intent.getStringExtra("endpoint");
Log.i("CompassApp", "onNewIntent called with endpoint: " + endpoint);
if (endpoint != null) {
Log.i("CompassApp", "redirecting to endpoint: " + endpoint);
try {
String payload = new JSONObject().put("endpoint", endpoint).toString();
Log.i("CompassApp", "Payload: " + payload);
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("bridgeRedirect(" + payload + ");", null));
} catch (JSONException e) {
Log.i("CompassApp", "Failed to encode JSON payload", e);
}
} else {
Log.i("CompassApp", "No relevant data");
}
}
@Override
public void onCreate(Bundle savedInstanceState) {
Log.i("CompassApp", "onCreate called");
super.onCreate(savedInstanceState);
WebView webView = this.bridge.getWebView();
webView.setWebViewClient(new BridgeWebViewClient(this.bridge));
WebView.setWebContentsDebuggingEnabled(true);
// Set a recognizable User-Agent (always reliable)
WebSettings settings = webView.getSettings();
settings.setUserAgentString(settings.getUserAgentString() + " CompassAppWebView");
settings.setJavaScriptEnabled(true);
webView.addJavascriptInterface(new NativeBridge(), "AndroidBridge");
registerPlugin(PushNotificationsPlugin.class);
// Initialize the Bridge with Push Notifications plugin
// this.init(savedInstanceState, new ArrayList<Class<? extends Plugin>>() {{
// add(com.getcapacitor.plugin.PushNotifications.class);
// }});
askNotificationPermission();
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode >= GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MIN && requestCode < GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MAX) {
PluginHandle pluginHandle = getBridge().getPlugin("SocialLogin");
if (pluginHandle == null) {
Log.i("CompassApp", "SocialLogin login handle is null");
return;
}
Plugin plugin = pluginHandle.getInstance();
if (!(plugin instanceof SocialLoginPlugin)) {
Log.i("CompassApp", "SocialLogin plugin instance is not SocialLoginPlugin");
return;
}
Log.i("CompassApp", "handleGoogleLoginIntent");
((SocialLoginPlugin) plugin).handleGoogleLoginIntent(requestCode, data);
}
}
// This function will never be called, leave it empty
@Override
public void IHaveModifiedTheMainActivityForTheUseWithSocialLoginPlugin() {
}
}

View File

@@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Compass</string>
<string name="title_activity_main">Compass</string>
<string name="package_name">com.compassconnections.app</string>
<string name="custom_url_scheme">com.compassconnections.app</string>
</resources>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowTranslucentStatus">true</item>
</style>
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
<item name="android:background">@null</item>
<item name="android:windowTranslucentStatus">true</item>
<item name="android:windowTranslucentNavigation">true</item>
</style>
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>

View File

@@ -0,0 +1,18 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Example local unit test, which will execute on the development machine (host).
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
public class ExampleUnitTest {
@Test
public void addition_isCorrect() throws Exception {
assertEquals(4, 2 + 2);
}
}

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