mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51:45 -04:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e924c2741 | ||
|
|
241b851c02 | ||
|
|
2ba9949035 | ||
|
|
3d4b76ffc3 | ||
|
|
f7fb0c6c82 | ||
|
|
e5fc734b90 | ||
|
|
10fa659e52 | ||
|
|
0ac315b017 | ||
|
|
bbfbd2daae | ||
|
|
cd2c4d3314 | ||
|
|
37ee7752c2 | ||
|
|
6b11e6b060 | ||
|
|
f650ab7394 | ||
|
|
fead7459d4 | ||
|
|
bbf3970121 | ||
|
|
26fb810840 | ||
|
|
af9074af6e | ||
|
|
4229c2a4fa | ||
|
|
fd58602e6d | ||
|
|
af26397ad7 | ||
|
|
859d01594a | ||
|
|
09a37058e6 | ||
|
|
edc7366b1d | ||
|
|
7306cb335b | ||
|
|
1e13cc4294 | ||
|
|
a88e5a9ec8 | ||
|
|
09d743c603 | ||
|
|
36c1ec528a | ||
|
|
aec9600036 | ||
|
|
6e1306bdd6 | ||
|
|
37f5c95716 | ||
|
|
0d48c541a0 | ||
|
|
8928cd1667 | ||
|
|
780f935fea | ||
|
|
5bf095178d | ||
|
|
e135293b43 | ||
|
|
be7e009909 | ||
|
|
ada8a713c1 | ||
|
|
eb7391dae0 | ||
|
|
8bdbd5e4fe | ||
|
|
137d15ae71 | ||
|
|
8a2ed6f8ff | ||
|
|
7766b43187 | ||
|
|
b9a637fdac | ||
|
|
3096dbc922 | ||
|
|
d6b0bb4378 | ||
|
|
43ef43ba72 | ||
|
|
314037dd06 | ||
|
|
7c4d66bbf5 | ||
|
|
49d28961ef | ||
|
|
9d649daee5 | ||
|
|
7598a47283 | ||
|
|
c9f7230d27 | ||
|
|
716633c6df | ||
|
|
8a215a765f | ||
|
|
e2ff41a0b1 | ||
|
|
d790fae74a | ||
|
|
1a17862f45 | ||
|
|
acd4c36531 | ||
|
|
f623450f08 | ||
|
|
ce681cfb67 | ||
|
|
a0a6523a25 | ||
|
|
8e2fa36d0e | ||
|
|
c953a84c1f | ||
|
|
2b403f0761 | ||
|
|
f954e3b2d7 | ||
|
|
24ee2a206e | ||
|
|
02d165829f | ||
|
|
b4d996bd14 | ||
|
|
60989faa03 | ||
|
|
4aeda8a1a7 | ||
|
|
023a20f263 |
420
.cursor/rules/dev-rules.mdc
Normal file
420
.cursor/rules/dev-rules.mdc
Normal file
@@ -0,0 +1,420 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
- next.js react tailwind frontend `/web`
|
||||
- broken down into pages, components, hooks, lib
|
||||
- express node api server `/backend/api`
|
||||
- one off scripts, like migrations `/backend/scripts`
|
||||
- supabase postgres. schema in `/backend/supabase`
|
||||
- supabase-generated types in `/backend/supabase/schema.ts`
|
||||
- files shared between backend directories `/backend/shared`
|
||||
- anything in `/backend` can import from `shared`, but not vice versa
|
||||
- files shared between the frontend and backend in `/common`
|
||||
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from `/common`, but not vice versa.
|
||||
|
||||
## Deployment
|
||||
|
||||
- The project has both dev and prod environments.
|
||||
- Backend is on GCP (Google Cloud Platform). Deployment handled by terraform.
|
||||
- Project ID is `compass-130ba`.
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
---
|
||||
|
||||
Here's an example component from web in our style:
|
||||
|
||||
```tsx
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { isAdminId, isModId } from 'common/envs/constants'
|
||||
import { type Headline } from 'common/news'
|
||||
import { EditNewsButton } from 'web/components/news/edit-news-button'
|
||||
import { Carousel } from 'web/components/widgets/carousel'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page'
|
||||
import { removeEmojis } from 'common/util/string'
|
||||
|
||||
export function HeadlineTabs(props: {
|
||||
headlines: Headline[]
|
||||
currentSlug: string
|
||||
endpoint: DashboardEndpoints
|
||||
hideEmoji?: boolean
|
||||
notSticky?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } =
|
||||
props
|
||||
const user = useUser()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'bg-canvas-50 w-full',
|
||||
!notSticky && 'sticky top-0 z-50'
|
||||
)}
|
||||
>
|
||||
<Carousel labelsParentClassName="gap-px">
|
||||
{headlines.map(({ id, slug, title }) => (
|
||||
<Tab
|
||||
key={id}
|
||||
label={hideEmoji ? removeEmojis(title) : title}
|
||||
href={`/${endpoint}/${slug}`}
|
||||
active={slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
{user && <Tab label="More" href="/dashboard" />}
|
||||
{user && (isAdminId(user.id) || isModId(user.id)) && (
|
||||
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />
|
||||
)}
|
||||
</Carousel>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components.
|
||||
|
||||
It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find.
|
||||
|
||||
Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS:
|
||||
|
||||
```ts
|
||||
import { api } from 'web/lib/api/api'
|
||||
// More imports...
|
||||
|
||||
export async function getStaticProps() {
|
||||
try {
|
||||
const headlines = await api('headlines', {})
|
||||
return {
|
||||
props: {
|
||||
headlines,
|
||||
revalidate: 30 * 60, // 30 minutes
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return { props: { headlines: [] }, revalidate: 60 }
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home(props: { headlines: Headline[] }) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If we are calling the API on the client, prefer using the `useAPIGetter` hook:
|
||||
|
||||
```ts
|
||||
export const YourTopicsSection = (props: {
|
||||
user: User
|
||||
className?: string
|
||||
}) => {
|
||||
const { user, className } = props
|
||||
const { data, refresh } = useAPIGetter('get-followed-groups', {
|
||||
userId: user.id,
|
||||
})
|
||||
const followedGroups = data?.groups ?? []
|
||||
...
|
||||
```
|
||||
|
||||
This stores the result in memory, and allows you to call refresh() to get an updated version.
|
||||
|
||||
---
|
||||
|
||||
We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in-memory caching so that navigating back to a page will preserve the same state and appear to load instantly.
|
||||
|
||||
Here's the definition of usePersistentInMemoryState:
|
||||
|
||||
```ts
|
||||
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
|
||||
const [state, setState] = useStateCheckEquality<T>(
|
||||
safeJsonParse(store[key]) ?? initialValue
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const storedValue = safeJsonParse(store[key]) ?? initialValue
|
||||
setState(storedValue as T)
|
||||
}, [key])
|
||||
|
||||
const saveState = useEvent((newState: T | ((prevState: T) => T)) => {
|
||||
setState((prevState) => {
|
||||
const updatedState = isFunction(newState) ? newState(prevState) : newState
|
||||
store[key] = JSON.stringify(updatedState)
|
||||
return updatedState
|
||||
})
|
||||
})
|
||||
|
||||
return [state, saveState] as const
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook:
|
||||
|
||||
```ts
|
||||
export function useApiSubscription(opts: SubscriptionOptions) {
|
||||
useEffect(() => {
|
||||
const ws = client
|
||||
if (ws != null) {
|
||||
if (opts.enabled ?? true) {
|
||||
ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
|
||||
return () => {
|
||||
ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [opts.enabled, JSON.stringify(opts.topics)])
|
||||
}
|
||||
```
|
||||
|
||||
In `use-bets`, we have this hook to get live updates with useApiSubscription:
|
||||
|
||||
```ts
|
||||
export const useContractBets = (
|
||||
contractId: string,
|
||||
opts?: APIParams<'bets'> & { enabled?: boolean }
|
||||
) => {
|
||||
const { enabled = true, ...apiOptions } = {
|
||||
contractId,
|
||||
...opts,
|
||||
}
|
||||
const optionsKey = JSON.stringify(apiOptions)
|
||||
|
||||
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
|
||||
[],
|
||||
`${optionsKey}-bets`
|
||||
)
|
||||
|
||||
const addBets = (bets: Bet[]) => {
|
||||
setNewBets((currentBets) => {
|
||||
const uniqueBets = sortBy(
|
||||
uniqBy([...currentBets, ...bets], 'id'),
|
||||
'createdTime'
|
||||
)
|
||||
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
|
||||
})
|
||||
}
|
||||
|
||||
const isPageVisible = useIsPageVisible()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPageVisible && enabled) {
|
||||
api('bets', apiOptions).then(addBets)
|
||||
}
|
||||
}, [optionsKey, enabled, isPageVisible])
|
||||
|
||||
useApiSubscription({
|
||||
topics: [`contract/${contractId}/new-bet`],
|
||||
onBroadcast: (msg) => {
|
||||
addBets(msg.data.bets as Bet[])
|
||||
},
|
||||
enabled,
|
||||
})
|
||||
|
||||
return newBets
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts`
|
||||
|
||||
```ts
|
||||
export function broadcastUpdatedPrivateUser(userId: string) {
|
||||
// don't send private user info because it's private and anyone can listen
|
||||
broadcast(`private-user/${userId}`, {})
|
||||
}
|
||||
|
||||
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
|
||||
broadcast(`user/${user.id}`, { user })
|
||||
}
|
||||
|
||||
export function broadcastUpdatedComment(comment: Comment) {
|
||||
broadcast(`user/${comment.onUserId}/comment`, { comment })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
We have our scripts in the directory `/backend/scripts`.
|
||||
|
||||
To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env.
|
||||
|
||||
Example from `/backend/scripts/manicode.ts`
|
||||
|
||||
```ts
|
||||
import { runScript } from 'run-script'
|
||||
|
||||
runScript(async ({ pg }) => {
|
||||
const userPrompt = process.argv[2]
|
||||
await pg.none(...)
|
||||
})
|
||||
```
|
||||
|
||||
Generally scripts should be run by me, especially if they modify backend state or schema.
|
||||
But if you need to run a script, you can use `bun`. For example:
|
||||
|
||||
```sh
|
||||
bun run manicode.ts "Generate a page called cowp, which has cows that make noises!"
|
||||
```
|
||||
|
||||
if that doesn't work, try
|
||||
|
||||
```sh
|
||||
bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `/common/src/api/schema.ts`.
|
||||
|
||||
E.g. Here is a hypothetical bet schema:
|
||||
|
||||
```ts
|
||||
bet: {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
returns: {} as CandidateBet & { betId: string },
|
||||
props: z
|
||||
.object({
|
||||
contractId: z.string(),
|
||||
amount: z.number().gte(1),
|
||||
replyToCommentId: z.string().optional(),
|
||||
limitProb: z.number().gte(0.01).lte(0.99).optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
// Used for binary and new multiple choice contracts (cpmm-multi-1).
|
||||
outcome: z.enum(['YES', 'NO']).default('YES'),
|
||||
//Multi
|
||||
answerId: z.string().optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
})
|
||||
.strict(),
|
||||
}
|
||||
```
|
||||
|
||||
Then, we define the bet endpoint in `backend/api/src/place-bet.ts`
|
||||
|
||||
```ts
|
||||
export const placeBet: APIHandler<'bet'> = async (props, auth) => {
|
||||
const isApi = auth.creds.kind === 'key'
|
||||
return await betsQueue.enqueueFn(
|
||||
() => placeBetMain(props, auth.uid, isApi),
|
||||
[props.contractId, auth.uid]
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
And finally, you need to register the handler in `backend/api/src/routes.ts`
|
||||
|
||||
```ts
|
||||
import { placeBet } from './place-bet'
|
||||
...
|
||||
|
||||
const handlers = {
|
||||
bet: placeBet,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
We have two ways to access our postgres database.
|
||||
|
||||
```ts
|
||||
import { db } from 'web/lib/supabase/db'
|
||||
|
||||
db.from('profiles').select('*').eq('user_id', userId)
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```ts
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [userId])
|
||||
```
|
||||
|
||||
The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query and update the database directly from the frontend.
|
||||
|
||||
`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this.
|
||||
|
||||
Another example using the direct client:
|
||||
|
||||
```ts
|
||||
export const getUniqueBettorIds = async (
|
||||
contractId: string,
|
||||
pg: SupabaseDirectClient
|
||||
) => {
|
||||
const res = await pg.manyOrNone(
|
||||
'select distinct user_id from contract_bets where contract_id = $1',
|
||||
[contractId]
|
||||
)
|
||||
return res.map((r) => r.user_id as string)
|
||||
}
|
||||
```
|
||||
|
||||
(you may notice we write sql in lowercase)
|
||||
|
||||
We have a few helper functions for updating and inserting data into the database.
|
||||
|
||||
```ts
|
||||
import {
|
||||
buikInsert,
|
||||
bulkUpdate,
|
||||
bulkUpdateData,
|
||||
bulkUpsert,
|
||||
insert,
|
||||
update,
|
||||
updateData,
|
||||
} from 'shared/supabase/utils'
|
||||
|
||||
...
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// you are encouraged to use tryCatch for these
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||
)
|
||||
|
||||
if (error) throw APIError(500, 'Error creating profile: ' + error.message)
|
||||
|
||||
await update(pg, 'profiles', 'user_id', { user_id: auth.uid, age: 99 })
|
||||
|
||||
await updateData(pg, 'private_users', { id: userId, notifications: { ... } })
|
||||
```
|
||||
|
||||
The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it does is sanitize and output sql query strings. It has several helper functions including:
|
||||
|
||||
- `select`: Specifies the columns to select
|
||||
- `from`: Specifies the table to query
|
||||
- `where`: Adds WHERE clauses
|
||||
- `orderBy`: Specifies the order of results
|
||||
- `limit`: Limits the number of results
|
||||
- `renderSql`: Combines all parts into a final SQL string
|
||||
|
||||
Example usage:
|
||||
|
||||
```typescript
|
||||
const query = renderSql(
|
||||
select('distinct user_id'),
|
||||
from('contract_bets'),
|
||||
where('contract_id = ${id}', { id }),
|
||||
orderBy('created_time desc'),
|
||||
limitValue != null && limit(limitValue)
|
||||
)
|
||||
|
||||
const res = await pg.manyOrNone(query)
|
||||
```
|
||||
|
||||
Use these functions instead of string concatenation.
|
||||
46
.github/workflows/ci.yml
vendored
46
.github/workflows/ci.yml
vendored
@@ -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
8
.gitignore
vendored
@@ -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
422
.windsurf/rules/compass.md
Normal 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.
|
||||
66
README.md
66
README.md
@@ -1,11 +1,13 @@
|
||||
|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
||||

|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
||||
[](https://codecov.io/gh/CompassConnections/Compass)
|
||||
[](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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
31
backend/api/jest.config.js
Normal file
31
backend/api/jest.config.js
Normal 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"
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
30
backend/api/src/delete-compatibility-answer.ts
Normal file
30
backend/api/src/delete-compatibility-answer.ts
Normal 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]
|
||||
)
|
||||
}
|
||||
@@ -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)`,
|
||||
|
||||
59
backend/api/tests/unit/get-users.unit.test.ts
Normal file
59
backend/api/tests/unit/get-users.unit.test.ts
Normal 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);
|
||||
|
||||
})
|
||||
})
|
||||
17
backend/api/tsconfig.test.json
Normal file
17
backend/api/tsconfig.test.json
Normal 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"]
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
31
backend/email/jest.config.js
Normal file
31
backend/email/jest.config.js
Normal 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"
|
||||
],
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
17
backend/email/tsconfig.test.json
Normal file
17
backend/email/tsconfig.test.json
Normal 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"]
|
||||
}
|
||||
31
backend/shared/jest.config.js
Normal file
31
backend/shared/jest.config.js
Normal 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"
|
||||
],
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
1
backend/shared/tests/integration/README.md
Normal file
1
backend/shared/tests/integration/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Note: may not be needed. `shared` rarely needs integration tests.
|
||||
17
backend/shared/tsconfig.test.json
Normal file
17
backend/shared/tsconfig.test.json
Normal 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"]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
62
common/src/logging.ts
Normal 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)
|
||||
@@ -22,6 +22,8 @@ const filterLabels: Record<string, string> = {
|
||||
orderBy: "",
|
||||
diet: "Diet",
|
||||
political_beliefs: "Political views",
|
||||
languages: "",
|
||||
mbti: "MBTI",
|
||||
}
|
||||
|
||||
export type locationType = {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
0
common/tests/integration/.keep
Normal file
0
common/tests/integration/.keep
Normal file
0
common/tests/unit/.keep
Normal file
0
common/tests/unit/.keep
Normal file
7
common/tests/unit/jsonToMarkdown.test.ts
Normal file
7
common/tests/unit/jsonToMarkdown.test.ts
Normal 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
444
docs/Next.js.md
Normal 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 doesn’t 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 don’t 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 user’s 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.
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
You’ll 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.
|
||||
|
||||
That’s 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 app’s 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 Google’s **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
23
jest.config.js
Normal 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"
|
||||
],
|
||||
};
|
||||
19
package.json
19
package.json
@@ -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",
|
||||
|
||||
@@ -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'] },
|
||||
// },
|
||||
],
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -4,9 +4,7 @@ set -e
|
||||
|
||||
cd "$(dirname "$0")"/..
|
||||
|
||||
export NEXT_PUBLIC_WEBVIEW=1
|
||||
|
||||
yarn build-web
|
||||
yarn build-web-view
|
||||
|
||||
source web/.env
|
||||
|
||||
|
||||
@@ -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
22
scripts/e2e.sh
Executable 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
|
||||
124
scripts/userCreation.ts
Normal file
124
scripts/userCreation.ts
Normal 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
3
tests/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Tests
|
||||
|
||||
TODO: add docs for tests
|
||||
0
tests/e2e/backend/config/.keep
Normal file
0
tests/e2e/backend/config/.keep
Normal file
26
tests/e2e/backend/fixtures/base.ts
Normal file
26
tests/e2e/backend/fixtures/base.ts
Normal 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"
|
||||
10
tests/e2e/backend/specs/api/api.test.ts
Normal file
10
tests/e2e/backend/specs/api/api.test.ts
Normal 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));
|
||||
|
||||
});
|
||||
9
tests/e2e/backend/specs/db.test.ts
Normal file
9
tests/e2e/backend/specs/db.test.ts
Normal 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);
|
||||
|
||||
})
|
||||
27
tests/e2e/backend/utils/database.ts
Normal file
27
tests/e2e/backend/utils/database.ts
Normal 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();
|
||||
55
tests/e2e/backend/utils/userInformation.ts
Normal file
55
tests/e2e/backend/utils/userInformation.ts
Normal 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;
|
||||
5
tests/e2e/web/TESTING_CONFIG.ts
Normal file
5
tests/e2e/web/TESTING_CONFIG.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const config = {
|
||||
BASE_URL: 'http://localhost:3000',
|
||||
DEFAULT_LOGIN: 'defaultUser@dev.com',
|
||||
DEFAULT_PASSWORD: 'defaultPassword',
|
||||
};
|
||||
24
tests/e2e/web/fixtures/signInFixture.ts
Normal file
24
tests/e2e/web/fixtures/signInFixture.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
42
tests/e2e/web/pages/signInPage.ts
Normal file
42
tests/e2e/web/pages/signInPage.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
10
tests/e2e/web/specs/postSignIn.spec.ts
Normal file
10
tests/e2e/web/specs/postSignIn.spec.ts
Normal 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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./backend/api" },
|
||||
{ "path": "./backend/api/tsconfig.test.json" }
|
||||
]
|
||||
}
|
||||
13
web/components/FavIcon.tsx
Normal file
13
web/components/FavIcon.tsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 scores—or{' '}
|
||||
</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) &&
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 Play–registered 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
|
||||
|
||||
@@ -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)
|
||||
@@ -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) => (
|
||||
|
||||
62
web/components/filters/language-filter.tsx
Normal file
62
web/components/filters/language-filter.tsx
Normal 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})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
65
web/components/filters/mbti-filter.tsx
Normal file
65
web/components/filters/mbti-filter.tsx
Normal 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})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
65
web/components/filters/relationship-status-filter.tsx
Normal file
65
web/components/filters/relationship-status-filter.tsx
Normal 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})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}*/}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user