72 Commits
1.6.0 ... 1.7.0

Author SHA1 Message Date
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
125 changed files with 3576 additions and 600 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

@@ -32,18 +32,32 @@ jobs:
run: npm run lint
- name: Run Jest tests
run: npm run test tests/jest
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
# - name: Build app
# - name: Build app
# env:
# DATABASE_URL: ${{ secrets.DATABASE_URL }}
# run: npm run build
# Optional: Playwright E2E tests
- name: Install Playwright deps
run: npx playwright install --with-deps
# npm install @playwright/test
# npx playwright install
run: |
npx playwright install --with-deps
# npm install @playwright/test
- name: Run E2E tests
env:
@@ -52,9 +66,29 @@ jobs:
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 nyc --reporter=lcov yarn --cwd=web serve &
npx nyc --reporter=lcov yarn --cwd=backend/api dev &
npx wait-on http://localhost:3000
npx playwright test tests/e2e
SERVER_PID=$(fuser -k 3000/tcp)
echo $SERVER_PID
kill $SERVER_PID
SERVER_PID=$(fuser -k 8088/tcp)
echo $SERVER_PID
kill $SERVER_PID
- 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

8
.gitignore vendored
View File

@@ -15,6 +15,7 @@
# Playwright
/tests/reports/playwright-report
/tests/e2e/web/.auth/
# next.js
/.next/
@@ -64,7 +65,7 @@ email-preview
*.last-run.json
*lock.hcl
/web/pages/test.tsx
/web/pages/_test.tsx
*.png
*.jpg
@@ -95,3 +96,8 @@ email-preview
android/app/release*
icons/
*.bak
test-results
/.nyc_output/
**/coverage

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,11 +1,13 @@
[![CI](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
[![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/compass)
[![CD](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd.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.
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
@@ -51,7 +53,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 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.)
@@ -65,8 +69,7 @@ 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 fields (intellectual interests, cause areas, personality type, conflict style, timezone, etc.)
- [ ] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
- [ ] Cover with tests (crucial, just the test template and framework are ready)
- [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)
@@ -102,38 +105,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
@@ -160,7 +148,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 fields.
##### 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
@@ -187,5 +185,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

@@ -76,7 +76,7 @@ javac -version
```
yarn install
yarn build-web
yarn build-web-view
```
### Local mode
@@ -110,18 +110,25 @@ cd android
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 # if you made changes to web app
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. Note that right now you can't use a physical device for the local web version (`10.0.2.2:3000 time out` )
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".
@@ -175,6 +182,10 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
./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
@@ -236,7 +247,7 @@ yarn dev # or prod
# Terminal 2: start frontend
export NEXT_PUBLIC_LOCAL_ANDROID=1
yarn build-web # if you made changes to web app
yarn build-web-view # if you made changes to web app
npx cap sync android
# Run on emulator or device
```
@@ -246,17 +257,41 @@ npx cap sync android
## 14. Deployment Workflow
```bash
# 1. Build web app for production
yarn build-web
# Build web app for production and Sync assets to Android
yarn build-sync-android
# 2. Sync assets to Android
npx cap sync android
# 3. Build signed release APK in Android Studio
# 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)
@@ -362,3 +397,17 @@ await admin.messaging().send(message)
```
---
## 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.

View File

@@ -8,8 +8,8 @@ android {
applicationId "com.compassconnections.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 13
versionName "1.1.2"
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.

View File

@@ -13,6 +13,7 @@ dependencies {
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')
}

View File

@@ -1,37 +0,0 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.compassconnections.app",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 13,
"versionName": "1.1.2",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 23
}

View File

@@ -14,5 +14,8 @@ project(':capacitor-push-notifications').projectDir = new File('../node_modules/
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capawesome-capacitor-live-update'
project(':capawesome-capacitor-live-update').projectDir = new File('../node_modules/@capawesome/capacitor-live-update/android')
include ':capgo-capacitor-social-login'
project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android')

View File

@@ -27,6 +27,12 @@ gcloud auth login
gcloud config set project YOUR_PROJECT_ID
```
You also need `opentofu` and `docker`. Try running this (from root) on Linux or macOS for a faster install:
```bash
./script/setup.sh
```
If it doesn't work, you can install them manually (google how to install `opentofu` and `docker` for your OS).
### Setup
This section is only for the people who are creating a server from scratch, for instance for a forked project.

View File

@@ -0,0 +1,31 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: [
"<rootDir>/tests/**/*.test.ts",
"<rootDir>/tests/**/*.spec.ts"
],
moduleNameMapper: {
"^api/(.*)$": "<rootDir>/src/$1",
"^shared/(.*)$": "<rootDir>/../shared/src/$1",
"^common/(.*)$": "<rootDir>/../../common/src/$1",
"^email/(.*)$": "<rootDir>/../email/emails/$1"
},
moduleFileExtensions: ["ts", "js", "json"],
clearMocks: true,
globals: {
'ts-jest': {
tsconfig: "<rootDir>/tsconfig.test.json"
}
},
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts"
],
};

View File

@@ -20,7 +20,9 @@
"verify": "yarn --cwd=../.. verify",
"verify:dir": "npx eslint . --max-warnings 0",
"regen-types": "cd ../supabase && make ENV=prod regen-types",
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev"
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev",
"test": "jest --config jest.config.js",
"test:coverage": "jest --config jest.config.js --coverage"
},
"engines": {
"node": ">=20.0.0"

View File

@@ -13,6 +13,7 @@ import {getCompatibleProfilesHandler} from './compatible-profiles'
import {createComment} from './create-comment'
import {createCompatibilityQuestion} from './create-compatibility-question'
import {setCompatibilityAnswer} from './set-compatibility-answer'
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
@@ -341,6 +342,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'hide-comment': hideComment,
'create-compatibility-question': createCompatibilityQuestion,
'set-compatibility-answer': setCompatibilityAnswer,
'delete-compatibility-answer': deleteCompatibilityAnswer,
'create-vote': createVote,
'vote': vote,
'contact': contact,

View File

@@ -25,8 +25,13 @@ export const contact: APIHandler<'contact'> = async (
const continuation = async () => {
try {
let user = null
if (userId) {
user = await pg.oneOrNone(` select name from users where id = $1 `, [userId])
}
const md = jsonToMarkdown(content)
const message: string = `**New Contact Message**\n${md}`
const tile = user ? `New message from ${user.name}` : 'New message'
const message: string = `**${tile}**\n${md}`
await sendDiscordMessage(message, 'contact')
} catch (e) {
console.error('Failed to send discord contact', e)

View File

@@ -0,0 +1,30 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError} from 'common/api/utils'
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
{id}, auth) => {
const pg = createSupabaseDirectClient()
// Verify user is the answer author
const item = await pg.oneOrNone(
`SELECT *
FROM compatibility_answers
WHERE id = $1
AND creator_id = $2`,
[id, auth.uid]
)
if (!item) {
throw new APIError(404, 'Item not found')
}
// Delete the answer
await pg.none(
`DELETE
FROM compatibility_answers
WHERE id = $1
AND creator_id = $2`,
[id, auth.uid]
)
}

View File

@@ -22,6 +22,9 @@ export type profileQueryType = {
pref_romantic_styles?: String[] | undefined,
diet?: String[] | undefined,
political_beliefs?: String[] | undefined,
mbti?: String[] | undefined,
relationship_status?: String[] | undefined,
languages?: String[] | undefined,
religion?: String[] | undefined,
wants_kids_strength?: number | undefined,
has_kids?: number | undefined,
@@ -58,6 +61,9 @@ export const loadProfiles = async (props: profileQueryType) => {
pref_romantic_styles,
diet,
political_beliefs,
mbti,
relationship_status,
languages,
religion,
wants_kids_strength,
has_kids,
@@ -91,6 +97,7 @@ export const loadProfiles = async (props: profileQueryType) => {
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
(!genders || genders.includes(l.gender ?? '')) &&
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
(!mbti || mbti.includes(l.mbti ?? '')) &&
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
@@ -104,6 +111,10 @@ export const loadProfiles = async (props: profileQueryType) => {
intersection(diet, l.diet).length) &&
(!political_beliefs ||
intersection(political_beliefs, l.political_beliefs).length) &&
(!relationship_status ||
intersection(relationship_status, l.relationship_status).length) &&
(!languages ||
intersection(languages, l.languages).length) &&
(!religion ||
intersection(religion, l.religion).length) &&
(!wants_kids_strength ||
@@ -166,6 +177,8 @@ export const loadProfiles = async (props: profileQueryType) => {
education_levels?.length && where(`education_level = ANY($(education_levels))`, {education_levels}),
mbti?.length && where(`mbti = ANY($(mbti))`, {mbti}),
pref_gender?.length &&
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {pref_gender}),
@@ -205,6 +218,18 @@ export const loadProfiles = async (props: profileQueryType) => {
{political_beliefs}
),
relationship_status?.length &&
where(
`relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`,
{relationship_status}
),
languages?.length &&
where(
`languages && $(languages)`,
{languages}
),
religion?.length &&
where(
`religion IS NULL OR religion = '{}' OR religion && $(religion)`,

View File

@@ -0,0 +1,59 @@
import { getUser } from "api/get-user";
import { createSupabaseDirectClient } from "shared/supabase/init";
import { toUserAPIResponse } from "common/api/user-types";
import { convertUser } from "common/supabase/users";
import { APIError } from "common/src/api/utils";
jest.mock("shared/supabase/init");
jest.mock("common/supabase/users");
jest.mock("common/api/utils");
describe('getUser', () =>{
let mockPg: any;
beforeEach(() => {
mockPg = {
oneOrNone: jest.fn(),
};
(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg);
jest.clearAllMocks();
});
it('should fetch user successfully by id', async () => {
const mockDbUser = {
created_time: '2025-11-11T16:42:05.188Z',
data: { link: {}, avatarUrl: "", isBannedFromPosting: false },
id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP',
name: 'Franklin Buckridge',
name_username_vector: "'buckridg':2,4 'franklin':1,3",
username: 'Franky_Buck'
};
const mockConvertedUser = {
created_time: new Date(),
id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP',
name: 'Franklin Buckridge',
name_username_vector: "'buckridg':2,4 'franklin':1,3",
username: 'Franky_Buck'
};
const mockApiResponse = {
created_time: '2025-11-11T16:42:05.188Z',
data: { link: {}, avatarUrl: "", isBannedFromPosting: false },
id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP',
name: 'Franklin Buckridge',
username: 'Franky_Buck'
};
// mockPg.oneOrNone.mockImplementation((query: any, params: any, callback: any) => {
// return Promise.resolve(callback(mockDbUser))
// })
// (convertUser as jest.Mock).mockReturnValue(mockConvertedUser);
// ( toUserAPIResponse as jest.Mock).mockReturnValue(mockApiResponse);
// const result = await getUser({id: 'feUaIfcxVmJZHJOVVfawLTTPgZiP'})
// console.log(result);
})
})

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
// Use / as the root for tests so path-mapped imports from
// ../shared and ../../common are within the configured rootDir.
// This avoids TS6059 during ts-jest compilation.
"rootDir": "../..",
"baseUrl": ".",
"paths": {
"api/*": ["src/*"],
"shared/*": ["../shared/src/*"],
"common/*": ["../../common/src/*"],
"email/*": ["../email/emails/*"]
}
},
"include": ["tests/**/*.ts", "src/**/*.ts"]
}

View File

@@ -37,12 +37,14 @@ export const sinclairProfile: ProfileRow = {
pref_age_min: 18,
pref_age_max: 21,
religion: [],
languages: ['english'],
pref_relation_styles: ['friendship'],
pref_romantic_styles: ['poly', 'open', 'mono'],
disabled: false,
wants_kids_strength: 3,
looking_for_matches: true,
visibility: 'public',
mbti: 'intj',
messaging_status: 'open',
comments_enabled: true,
has_kids: 0,
@@ -50,6 +52,7 @@ export const sinclairProfile: ProfileRow = {
drinks_per_month: 0,
diet: null,
political_beliefs: ['e/acc', 'libertarian'],
relationship_status: ['married'],
religious_belief_strength: null,
religious_beliefs: null,
political_details: '',
@@ -142,6 +145,7 @@ export const jamesProfile: ProfileRow = {
pref_age_min: 22,
pref_age_max: 32,
religion: [],
languages: ['english'],
pref_relation_styles: ['friendship'],
pref_romantic_styles: ['poly', 'open', 'mono'],
wants_kids_strength: 4,
@@ -154,8 +158,10 @@ export const jamesProfile: ProfileRow = {
drinks_per_month: 5,
diet: null,
political_beliefs: ['libertarian'],
relationship_status: ['single'],
religious_belief_strength: null,
religious_beliefs: '',
mbti: 'intj',
political_details: '',
photo_urls: [
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2Flove-images%2FKl0WtbZsZW.jpg?alt=media&token=c928604f-e5ff-4406-a229-152864a4aa48',

View File

@@ -0,0 +1,31 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: [
"<rootDir>/tests/**/*.test.ts",
"<rootDir>/tests/**/*.spec.ts"
],
moduleNameMapper: {
"^api/(.*)$": "<rootDir>/src/$1",
"^shared/(.*)$": "<rootDir>/../shared/src/$1",
"^common/(.*)$": "<rootDir>/../../common/src/$1",
"^email/(.*)$": "<rootDir>/../email/emails/$1"
},
moduleFileExtensions: ["ts", "js", "json"],
clearMocks: true,
globals: {
'ts-jest': {
tsconfig: "<rootDir>/tsconfig.test.json"
}
},
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts"
],
};

View File

@@ -4,7 +4,8 @@
"private": true,
"scripts": {
"dev": "email dev --port 3001",
"build": "tsc -b"
"build": "tsc -b",
"test": "jest --config jest.config.js --passWithNoTests"
},
"dependencies": {
"@react-email/components": "0.0.33",

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
// Use / as the root for tests so path-mapped imports from
// ../shared and ../../common are within the configured rootDir.
// This avoids TS6059 during ts-jest compilation.
"rootDir": "../..",
"baseUrl": ".",
"paths": {
"api/*": ["src/*"],
"shared/*": ["../shared/src/*"],
"common/*": ["../../common/src/*"],
"email/*": ["../email/emails/*"]
}
},
"include": ["tests/**/*.ts", "src/**/*.ts"]
}

View File

@@ -0,0 +1,31 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: [
"<rootDir>/tests/**/*.test.ts",
"<rootDir>/tests/**/*.spec.ts"
],
moduleNameMapper: {
"^api/(.*)$": "<rootDir>/src/$1",
"^shared/(.*)$": "<rootDir>/../shared/src/$1",
"^common/(.*)$": "<rootDir>/../../common/src/$1",
"^email/(.*)$": "<rootDir>/../email/emails/$1"
},
moduleFileExtensions: ["ts", "js", "json"],
clearMocks: true,
globals: {
'ts-jest': {
tsconfig: "<rootDir>/tsconfig.test.json"
}
},
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts"
],
};

View File

@@ -6,7 +6,8 @@
"build": "tsc -b && yarn --cwd=../../common tsc-alias && tsc-alias",
"compile": "tsc -b",
"verify": "yarn --cwd=../.. verify",
"verify:dir": "npx eslint . --max-warnings 0"
"verify:dir": "npx eslint . --max-warnings 0",
"test": "jest --config jest.config.js --passWithNoTests"
},
"sideEffects": false,
"dependencies": {

View File

@@ -0,0 +1 @@
Note: may not be needed. `shared` rarely needs integration tests.

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
// Use / as the root for tests so path-mapped imports from
// ../shared and ../../common are within the configured rootDir.
// This avoids TS6059 during ts-jest compilation.
"rootDir": "../..",
"baseUrl": ".",
"paths": {
"api/*": ["src/*"],
"shared/*": ["../shared/src/*"],
"common/*": ["../../common/src/*"],
"email/*": ["../email/emails/*"]
}
},
"include": ["tests/**/*.ts", "src/**/*.ts"]
}

View File

@@ -8,7 +8,7 @@ create table if not exists
-- Foreign Keys
alter table contact
add constraint contact_user_id_fkey foreign key (user_id) references users (id);
add constraint contact_user_id_fkey foreign key (user_id) references users (id) on delete set null;
-- Row Level Security
alter table contact enable row level security;

View File

@@ -0,0 +1,6 @@
-- Add languages column to profiles table
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS languages TEXT[] null;
-- Create GIN index for array operations
CREATE INDEX IF NOT EXISTS idx_profiles_languages ON profiles USING GIN (languages);

View File

@@ -0,0 +1,6 @@
-- Add MBTI column to profiles table
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS mbti TEXT;
-- Create GIN index for array operations
CREATE INDEX IF NOT EXISTS idx_profiles_mbti ON profiles USING btree (mbti);

View File

@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS profiles (
pinned_url TEXT,
political_details TEXT,
political_beliefs TEXT[],
relationship_status TEXT[],
pref_age_max INTEGER NULL,
pref_age_min INTEGER NULL,
pref_gender TEXT[] NOT NULL,

View File

@@ -8,15 +8,15 @@ create table if not exists
id text default uuid_generate_v4 () not null,
parent_id text,
parent_type text,
user_id text not null
user_id text
);
-- Foreign Keys
alter table reports
add constraint reports_content_owner_id_fkey foreign key (content_owner_id) references users (id);
add constraint reports_content_owner_id_fkey foreign key (content_owner_id) references users (id) on delete set null;
alter table reports
add constraint reports_user_id_fkey foreign key (user_id) references users (id);
add constraint reports_user_id_fkey foreign key (user_id) references users (id) on delete set null;
-- Row Level Security
alter table reports enable row level security;

View File

@@ -9,7 +9,12 @@ const config: CapacitorConfig = {
appId: 'com.compassconnections.app',
appName: 'Compass',
webDir: 'web/out',
server: LOCAL_ANDROID ? { url: `http://${LOCAL_URL}:3000`, cleartext: true } : {}
server: LOCAL_ANDROID ? { url: `http://${LOCAL_URL}:3000`, cleartext: true } : {},
plugins: {
LiveUpdate: {
appId: "969bc540-8077-492f-8403-b554bee5de50"
}
}
};
export default config;

View File

@@ -1,12 +1,16 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
const { pathsToModuleNameMapper } = require('ts-jest')
const { compilerOptions } = require('./tsconfig')
const {pathsToModuleNameMapper} = require('ts-jest')
const {compilerOptions} = require('./tsconfig')
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
prefix: '<rootDir>/',
}),
testMatch: ['**/*.test.ts'],
preset: 'ts-jest',
testEnvironment: 'node',
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, {
prefix: '<rootDir>/',
}),
testMatch: ['**/*.test.ts'],
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts"
],
}

View File

@@ -7,7 +7,7 @@
"compile": "tsc -b",
"verify": "yarn --cwd=.. verify",
"verify:dir": "npx eslint . --max-warnings 0",
"test": "jest"
"test": "jest --config jest.config.js --passWithNoTests"
},
"sideEffects": false,
"dependencies": {

View File

@@ -295,6 +295,47 @@ export const API = (_apiTypeCheck = {
summary: 'Remove the pinned photo from a profile',
tag: 'Profiles',
},
'create-compatibility-question': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
options: z.record(z.string(), z.number()),
}),
summary: 'Create a new compatibility question with options',
tag: 'Compatibility',
},
'set-compatibility-answer': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as Row<'compatibility_answers'>,
props: z
.object({
questionId: z.number(),
multipleChoice: z.number(),
prefChoices: z.array(z.number()),
importance: z.number(),
explanation: z.string().nullable().optional(),
})
.strict(),
summary: 'Submit or update a compatibility answer',
tag: 'Compatibility',
},
'get-profile-answers': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}).strict(),
returns: {} as {
status: 'success'
answers: Row<'compatibility_answers'>[]
},
summary: 'Get compatibility answers for a profile',
tag: 'Compatibility',
},
'get-compatibility-questions': {
method: 'GET',
authed: true,
@@ -310,6 +351,16 @@ export const API = (_apiTypeCheck = {
summary: 'Retrieve compatibility questions and stats',
tag: 'Compatibility',
},
'delete-compatibility-answer': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
id: z.number(),
}),
summary: 'Delete a compatibility answer',
tag: 'Compatibility',
},
'like-profile': {
method: 'POST',
authed: true,
@@ -405,6 +456,9 @@ export const API = (_apiTypeCheck = {
pref_romantic_styles: arraybeSchema.optional(),
diet: arraybeSchema.optional(),
political_beliefs: arraybeSchema.optional(),
mbti: arraybeSchema.optional(),
relationship_status: arraybeSchema.optional(),
languages: arraybeSchema.optional(),
wants_kids_strength: z.coerce.number().optional(),
has_kids: z.coerce.number().optional(),
is_smoker: zBoolean.optional().optional(),
@@ -623,47 +677,6 @@ export const API = (_apiTypeCheck = {
// summary: 'Get reactions for a message',
// tag: 'Messages',
// },
'create-compatibility-question': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
options: z.record(z.string(), z.number()),
}),
summary: 'Create a new compatibility question with options',
tag: 'Compatibility',
},
'set-compatibility-answer': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as Row<'compatibility_answers'>,
props: z
.object({
questionId: z.number(),
multipleChoice: z.number(),
prefChoices: z.array(z.number()),
importance: z.number(),
explanation: z.string().nullable().optional(),
})
.strict(),
summary: 'Submit or update a compatibility answer',
tag: 'Compatibility',
},
'get-profile-answers': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}).strict(),
returns: {} as {
status: 'success'
answers: Row<'compatibility_answers'>[]
},
summary: 'Get compatibility answers for a profile',
tag: 'Compatibility',
},
'create-vote': {
method: 'POST',
authed: true,

View File

@@ -1,6 +1,6 @@
import { z } from 'zod'
import { type JSONContent } from '@tiptap/core'
import { arrify } from 'common/util/array'
import {z} from 'zod'
import {type JSONContent} from '@tiptap/core'
import {arrify} from 'common/util/array'
/* GET request array can be like ?a=1 or ?a=1&a=2 */
export const arraybeSchema = z
@@ -67,6 +67,7 @@ export const baseProfilesSchema = z.object({
region_code: z.string().optional().nullable(),
visibility: z.union([z.literal('public'), z.literal('member')]),
wants_kids_strength: z.number().nullable(),
languages: z.array(z.string()).optional().nullable(),
})
const optionalProfilesSchema = z.object({
@@ -81,6 +82,7 @@ const optionalProfilesSchema = z.object({
drinks_min: z.number().min(0).optional().nullable(),
drinks_per_month: z.number().min(0).optional().nullable(),
education_level: z.string().optional().nullable(),
mbti: z.string().optional().nullable(),
ethnicity: z.array(z.string()).optional().nullable(),
has_kids: z.number().min(0).optional().nullable(),
has_pets: zBoolean.optional().nullable(),
@@ -89,6 +91,7 @@ const optionalProfilesSchema = z.object({
occupation: z.string().optional().nullable(),
occupation_title: z.string().optional().nullable(),
political_beliefs: z.array(z.string()).optional().nullable(),
relationship_status: z.array(z.string()).optional().nullable(),
political_details: z.string().optional().nullable(),
pref_romantic_styles: z.array(z.string()).nullable(),
religion: z.array(z.string()).optional().nullable(),

View File

@@ -16,6 +16,7 @@ export type FilterFields = {
radius: number | null
genders: string[]
education_levels: string[]
mbti: string[]
name: string | undefined
shortBio: boolean | undefined
drinks_min: number | undefined
@@ -27,6 +28,8 @@ export type FilterFields = {
| 'pref_romantic_styles'
| 'diet'
| 'political_beliefs'
| 'relationship_status'
| 'languages'
| 'is_smoker'
| 'has_kids'
| 'pref_gender'
@@ -71,7 +74,10 @@ export const initialFilters: Partial<FilterFields> = {
pref_romantic_styles: undefined,
diet: undefined,
political_beliefs: undefined,
relationship_status: undefined,
languages: undefined,
religion: undefined,
mbti: undefined,
pref_gender: undefined,
shortBio: undefined,
drinks_min: undefined,

62
common/src/logging.ts Normal file
View File

@@ -0,0 +1,62 @@
import {IS_LOCAL} from "common/hosting/constants"
class Logger {
private readonly isLocal: boolean;
constructor(isLocal = false) {
this.isLocal = isLocal;
}
private getCallerFrame(skip = 2): string | undefined {
// Create an Error to get stack trace
const err = new Error();
if (!err.stack) return undefined;
const lines = err.stack.split("\n");
// skip frames (0: Error, 1: Logger method, 2+: caller)
return lines[skip]?.trim();
}
trace(...args: any[]) {
// Does not seem to really work. Was trying to show the real file where the log was called from.
if (!this.isLocal) return;
const caller = this.getCallerFrame(3); // skip Logger frames
if (caller) {
console.log("Trace:", ...args, "\nCalled from:", caller);
} else {
console.trace(...args);
}
}
log(...args: any[]) {
if (this.isLocal) console.log(...args);
}
info(...args: any[]) {
if (this.isLocal) console.info(...args);
}
warn(...args: any[]) {
if (this.isLocal) console.warn(...args);
}
error(...args: any[]) {
if (this.isLocal) console.error(...args);
}
debug(...args: any[]) {
if (this.isLocal) console.debug(...args);
}
table(...args: any[]) {
if (this.isLocal) console.table(...args);
}
group(...args: any[]) {
if (this.isLocal) console.group(...args);
}
}
export const logger = new Logger(IS_LOCAL)

View File

@@ -22,6 +22,8 @@ const filterLabels: Record<string, string> = {
orderBy: "",
diet: "Diet",
political_beliefs: "Political views",
languages: "",
mbti: "MBTI",
}
export type locationType = {

View File

@@ -76,6 +76,6 @@ describe('getSocialUrl', () => {
it('should handle discord user IDs and default invite', () => {
expect(getSocialUrl('discord', '123456789012345678')).toBe('https://discord.com/users/123456789012345678')
expect(getSocialUrl('discord', 'not-an-id')).toBe({discordLink})
expect(getSocialUrl('discord', 'not-an-id')).toBe(discordLink)
})
})

View File

@@ -563,8 +563,10 @@ export type Database = {
height_in_inches: number | null
id: number
is_smoker: boolean | null
languages: string[] | null
last_modification_time: string
looking_for_matches: boolean
mbti: string | null
messaging_status: string
occupation: string | null
occupation_title: string | null
@@ -579,6 +581,7 @@ export type Database = {
pref_romantic_styles: string[] | null
referred_by_username: string | null
region_code: string | null
relationship_status: string[] | null
religion: string[] | null
religious_belief_strength: number | null
religious_beliefs: string | null
@@ -614,8 +617,10 @@ export type Database = {
height_in_inches?: number | null
id?: number
is_smoker?: boolean | null
languages?: string[] | null
last_modification_time?: string
looking_for_matches?: boolean
mbti?: string | null
messaging_status?: string
occupation?: string | null
occupation_title?: string | null
@@ -630,6 +635,7 @@ export type Database = {
pref_romantic_styles?: string[] | null
referred_by_username?: string | null
region_code?: string | null
relationship_status?: string[] | null
religion?: string[] | null
religious_belief_strength?: number | null
religious_beliefs?: string | null
@@ -665,8 +671,10 @@ export type Database = {
height_in_inches?: number | null
id?: number
is_smoker?: boolean | null
languages?: string[] | null
last_modification_time?: string
looking_for_matches?: boolean
mbti?: string | null
messaging_status?: string
occupation?: string | null
occupation_title?: string | null
@@ -681,6 +689,7 @@ export type Database = {
pref_romantic_styles?: string[] | null
referred_by_username?: string | null
region_code?: string | null
relationship_status?: string[] | null
religion?: string[] | null
religious_belief_strength?: number | null
religious_beliefs?: string | null
@@ -775,7 +784,7 @@ export type Database = {
id: string
parent_id: string | null
parent_type: string | null
user_id: string
user_id: string | null
}
Insert: {
content_id: string
@@ -786,7 +795,7 @@ export type Database = {
id?: string
parent_id?: string | null
parent_type?: string | null
user_id: string
user_id?: string | null
}
Update: {
content_id?: string
@@ -797,7 +806,7 @@ export type Database = {
id?: string
parent_id?: string | null
parent_type?: string | null
user_id?: string
user_id?: string | null
}
Relationships: [
{

View File

@@ -1,20 +1,15 @@
import {
getText,
getSchema,
getTextSerializersFromSchema,
JSONContent,
} from '@tiptap/core'
import { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { StarterKit } from '@tiptap/starter-kit'
import { Image } from '@tiptap/extension-image'
import { Link } from '@tiptap/extension-link'
import { Mention } from '@tiptap/extension-mention'
import {getSchema, getText, getTextSerializersFromSchema, JSONContent,} from '@tiptap/core'
import {Node as ProseMirrorNode} from '@tiptap/pm/model'
import {StarterKit} from '@tiptap/starter-kit'
import {Image} from '@tiptap/extension-image'
import {Link} from '@tiptap/extension-link'
import {Mention} from '@tiptap/extension-mention'
import Iframe from './tiptap-iframe'
import { find } from 'linkifyjs'
import { uniq } from 'lodash'
import { compareTwoStrings } from 'string-similarity'
import {find} from 'linkifyjs'
import {uniq} from 'lodash'
import {compareTwoStrings} from 'string-similarity'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
/** get first url in text. like "notion.so " -> "http://notion.so" "notion" -> null */
export function getUrl(text: string) {
const results = find(text, 'url')
return results.length ? results[0].href : null
@@ -48,10 +43,10 @@ export function parseMentions(data: JSONContent): string[] {
export const extensions = [
StarterKit,
Link,
Image.extend({ renderText: () => '[image]' }),
Image.extend({renderText: () => '[image]'}),
Mention, // user @mention
Iframe.extend({
renderText: ({ node }) =>
renderText: ({node}) =>
'[embed]' + node.attrs.src ? `(${node.attrs.src})` : '',
}),
]
@@ -78,8 +73,68 @@ export function parseJsonContentToText(content: JSONContent | string) {
}
export function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
return new Uint8Array([...rawData].map(c => c.charCodeAt(0)));
}
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
return new Uint8Array([...rawData].map(c => c.charCodeAt(0)))
}
export function cleanDoc(doc: JSONContent) {
try {
return _cleanDoc(doc)
} catch (e) {
console.error('error cleaning doc', doc, e)
return doc
}
}
function _cleanDoc(doc: JSONContent) {
if (!doc || !Array.isArray(doc.content)) return doc;
let content = [...doc.content];
const isEmptyParagraph = (node: JSONContent) =>
node.type === "paragraph" &&
(!node.content || node.content.length === 0);
// Remove empty paragraphs at the start
while (content.length > 0 && isEmptyParagraph(content[0])) {
content.shift();
}
// Remove empty paragraphs at the end
while (content.length > 0 && isEmptyParagraph(content[content.length - 1])) {
content.pop();
}
// Trim leading/trailing hardBreaks within first and last paragraphs
const trimHardBreaks = (paragraph: JSONContent) => {
if (!paragraph.content) return paragraph;
let nodes = [...paragraph.content];
// Remove hardBreaks at the start
while (nodes.length > 0 && nodes[0].type === "hardBreak") {
nodes.shift();
}
// Remove hardBreaks at the end
while (nodes.length > 0 && nodes[nodes.length - 1].type === "hardBreak") {
nodes.pop();
}
return { ...paragraph, content: nodes };
};
if (content.length > 0) {
content[0] = trimHardBreaks(content[0]);
if (content.length > 1) {
content[content.length - 1] = trimHardBreaks(content[content.length - 1]);
}
}
// Remove any now-empty paragraphs created by hardBreak trimming
content = content.filter(node => !(node.type === "paragraph" && (!node.content || node.content.length === 0)));
return { ...doc, content };
}

View File

0
common/tests/unit/.keep Normal file
View File

View File

@@ -0,0 +1,7 @@
import {jsonToMarkdown} from "../../src/md";
describe('JSON to Markdown', () => {
it('', () => {
expect(jsonToMarkdown({})).toBe('')
})
})

444
docs/Next.js.md Normal file
View File

@@ -0,0 +1,444 @@
# Next.js
But how does page rendering work with Next.js?
### Terminology
- SSR: server-side rendering (Edge or Lambda)
- SSG: static site generation
- ISR: incremental static regeneration
### DOM
Document Object Model: The in-browser tree of HTML elements representing the page.
### Client
The user's environment, the browser. The front-end is the code running in that environment.
On Compass, it can be any browser (Chrome, Firefox, etc.).
### Server
Any remote infrastructure, i.e., not running in the user's environment / OS. The back-end is the code that runs in that environment.
On Compass, there are two servers:
- Web server: hosted on Vercel at `compassmeet.com`, which mostly provides the web pages to the client. That's the server we are talking about in the rest of the document.
- Core server: hosted on Google Cloud at `api.compassmeet.com`, a server with more resources and permissions to update the database. It's in charge of any operation related to non-web data (i.e., no HTML or CSS) such as accounts, profiles, messages, and votes.
---
### React
React is a client-side UI library.
Its core job: create and update a **virtual DOM**, then reconcile that with the **real DOM** in the browser.
React itself **does not** define routing, data fetching conventions, or server rendering (it allows it, but doesnt provide a full system).
**Key behavior:**
- React components run on the **client** by default.
- React can be rendered on the **server** (via frameworks like Next.js), but this requires additional tooling.
- React uses a Virtual DOM to compute minimal changes, then applies them to the real DOM.
---
### Hydration
When a framework pre-renders HTML on the server, the browser receives static markup (HTML, JS and CSS). React then runs on the client and attaches event listeners and internal state to that markup.
Hydration bridges static HTML (build time) and interactive React behavior (run time).
You only need hydration if you have server-rendered HTML that must become interactive.
---
React re-renders a component **whenever its state or props change**. Hooks dont cause re-renders by themselves — **their returned values changing** does.
### React re-renders when:
1. **A state updater runs**
- `setState(...)` from `useState`
- `dispatch(...)` from `useReducer`
2. **Parent props change**
- Any parent re-render that produces new props for the child
3. **Context value changes**
- When a context provider updates its value, all consumers re-render
4. **External stores change** (in React 18+ “use sync external store” pattern)
- `useSyncExternalStore`
- Custom store hooks subscribing to something (auth store, Zustand, Redux, etc.)
5. **Server → Client hydration mismatch forces a re-render**
- Rare; usually an error condition
Re-rendering **does NOT happen** simply because:
- You called the component function manually
- A hook _exists_
- A ref changed (`useRef`)
- An effect ran (`useEffect`)
- A variable changed outside React state
---
# Difference Between Re-rendering and Hydration
## Hydration
- Happens **once**, right after the server-rendered HTML loads in the browser.
- React attaches event listeners and internal state structures to the existing DOM.
- Restores component tree in memory but does _not_ replace DOM unless mismatched.
Hydration is **startup initialization**.
## Re-rendering
- Happens **after hydration**, repeatedly, whenever React thinks the UI must update.
- Reconciliation compares new virtual DOM to old and mutates the real DOM minimally.
### Important distinction:
- Variables inside components do **not** persist across renders.
- Only state, context, memoized values, refs, and hooks preserve information.
---
### Next.js: What it adds
Next.js is a React framework that controls **where** code runs (server vs client), **when** it runs (build vs request time), and how HTML is generated. It adds routing, rendering strategies, data fetching conventions, and server infrastructure.
Next.js introduces:
- Server Components vs Client Components
- Route-based rendering
- Built-in server rendering pipelines
- Build-time optimizations
---
### Client vs Server in Next.js
**Server Components:**
- Render **on the server**, never shipped to the client.
- Can safely access databases, filesystem, secrets.
- Output is serialized to HTML + a data format React uses to assemble the UI.
**Client Components (`"use client"`):**
- Render **in the browser**.
- Shipped to the client as JS bundles.
- Needed for interactivity (state, events, effects).
Notes:
- You can mix Server and Client components; the boundary matters for bundle size and where code executes.
- Only Client Components hydrate.
---
### Build-Time vs Run-Time
#### Build-Time (during `next build`)
- The compiler analyzes the app, identifies server boundaries, optimizes routing.
- Static pages (SSG) are rendered to final HTML.
- Partial data may be pre-fetched if using static data functions.
- Bundles for client components are built.
#### Run-Time
- Server Components for SSR are executed on each request.
- Serverless or edge functions run as needed.
- Client Components hydrate and run effects in the browser.
---
### Rendering Strategies
#### SSR (Server-Side Rendering)
- Used a webpage or API endpoint (to get server-side data like build ID).
- Generated on **every request**.
- Good for dynamic data that must be fresh.
- Initial load: server renders HTML → client hydrates.
- Runs server logic each time a user requests the page.
Can be dynamic or edge.
###### λ (Dynamic)
- **Server-rendered on demand using Node.js**
- Each request hits a Node.js server (or serverless function)
- Full access to Node APIs, filesystem, secrets, DB connections
- Typical use: **SSR pages with dynamic data** not suitable for edge
- Latency depends on server location
###### ℇ (Edge Runtime)
- **Server-rendered on demand using Edge Runtime**
- Runs on global edge nodes (CDN locations)
- Faster response due to geographic proximity
- Limited APIs: no filesystem, limited Node.js modules, mostly fetch and standard web APIs
- Typical use: **low-latency SSR or ISR at the edge**
#### SSG (Static Site Generation)
- HTML is generated **at build time**.
- Served as static files.
- Zero server cost at request time.
- Best for data that changes rarely.
#### ISR (Incremental Static Regeneration)
- A hybrid of SSG + scheduled revalidation.
- Page is generated at build, then regenerated **in the background** after a specified interval.
- Allows static pages with reasonably fresh content without full rebuilds.
Example behavior:
- First request after the revalidation window triggers a background regeneration.
- Users keep seeing the old page until the new one is ready, then updates swap in.
There are components that:
- Run once **on the server** to generate HTML (SSR phase)
- Then run again **in the browser** for hydration (client phase)
---
### What Runs Where: Quick Table
| Task / Code | Build Time | Server Run Time | Client Run Time |
| -------------------------- | ---------- | ---------------- | --------------- |
| Pre-rendering SSG pages | Yes | No | No |
| ISR regeneration | No | Yes (background) | No |
| SSR rendering | No | Yes | No |
| React event handling | No | No | Yes |
| `useEffect` | No | No | Yes |
| Server Component rendering | No | Yes | No |
| Client Component hydration | No | No | Yes |
---
# 1. Component Type Detection
## A. **Client Component (App Router)**
A component is a **Client Component** if:
- The file begins with `"use client"`, or
- It uses **client-only hooks** (`useState`, `useEffect`, `useRef`, etc.), or
- It references **browser APIs** (`window`, `document`, `localStorage`, etc.), or
- It uses **client navigation** (`Router.replace`, `useRouter`), or
- It uses **interactive JSX handlers**: `onClick`, `onSubmit`, etc.
**Implications:**
- Runs **only in browser**
- **Hydrates** on the client
- **No SSR** of its data
- May receive HTML shell from server, but logic/data loads on client
Client Components = browser-only, hydrated, interactive.
---
## B. **Server Component (App Router)**
A component is a **Server Component** if:
- No `"use client"`
- No client hooks
- No browser APIs
- No interactive handlers
- Uses server-only capabilities (DB queries, file system, server fetch, secrets)
**Implications:**
- Runs on **server at build time** (if static) and/or **server at request time**
- **No hydration** for the server part
- Can output HTML directly
- Can trigger SSG / SSR / ISR depending on cache mode or revalidate
---
# 3. Rendering Strategy (Pages Router Rules)
In the **Pages Router**, rendering is dictated entirely by which data-fetching function you export.
Below are the functions and exactly what they imply.
---
# 4. `getServerSideProps` — What It Does and What It Implies
```js
export async function getServerSideProps(context) { ... }
```
### What it does:
- Runs **on the server for every request**.
- Provides props to the page component.
- Has access to:
- database
- filesystem
- environment variables
- cookies, headers, auth context
### What it implies:
- The page is **SSR** (Server-Side Rendered).
- HTML is generated **on each request**.
- **No SSG** or ISR possible.
- The page is never static.
### Rendering outcome:
- **SSR HTML** + hydration for any client-side React in the page.
---
# 5. `getStaticProps` — What It Does and What It Implies
```js
export async function getStaticProps(context) { ... }
```
### What it does:
- Runs **once at build time**.
- Fetches data needed for static generation.
- Provides props to the component.
### What it implies:
- The page is **SSG** (Static Site Generated).
- The output is static HTML + static JSON.
- Zero server rendering at request time.
### Rendering outcome:
- **Purely static HTML** served from CDN.
- Hydration if component includes client logic (but no server execution).
### When ISR occurs:
- If you return `{ revalidate: N }` from `getStaticProps`, the page becomes **ISR**.
Example ISR config:
```js
export async function getStaticProps() {
return {
props: { ... },
revalidate: 60 // seconds
}
}
```
---
# 6. `getStaticPaths` — What It Does and What It Implies
Used for **dynamic SSG pages** (e.g., `[id].js`).
```js
export async function getStaticPaths() { ... }
```
### What it does:
- Runs **at build time**.
- Tells Next.js which dynamic routes to pre-render.
- Works together with `getStaticProps`.
### What it implies:
- Page is **SSG** or **ISR** depending on `getStaticProps`.
- The routing structure is fixed at build time unless fallback mode is used.
### Fallback modes define run-time:
- `fallback: false` → Only pages listed exist; 404 for others
- `fallback: true` → Generate pages at runtime, then cache them as static
- `fallback: "blocking"` → Block until server generates static page
Fallback generation effectively behaves like **ISR** for pages not pre-rendered.
---
# 7. Summary Table (Pages Router)
| Export | When It Runs | Rendering Model | Triggered By |
| -------------------- | -------------------- | -------------------------------------- | --------------------------- |
| `getServerSideProps` | On **every request** | **SSR** | Always dynamic |
| `getStaticProps` | **Build time** | **SSG** (or **ISR** with `revalidate`) | No runtime server |
| `getStaticPaths` | **Build time** | **SSG** for dynamic routes | Works with `getStaticProps` |
---
# 8. Combining Rules: How to Infer Rendering from Code
### If you see `getServerSideProps`:
- The page is always **SSR**
- Component receives props from server
- Component itself is a normal React component rendered server-side then hydrated
- No client data loading unless the component explicitly fetches in browser
### If you see `getStaticProps`:
- The page is **SSG** or **ISR**
- Only runs again during revalidation
- Component is static unless you add client-side fetching
### If you see `getStaticPaths`:
- The file uses dynamic **SSG** or **ISR**
- Builds static versions of dynamic routes
### If you see `"use client"`:
- Entire file is **client-rendered**
- Data in this component does **not** SSR
- Even if the page uses SSG/SSR, this component runs only in browser
### If you see hooks (`useState`, `useEffect`, etc.):
- The component must be **client-side**
- It must hydrate
- It cannot participate in server rendering logic
- SSG/SSR/ISR still occurs for the page shell, but the logic inside this component runs only in browser
### If you see server-side code (DB queries, secrets):
- Component must be **Server Component** (App Router) or handled inside `getServerSideProps`/`getStaticProps`
---
### How to Think About It When Architecting
1. **Default to Server Components** whenever no browser interactivity is needed.
Reduces bundle size and avoids unnecessary hydration.
2. **Use Client Components** only where interaction happens (buttons, forms, animations, local state).
3. **Choose a rendering model based on data volatility**:
- Rarely changing: SSG
- Somewhat changing and OK with slightly stale: ISR
- Must always be fresh or personalized: SSR
4. **Remember:** Hydration cost scales with the amount of Client Components. Keep them narrow.
5. **Consider caching**:
Next.js can automatically cache server component results; knowing what is cached impacts performance heavily.
### Backend vs Frontend on Next.js
| Term | True Meaning | Might Confuse People Because… |
| ------------ | ------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| **Frontend** | Code that executes in the users environment (browser, WebView) | SSR code _belongs to frontend logic_ but executes on server |
| **Backend** | Code that executes on remote infrastructure (server, VM, cloud function) | Some “backend-like” behavior can occur in browser via caching or local APIs |
### Downtime
To simulate downtime **you need the error to happen at runtime, not at build time**. That means the page must be **server-rendered**, not statically generated.

View File

@@ -17,12 +17,12 @@ To do so, you can add code in a similar way as in [this commit](https://github.c
Note that you will also need to add a column to the `profiles` table in the dev database before running the code; you can do so via this SQL command (change the type if not `TEXT`):
```sql
ALTER TABLE profiles ADD COLUMN some_new_profile_field TEXT;
ALTER TABLE profiles ADD COLUMN profile_field TEXT;
```
Store it in `add_some_some_profile_field.sql` in the [migrations](../backend/supabase/migrations) folder and run [migrate.sh](../scripts/migrate.sh) from the root folder:
Store it in `add_profile_field.sql` in the [migrations](../backend/supabase/migrations) folder and run [migrate.sh](../scripts/migrate.sh) from the root folder:
```bash
./scripts/migrate.sh backend/supabase/migrations/add_some_new_profile_field.sql
./scripts/migrate.sh backend/supabase/migrations/add_profile_field.sql
```
Then sync the database types from supabase to the local files (which assist Typescript in typing):

View File

@@ -1,180 +0,0 @@
# WebView OAuth Sign-in
How to let a WebView-based app safely complete OAuth, even though Google blocks sign-in *inside* WebViews.
---
## 1. The problem
Google OAuth refuses to complete inside a WebView.
Youll get errors like:
```
403 disallowed_useragent
or
This browser or app may not be secure
```
This is because embedded WebViews can intercept credentials, and Google requires that the sign-in happen in a **real browser** (like Chrome, Safari, Firefox).
So we must:
1. Start the login **from inside** the WebView app.
2. Open the Google login page in the **system browser**.
3. After the user finishes signing in, Google redirects to a **custom URL** (deep link or universal link).
4. The app intercepts that redirect, extracts the `code` from it, and injects it back into the WebView.
Thats the “catch the redirect with a custom scheme or deep link” part.
---
## 2. What a deep link / custom scheme is
A **custom scheme** is a URL protocol that your app owns.
Example:
```
com.compassmeet://auth
```
or
```
compassmeet://auth
```
When Android (or iOS) sees a redirect to one of these URLs, it **launches your app** and passes it the URL data.
You register this scheme in your `AndroidManifest.xml` so Android knows which app handles it.
---
## 3.
### Step 1 — Start flow inside the WebView
Your web code (running inside WebView) does:
```ts
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: 'com.compassmeet://auth', // your deep link
response_type: 'code',
scope: 'openid email profile',
});
window.open(`https://accounts.google.com/o/oauth2/v2/auth?${params}`, '_system');
```
Here, `_system` (or using Capacitor Browser plugin) opens the **system browser**.
---
### Step 2 — User signs in (in the browser)
After login, Google redirects to your registered `redirect_uri`, e.g.:
```
com.compassmeet://auth?code=4/0AfJohXyZ...
```
---
### Step 3 — The app intercepts that deep link
In your **Android app code**, you register an intent filter in `AndroidManifest.xml`:
```xml
<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.compassmeet" android:host="auth" />
</intent-filter>
```
Then, in your apps main activity, you listen for deep links.
In java:
```java
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
String data = intent.getDataString();
String payload = new JSONObject().put("data", data).toString();
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("oauthRedirect(" + payload + ");", null));
}
```
That line emits a custom JavaScript event inside the WebView so your web app can pick it up.
---
### Step 4 — WebView catches redirect event and exchanges the code in backend
In your web app (TypeScript side):
```ts
window.addEventListener('oauthRedirect', async (event: any) => {
const url = new URL(event.detail);
const code = url.searchParams.get('code');
// fetch backend API
const tokens = await api('...', {code})
});
```
Backend endpoint
```ts
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
code,
redirect_uri: 'com.compassmeet://auth',
grant_type: 'authorization_code',
}),
});
const tokens = await tokenResponse.json();
console.log('Tokens:', tokens);
```
At this point:
* You have your `access_token` and `id_token`.
* You can sign into Firebase or use them directly.
---
## 4. Why this works and what makes it safe
* The login itself happens in Googles **system browser**, not in your WebView.
* The deep link ensures the token is delivered **only** to your app.
---
## 5. Universal links alternative
If you want to use a normal HTTPS redirect (e.g. `https://www.compassmeet.com/auth/callback`), you can register it as a **universal link**:
* User finishes login → redirected to your HTTPS domain.
* That URL is also registered to open your app (via Digital Asset Links JSON).
* Android recognizes it and launches your app instead of loading the page in the browser.
* The rest of the flow is the same.
However, universal links are more setup-heavy (require hosting a `.well-known/assetlinks.json` file).
---
## 6. Summary
| Step | What happens | Where |
| ---- | -------------------------------------------------------------- | ----------------- |
| 1 | Open Google OAuth URL | WebView |
| 2 | User signs in | System browser |
| 3 | Browser redirects to deep link (e.g. `com.compassmeet://auth`) | OS → App |
| 4 | App intercepts deep link and injects it into WebView | Native layer |
| 5 | WebView exchanges `code` with backend for tokens | Web app + backend |

23
jest.config.js Normal file
View File

@@ -0,0 +1,23 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
testPathIgnorePatterns: [
// '/node_modules/',
// '/dist/',
// '/coverage/',
'/tests/e2e',
// '/lib/',
// 'backend/email/emails/test.ts',
'common/src/socials.test.ts',
// 'backend/api/src',
// 'martin',
],
projects: [
"<rootDir>/backend/api",
"<rootDir>/backend/shared",
"<rootDir>/backend/email",
"<rootDir>/common",
"<rootDir>/web"
],
};

View File

@@ -1,6 +1,6 @@
{
"name": "compass",
"version": "1.6.0",
"version": "1.7.0",
"private": true,
"workspaces": [
"common",
@@ -16,18 +16,19 @@
"dev": "./scripts/run_local.sh dev",
"prod": "./scripts/run_local.sh prod",
"clean-install": "./scripts/install.sh",
"build-web": "./scripts/build_web.sh",
"build-web-view": "./scripts/build_web_view.sh",
"build-sync-android": "./scripts/build_sync_android.sh",
"sync-android": "./scripts/sync_android.sh",
"migrate": "./scripts/migrate.sh",
"test": "jest",
"test": "yarn workspaces run test",
"test:coverage": "yarn workspaces run test --coverage",
"test:watch": "yarn workspaces run test --watch",
"test:update": "yarn workspaces run test --updateSnapshot",
"test:e2e": "./scripts/e2e.sh",
"playwright": "playwright test",
"playwright:ui": "playwright test --ui",
"playwright:debug": "playwright test --debug",
"playwright:report": "npx playwright show-report tests/reports/playwright-report",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:update": "jest --updateSnapshot",
"postinstall": "./scripts/post_install.sh"
},
"dependencies": {
@@ -36,6 +37,7 @@
"@capacitor/keyboard": "7.0.3",
"@capacitor/push-notifications": "7.0.3",
"@capacitor/status-bar": "7.0.3",
"@capawesome/capacitor-live-update": "7.2.2",
"@capgo/capacitor-social-login": "7.14.9",
"@playwright/test": "^1.54.2",
"colorette": "^2.0.20",
@@ -48,6 +50,8 @@
"@capacitor/android": "7.4.4",
"@capacitor/assets": "3.0.5",
"@capacitor/cli": "7.4.4",
"@faker-js/faker": "10.1.0",
"@types/jest": "29.2.4",
"@testing-library/dom": "^10.0.0",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
@@ -55,16 +59,19 @@
"@types/node": "20.12.11",
"@typescript-eslint/eslint-plugin": "7.4.0",
"@typescript-eslint/parser": "7.4.0",
"chalk": "5.6.2",
"concurrently": "8.2.2",
"dotenv-cli": "^10.0.0",
"eslint": "8.57.0",
"eslint-plugin-lodash": "^7.4.0",
"eslint-plugin-unused-imports": "4.1.4",
"jest": "29.3.1",
"nodemon": "2.0.20",
"prettier": "3.6.2",
"prettier-plugin-sql": "0.19.2",
"prettier-plugin-tailwindcss": "^0.2.1",
"ts-node": "10.9.1",
"ts-jest": "29.0.3",
"tsc-alias": "1.8.2",
"tsconfig-paths": "4.2.0",
"tsx": "4.20.6",

View File

@@ -1,4 +1,5 @@
import { defineConfig, devices } from '@playwright/test';
import path from 'path';
export default defineConfig({
testDir: './tests/e2e',
@@ -6,15 +7,16 @@ export default defineConfig({
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', {outputFolder: `tests/reports/playwright-report`, open: 'on-falure'}]],
reporter: [['html', {outputFolder: `tests/reports/playwright-report`, open: 'on-failure'}]],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
name: 'main',
use: {
...devices['Desktop Chrome'], },
},
// {
// name: 'firefox',
@@ -25,4 +27,5 @@ export default defineConfig({
// use: { ...devices['Desktop Safari'] },
// },
],
});
});

View File

@@ -4,9 +4,7 @@ set -e
cd "$(dirname "$0")"/..
export NEXT_PUBLIC_WEBVIEW=1
yarn build-web
yarn build-web-view
source web/.env

View File

@@ -4,6 +4,8 @@ set -e
cd "$(dirname "$0")"/..
export NEXT_PUBLIC_WEBVIEW=1
# Paths
ROOT_ENV=".env" # your root .env
WEB_ENV="web/.env" # target for frontend
@@ -27,9 +29,10 @@ cd web
rm -rf .next
# Hack to ignore getStaticProps and getStaticPaths for mobile webview build
# as Next.js doesn't support SSR / ISR on mobile
# Hack to ignore getServerSideProps, getStaticProps and getStaticPaths for mobile webview build
# as Next.js doesn't support SSG, SSR and ISR on mobile
USERNAME_PAGE=pages/[username]/index.tsx
HOME_PAGE=pages/index.tsx
# rename getStaticProps to _getStaticProps
sed -i.bak 's/\bgetStaticProps\b/_getStaticProps/g' $USERNAME_PAGE
@@ -37,9 +40,11 @@ sed -i.bak 's/\bgetStaticProps\b/_getStaticProps/g' $USERNAME_PAGE
# rename getStaticPaths to _getStaticPaths
sed -i.bak 's/\bgetStaticPaths\b/_getStaticPaths/g' $USERNAME_PAGE
# rename getServerSideProps to _getServerSideProps
sed -i.bak 's/\bgetServerSideProps\b/_getServerSideProps/g' $HOME_PAGE
yarn build
sed -i.bak 's/\b_getStaticProps\b/getStaticProps/g' $USERNAME_PAGE
# rename getStaticPaths to _getStaticPaths
sed -i.bak 's/\b_getStaticPaths\b/getStaticPaths/g' $USERNAME_PAGE
sed -i.bak 's/\b_getServerSideProps\b/getServerSideProps/g' $HOME_PAGE

22
scripts/e2e.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"/..
npx playwright install chromium
export NEXT_PUBLIC_API_URL=localhost:8088
export NEXT_PUBLIC_FIREBASE_ENV=DEV
npx nyc --reporter=lcov yarn --cwd=web serve &
npx nyc --reporter=lcov yarn --cwd=backend/api dev &
npx wait-on http://localhost:3000
npx playwright test tests/e2e
SERVER_PID=$(fuser -k 3000/tcp)
echo $SERVER_PID
kill $SERVER_PID
SERVER_PID=$(fuser -k 8088/tcp)
echo $SERVER_PID
kill $SERVER_PID

View File

124
scripts/userCreation.ts Normal file
View File

@@ -0,0 +1,124 @@
//Run with:
// export ENVIRONMENT=DEV && ./scripts/build_api.sh && npx tsx ./scripts/userCreation.ts
import {createSupabaseDirectClient} from "../backend/shared/lib/supabase/init";
import {insert} from "../backend/shared/lib/supabase/utils";
import {PrivateUser} from "../common/lib/user";
import {getDefaultNotificationPreferences} from "../common/lib/user-notification-preferences";
import {randomString} from "../common/lib/util/random";
import UserAccountInformation from "../tests/e2e/backend/utils/userInformation";
type ProfileType = 'basic' | 'medium' | 'full'
/**
* Function used to populate the database with profiles.
*
* @param pg - Supabase client used to access the database.
* @param userInfo - Class object containing information to create a user account generated by `fakerjs`.
* @param profileType - Optional param used to signify how much information is used in the account generation.
*/
async function seedDatabase (pg: any, userInfo: UserAccountInformation, profileType?: string) {
const userId = userInfo.user_id
const deviceToken = randomString()
const bio = {
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{
"text": userInfo.bio,
"type": "text"
}
]
}
]
}
const basicProfile = {
user_id: userId,
bio_length: userInfo.bio.length,
bio: bio,
age: userInfo.age,
born_in_location: userInfo.born_in_location,
company: userInfo.company,
}
const mediumProfile = {
...basicProfile,
drinks_per_month: userInfo.drinks_per_month,
diet: [userInfo.randomElement(userInfo.diet)],
education_level: userInfo.randomElement(userInfo.education_level),
ethnicity: [userInfo.randomElement(userInfo.ethnicity)],
gender: userInfo.randomElement(userInfo.gender),
height_in_inches: userInfo.height_in_inches,
pref_gender: [userInfo.randomElement(userInfo.pref_gender)],
pref_age_min: userInfo.pref_age.min,
pref_age_max: userInfo.pref_age.max,
}
const fullProfile = {
...mediumProfile,
occupation_title: userInfo.occupation_title,
political_beliefs: [userInfo.randomElement(userInfo.political_beliefs)],
pref_relation_styles: [userInfo.randomElement(userInfo.pref_relation_styles)],
religion: [userInfo.randomElement(userInfo.religion)],
}
const profileData = profileType === 'basic' ? basicProfile
: profileType === 'medium' ? mediumProfile
: fullProfile
const user = {
// avatarUrl,
isBannedFromPosting: false,
link: {},
}
const privateUser: PrivateUser = {
id: userId,
email: userInfo.email,
initialIpAddress: userInfo.ip,
initialDeviceToken: deviceToken,
notificationPreferences: getDefaultNotificationPreferences(),
blockedUserIds: [],
blockedByUserIds: [],
}
await pg.tx(async (tx:any) => {
await insert(tx, 'users', {
id: userId,
name: userInfo.name,
username: userInfo.name,
data: user,
})
await insert(tx, 'private_users', {
id: userId,
data: privateUser,
})
await insert(tx, 'profiles', profileData )
})
}
(async () => {
const pg = createSupabaseDirectClient()
//Edit the count seedConfig to specify the amount of each profiles to create
const seedConfig = [
{ count: 1, profileType: 'basic' as ProfileType },
{ count: 1, profileType: 'medium' as ProfileType },
{ count: 1, profileType: 'full' as ProfileType },
]
for (const {count, profileType } of seedConfig) {
for (let i = 0; i < count; i++) {
const userInfo = new UserAccountInformation()
await seedDatabase(pg, userInfo, profileType)
}
}
process.exit(0)
})()

3
tests/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Tests
TODO: add docs for tests

View File

View File

@@ -0,0 +1,26 @@
import { test as base, APIRequestContext, request } from '@playwright/test';
import { createSupabaseDirectClient } from "../../../../backend/shared/src/supabase/init";
export type TestOptions = {
backendPage: {
api: APIRequestContext,
db: any
}
}
export const test = base.extend<TestOptions>({
backendPage: async ({}, use) => {
const apiContext = await request.newContext({
baseURL: 'https://api.compassmeet.com'
});
const helpers = {
api: apiContext,
db: createSupabaseDirectClient()
}
await use(helpers)
await apiContext.dispose();
},
})
export { expect } from "@playwright/test"

View File

@@ -0,0 +1,10 @@
import { test, expect } from "../../fixtures/base";
test('Check API health', async ({backendPage}) => {
const responseHealth = await backendPage.api.get('/health');
expect(responseHealth.status()).toBe(200)
// const responseBody = await responseHealth.json()
// console.log(JSON.stringify(responseBody, null, 2));
});

View File

@@ -0,0 +1,9 @@
import {expect, test } from '../fixtures/base';
import { databaseUtils } from "../utils/database";
test('View database', async ({backendPage}) => {
const userAccount = await databaseUtils.findUserByName(backendPage, 'Franklin Buckridge')
const userProfile = await databaseUtils.findProfileById(backendPage, userAccount.id)
console.log(userAccount);
})

View File

@@ -0,0 +1,27 @@
class DatabaseTestingUtilities {
findUserByName = async (page: any, name: string) => {
const queryUserById = `
SELECT p.*
FROM public.users AS p
WHERE name = $1
`;
const userResults = await page.db.query(queryUserById,[name])
return userResults[0]
}
findProfileById = async (page: any, user_id: string) => {
const queryProfileById = `
SELECT
p.*,
TO_CHAR(p.created_time, 'Mon DD, YYYY HH12:MI AM') as created_date,
TO_CHAR(p.last_modification_time, 'Mon DD, YYYY HH12:MI AM') as modified_date
FROM public.profiles AS p
WHERE user_id = $1
`;
const profileResults = await page.db.query(queryProfileById,[user_id])
return profileResults[0]
}
}
export const databaseUtils = new DatabaseTestingUtilities();

View File

@@ -0,0 +1,55 @@
import { faker } from "@faker-js/faker";
import {
RELATIONSHIP_CHOICES,
POLITICAL_CHOICES,
RELIGION_CHOICES,
DIET_CHOICES,
EDUCATION_CHOICES,
RACE_CHOICES,
} from "web/components/filters/choices";
class UserAccountInformation {
name = faker.person.fullName();
email = faker.internet.email();
user_id = faker.string.alpha(28)
password = faker.internet.password();
ip = faker.internet.ip()
age = faker.number.int({min: 18, max:100});
bio = faker.lorem.words({min: 200, max:350});
born_in_location = faker.location.country();
gender = [
'Female',
'Male',
'Other'
];
pref_gender = [
'Female',
'Male',
'Other'
];
pref_age = {
min: faker.number.int({min: 18, max:27}),
max: faker.number.int({min: 36, max:68})
};
pref_relation_styles = Object.values(RELATIONSHIP_CHOICES);
political_beliefs = Object.values(POLITICAL_CHOICES);
religion = Object.values(RELIGION_CHOICES);
diet = Object.values(DIET_CHOICES);
drinks_per_month = faker.number.int({min: 4, max:40});
height_in_inches = faker.number.float({min: 56, max: 78, fractionDigits:2});
ethnicity = Object.values(RACE_CHOICES);
education_level = Object.values(EDUCATION_CHOICES);
company = faker.company.name();
occupation_title = faker.person.jobTitle();
university = faker.company.name();
randomElement (array: Array<string>) {
return array[Math.floor(Math.random() * array.length)].toLowerCase()
}
}
export default UserAccountInformation;

View File

@@ -0,0 +1,5 @@
export const config = {
BASE_URL: 'http://localhost:3000',
DEFAULT_LOGIN: 'defaultUser@dev.com',
DEFAULT_PASSWORD: 'defaultPassword',
};

View File

@@ -0,0 +1,24 @@
import { test as base, Page, expect } from '@playwright/test';
import { SignInPage } from '../pages/signInPage';
import { config } from '../TESTING_CONFIG';
export const test = base.extend<{
authenticatedPage: Page;
}>({
authenticatedPage: async ({ page }, use) => {
const signInPage = new SignInPage(page);
await page.goto('/signin');
await signInPage.fillEmailField(config.DEFAULT_LOGIN);
await signInPage.fillPasswprdField(config.DEFAULT_PASSWORD);
await signInPage.clickSignInWithEmailButton();
await page.waitForLoadState('networkidle');
await page.waitForURL('/');
expect(page.url()).not.toContain('/signin')
await use(page);
},
});

View File

@@ -0,0 +1,42 @@
import { expect, Locator, Page } from '@playwright/test';
//sets up of all the functions that signin tests will use.
export class SignInPage{
private readonly signInLink: Locator;
private readonly emailField: Locator;
private readonly passwordField: Locator;
private readonly signInWithEmailButton: Locator;
private readonly signInWithGoogleButton: Locator;
constructor(public readonly page: Page) {
this.signInLink=page.getByRole('link', { name: 'Sign in' });
this.emailField=page.getByLabel('Email');
this.passwordField=page.getByLabel('Password');
this.signInWithEmailButton=page.getByRole('button',{name: 'Sign in With Email'});
this.signInWithGoogleButton=page.getByRole('button',{name: 'Google'});
}
async clickSignInText() {
await this.signInLink.click();
}
async clickSignInWithEmailButton() {
await this.signInWithEmailButton.click();
}
async clickSignInWithEGoogleButton() {
await this.signInWithGoogleButton.click();
}
async fillEmailField(email: string) {
await expect(this.emailField).toBeVisible();
await this.emailField.fill(email);
}
async fillPasswprdField(password: string) {
await expect(this.passwordField).toBeVisible();
await this.passwordField.fill(password);
}
}

View File

@@ -0,0 +1,10 @@
import { expect } from '@playwright/test';
import { test } from '../fixtures/signInFixture';
test('should be logged in and see settings page', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/settings');
await expect(
authenticatedPage.getByRole('heading', { name: 'Theme' })
).toBeVisible();
});

View File

@@ -1,9 +0,0 @@
import {expect, test} from '@playwright/test';
test('shows', async ({page}) => {
await page.goto('/'); // Adjust this to your route
expect(await page.title()).toBe('Compass');
//
// const spinner = page.locator('[data-testid="spinner"]');
// await expect(spinner).toBeVisible();
});

View File

@@ -1,11 +0,0 @@
// import '@testing-library/jest-dom';
describe('LoadingSpinner', () => {
it('renders something', () => {
// render(<LoadingSpinner/>);
//
// // Check if the spinner has the correct classes
// const spinner = screen.getByTestId('spinner');
// expect(spinner).toHaveClass('animate-spin');
});
});

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./backend/api" },
{ "path": "./backend/api/tsconfig.test.json" }
]
}

View File

@@ -0,0 +1,13 @@
type FavIconProps = {
className?: string;
};
const FavIcon = ({ className }: FavIconProps) => (
<img
src="favicon.svg"
alt="Compass logo"
className={`w-12 h-12 ${className ?? ""}`}
/>
);
export default FavIcon;

View File

@@ -1,11 +1,12 @@
import { User } from 'common/user'
import { QuestionWithCountType } from 'web/hooks/use-questions'
import { useState } from 'react'
import { Button } from 'web/components/buttons/button'
import { Col } from 'web/components/layout/col'
import { MODAL_CLASS, Modal } from 'web/components/layout/modal'
import { AnswerCompatibilityQuestionContent } from './answer-compatibility-question-content'
import {User} from 'common/user'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {useState} from 'react'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {AnswerCompatibilityQuestionContent} from './answer-compatibility-question-content'
import router from "next/router";
import Link from "next/link";
export function AnswerCompatibilityQuestionButton(props: {
user: User | null | undefined
@@ -29,8 +30,8 @@ export function AnswerCompatibilityQuestionButton(props: {
return (
<>
{size === 'md' ? (
<Button onClick={() => setOpen(true)} color="gray-outline">
Answer Questions{' '}
<Button onClick={() => setOpen(true)} color="none" className={'px-3 py-2 rounded-md border border-primary-600 text-ink-700 hover:bg-primary-50 hover:text-ink-900'}>
Answer{isCore && ' Core'} Questions{' '}
<span className="text-primary-600 ml-2">
+{questionsToAnswer.length}
</span>
@@ -57,12 +58,21 @@ export function AnswerCompatibilityQuestionButton(props: {
)
}
export function CompatibilityPageButton() {
return (
<Link
href="/compatibility"
className="px-3 py-2 rounded-md border border-primary-600 text-ink-700 hover:bg-primary-50 flex items-center justify-center text-center"
>View List of Questions</Link>
)
}
export function AnswerSkippedCompatibilityQuestionsButton(props: {
user: User | null | undefined
skippedQuestions: QuestionWithCountType[]
refreshCompatibilityAll: () => void
}) {
const { user, skippedQuestions, refreshCompatibilityAll } = props
const {user, skippedQuestions, refreshCompatibilityAll} = props
const [open, setOpen] = useState(false)
if (!user) return null
return (
@@ -92,7 +102,7 @@ function AnswerCompatibilityQuestionModal(props: {
refreshCompatibilityAll: () => void
onClose?: () => void
}) {
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose } = props
const {open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose} = props
const [questionIndex, setQuestionIndex] = useState(0)
return (
<Modal

View File

@@ -1,7 +1,7 @@
import {RadioGroup} from '@headlessui/react'
import {UserIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import {Row as rowFor, run} from 'common/supabase/utils'
import {Row as rowFor} from 'common/supabase/utils'
import {User} from 'common/user'
import {shortenNumber} from 'common/util/format'
import {sortBy} from 'lodash'
@@ -15,10 +15,9 @@ import {RadioToggleGroup} from 'web/components/widgets/radio-toggle-group'
import {Tooltip} from 'web/components/widgets/tooltip'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {track} from 'web/lib/service/analytics'
import {db} from 'web/lib/supabase/db'
import {api} from 'web/lib/api'
import {filterKeys} from '../questions-form'
import toast from "react-hot-toast";
import toast from "react-hot-toast"
export type CompatibilityAnswerSubmitType = Omit<
rowFor<'compatibility_answers'>,
@@ -70,9 +69,9 @@ export const submitCompatibilityAnswer = async (
// Track only if upsert succeeds
track('answer compatibility question', {
...newAnswer,
});
})
} catch (error) {
console.error('Failed to set compatibility answer:', error);
console.error('Failed to set compatibility answer:', error)
toast.error('Error submitting. Try again?')
}
}
@@ -83,20 +82,15 @@ export const deleteCompatibilityAnswer = async (
) => {
if (!userId || !id) return
try {
await run(
db
.from('compatibility_answers')
.delete()
.match({id: id, creator_id: userId})
)
await track('delete compatibility question', {id});
await api('delete-compatibility-answer', {id})
await track('delete compatibility question', {id})
} catch (error) {
console.error('Failed to delete prompt answer:', error);
console.error('Failed to delete prompt answer:', error)
toast.error('Error deleting. Try again?')
}
}
function getEmptyAnswer(userId: string, questionId: number) {
export function getEmptyAnswer(userId: string, questionId: number) {
return {
creator_id: userId,
explanation: null,
@@ -112,7 +106,7 @@ export function AnswerCompatibilityQuestionContent(props: {
user: User
index?: number
total?: number
answer?: rowFor<'compatibility_answers'> | null
answer?: CompatibilityAnswerSubmitType | null
onSubmit: () => void
onNext?: () => void
isLastQuestion: boolean
@@ -160,11 +154,11 @@ export function AnswerCompatibilityQuestionContent(props: {
return (
<Col className="h-full w-full gap-4">
<Col className="gap-1">
{compatibilityQuestion.importance_score > 0 && <Row className="text-blue-400 -mt-4 w-full justify-start text-sm">
<span>
Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.
</span>
</Row>}
{/*{compatibilityQuestion.importance_score > 0 && <Row className="text-blue-400 -mt-4 w-full justify-start text-sm">*/}
{/* <span>*/}
{/* Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.*/}
{/* </span>*/}
{/*</Row>}*/}
{index !== null &&
index !== undefined &&
total !== null &&
@@ -331,7 +325,7 @@ export const SelectAnswer = (props: {
)
}
// TODO: redo with checkbox semantics
// redo with checkbox semantics
export const MultiSelectAnswers = (props: {
values: number[]
setValue: (value: number[]) => void

View File

@@ -23,12 +23,16 @@ import {AddCompatibilityQuestionButton} from './add-compatibility-question-butto
import {
AnswerCompatibilityQuestionButton,
AnswerSkippedCompatibilityQuestionsButton,
CompatibilityPageButton,
} from './answer-compatibility-question-button'
import {
AnswerCompatibilityQuestionContent,
CompatibilityAnswerSubmitType,
deleteCompatibilityAnswer,
getEmptyAnswer,
IMPORTANCE_CHOICES,
IMPORTANCE_DISPLAY_COLORS,
submitCompatibilityAnswer,
} from './answer-compatibility-question-content'
import clsx from 'clsx'
import {shortenName} from 'web/components/widgets/user-link'
@@ -38,10 +42,11 @@ import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-sta
import {useIsLooking} from 'web/hooks/use-is-looking'
import {DropdownButton} from '../filters/desktop-filters'
import {buildArray} from 'common/util/array'
import toast from "react-hot-toast";
const NUM_QUESTIONS_TO_SHOW = 8
function separateQuestionsArray(
export function separateQuestionsArray(
questions: QuestionWithCountType[],
skippedAnswerQuestionIds: Set<number>,
answeredQuestionIds: Set<number>
@@ -199,7 +204,7 @@ export function CompatibilityQuestionsDisplay(props: {
<span className="text-ink-600 text-sm">
Answer more questions to increase your compatibility scoresor{' '}
</span>
)}
)}
<AddCompatibilityQuestionButton
refreshCompatibilityAll={refreshCompatibilityAll}
/>
@@ -225,12 +230,15 @@ export function CompatibilityQuestionsDisplay(props: {
</>
)}
{otherQuestions.length >= 1 && isCurrentUser && !fromProfilePage && (
<AnswerCompatibilityQuestionButton
user={user}
otherQuestions={otherQuestions}
refreshCompatibilityAll={refreshCompatibilityAll}
fromSignup={fromSignup}
/>
<Row className={'w-full justify-center gap-8'}>
<AnswerCompatibilityQuestionButton
user={user}
otherQuestions={otherQuestions}
refreshCompatibilityAll={refreshCompatibilityAll}
fromSignup={fromSignup}
/>
<CompatibilityPageButton/>
</Row>
)}
{skippedQuestions.length > 0 && isCurrentUser && (
<Row className="w-full justify-end">
@@ -300,12 +308,13 @@ function CompatibilitySortWidget(props: {
)
}
function CompatibilityAnswerBlock(props: {
answer: rowFor<'compatibility_answers'>
export function CompatibilityAnswerBlock(props: {
answer?: rowFor<'compatibility_answers'>
yourQuestions: QuestionWithCountType[]
question?: QuestionWithCountType
user: User
isCurrentUser: boolean
profile: Profile
profile?: Profile
refreshCompatibilityAll: () => void
fromProfilePage?: Profile
}) {
@@ -318,11 +327,17 @@ function CompatibilityAnswerBlock(props: {
refreshCompatibilityAll,
fromProfilePage,
} = props
const question = yourQuestions.find((q) => q.id === answer.question_id)
const question = props.question || yourQuestions.find((q) => q.id === answer?.question_id)
const [editOpen, setEditOpen] = useState<boolean>(false)
const currentUser = useUser()
const currentProfile = useProfile()
const [newAnswer, setNewAnswer] = useState<CompatibilityAnswerSubmitType | undefined>(props.answer)
useEffect(() => {
setNewAnswer(props.answer)
}, [props.answer]);
const comparedProfile = isCurrentUser
? null
: !!fromProfilePage
@@ -332,26 +347,28 @@ function CompatibilityAnswerBlock(props: {
if (
!question ||
!question.multiple_choice_options ||
answer.multiple_choice == null
answer && answer?.multiple_choice == null
)
return null
const answerText = getStringKeyFromNumValue(
const answerText = answer ? getStringKeyFromNumValue(
answer.multiple_choice,
question.multiple_choice_options as Record<string, number>
)
const preferredAnswersText = answer.pref_choices.map((choice) =>
) : null
const preferredAnswersText = answer ? answer.pref_choices.map((choice) =>
getStringKeyFromNumValue(
choice,
question.multiple_choice_options as Record<string, number>
)
)
) : []
const distinctPreferredAnswersText = preferredAnswersText.filter(
(text) => text !== answerText
)
const preferredDoesNotIncludeAnswerText =
!preferredAnswersText.includes(answerText)
answerText && !preferredAnswersText.includes(answerText)
const isAnswered = answer && answer.multiple_choice > -1
const isSkipped = answer && answer.importance == -1
return (
<Col
className={
@@ -361,7 +378,7 @@ function CompatibilityAnswerBlock(props: {
<Row className="text-ink-800 justify-between gap-1 font-semibold">
{question.question}
<Row className="gap-4 font-normal">
{comparedProfile && (
{comparedProfile && isAnswered && (
<div className="hidden sm:block">
<CompatibilityDisplay
question={question}
@@ -373,7 +390,7 @@ function CompatibilityAnswerBlock(props: {
/>
</div>
)}
{isCurrentUser && (
{isCurrentUser && isAnswered && (
<>
<ImportanceButton
className="hidden sm:block"
@@ -391,7 +408,30 @@ function CompatibilityAnswerBlock(props: {
name: 'Delete',
icon: <TrashIcon className="h-5 w-5"/>,
onClick: () => {
deleteCompatibilityAnswer(answer.id, user.id).then(() => refreshCompatibilityAll())
deleteCompatibilityAnswer(answer.id, user.id)
.then(() => refreshCompatibilityAll())
.catch((e) => {toast.error(e.message)})
.finally(() => {})
},
},
]}
closeOnClick
menuWidth="w-40"
/>
</>
)}
{isCurrentUser && !isAnswered && !isSkipped && (
<>
<DropdownMenu
items={[
{
name: 'Skip',
icon: <TrashIcon className="h-5 w-5"/>,
onClick: () => {
submitCompatibilityAnswer(getEmptyAnswer(user.id, question.id))
.then(() => {refreshCompatibilityAll()})
.catch((e) => {toast.error(e.message)})
.finally(() => {})
},
},
]}
@@ -402,11 +442,11 @@ function CompatibilityAnswerBlock(props: {
)}
</Row>
</Row>
<Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
{answerText && <Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
{answerText}
</Row>
</Row>}
<Row className="px-2 -mt-4">
{answer.explanation && (
{answer?.explanation && (
<Linkify className="" text={answer.explanation}/>
)}
</Row>
@@ -429,9 +469,30 @@ function CompatibilityAnswerBlock(props: {
</Row>
</Col>
)}
{!isAnswered && (
<Row className="flex-wrap gap-2 mt-0">
{sortBy(
Object.entries(question.multiple_choice_options),
1
).map(([label]) => label).map((label, i) => (
<button
key={label}
onClick={() => {
const _answer = getEmptyAnswer(user.id, question.id)
_answer.multiple_choice = i
setNewAnswer(_answer)
setEditOpen(true)
}}
className="bg-canvas-100 hover:bg-canvas-200 w-fit gap-1 rounded px-2 py-1 text-sm"
>
{label}
</button>
))}
</Row>
)}
<Col>
{comparedProfile && (
{comparedProfile && isAnswered && (
<Row className="w-full justify-end sm:hidden">
<CompatibilityDisplay
question={question}
@@ -443,7 +504,7 @@ function CompatibilityAnswerBlock(props: {
/>
</Row>
)}
{isCurrentUser && (
{isCurrentUser && isAnswered && (
<Row className="w-full justify-end sm:hidden">
<ImportanceButton
importance={answer.importance}
@@ -451,20 +512,21 @@ function CompatibilityAnswerBlock(props: {
/>
</Row>
)}
{/*{question.importance_score == 0 && <div className="text-ink-500 text-sm">Core Question</div>}*/}
</Col>
<Modal open={editOpen} setOpen={setEditOpen}>
<Col className={MODAL_CLASS}>
<AnswerCompatibilityQuestionContent
key={`edit answer.id`}
compatibilityQuestion={question}
answer={answer}
answer={newAnswer}
user={user}
onSubmit={() => {
setEditOpen(false)
refreshCompatibilityAll()
}}
isLastQuestion={true}
noSkip
noSkip={isAnswered}
/>
</Col>
</Modal>
@@ -474,7 +536,7 @@ function CompatibilityAnswerBlock(props: {
function CompatibilityDisplay(props: {
question: QuestionWithCountType
profile1: Profile
profile1?: Profile
profile2: Profile
answer1: rowFor<'compatibility_answers'>
currentUserIsComparedProfile: boolean
@@ -514,7 +576,7 @@ function CompatibilityDisplay(props: {
const [open, setOpen] = useState(false)
if (profile1.id === profile2.id) return null
if (!profile1 || profile1.id === profile2.id) return null
const showCreateAnswer =
(!answer2 || answer2.importance == -1) &&

View File

@@ -81,7 +81,7 @@ export const Button = forwardRef(function Button(
props: {
className?: string
size?: SizeType
color?: ColorType
color?: ColorType | null
type?: 'button' | 'reset' | 'submit'
loading?: boolean
} & JSX.IntrinsicElements['button'],
@@ -101,7 +101,7 @@ export const Button = forwardRef(function Button(
return (
<button
type={type}
className={clsx(buttonClass(size, color), className)}
className={clsx(color && buttonClass(size, color), className)}
disabled={disabled || loading}
ref={ref}
{...rest}

View File

@@ -10,7 +10,6 @@ export type DropdownItem = {
onClick: () => void | Promise<void>
}
// NOTE: you can't conditionally render any of the items from a useEffect hook, or you'll get hydration errors
export default function DropdownMenu(props: {
items: DropdownItem[]
icon?: ReactNode
@@ -90,9 +89,9 @@ export default function DropdownMenu(props: {
className={clsx(
selectedItemName && item.name == selectedItemName
? 'bg-primary-100'
: 'hover:bg-canvas-100 hover:text-ink-900',
: 'hover:bg-canvas-200 hover:text-ink-900',
'text-ink-700',
'flex w-full gap-2 px-4 py-2 text-left text-sm'
'flex w-full gap-2 px-4 py-2 text-left text-sm rounded-md'
)}
>
{item.icon && <div className="w-5">{item.icon}</div>}

View File

@@ -29,6 +29,12 @@ export function ContactComponent() {
You can also contact us through this <Link href={formLink}>feedback form</Link> or any of our <Link
href={'/social'}>socials</Link>. Feel free to give your contact information if you'd like us to get back to you.
</p>
<h4 className="">Android App</h4>
<p className={'custom-link mb-4'}>
To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Playregistered email address so we can add you as a tester.
You'll be able to download the app from the Play Store and use it right away.
You email address will NOT be shared with anyone else and will be used solely for the purpose of the review process.
</p>
<Col>
<div className={'mb-2'}>
<TextEditor

View File

@@ -1,3 +1,5 @@
import {invert} from "lodash";
export const RELATIONSHIP_CHOICES = {
// Other: 'other',
Collaboration: 'collaboration',
@@ -5,6 +7,14 @@ export const RELATIONSHIP_CHOICES = {
Relationship: 'relationship',
};
export const RELATIONSHIP_STATUS_CHOICES = {
Single: 'single',
Married: 'married',
'Casual': 'casual',
'Long-term': 'long_term',
'Open': 'open',
};
export const ROMANTIC_CHOICES = {
Monogamous: 'mono',
Polyamorous: 'poly',
@@ -40,9 +50,9 @@ export const DIET_CHOICES = {
export const EDUCATION_CHOICES = {
'High school': 'high-school',
'College': 'some-college',
Bachelors: 'bachelors',
Masters: 'masters',
PhD: 'doctorate',
'Bachelors': 'bachelors',
'Masters': 'masters',
'PhD': 'doctorate',
}
export const RELIGION_CHOICES = {
@@ -64,26 +74,146 @@ export const RELIGION_CHOICES = {
'Other': 'other',
}
export const REVERTED_RELATIONSHIP_CHOICES = Object.fromEntries(
Object.entries(RELATIONSHIP_CHOICES).map(([key, value]) => [value, key])
);
export const LANGUAGE_CHOICES = {
'Akan': 'akan',
'Amharic': 'amharic',
'Arabic': 'arabic',
'Assamese': 'assamese',
'Awadhi': 'awadhi',
'Azerbaijani': 'azerbaijani',
'Balochi': 'balochi',
'Belarusian': 'belarusian',
'Bengali': 'bengali',
'Bhojpuri': 'bhojpuri',
'Burmese': 'burmese',
'Cebuano': 'cebuano',
'Chewa': 'chewa',
'Chhattisgarhi': 'chhattisgarhi',
'Chittagonian': 'chittagonian',
'Czech': 'czech',
'Deccan': 'deccan',
'Dhundhari': 'dhundhari',
'Dutch': 'dutch',
'Eastern Min': 'eastern-min',
'English': 'english',
'French': 'french',
'Fula': 'fula',
'Gan': 'gan',
'German': 'german',
'Greek': 'greek',
'Gujarati': 'gujarati',
'Haitian Creole': 'haitian-creole',
'Hakka': 'hakka',
'Haryanvi': 'haryanvi',
'Hausa': 'hausa',
'Hiligaynon': 'hiligaynon',
'Hindi': 'hindi',
'Hmong': 'hmong',
'Hungarian': 'hungarian',
'Igbo': 'igbo',
'Ilocano': 'ilocano',
'Italian': 'italian',
'Japanese': 'japanese',
'Javanese': 'javanese',
'Jin': 'jin',
'Kannada': 'kannada',
'Kazakh': 'kazakh',
'Khmer': 'khmer',
'Kinyarwanda': 'kinyarwanda',
'Kirundi': 'kirundi',
'Konkani': 'konkani',
'Korean': 'korean',
'Kurdish': 'kurdish',
'Madurese': 'madurese',
'Magahi': 'magahi',
'Maithili': 'maithili',
'Malagasy': 'malagasy',
'Malay/Indonesian': 'malay/indonesian',
'Malayalam': 'malayalam',
'Mandarin': 'mandarin',
'Marathi': 'marathi',
'Marwari': 'marwari',
'Mossi': 'mossi',
'Nepali': 'nepali',
'Northern Min': 'northern-min',
'Odia': 'odia',
'Oromo': 'oromo',
'Pashto': 'pashto',
'Persian': 'persian',
'Polish': 'polish',
'Portuguese': 'portuguese',
'Punjabi': 'punjabi',
'Quechua': 'quechua',
'Romanian': 'romanian',
'Russian': 'russian',
'Saraiki': 'saraiki',
'Serbo-Croatian': 'serbo-croatian',
'Shona': 'shona',
'Sindhi': 'sindhi',
'Sinhala': 'sinhala',
'Somali': 'somali',
'Southern Min': 'southern-min',
'Spanish': 'spanish',
'Sundanese': 'sundanese',
'Swedish': 'swedish',
'Sylheti': 'sylheti',
'Tagalog': 'tagalog',
'Tamil': 'tamil',
'Telugu': 'telugu',
'Thai': 'thai',
'Turkish': 'turkish',
'Turkmen': 'turkmen',
'Ukrainian': 'ukrainian',
'Urdu': 'urdu',
'Uyghur': 'uyghur',
'Uzbek': 'uzbek',
'Vietnamese': 'vietnamese',
'Wu': 'wu',
'Xhosa': 'xhosa',
'Xiang': 'xiang',
'Yoruba': 'yoruba',
'Yue': 'yue',
'Zhuang': 'zhuang',
'Zulu': 'zulu',
}
export const REVERTED_ROMANTIC_CHOICES = Object.fromEntries(
Object.entries(ROMANTIC_CHOICES).map(([key, value]) => [value, key])
);
export const RACE_CHOICES = {
'Black/African origin': 'african',
'East Asian': 'asian',
'South/Southeast Asian': 'south_asian',
'White/Caucasian': 'caucasian',
'Hispanic/Latino': 'hispanic',
'Middle Eastern': 'middle_eastern',
'Native American/Indigenous': 'native_american',
Other: 'other',
}
export const REVERTED_POLITICAL_CHOICES = Object.fromEntries(
Object.entries(POLITICAL_CHOICES).map(([key, value]) => [value, key])
);
export const MBTI_CHOICES = {
'INTJ': 'intj',
'INTP': 'intp',
'INFJ': 'infj',
'INFP': 'infp',
'ISTJ': 'istj',
'ISTP': 'istp',
'ISFJ': 'isfj',
'ISFP': 'isfp',
'ENTJ': 'entj',
'ENTP': 'entp',
'ENFJ': 'enfj',
'ENFP': 'enfp',
'ESTJ': 'estj',
'ESTP': 'estp',
'ESFJ': 'esfj',
'ESFP': 'esfp',
}
export const REVERTED_DIET_CHOICES = Object.fromEntries(
Object.entries(DIET_CHOICES).map(([key, value]) => [value, key])
);
export const REVERTED_EDUCATION_CHOICES = Object.fromEntries(
Object.entries(EDUCATION_CHOICES).map(([key, value]) => [value, key])
);
export const REVERTED_RELIGION_CHOICES = Object.fromEntries(
Object.entries(RELIGION_CHOICES).map(([key, value]) => [value, key])
);
export const INVERTED_RELATIONSHIP_CHOICES = invert(RELATIONSHIP_CHOICES)
export const INVERTED_RELATIONSHIP_STATUS_CHOICES = invert(RELATIONSHIP_STATUS_CHOICES)
export const INVERTED_ROMANTIC_CHOICES = invert(ROMANTIC_CHOICES)
export const INVERTED_POLITICAL_CHOICES = invert(POLITICAL_CHOICES)
export const INVERTED_DIET_CHOICES = invert(DIET_CHOICES)
export const INVERTED_EDUCATION_CHOICES = invert(EDUCATION_CHOICES)
export const INVERTED_RELIGION_CHOICES = invert(RELIGION_CHOICES)
export const INVERTED_LANGUAGE_CHOICES = invert(LANGUAGE_CHOICES)
export const INVERTED_RACE_CHOICES = invert(RACE_CHOICES)
export const INVERTED_MBTI_CHOICES = invert(MBTI_CHOICES)

View File

@@ -26,12 +26,20 @@ import {PoliticalFilter, PoliticalFilterText} from "web/components/filters/polit
import {GiFruitBowl} from "react-icons/gi";
import {RiScales3Line} from "react-icons/ri";
import {EducationFilter, EducationFilterText} from "web/components/filters/education-filter";
import {MbtiFilter, MbtiFilterText} from "web/components/filters/mbti-filter";
import {BsPersonVcard} from "react-icons/bs";
import {LuCigarette, LuGraduationCap} from "react-icons/lu";
import {DrinksFilter, DrinksFilterText} from "web/components/filters/drinks-filter";
import {MdLocalBar} from 'react-icons/md'
import {MdLanguage, MdLocalBar} from 'react-icons/md'
import {SmokerFilter, SmokerFilterText} from "web/components/filters/smoker-filter"
import {ReligionFilter, ReligionFilterText} from "web/components/filters/religion-filter";
import {PiHandsPrayingBold} from "react-icons/pi";
import {LanguageFilter, LanguageFilterText} from "web/components/filters/language-filter";
import {
RelationshipStatusFilter,
RelationshipStatusFilterText
} from "web/components/filters/relationship-status-filter";
import {BsPersonHeart} from "react-icons/bs";
export function DesktopFilters(props: {
filters: Partial<FilterFields>
@@ -90,6 +98,34 @@ export function DesktopFilters(props: {
menuWidth="w-50"
/>
{/* RELATIONSHIP STATUS */}
<CustomizeableDropdown
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<BsPersonHeart className="h-4 w-4"/>
<RelationshipStatusFilterText
options={
filters.relationship_status as
| string[]
| undefined
}
defaultLabel={'Any relationship status'}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<RelationshipStatusFilter filters={filters} updateFilter={updateFilter}/>
}
popoverClassName="bg-canvas-50"
menuWidth="w-50"
/>
{/* LOCATION */}
<CustomizeableDropdown
buttonContent={(open: boolean) => (
@@ -355,6 +391,33 @@ export function DesktopFilters(props: {
menuWidth="w-80"
/>
{/* LANGUAGES */}
<CustomizeableDropdown
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<MdLanguage className="h-4 w-4"/>
<LanguageFilterText
options={
filters.languages as
| string[]
| undefined
}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<LanguageFilter filters={filters} updateFilter={updateFilter}/>
}
popoverClassName="bg-canvas-50 col-span-full max-h-80 overflow-y-auto"
menuWidth="w-50"
/>
{/* POLITICS */}
<CustomizeableDropdown
buttonContent={(open) => (
@@ -403,12 +466,40 @@ export function DesktopFilters(props: {
/>
)}
dropdownMenuContent={
<ReligionFilter filters={filters} updateFilter={updateFilter}/>
<ReligionFilter
filters={filters}
updateFilter={updateFilter}
className={'w-[350px] grid grid-cols-2'}
/>
}
popoverClassName="bg-canvas-50"
menuWidth="w-50"
/>
{/* MBTI */}
<CustomizeableDropdown
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<BsPersonVcard className="h-4 w-4" />
<MbtiFilterText
options={filters.mbti as string[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
defaultLabel={'Any MBTI'}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<MbtiFilter filters={filters} updateFilter={updateFilter} />
}
popoverClassName="bg-canvas-50"
menuWidth="w-[350px] grid-cols-2"
/>
{/* SMOKER */}
<CustomizeableDropdown
buttonContent={(open) => (

View File

@@ -0,0 +1,62 @@
import clsx from 'clsx'
import {convertLanguageTypes,} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {LANGUAGE_CHOICES} from "web/components/filters/choices";
import {FilterFields} from "common/filters";
import {getSortedOptions} from "common/util/sorting";
export function LanguageFilterText(props: {
options: string[] | undefined
highlightedClass?: string
}) {
const {options, highlightedClass} = props
const length = (options ?? []).length
if (!options || length < 1) {
return (
<span className={clsx('text-semibold', highlightedClass)}>Any language</span>
)
}
if (length > 2) {
return (
<span>
<span className={clsx('font-semibold', highlightedClass)}>
Multiple
</span>
</span>
)
}
const sortedOptions = getSortedOptions(options, LANGUAGE_CHOICES)
const convertedTypes = sortedOptions.map((r) => convertLanguageTypes(r as any))
return (
<div>
<span className={clsx('font-semibold', highlightedClass)}>
{stringOrStringArrayToText({
text: convertedTypes,
capitalizeFirstLetterOption: true,
})}{' '}
</span>
</div>
)
}
export function LanguageFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
}) {
const {filters, updateFilter} = props
return (
<MultiCheckbox
selected={filters.languages ?? []}
choices={LANGUAGE_CHOICES as any}
onChange={(c) => {
updateFilter({languages: c})
}}
/>
)
}

View File

@@ -0,0 +1,65 @@
import clsx from 'clsx'
import {MBTI_CHOICES} from 'web/components/filters/choices'
import {FilterFields} from 'common/filters'
import {getSortedOptions} from 'common/util/sorting'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function MbtiFilterText(props: {
options: string[] | undefined
highlightedClass?: string
defaultLabel?: string
}) {
const {options, highlightedClass, defaultLabel} = props
const length = (options ?? []).length
const label = defaultLabel || 'Any'
if (!options || length < 1) {
return (
<span className={clsx('text-semibold', highlightedClass)}>{label}</span>
)
}
if (length > 2) {
return (
<span>
<span className={clsx('font-semibold', highlightedClass)}>
Multiple
</span>
</span>
)
}
const sortedOptions = getSortedOptions(options, MBTI_CHOICES)
const displayTypes = sortedOptions.map((type) => type.toUpperCase())
return (
<div>
<span className={clsx('font-semibold', highlightedClass)}>
{stringOrStringArrayToText({
text: displayTypes,
capitalizeFirstLetterOption: false,
})}{' '}
</span>
</div>
)
}
export function MbtiFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
}) {
const {filters, updateFilter} = props
return (
<MultiCheckbox
className={'grid grid-cols-2 xs:grid-cols-4'}
selected={filters.mbti ?? []}
choices={MBTI_CHOICES as any}
onChange={(c) => {
updateFilter({mbti: c})
}}
/>
)
}

View File

@@ -25,6 +25,12 @@ import {EducationFilter, EducationFilterText} from "web/components/filters/educa
import {DrinksFilter, DrinksFilterText, getNoMinMaxDrinks} from "./drinks-filter";
import {SmokerFilter, SmokerFilterText} from "./smoker-filter";
import {ReligionFilter, ReligionFilterText} from "web/components/filters/religion-filter";
import {LanguageFilter, LanguageFilterText} from "web/components/filters/language-filter";
import {
RelationshipStatusFilter,
RelationshipStatusFilterText
} from "web/components/filters/relationship-status-filter";
import {MbtiFilter, MbtiFilterText} from "web/components/filters/mbti-filter";
function MobileFilters(props: {
filters: Partial<FilterFields>
@@ -104,6 +110,26 @@ function MobileFilters(props: {
<RelationshipFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* Relationship Status */}
<MobileFilterSection
title="Relationship Status"
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.relationship_status || undefined)}
selection={
<RelationshipStatusFilterText
options={filters.relationship_status as string[]}
highlightedClass={
hasAny(filters.relationship_status || undefined)
? 'text-primary-600'
: 'text-ink-900'
}
/>
}
>
<RelationshipStatusFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* LOCATION */}
<MobileFilterSection
title="Location"
@@ -317,6 +343,27 @@ function MobileFilters(props: {
<SmokerFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* LANGUAGES */}
<MobileFilterSection
title="Languages"
// className="col-span-full max-h-80 overflow-y-auto"
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.languages || undefined)}
selection={
<LanguageFilterText
options={filters.languages as string[]}
highlightedClass={
hasAny(filters.languages || undefined)
? 'text-primary-600'
: 'text-ink-900'
}
/>
}
>
<LanguageFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* POLITICS */}
<MobileFilterSection
title="Politics"
@@ -355,6 +402,24 @@ function MobileFilters(props: {
<ReligionFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* MBTI */}
<MobileFilterSection
title="MBTI"
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.mbti)}
selection={
<MbtiFilterText
options={filters.mbti as string[]}
highlightedClass={
hasAny(filters.mbti) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<MbtiFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* EDUCATION */}
<MobileFilterSection
title="Education"

View File

@@ -0,0 +1,65 @@
import clsx from 'clsx'
import {convertRelationshipStatusTypes,} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {RELATIONSHIP_STATUS_CHOICES} from "web/components/filters/choices"
import {FilterFields} from "common/filters"
import {getSortedOptions} from "common/util/sorting"
export function RelationshipStatusFilterText(props: {
options: string[] | undefined
highlightedClass?: string
defaultLabel?: string
}) {
const {options, highlightedClass, defaultLabel} = props
const length = (options ?? []).length
const label = defaultLabel || 'Any'
if (!options || length < 1) {
return (
<span className={clsx('text-semibold', highlightedClass)}>{label}</span>
)
}
if (length > 2) {
return (
<span>
<span className={clsx('font-semibold', highlightedClass)}>
Multiple
</span>
</span>
)
}
const sortedOptions = getSortedOptions(options, RELATIONSHIP_STATUS_CHOICES)
const convertedTypes = sortedOptions.map((r) => convertRelationshipStatusTypes(r as any))
return (
<div>
<span className={clsx('font-semibold', highlightedClass)}>
{stringOrStringArrayToText({
text: convertedTypes,
capitalizeFirstLetterOption: true,
})}{' '}
</span>
</div>
)
}
export function RelationshipStatusFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
}) {
const {filters, updateFilter} = props
return (
<MultiCheckbox
selected={filters.relationship_status ?? []}
choices={RELATIONSHIP_STATUS_CHOICES as any}
onChange={(c) => {
updateFilter({relationship_status: c})
}}
/>
)
}

View File

@@ -49,8 +49,9 @@ export function ReligionFilterText(props: {
export function ReligionFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
className?: string
}) {
const {filters, updateFilter} = props
const {filters, updateFilter, className} = props
return (
<>
<MultiCheckbox
@@ -59,6 +60,7 @@ export function ReligionFilter(props: {
onChange={(c) => {
updateFilter({religion: c})
}}
className={className}
/>
</>
)

View File

@@ -6,6 +6,7 @@ import {debounce, isEqual} from "lodash";
import {wantsKidsDatabase, wantsKidsDatabaseToWantsKidsFilter, wantsKidsToHasKidsFilter} from "common/wants-kids";
import {FilterFields, initialFilters, OriginLocation} from "common/filters";
import {MAX_INT, MIN_INT} from "common/constants";
import {logger} from "common/logging";
export const useFilters = (you: Profile | undefined) => {
const isLooking = useIsLooking()
@@ -14,11 +15,11 @@ export const useFilters = (you: Profile | undefined) => {
'profile-filters-4'
)
// console.log('filters', filters)
// logger.log('filters', filters)
const updateFilter = (newState: Partial<FilterFields>) => {
const updatedState = {...newState}
// console.log('updating filters', updatedState)
// logger.log('updating filters', updatedState)
setFilters((prevState) => ({...prevState, ...updatedState}))
}
@@ -73,6 +74,9 @@ export const useFilters = (you: Profile | undefined) => {
pref_romantic_styles: you?.pref_romantic_styles?.length ? you.pref_romantic_styles : undefined,
diet: you?.diet?.length ? you.diet : undefined,
political_beliefs: you?.political_beliefs?.length ? you.political_beliefs : undefined,
mbti: you?.mbti ? [you.mbti] : undefined,
relationship_status: you?.relationship_status?.length ? you.relationship_status : undefined,
languages: you?.languages?.length ? you.languages : undefined,
religion: you?.religion?.length ? you.religion : undefined,
wants_kids_strength: wantsKidsDatabaseToWantsKidsFilter(
(you?.wants_kids_strength ?? 2) as wantsKidsDatabase
@@ -82,7 +86,7 @@ export const useFilters = (you: Profile | undefined) => {
),
is_smoker: you?.is_smoker,
}
console.debug(you, yourFilters)
logger.debug(you, yourFilters)
const isYourFilters =
!!you
@@ -90,10 +94,13 @@ export const useFilters = (you: Profile | undefined) => {
&& isEqual(filters.genders?.length ? filters.genders : undefined, yourFilters.genders?.length ? yourFilters.genders : undefined)
&& (!you.gender && !filters.pref_gender?.length || filters.pref_gender?.length == 1 && isEqual(filters.pref_gender?.length ? filters.pref_gender[0] : undefined, you.gender))
&& (!you.education_level && !filters.education_levels?.length || filters.education_levels?.length == 1 && isEqual(filters.education_levels?.length ? filters.education_levels[0] : undefined, you.education_level))
&& (!you.mbti && !filters.mbti?.length || filters.mbti?.length == 1 && isEqual(filters.mbti?.length ? filters.mbti[0] : undefined, you.mbti))
&& isEqual(new Set(filters.pref_romantic_styles), new Set(you.pref_romantic_styles))
&& isEqual(new Set(filters.pref_relation_styles), new Set(you.pref_relation_styles))
&& isEqual(new Set(filters.diet), new Set(you.diet))
&& isEqual(new Set(filters.political_beliefs), new Set(you.political_beliefs))
&& isEqual(new Set(filters.relationship_status), new Set(you.relationship_status))
&& isEqual(new Set(filters.languages), new Set(you.languages))
&& isEqual(new Set(filters.religion), new Set(you.religion))
&& filters.pref_age_max == yourFilters.pref_age_max
&& filters.pref_age_min == yourFilters.pref_age_min

View File

@@ -32,6 +32,7 @@ type TabProps = {
trackingName?: string
// Default is to lazy render tabs as they are selected. If true, it will render all tabs at once.
renderAllTabs?: boolean
name?: string // a unique identifier for the tabs, used for caching
}
export function MinimalistTabs(props: TabProps & { activeIndex: number }) {
@@ -166,7 +167,11 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
<Row className="justify-center">{tab.stackedTabIcon}</Row>
)}
<Row className={'items-center'}>
{tab.title}
<Col>
{tab.title.split('\n').map((line, i) => (
<Row className={'items-center justify-center'} key={i}>{line}</Row>
))}
</Col>
{tab.inlineTabIcon}
</Row>
</Tooltip>
@@ -199,7 +204,7 @@ export function UncontrolledTabs(props: TabProps & { defaultIndex?: number }) {
const { defaultIndex, onClick, ...rest } = props
const [activeIndex, setActiveIndex] = usePersistentInMemoryState(
defaultIndex ?? 0,
`tab-${props.trackingName}-${props.tabs[0]?.title}`
`tab-${props.trackingName}-${props.name ?? props.tabs[0]?.title}`
)
if ((defaultIndex ?? 0) > props.tabs.length - 1) {
console.error('default index greater than tabs length')
@@ -212,6 +217,7 @@ export function UncontrolledTabs(props: TabProps & { defaultIndex?: number }) {
setActiveIndex(i)
onClick?.(titleOrQueryTitle, i)
}}
labelsParentClassName={'gap-0 xs:gap-4'}
/>
)
}

View File

@@ -1,14 +1,16 @@
import { Row } from 'web/components/layout/row'
import { Checkbox } from 'web/components/widgets/checkbox'
import clsx from "clsx";
export const MultiCheckbox = (props: {
choices: { [key: string]: string }
selected: string[]
onChange: (selected: string[]) => void
className?: string
}) => {
const { choices, selected, onChange } = props
const { choices, selected, onChange, className } = props
return (
<Row className={'flex-wrap gap-3'}>
<Row className={clsx('flex-wrap', className)}>
{Object.entries(choices).map(([key, value]) => (
<Checkbox
key={key}

View File

@@ -11,6 +11,7 @@ import SiteLogo from '../site-logo'
import {Button, ColorType, SizeType} from 'web/components/buttons/button'
import {signupRedirect} from 'web/lib/util/signup'
import {useProfile} from 'web/hooks/use-profile'
import Image from 'next/image'
export default function Sidebar(props: {
className?: string
@@ -46,6 +47,14 @@ export default function Sidebar(props: {
{navOptions.map((item) => (
<SidebarItem key={item.name} item={item} currentPage={currentPage}/>
))}
<Image
src="https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2FGoogle_Play_Store_badge_EN.svg.png?alt=media&token=3e0e8605-800a-422b-84d1-8ecec8af3e80"
alt="divider"
width={160}
height={80}
className="mx-auto pt-4 hover:opacity-70 cursor-pointer invert dark:invert-0"
onClick={() => router.push('/contact')}
/>
{user === null && <SignUpButton className="mt-4" text="Sign up"/>}
{/*{user === null && <SignUpAsMatchmaker className="mt-2" />}*/}

View File

@@ -13,7 +13,6 @@ import {updateProfile, updateUser} from 'web/lib/api'
import {Column} from 'common/supabase/utils'
import {User} from 'common/user'
import {track} from 'web/lib/service/analytics'
import {Races} from './race'
import {Carousel} from 'web/components/widgets/carousel'
import {tryCatch} from 'common/util/try-catch'
import {ProfileRow} from 'common/profiles/profile'
@@ -31,8 +30,12 @@ import {MultipleChoiceOptions} from "common/profiles/multiple-choice";
import {
DIET_CHOICES,
EDUCATION_CHOICES,
POLITICAL_CHOICES,
LANGUAGE_CHOICES,
MBTI_CHOICES,
POLITICAL_CHOICES,
RACE_CHOICES,
RELATIONSHIP_CHOICES,
RELATIONSHIP_STATUS_CHOICES,
RELIGION_CHOICES,
ROMANTIC_CHOICES
} from "web/components/filters/choices";
@@ -275,6 +278,15 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Relationship status</label>
<MultiCheckbox
choices={RELATIONSHIP_STATUS_CHOICES}
selected={profile['relationship_status'] ?? []}
onChange={(selected) => setProfile('relationship_status', selected)}
/>
</Col>
{lookingRelationship && <>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Relationship style</label>
@@ -382,6 +394,21 @@ export const OptionalProfileUserForm = (props: {
</div>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Languages</label>
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="col-span-full max-h-60 overflow-y-auto w-full">
<MultiCheckbox
choices={LANGUAGE_CHOICES}
selected={profile.languages || []}
onChange={(selected) => setProfile('languages', selected)}
/>
</div>
</div>
</div>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Political beliefs</label>
<MultiCheckbox
@@ -414,6 +441,16 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
<Col className={clsx(colClassName, 'max-w-[550px]')}>
<label className={clsx(labelClassName)}>MBTI Personality Type</label>
<ChoicesToggleGroup
currentChoice={profile['mbti'] ?? ''}
choicesMap={MBTI_CHOICES}
setChoice={(c) => setProfile('mbti', c)}
className="grid grid-cols-4 xs:grid-cols-8"
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Diet</label>
<MultiCheckbox
@@ -529,7 +566,7 @@ export const OptionalProfileUserForm = (props: {
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>Ethnicity/origin</label>
<MultiCheckbox
choices={Races}
choices={RACE_CHOICES}
selected={profile['ethnicity'] ?? []}
onChange={(selected) => setProfile('ethnicity', selected)}
/>

View File

@@ -1,17 +1,22 @@
import clsx from 'clsx'
import {convertRelationshipType, type RelationshipType,} from 'web/lib/util/convert-types'
import {convertRace, convertRelationshipType, type RelationshipType,} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {ReactNode} from 'react'
import {
REVERTED_DIET_CHOICES, REVERTED_EDUCATION_CHOICES,
REVERTED_POLITICAL_CHOICES, REVERTED_RELIGION_CHOICES,
REVERTED_ROMANTIC_CHOICES
INVERTED_DIET_CHOICES,
INVERTED_EDUCATION_CHOICES,
INVERTED_LANGUAGE_CHOICES,
INVERTED_MBTI_CHOICES,
INVERTED_POLITICAL_CHOICES,
INVERTED_RELATIONSHIP_STATUS_CHOICES,
INVERTED_RELIGION_CHOICES,
INVERTED_ROMANTIC_CHOICES
} from 'web/components/filters/choices'
import {BiSolidDrink} from 'react-icons/bi'
import {BsPersonHeart} from 'react-icons/bs'
import {BsPersonHeart, BsPersonVcard} from 'react-icons/bs'
import {FaChild} from 'react-icons/fa6'
import {LuBriefcase, LuCigarette, LuCigaretteOff, LuGraduationCap,} from 'react-icons/lu'
import {MdNoDrinks, MdOutlineChildFriendly} from 'react-icons/md'
import {MdLanguage, MdNoDrinks, MdOutlineChildFriendly} from 'react-icons/md'
import {PiHandsPrayingBold, PiMagnifyingGlassBold,} from 'react-icons/pi'
import {RiScales3Line} from 'react-icons/ri'
import {Col} from 'web/components/layout/col'
@@ -20,7 +25,6 @@ import {fromNow} from 'web/lib/util/time'
import {convertGenderPlural, Gender} from 'common/gender'
import {HiOutlineGlobe} from 'react-icons/hi'
import {UserHandles} from 'web/components/user/user-handles'
import {convertRace} from './race'
import {Profile} from 'common/profiles/profile'
import {UserActivity} from "common/user";
import {ClockIcon} from "@heroicons/react/solid";
@@ -74,18 +78,30 @@ export default function ProfileAbout(props: {
>
<Seeking profile={profile}/>
<RelationshipType profile={profile}/>
<AboutRow
icon={<BsPersonHeart className="h-5 w-5"/>}
text={profile.relationship_status?.map(v => INVERTED_RELATIONSHIP_STATUS_CHOICES[v])}
/>
<Education profile={profile}/>
<Occupation profile={profile}/>
<AboutRow
icon={<MdLanguage className="h-5 w-5"/>}
text={profile.languages?.map(v => INVERTED_LANGUAGE_CHOICES[v])}
/>
<AboutRow
icon={<RiScales3Line className="h-5 w-5"/>}
text={profile.political_beliefs?.map(belief => REVERTED_POLITICAL_CHOICES[belief])}
text={profile.political_beliefs?.map(belief => INVERTED_POLITICAL_CHOICES[belief])}
suffix={profile.political_details}
/>
<AboutRow
icon={<PiHandsPrayingBold className="h-5 w-5"/>}
text={profile.religion?.map(belief => REVERTED_RELIGION_CHOICES[belief])}
text={profile.religion?.map(belief => INVERTED_RELIGION_CHOICES[belief])}
suffix={profile.religious_beliefs}
/>
<AboutRow
icon={<BsPersonVcard className="h-5 w-5"/>}
text={profile.mbti ? INVERTED_MBTI_CHOICES[profile.mbti] : null}
/>
<AboutRow
icon={<HiOutlineGlobe className="h-5 w-5"/>}
text={profile.ethnicity
@@ -96,7 +112,7 @@ export default function ProfileAbout(props: {
<Drinks profile={profile}/>
<AboutRow
icon={<GiFruitBowl className="h-5 w-5"/>}
text={profile.diet?.map(e => REVERTED_DIET_CHOICES[e])}
text={profile.diet?.map(e => INVERTED_DIET_CHOICES[e])}
/>
<HasKids profile={profile}/>
<WantsKids profile={profile}/>
@@ -163,7 +179,7 @@ function RelationshipType(props: { profile: Profile }) {
})
if (relationshipTypes?.includes('relationship')) {
const romanticStyles = profile.pref_romantic_styles
?.map((style) => REVERTED_ROMANTIC_CHOICES[style].toLowerCase())
?.map((style) => INVERTED_ROMANTIC_CHOICES[style].toLowerCase())
.filter(Boolean)
if (romanticStyles && romanticStyles.length > 0) {
seekingGenderText += ` (${romanticStyles.join(', ')})`
@@ -186,7 +202,7 @@ function Education(props: { profile: Profile }) {
let text = ''
if (educationLevel) {
text += capitalizeAndRemoveUnderscores(REVERTED_EDUCATION_CHOICES[educationLevel])
text += capitalizeAndRemoveUnderscores(INVERTED_EDUCATION_CHOICES[educationLevel])
}
if (university) {
if (educationLevel) text += ' at '

View File

@@ -1,21 +0,0 @@
import { invert } from 'lodash'
// label -> backend
export const Races = {
'Black/African origin': 'african',
'East Asian': 'asian',
'South/Southeast Asian': 'south_asian',
'White/Caucasian': 'caucasian',
'Hispanic/Latino': 'hispanic',
'Middle Eastern': 'middle_eastern',
'Native American/Indigenous': 'native_american',
Other: 'other',
} as const
export type Race = (typeof Races)[keyof typeof Races]
const raceTolabel = invert(Races)
export function convertRace(race: Race) {
return raceTolabel[race]
}

View File

@@ -28,6 +28,7 @@ export const initialRequiredState = {
city: '',
pinned_url: '',
photo_urls: [],
languages: ['english'],
bio: null,
}
@@ -108,20 +109,20 @@ export const RequiredProfileUserForm = (props: {
{!profileCreatedAlready && <>
{step === 0 && <Col>
<label className={clsx(labelClassName)}>Username</label>
<Row className={'items-center gap-2'}>
<Input
disabled={loadingUsername}
type="text"
placeholder="Username"
value={username}
onChange={(e) => {
updateUserState({username: e.target.value || ''})
}}
onBlur={updateUsername}
/>
{loadingUsername && <LoadingIndicator className={'ml-2'}/>}
</Row>
<label className={clsx(labelClassName)}>Username</label>
<Row className={'items-center gap-2'}>
<Input
disabled={loadingUsername}
type="text"
placeholder="Username"
value={username}
onChange={(e) => {
updateUserState({username: e.target.value || ''})
}}
onBlur={updateUsername}
/>
{loadingUsername && <LoadingIndicator className={'ml-2'}/>}
</Row>
{errorUsername && (
<span className="text-error text-sm">{errorUsername}</span>
)}

View File

@@ -1,7 +1,7 @@
import Link from 'next/link'
import clsx from 'clsx'
import {Row} from 'web/components/layout/row'
import FavIcon from "web/public/FavIcon";
import FavIcon from "web/components/FavIcon";
import {isProd} from "common/envs/is-prod";
export default function SiteLogo(props: {

View File

@@ -10,7 +10,7 @@ export function Checkbox(props: {
const { label, checked, toggle, className, disabled } = props
return (
<div className={clsx(className, 'space-y-5')}>
<div className={clsx(className, 'space-y-5 px-2 py-1 hover:bg-canvas-200 hover:rounded')}>
<div className="relative flex items-center">
<div className="flex h-6 items-center">
<input

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