Add pretty formatting (#29)

* Test

* Add pretty formatting

* Fix Tests

* Fix Tests

* Fix Tests

* Fix

* Add pretty formatting fix

* Fix

* Test

* Fix tests

* Clean typeckech

* Add prettier check

* Fix api tsconfig

* Fix api tsconfig

* Fix tsconfig

* Fix

* Fix

* Prettier
This commit is contained in:
Martin Braquet
2026-02-20 17:32:27 +01:00
committed by GitHub
parent 1994697fa1
commit ba9b3cfb06
695 changed files with 22382 additions and 23209 deletions

View File

@@ -5,7 +5,7 @@ module.exports = {
version: 'detect',
},
},
plugins: ['lodash', 'unused-imports'],
plugins: ['lodash', 'unused-imports', 'simple-import-sort'],
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
@@ -15,11 +15,11 @@ module.exports = {
'prettier',
],
rules: {
"react/prop-types": "off",
'react/prop-types': 'off',
'react/display-name': 'off',
'react/no-unescaped-entities': 'off',
'react/jsx-no-target-blank': 'off',
'react/no-unstable-nested-components': ['error', { allowAsProps: true }],
'react/no-unstable-nested-components': ['error', {allowAsProps: true}],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
@@ -32,14 +32,13 @@ module.exports = {
},
],
'@next/next/no-img-element': 'off',
'linebreak-style': [
'error',
process.platform === 'win32' ? 'windows' : 'unix',
],
'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'],
'lodash/import-scope': [2, 'member'],
'unused-imports/no-unused-imports': 'warn',
'react-hooks/exhaustive-deps': 'off',
'no-constant-condition': 'off',
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
},
ignorePatterns: ['/public/mtg/*'],
env: {

View File

@@ -1,5 +1,36 @@
# Ignore Next artifacts
.next/
out/
public/**/*.json
public/**/*.html
public/**/*.html
# Dependencies
node_modules
.yarn
# Build outputs
dist
build
.next
out
# Generated files
coverage
*.min.js
*.min.css
# Database / migrations
**/*.sql
# Config / lock files
yarn.lock
package-lock.json
pnpm-lock.yaml
# Android / iOS
android
ios
capacitor.config.ts
# Playwright
tests/reports
playwright-report
coverage

View File

@@ -11,6 +11,7 @@ This is the setup for deployment on Vercel, which you only need to do if you cre
Set up a Vercel account and link it to your GitHub repository.
Add the following environment variables and the ones in `.env` in the Vercel dashboard:
```bash
NEXT_PUBLIC_VERCEL=1
```
@@ -18,6 +19,7 @@ NEXT_PUBLIC_VERCEL=1
##### `next` version
The `next` version is 14.1.0, as we get the following error with 15.1.2 and above when accessing `/[username]` pages on Vercel:
```
Cannot find module 'next/dist/compiled/source-map'
Require stack:
@@ -26,4 +28,5 @@ Require stack:
Did you forget to add it to "dependencies" in `package.json`?
Node.js process exited with exit status: 1. The logs above can help with debugging the issue.
```
TODO: investigate, find a fix and upgrade.
TODO: investigate, find a fix and upgrade.

View File

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

View File

@@ -1,13 +1,13 @@
'use client'
import MarkdownPage, {MD_PATHS} from 'web/components/markdown'
import {useMarkdown} from "web/hooks/use-markdown"
import {useMarkdown} from 'web/hooks/use-markdown'
type Props = {
filename: typeof MD_PATHS[number]
filename: (typeof MD_PATHS)[number]
}
export function MarkdownPageLoader({filename}: Props) {
const content = useMarkdown(filename)
return <MarkdownPage content={content} filename={filename}/>
return <MarkdownPage content={content} filename={filename} />
}

View File

@@ -1,21 +1,19 @@
import { removeUndefinedProps } from 'common/util/object'
import { buildOgUrl } from 'common/util/og'
import {isProd} from 'common/envs/is-prod'
import {removeUndefinedProps} from 'common/util/object'
import {buildOgUrl} from 'common/util/og'
import Head from 'next/head'
import {isProd} from "common/envs/is-prod";
export function SEO<P extends Record<string, string | undefined>>(props: {
title: string
description: string
url?: string
ogProps?: { props: P; endpoint: string }
ogProps?: {props: P; endpoint: string}
image?: string
}) {
const { title, description, url, image, ogProps } = props
const {title, description, url, image, ogProps} = props
const imageUrl =
image ??
(ogProps &&
buildOgUrl(removeUndefinedProps(ogProps.props) as any, ogProps.endpoint))
image ?? (ogProps && buildOgUrl(removeUndefinedProps(ogProps.props) as any, ogProps.endpoint))
const absUrl = 'https://compassmeet.com' + url
const endTitle = isProd() ? 'Compass' : 'Compass dev'
@@ -24,12 +22,7 @@ export function SEO<P extends Record<string, string | undefined>>(props: {
<Head>
<title>{`${title} | ${endTitle}`}</title>
<meta
property="og:title"
name="twitter:title"
content={title}
key="title"
/>
<meta property="og:title" name="twitter:title" content={title} key="title" />
<meta name="description" content={description} key="description1" />
<meta
property="og:description"
@@ -43,10 +36,7 @@ export function SEO<P extends Record<string, string | undefined>>(props: {
{url && <meta property="og:url" content={absUrl} key="url" />}
{url && (
<meta
name="apple-itunes-app"
content={'app-id=6444136749, app-argument=' + absUrl}
/>
<meta name="apple-itunes-app" content={'app-id=6444136749, app-argument=' + absUrl} />
)}
{imageUrl && (

View File

@@ -1,14 +1,14 @@
import {WithPrivateUser} from "web/components/user/with-user"
import {PrivateUser} from "common/user"
import {Col} from "web/components/layout/col"
import {HOSTING_ENV, IS_VERCEL} from "common/hosting/constants"
import {Capacitor} from "@capacitor/core"
import {useEffect, useState} from "react"
import {App} from "@capacitor/app"
import {api} from "web/lib/api"
import {githubRepo} from "common/constants"
import {CustomLink} from "web/components/links"
import {Button} from "web/components/buttons/button"
import {App} from '@capacitor/app'
import {Capacitor} from '@capacitor/core'
import {githubRepo} from 'common/constants'
import {HOSTING_ENV, IS_VERCEL} from 'common/hosting/constants'
import {PrivateUser} from 'common/user'
import {useEffect, useState} from 'react'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {CustomLink} from 'web/components/links'
import {WithPrivateUser} from 'web/components/user/with-user'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
export type WebBuild = {
@@ -43,7 +43,7 @@ export type Runtime = {
}
export type Diagnostics = {
web?: WebBuild,
web?: WebBuild
android?: Android
backend?: Backend
runtime: Runtime
@@ -56,12 +56,8 @@ function useDiagnostics() {
const load = async () => {
const diagnostics: Diagnostics = {
runtime: {
platform: IS_VERCEL
? 'web'
: Capacitor.isNativePlatform()
? 'android'
: HOSTING_ENV
}
platform: IS_VERCEL ? 'web' : Capacitor.isNativePlatform() ? 'android' : HOSTING_ENV,
},
}
if (IS_VERCEL) {
@@ -97,7 +93,7 @@ function useDiagnostics() {
version: backend.version,
gitSha: backend.git?.revision,
gitMessage: backend.git?.message,
commitDate: backend.git?.commitDate
commitDate: backend.git?.commitDate,
}
}
@@ -125,16 +121,11 @@ function diagnosticsToText(d: Diagnostics): string {
.trim()
}
export const AboutSettings = () => (
<WithPrivateUser>
{user => <LoadedAboutSettings privateUser={user}/>}
</WithPrivateUser>
<WithPrivateUser>{(user) => <LoadedAboutSettings privateUser={user} />}</WithPrivateUser>
)
const LoadedAboutSettings = (props: {
privateUser: PrivateUser,
}) => {
const LoadedAboutSettings = (props: {privateUser: PrivateUser}) => {
const {} = props
const [copyFeedback, setCopyFeedback] = useState('')
@@ -152,21 +143,20 @@ const LoadedAboutSettings = (props: {
}, 2000)
}
return <Col className={''}>
<RuntimeInfo info={diagnostics.runtime}/>
<WebBuildInfo info={diagnostics.web}/>
<AndroidInfo info={diagnostics.android}/>
<BackendInfo info={diagnostics.backend}/>
<Button
onClick={handleCopy}
className="w-fit mt-4"
>
{copyFeedback || t('about.settings.copy_info', 'Copy Info')}
</Button>
</Col>
return (
<Col className={''}>
<RuntimeInfo info={diagnostics.runtime} />
<WebBuildInfo info={diagnostics.web} />
<AndroidInfo info={diagnostics.android} />
<BackendInfo info={diagnostics.backend} />
<Button onClick={handleCopy} className="w-fit mt-4">
{copyFeedback || t('about.settings.copy_info', 'Copy Info')}
</Button>
</Col>
)
}
const WebBuildInfo = (props: { info?: WebBuild }) => {
const WebBuildInfo = (props: {info?: WebBuild}) => {
const {info} = props
if (!info) return
const env = info.environment
@@ -174,56 +164,72 @@ const WebBuildInfo = (props: { info?: WebBuild }) => {
const sha = info.gitSha
const deploymentId = info.deploymentId
const url = `${githubRepo}/commit/${sha}`
return <Col className={'custom-link'}>
<h3>Web build (Vercel)</h3>
<p>Commit SHA: <CustomLink href={url}>{sha}</CustomLink></p>
<p>Commit message: {gitMessage}</p>
<p>Vercel deployment ID: {deploymentId}</p>
<p>Environment: {env}</p>
</Col>
return (
<Col className={'custom-link'}>
<h3>Web build (Vercel)</h3>
<p>
Commit SHA: <CustomLink href={url}>{sha}</CustomLink>
</p>
<p>Commit message: {gitMessage}</p>
<p>Vercel deployment ID: {deploymentId}</p>
<p>Environment: {env}</p>
</Col>
)
}
const AndroidInfo = (props: { info?: Android }) => {
const AndroidInfo = (props: {info?: Android}) => {
const {info} = props
if (!info) return
const sha = info.liveUpdate?.commitSha
const url = `${githubRepo}/commit/${sha}`
return <Col className={'custom-link'}>
<h3>Android (Capacitor)</h3>
<p>App version (Android): {info.appVersion}</p>
<p>Native build number (Android): {info.buildNumber}</p>
{info.liveUpdate &&
return (
<Col className={'custom-link'}>
<h3>Android (Capacitor)</h3>
<p>App version (Android): {info.appVersion}</p>
<p>Native build number (Android): {info.buildNumber}</p>
{info.liveUpdate && (
<>
<p>Live update build ID (Capawesome): {info.liveUpdate?.bundleId}</p>
<p>Live update commit SHA (Capawesome): <CustomLink href={url}>{sha}</CustomLink></p>
<p>Live update commit message (Capawesome): {info.liveUpdate?.commitMessage}</p>
<p>Live update commit date (Capawesome): {info.liveUpdate?.commitDate}</p>
<p>Live update build ID (Capawesome): {info.liveUpdate?.bundleId}</p>
<p>
Live update commit SHA (Capawesome): <CustomLink href={url}>{sha}</CustomLink>
</p>
<p>Live update commit message (Capawesome): {info.liveUpdate?.commitMessage}</p>
<p>Live update commit date (Capawesome): {info.liveUpdate?.commitDate}</p>
</>
}
</Col>
)}
</Col>
)
}
const BackendInfo = (props: { info?: Backend }) => {
const BackendInfo = (props: {info?: Backend}) => {
const {info} = props
if (!info) return
const sha = info.gitSha
const commitDate = info.commitDate
const commitMessage = info.gitMessage
const url = `${githubRepo}/commit/${sha}`
return <Col className={'custom-link'}>
<h3>Backend</h3>
<p>API version: {info.version}</p>
{sha && <p>API commit SHA: <CustomLink href={url}>{sha}</CustomLink></p>}
{commitMessage && <p>API commit message: {commitMessage}</p>}
{commitDate && <p>API commit date: {commitDate}</p>}
</Col>
return (
<Col className={'custom-link'}>
<h3>Backend</h3>
<p>API version: {info.version}</p>
{sha && (
<p>
API commit SHA: <CustomLink href={url}>{sha}</CustomLink>
</p>
)}
{commitMessage && <p>API commit message: {commitMessage}</p>}
{commitDate && <p>API commit date: {commitDate}</p>}
</Col>
)
}
const RuntimeInfo = (props: { info?: Runtime }) => {
const RuntimeInfo = (props: {info?: Runtime}) => {
const {info} = props
if (!info) return
return <Col className={'custom-link'}>
<h3>Runtime</h3>
<p>Platform: {info.platform}</p>
</Col>
return (
<Col className={'custom-link'}>
<h3>Runtime</h3>
<p>Platform: {info.platform}</p>
</Col>
)
}

View File

@@ -1,39 +1,39 @@
import {ProfileWithoutUser} from "common/profiles/profile";
import {OptionTableKey} from "common/profiles/constants";
import {Col} from "web/components/layout/col";
import clsx from "clsx";
import {colClassName, labelClassName} from "web/pages/signup";
import {MultiCheckbox} from "web/components/multi-checkbox";
import {invert} from "lodash";
import {useLocale} from "web/lib/locale";
import clsx from 'clsx'
import {OptionTableKey} from 'common/profiles/constants'
import {ProfileWithoutUser} from 'common/profiles/profile'
import {invert} from 'lodash'
import {Col} from 'web/components/layout/col'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useLocale} from 'web/lib/locale'
import {colClassName, labelClassName} from 'web/pages/signup'
export function AddOptionEntry(props: {
title?: string
choices: { [key: string]: string }
choices: {[key: string]: string}
setChoices: (choices: any) => void
profile: ProfileWithoutUser,
profile: ProfileWithoutUser
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
label: OptionTableKey,
label: OptionTableKey
}) {
const {profile, setProfile, label, choices, setChoices, title} = props
const {locale} = useLocale()
const sortedChoices = Object.fromEntries(
Object.entries(invert(choices)).sort((a, b) =>
a[0].localeCompare(b[0], locale)
)
Object.entries(invert(choices)).sort((a, b) => a[0].localeCompare(b[0], locale)),
)
return <Col className={clsx(colClassName)}>
{title && <label className={clsx(labelClassName)}>{title}</label>}
<MultiCheckbox
choices={sortedChoices}
selected={(profile[label] ?? []).map((s) => String(s))}
onChange={(selected) => setProfile(label, selected)}
addOption={(v: string) => {
console.log(`Adding ${label}:`, v)
setChoices((prev: string[]) => ({...prev, [v]: v}))
setProfile(label, [...(profile[label] ?? []), v])
return {key: v, value: v}
}}
/>
</Col>
}
return (
<Col className={clsx(colClassName)}>
{title && <label className={clsx(labelClassName)}>{title}</label>}
<MultiCheckbox
choices={sortedChoices}
selected={(profile[label] ?? []).map((s) => String(s))}
onChange={(selected) => setProfile(label, selected)}
addOption={(v: string) => {
console.log(`Adding ${label}:`, v)
setChoices((prev: string[]) => ({...prev, [v]: v}))
setProfile(label, [...(profile[label] ?? []), v])
return {key: v, value: v}
}}
/>
</Col>
)
}

View File

@@ -1,27 +1,26 @@
import {PlusIcon, XIcon} from '@heroicons/react/outline'
import {MAX_ANSWER_LENGTH} from 'common/envs/constants'
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/profiles/constants'
import {Row as rowFor} from 'common/supabase/utils'
import {User} from 'common/user'
import {uniq} from 'lodash'
import {useState} from 'react'
import {toast} from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {ExpandingInput} from 'web/components/widgets/expanding-input'
import {PlusIcon, XIcon} from '@heroicons/react/outline'
import {MAX_ANSWER_LENGTH} from 'common/envs/constants'
import {useUser} from 'web/hooks/use-user'
import {User} from 'common/user'
import {useEvent} from 'web/hooks/use-event'
import {track} from 'web/lib/service/analytics'
import {toast} from 'react-hot-toast'
import {api} from 'web/lib/api'
import {Row as rowFor} from 'common/supabase/utils'
import {AnswerCompatibilityQuestionContent} from './answer-compatibility-question-content'
import {uniq} from 'lodash'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/profiles/constants'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
export function AddCompatibilityQuestionButton(props: {
refreshCompatibilityAll: () => void
}) {
import {AnswerCompatibilityQuestionContent} from './answer-compatibility-question-content'
export function AddCompatibilityQuestionButton(props: {refreshCompatibilityAll: () => void}) {
const {refreshCompatibilityAll} = props
const [open, setOpen] = useState(false)
const t = useT()
@@ -51,8 +50,7 @@ function AddCompatibilityQuestionModal(props: {
onClose?: () => void
}) {
const {open, setOpen, user, onClose} = props
const [dbQuestion, setDbQuestion] =
useState<rowFor<'compatibility_prompts'> | null>(null)
const [dbQuestion, setDbQuestion] = useState<rowFor<'compatibility_prompts'> | null>(null)
const afterAddQuestion = (newQuestion: rowFor<'compatibility_prompts'>) => {
setDbQuestion(newQuestion)
console.debug('setDbQuestion', newQuestion)
@@ -62,10 +60,7 @@ function AddCompatibilityQuestionModal(props: {
<Modal open={open} setOpen={setOpen} onClose={onClose}>
<Col className={MODAL_CLASS}>
{!dbQuestion ? (
<CreateCompatibilityModalContent
afterAddQuestion={afterAddQuestion}
setOpen={setOpen}
/>
<CreateCompatibilityModalContent afterAddQuestion={afterAddQuestion} setOpen={setOpen} />
) : (
<AnswerCompatibilityQuestionContent
compatibilityQuestion={dbQuestion as QuestionWithCountType}
@@ -111,8 +106,7 @@ function CreateCompatibilityModalContent(props: {
setOptions(newOptions)
}
const optionsAreValid =
options.every((o) => o.trim().length > 0) && options.length >= 2
const optionsAreValid = options.every((o) => o.trim().length > 0) && options.length >= 2
const questionIsValid = question.trim().length > 0
@@ -127,7 +121,7 @@ function CreateCompatibilityModalContent(props: {
}
return obj
},
{} as Record<string, number>
{} as Record<string, number>,
)
}
@@ -146,10 +140,7 @@ function CreateCompatibilityModalContent(props: {
track('create compatibility question')
} catch (_e) {
toast.error(
t(
'answers.add.error_create',
'Error creating compatibility question. Try again?'
)
t('answers.add.error_create', 'Error creating compatibility question. Try again?'),
)
}
})
@@ -190,14 +181,14 @@ function CreateCompatibilityModalContent(props: {
className="bg-ink-400 text-ink-0 hover:bg-ink-600 transition-color absolute -right-1.5 -top-1.5 rounded-full p-0.5"
onClick={() => deleteOption(index)}
>
<XIcon className="z-10 h-3 w-3"/>
<XIcon className="z-10 h-3 w-3" />
</button>
)}
</div>
))}
<Button onClick={addOption} color="gray-outline">
<Row className="items-center gap-1">
<PlusIcon className="h-4 w-4"/>
<PlusIcon className="h-4 w-4" />
{t('answers.add.add_option', 'Add Option')}
</Row>
</Button>

View File

@@ -1,15 +1,16 @@
import clsx from 'clsx'
import {User} from 'common/user'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import Link from 'next/link'
import router from 'next/router'
import {useState} from 'react'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
import {AnswerCompatibilityQuestionContent} from './answer-compatibility-question-content'
import router from "next/router";
import Link from "next/link";
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {useT} from 'web/lib/locale'
import clsx from "clsx";
import toast from "react-hot-toast";
import {AnswerCompatibilityQuestionContent} from './answer-compatibility-question-content'
export function AnswerCompatibilityQuestionButton(props: {
user: User | null | undefined
@@ -18,27 +19,29 @@ export function AnswerCompatibilityQuestionButton(props: {
fromSignup?: boolean
size?: 'sm' | 'md'
}) {
const {
user,
otherQuestions,
refreshCompatibilityAll,
fromSignup,
size = 'md',
} = props
const {user, otherQuestions, refreshCompatibilityAll, fromSignup, size = 'md'} = props
const [open, setOpen] = useState(fromSignup ?? false)
const t = useT()
if (!user) return null
if (!fromSignup && otherQuestions.length === 0) return null
const isCore = otherQuestions.some((q) => q.importance_score === 0)
const questionsToAnswer = isCore ? otherQuestions.filter((q) => q.importance_score === 0) : otherQuestions
const questionsToAnswer = isCore
? otherQuestions.filter((q) => q.importance_score === 0)
: otherQuestions
return (
<>
{size === 'md' ? (
<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'}>
{t('answers.answer.cta', 'Answer{core} Questions', { core: isCore ? ' Core' : '' })}{' '}
<span className="text-primary-600 ml-2">
+{questionsToAnswer.length}
</span>
<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'
}
>
{t('answers.answer.cta', 'Answer{core} Questions', {
core: isCore ? ' Core' : '',
})}{' '}
<span className="text-primary-600 ml-2">+{questionsToAnswer.length}</span>
</Button>
) : (
<button
@@ -69,7 +72,9 @@ export function CompatibilityPageButton() {
<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"
>{t('answers.answer.view_list', 'View List of Questions')}</Link>
>
{t('answers.answer.view_list', 'View List of Questions')}
</Link>
)
}
@@ -85,11 +90,10 @@ export function AnswerSkippedCompatibilityQuestionsButton(props: {
if (!user) return null
return (
<>
<button
onClick={() => setOpen(true)}
className="text-ink-500 text-sm hover:underline"
>
{t('answers.answer.answer_skipped', 'Answer {n} skipped questions', { n: String(skippedQuestions.length) })}{' '}
<button onClick={() => setOpen(true)} className="text-ink-500 text-sm hover:underline">
{t('answers.answer.answer_skipped', 'Answer {n} skipped questions', {
n: String(skippedQuestions.length),
})}{' '}
</button>
<AnswerCompatibilityQuestionModal
open={open}
@@ -103,42 +107,44 @@ export function AnswerSkippedCompatibilityQuestionsButton(props: {
)
}
function CompatibilityOnboardingScreen({onNext, onSkip}: { onNext: () => void; onSkip: () => void }) {
function CompatibilityOnboardingScreen({onNext, onSkip}: {onNext: () => void; onSkip: () => void}) {
const t = useT()
return (
<Col className={clsx(SCROLLABLE_MODAL_CLASS, "max-w-2xl mx-auto text-center px-6")}>
<Col className={clsx(SCROLLABLE_MODAL_CLASS, 'max-w-2xl mx-auto text-center px-6')}>
<h1 className="text-4xl font-bold text-ink-900 mb-6">
{t('compatibility.onboarding.title', 'See who you\'ll actually align with')}
{t('compatibility.onboarding.title', "See who you'll actually align with")}
</h1>
<div className="text-lg text-ink-700 leading-relaxed mb-8 space-y-4">
<p>
{t('compatibility.onboarding.body1', 'Answer a few short questions to calculate compatibility based on values and preferences — not photos or swipes.')}
{t(
'compatibility.onboarding.body1',
'Answer a few short questions to calculate compatibility based on values and preferences — not photos or swipes.',
)}
</p>
<p>
{t('compatibility.onboarding.body2', 'Your answers directly affect who matches with you and how strongly.')}
{t(
'compatibility.onboarding.body2',
'Your answers directly affect who matches with you and how strongly.',
)}
</p>
</div>
<div className="bg-primary-50 border border-primary-200 rounded-lg p-4 mb-8">
<p className="text-primary-800 font-medium">
{t('compatibility.onboarding.impact', 'Most people who answer at least 5 questions see far more relevant matches.')}
{t(
'compatibility.onboarding.impact',
'Most people who answer at least 5 questions see far more relevant matches.',
)}
</p>
</div>
<Col className="gap-4">
<Button
onClick={onNext}
size="lg"
className="w-full max-w-xs mx-auto"
>
<Button onClick={onNext} size="lg" className="w-full max-w-xs mx-auto">
{t('compatibility.onboarding.start', 'Start answering')}
</Button>
<button
onClick={onSkip}
className="text-sm text-ink-500 hover:text-ink-700 underline"
>
<button onClick={onSkip} className="text-sm text-ink-500 hover:text-ink-700 underline">
{t('compatibility.onboarding.later', 'Do this later')}
</button>
</Col>
@@ -172,7 +178,7 @@ function AnswerCompatibilityQuestionModal(props: {
setShowOnboarding(false)
setOpen(false)
}
return (
<Modal
open={open}

View File

@@ -6,6 +6,7 @@ import {User} from 'common/user'
import {shortenNumber} from 'common/util/format'
import {sortBy} from 'lodash'
import {useState} from 'react'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
@@ -14,11 +15,11 @@ import {ExpandingInput} from 'web/components/widgets/expanding-input'
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 {api} from 'web/lib/api'
import {filterKeys} from '../questions-form'
import toast from "react-hot-toast"
import {useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
import {filterKeys} from '../questions-form'
export type CompatibilityAnswerSubmitType = Omit<
rowFor<'compatibility_answers'>,
@@ -28,7 +29,7 @@ export type CompatibilityAnswerSubmitType = Omit<
export const IMPORTANCE_CHOICES = {
'Not Important': 0,
'Somewhat Important': 1,
'Important': 2,
Important: 2,
'Very Important': 3,
}
@@ -50,9 +51,7 @@ export const IMPORTANCE_DISPLAY_COLORS: ImportanceColorsType = {
3: `bg-yellow-400/80`,
}
export const submitCompatibilityAnswer = async (
newAnswer: CompatibilityAnswerSubmitType
) => {
export const submitCompatibilityAnswer = async (newAnswer: CompatibilityAnswerSubmitType) => {
if (!newAnswer) return
const input = {
...filterKeys(newAnswer, (key, _) => !['id', 'created_time'].includes(key)),
@@ -78,10 +77,7 @@ export const submitCompatibilityAnswer = async (
}
}
export const deleteCompatibilityAnswer = async (
id: number,
userId: string
) => {
export const deleteCompatibilityAnswer = async (id: number, userId: string) => {
if (!userId || !id) return
try {
await api('delete-compatibility-answer', {id})
@@ -115,20 +111,12 @@ export function AnswerCompatibilityQuestionContent(props: {
isLastQuestion: boolean
noSkip?: boolean
}) {
const {
compatibilityQuestion,
user,
onSubmit,
isLastQuestion,
onNext,
noSkip,
index,
total,
} = props
const {compatibilityQuestion, user, onSubmit, isLastQuestion, onNext, noSkip, index, total} =
props
const t = useT()
const [answer, setAnswer] = useState<CompatibilityAnswerSubmitType>(
(props.answer as CompatibilityAnswerSubmitType) ??
getEmptyAnswer(user.id, compatibilityQuestion.id)
getEmptyAnswer(user.id, compatibilityQuestion.id),
)
const [loading, setLoading] = useState(false)
@@ -140,13 +128,11 @@ export function AnswerCompatibilityQuestionContent(props: {
return null
}
const optionOrder = sortBy(
Object.entries(compatibilityQuestion.multiple_choice_options),
1
).map(([label]) => label)
const optionOrder = sortBy(Object.entries(compatibilityQuestion.multiple_choice_options), 1).map(
([label]) => label,
)
const multipleChoiceValid =
answer.multiple_choice != null && answer.multiple_choice !== -1
const multipleChoiceValid = answer.multiple_choice != null && answer.multiple_choice !== -1
const prefChoicesValid = answer.pref_choices && answer.pref_choices.length > 0
@@ -170,8 +156,7 @@ export function AnswerCompatibilityQuestionContent(props: {
total > 1 && (
<Row className="text-ink-500 -mt-4 w-full justify-end text-sm">
<span>
<span className="text-ink-600 font-semibold">{index + 1}</span>{' '}
/ {total}
<span className="text-ink-600 font-semibold">{index + 1}</span> / {total}
</span>
</Row>
)}
@@ -179,49 +164,52 @@ export function AnswerCompatibilityQuestionContent(props: {
{shortenedPopularity && (
<Row className="text-ink-500 select-none items-center text-sm">
<Tooltip
text={t('answers.content.people_answered', '{count} people have answered this question', {count: String(shortenedPopularity)})}
text={t(
'answers.content.people_answered',
'{count} people have answered this question',
{count: String(shortenedPopularity)},
)}
>
{shortenedPopularity}
</Tooltip>
<UserIcon className="h-4 w-4"/>
<UserIcon className="h-4 w-4" />
</Row>
)}
</Col>
<Col
className={clsx(
SCROLLABLE_MODAL_CLASS,
'w-full gap-4 flex-1 min-h-0 pr-2'
)}
>
<Col className={clsx(SCROLLABLE_MODAL_CLASS, 'w-full gap-4 flex-1 min-h-0 pr-2')}>
<Col className="gap-2">
<span className="text-ink-500 text-sm">{t('answers.preferred.your_answer', 'Your answer')}</span>
<span className="text-ink-500 text-sm">
{t('answers.preferred.your_answer', 'Your answer')}
</span>
<SelectAnswer
value={answer.multiple_choice}
setValue={(choice) =>
setAnswer({...answer, multiple_choice: choice})
}
setValue={(choice) => setAnswer({...answer, multiple_choice: choice})}
options={optionOrder}
/>
</Col>
<Col className="gap-2">
<span
className="text-ink-500 text-sm">{t('answers.content.answers_you_accept', "Answers you'll accept")}</span>
<span className="text-ink-500 text-sm">
{t('answers.content.answers_you_accept', "Answers you'll accept")}
</span>
<MultiSelectAnswers
values={answer.pref_choices ?? []}
setValue={(choice) =>
setAnswer({...answer, pref_choices: choice})
}
setValue={(choice) => setAnswer({...answer, pref_choices: choice})}
options={optionOrder}
/>
</Col>
<Col className="gap-2">
<span className="text-ink-500 text-sm">{t('answers.content.importance', 'Importance')}</span>
<span className="text-ink-500 text-sm">
{t('answers.content.importance', 'Importance')}
</span>
<RadioToggleGroup
currentChoice={answer.importance ?? -1}
choicesMap={Object.fromEntries(Object.entries(IMPORTANCE_CHOICES).map(([k, v]) => [t(`answers.importance.${v}`, k), v]))}
setChoice={(choice: number) =>
setAnswer({...answer, importance: choice})
}
choicesMap={Object.fromEntries(
Object.entries(IMPORTANCE_CHOICES).map(([k, v]) => [
t(`answers.importance.${v}`, k),
v,
]),
)}
setChoice={(choice: number) => setAnswer({...answer, importance: choice})}
indexColors={IMPORTANCE_RADIO_COLORS}
/>
</Col>
@@ -233,23 +221,19 @@ export function AnswerCompatibilityQuestionContent(props: {
className={'w-full'}
rows={3}
value={answer.explanation ?? ''}
onChange={(e) =>
setAnswer({...answer, explanation: e.target.value})
}
onChange={(e) => setAnswer({...answer, explanation: e.target.value})}
/>
</Col>
</Col>
<Row className="w-full justify-between gap-4 shrink-0">
{noSkip ? (
<div/>
<div />
) : (
<button
disabled={loading || skipLoading}
onClick={() => {
setSkipLoading(true)
submitCompatibilityAnswer(
getEmptyAnswer(user.id, compatibilityQuestion.id)
)
submitCompatibilityAnswer(getEmptyAnswer(user.id, compatibilityQuestion.id))
.then(() => {
if (isLastQuestion) {
onSubmit()
@@ -261,7 +245,7 @@ export function AnswerCompatibilityQuestionContent(props: {
}}
className={clsx(
'text-ink-500 disabled:text-ink-300 text-sm hover:underline disabled:cursor-not-allowed',
skipLoading && 'animate-pulse'
skipLoading && 'animate-pulse',
)}
>
{t('answers.menu.skip', 'Skip')}
@@ -269,11 +253,7 @@ export function AnswerCompatibilityQuestionContent(props: {
)}
<Button
disabled={
!multipleChoiceValid ||
!prefChoicesValid ||
!importanceValid ||
loading ||
skipLoading
!multipleChoiceValid || !prefChoicesValid || !importanceValid || loading || skipLoading
}
loading={loading}
onClick={() => {
@@ -319,7 +299,7 @@ export const SelectAnswer = (props: {
disabled
? 'text-ink-300 aria-checked:bg-ink-300 aria-checked:text-ink-0 cursor-not-allowed'
: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-primary-100 aria-checked:text-primary-900 aria-checked:hover:bg-primary-50 cursor-pointer',
'ring-primary-500 flex items-center rounded-md p-2 outline-none transition-all focus-visible:ring-2 sm:px-3'
'ring-primary-500 flex items-center rounded-md p-2 outline-none transition-all focus-visible:ring-2 sm:px-3',
)
}
>
@@ -351,14 +331,10 @@ export const MultiSelectAnswers = (props: {
values.includes(i)
? 'text-primary-700 bg-primary-100 hover:bg-primary-50'
: 'text-ink-700 hover:bg-ink-50',
'ring-primary-500 flex cursor-pointer items-center rounded-md p-2 outline-none transition-all focus-visible:ring-2 disabled:cursor-not-allowed sm:px-3'
'ring-primary-500 flex cursor-pointer items-center rounded-md p-2 outline-none transition-all focus-visible:ring-2 disabled:cursor-not-allowed sm:px-3',
)}
onClick={() =>
setValue(
values.includes(i)
? values.filter((v) => v !== i)
: [...values, i]
)
setValue(values.includes(i) ? values.filter((v) => v !== i) : [...values, i])
}
>
{label}

View File

@@ -1,11 +1,11 @@
import { QuestionWithCountType } from 'web/hooks/use-questions'
import { Row as rowFor } from 'common/supabase/utils'
import { Row } from 'web/components/layout/row'
import { Col } from 'web/components/layout/col'
import {CheckCircleIcon, XCircleIcon} from '@heroicons/react/outline'
import clsx from 'clsx'
import { User } from 'common/user'
import { shortenName } from 'web/components/widgets/user-link'
import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/outline'
import {Row as rowFor} from 'common/supabase/utils'
import {User} from 'common/user'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {shortenName} from 'web/components/widgets/user-link'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {useT} from 'web/lib/locale'
export function PreferredList(props: {
@@ -15,17 +15,12 @@ export function PreferredList(props: {
comparedUser: User
isComparedUser?: boolean
}) {
const { question, answer, comparedAnswer, comparedUser, isComparedUser } =
props
const {question, answer, comparedAnswer, comparedUser, isComparedUser} = props
const t = useT()
const { multiple_choice_options } = question
const {multiple_choice_options} = question
if (!multiple_choice_options) return null
const sortedEntries = Object.entries(multiple_choice_options).sort(
(a, b) => a[1] - b[1]
)
const comparedUserIsCompatible = answer.pref_choices?.includes(
comparedAnswer.multiple_choice
)
const sortedEntries = Object.entries(multiple_choice_options).sort((a, b) => a[1] - b[1])
const comparedUserIsCompatible = answer.pref_choices?.includes(comparedAnswer.multiple_choice)
return (
<Col className="gap-2">
@@ -37,9 +32,9 @@ export function PreferredList(props: {
answer.pref_choices?.includes(value)
? 'text-ink-1000 dark:text-ink-1000'
: comparedAnswer.multiple_choice === value
? 'opacity-20'
: 'hidden',
'bg-canvas-50 relative w-2/3 gap-1 rounded py-2 pl-2 pr-8'
? 'opacity-20'
: 'hidden',
'bg-canvas-50 relative w-2/3 gap-1 rounded py-2 pl-2 pr-8',
)}
>
{key}
@@ -48,7 +43,7 @@ export function PreferredList(props: {
<Row
className={clsx(
'items-center gap-0.5 text-xs',
comparedUserIsCompatible ? 'text-teal-700' : 'text-scarlet-700'
comparedUserIsCompatible ? 'text-teal-700' : 'text-scarlet-700',
)}
>
{comparedUserIsCompatible ? (
@@ -58,7 +53,9 @@ export function PreferredList(props: {
)}
{isComparedUser
? t('answers.preferred.your_answer', 'Your answer')
: t('answers.preferred.user_answer', "{name}'s answer", { name: shortenName(comparedUser.name) })}
: t('answers.preferred.user_answer', "{name}'s answer", {
name: shortenName(comparedUser.name),
})}
</Row>
)}
</Row>
@@ -71,12 +68,10 @@ export function PreferredListNoComparison(props: {
question: QuestionWithCountType
answer: rowFor<'compatibility_answers'>
}) {
const { question, answer } = props
const { multiple_choice_options } = question
const {question, answer} = props
const {multiple_choice_options} = question
if (!multiple_choice_options) return null
const sortedEntries = Object.entries(multiple_choice_options).sort(
(a, b) => a[1] - b[1]
)
const sortedEntries = Object.entries(multiple_choice_options).sort((a, b) => a[1] - b[1])
return (
<Col className="gap-2">
{sortedEntries.map(([key, value]) => (
@@ -84,10 +79,8 @@ export function PreferredListNoComparison(props: {
<div
key={key}
className={clsx(
answer.pref_choices?.includes(value)
? 'text-ink-1000 dark:text-ink-1000'
: 'hidden',
'bg-canvas-50 relative w-2/3 gap-1 rounded py-2 pl-2 pr-8'
answer.pref_choices?.includes(value) ? 'text-ink-1000 dark:text-ink-1000' : 'hidden',
'bg-canvas-50 relative w-2/3 gap-1 rounded py-2 pl-2 pr-8',
)}
>
{key}

View File

@@ -1,23 +1,38 @@
import {PencilIcon, TrashIcon} from '@heroicons/react/outline'
import {getAnswerCompatibility, getScoredAnswerCompatibility,} from 'common/profiles/compatibility-score'
import clsx from 'clsx'
import {
getAnswerCompatibility,
getScoredAnswerCompatibility,
} from 'common/profiles/compatibility-score'
import {Profile} from 'common/profiles/profile'
import {Row as rowFor} from 'common/supabase/utils'
import {User} from 'common/user'
import {buildArray} from 'common/util/array'
import {keyBy, partition, sortBy} from 'lodash'
import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
import DropdownMenu from 'web/components/comments/dropdown-menu'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {CompatibleBadge} from 'web/components/widgets/compatible-badge'
import {Linkify} from 'web/components/widgets/linkify'
import {Pagination} from 'web/components/widgets/pagination'
import {shortenName} from 'web/components/widgets/user-link'
import {useIsLooking} from 'web/hooks/use-is-looking'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {useProfile} from 'web/hooks/use-profile'
import {useCompatibleProfiles} from 'web/hooks/use-profiles'
import {
QuestionWithCountType,
useCompatibilityQuestionsWithAnswerCount,
useUserCompatibilityAnswers,
} from 'web/hooks/use-questions'
import {useEffect, useState} from 'react'
import DropdownMenu from 'web/components/comments/dropdown-menu'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS,} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {Linkify} from 'web/components/widgets/linkify'
import {Pagination} from 'web/components/widgets/pagination'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {db} from 'web/lib/supabase/db'
import {DropdownButton} from '../filters/desktop-filters'
import {Subtitle} from '../widgets/profile-subtitle'
import {AddCompatibilityQuestionButton} from './add-compatibility-question-button'
import {
@@ -34,25 +49,14 @@ import {
IMPORTANCE_DISPLAY_COLORS,
submitCompatibilityAnswer,
} from './answer-compatibility-question-content'
import clsx from 'clsx'
import {shortenName} from 'web/components/widgets/user-link'
import {PreferredList, PreferredListNoComparison,} from './compatibility-question-preferred-list'
import {useUser} from 'web/hooks/use-user'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
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";
import {useCompatibleProfiles} from "web/hooks/use-profiles";
import {CompatibleBadge} from "web/components/widgets/compatible-badge";
import {useT} from 'web/lib/locale'
import {PreferredList, PreferredListNoComparison} from './compatibility-question-preferred-list'
const NUM_QUESTIONS_TO_SHOW = 8
export function separateQuestionsArray(
questions: QuestionWithCountType[],
skippedAnswerQuestionIds: Set<number>,
answeredQuestionIds: Set<number>
answeredQuestionIds: Set<number>,
) {
const skippedQuestions: QuestionWithCountType[] = []
const answeredQuestions: QuestionWithCountType[] = []
@@ -71,11 +75,7 @@ export function separateQuestionsArray(
return {skippedQuestions, answeredQuestions, otherQuestions}
}
type CompatibilitySort =
| 'your-important'
| 'their-important'
| 'disagree'
| 'your-unanswered'
type CompatibilitySort = 'your-important' | 'their-important' | 'disagree' | 'your-unanswered'
export function CompatibilityQuestionsDisplay(props: {
isCurrentUser: boolean
@@ -91,27 +91,24 @@ export function CompatibilityQuestionsDisplay(props: {
const compatibleProfiles = useCompatibleProfiles(currentUser?.id)
const compatibilityScore = compatibleProfiles?.profileCompatibilityScores?.[profile.user_id]
const {refreshCompatibilityQuestions, compatibilityQuestions} = useCompatibilityQuestionsWithAnswerCount()
const {refreshCompatibilityQuestions, compatibilityQuestions} =
useCompatibilityQuestionsWithAnswerCount()
const {refreshCompatibilityAnswers, compatibilityAnswers} = useUserCompatibilityAnswers(user.id)
const [skippedAnswers, answers] = partition(
compatibilityAnswers,
(answer) => answer.importance == -1
(answer) => answer.importance == -1,
)
const answeredQuestionIds = new Set(
answers.map((answer) => answer.question_id)
)
const answeredQuestionIds = new Set(answers.map((answer) => answer.question_id))
const skippedAnswerQuestionIds = new Set(
skippedAnswers.map((answer) => answer.question_id)
)
const skippedAnswerQuestionIds = new Set(skippedAnswers.map((answer) => answer.question_id))
const {skippedQuestions, answeredQuestions, otherQuestions} = separateQuestionsArray(
compatibilityQuestions,
skippedAnswerQuestionIds,
answeredQuestionIds
answeredQuestionIds,
)
const refreshCompatibilityAll = () => {
@@ -122,7 +119,7 @@ export function CompatibilityQuestionsDisplay(props: {
const isLooking = useIsLooking()
const [sort, setSort] = usePersistentInMemoryState<CompatibilitySort>(
!isLooking && !fromProfilePage ? 'their-important' : 'your-important',
`compatibility-sort-${user.id}`
`compatibility-sort-${user.id}`,
)
const comparedUserId = fromProfilePage?.user_id ?? currentUser?.id
@@ -150,9 +147,7 @@ export function CompatibilityQuestionsDisplay(props: {
} else if (sort === 'their-important') {
return -a.importance
} else if (sort === 'disagree') {
return comparedAnswer
? getScoredAnswerCompatibility(a, comparedAnswer)
: Infinity
return comparedAnswer ? getScoredAnswerCompatibility(a, comparedAnswer) : Infinity
} else if (sort === 'your-unanswered') {
// Not answered first, then skipped, then answered.
return comparedAnswer ? (comparedAnswer.importance >= 0 ? 2 : 1) : 0
@@ -161,14 +156,14 @@ export function CompatibilityQuestionsDisplay(props: {
// Break ties with their answer importance.
(a) => -a.importance,
// Then by whether they wrote an explanation.
(a) => (a.explanation ? 0 : 1)
(a) => (a.explanation ? 0 : 1),
)
const [page, setPage] = useState(0)
const currentSlice = page * NUM_QUESTIONS_TO_SHOW
const shownAnswers = sortedAndFilteredAnswers.slice(
currentSlice,
currentSlice + NUM_QUESTIONS_TO_SHOW
currentSlice + NUM_QUESTIONS_TO_SHOW,
)
if (!isCurrentUser && !answeredQuestions.length) return null
@@ -180,11 +175,13 @@ export function CompatibilityQuestionsDisplay(props: {
<Subtitle>
{isCurrentUser
? t('answers.display.your_prompts', 'Your Compatibility Prompts')
: t('answers.display.user_prompts', "{name}'s Compatibility Prompts", {name: shortenName(user.name)})}
: t('answers.display.user_prompts', "{name}'s Compatibility Prompts", {
name: shortenName(user.name),
})}
</Subtitle>
{compatibilityScore &&
<CompatibleBadge compatibility={compatibilityScore} className={'mt-7 mr-4'}/>
}
{compatibilityScore && (
<CompatibleBadge compatibility={compatibilityScore} className={'mt-7 mr-4'} />
)}
</Row>
{(!isCurrentUser || fromProfilePage) && (
<CompatibilitySortWidget
@@ -199,28 +196,44 @@ export function CompatibilityQuestionsDisplay(props: {
{answeredQuestions.length <= 0 ? (
<span className="text-ink-600 text-sm">
{isCurrentUser
? t('answers.display.none_answered_you', "You haven't answered any compatibility questions yet!")
: t('answers.display.none_answered_user', "{name} hasn't answered any compatibility questions yet!", {name: user.name})}{' '}
? t(
'answers.display.none_answered_you',
"You haven't answered any compatibility questions yet!",
)
: t(
'answers.display.none_answered_user',
"{name} hasn't answered any compatibility questions yet!",
{name: user.name},
)}{' '}
{isCurrentUser && (
<>{t('answers.display.add_some', "Add some to better see who you'd be most compatible with.")}</>
<>
{t(
'answers.display.add_some',
"Add some to better see who you'd be most compatible with.",
)}
</>
)}
</span>
) : (
<>
{isCurrentUser && !fromProfilePage && (
<span className='custom-link'>
<span className="custom-link">
{otherQuestions.length < 1 ? (
<span className="text-ink-600 text-sm">
{t('answers.display.already_answered_all', "You've already answered all the compatibility questions—")}
{t(
'answers.display.already_answered_all',
"You've already answered all the compatibility questions—",
)}
</span>
) : (
<span className="text-ink-600 text-sm">
{t('answers.display.answer_more', 'Answer more questions to increase your compatibility scores—or ')}
{t(
'answers.display.answer_more',
'Answer more questions to increase your compatibility scores—or ',
)}
</span>
)}
<AddCompatibilityQuestionButton
refreshCompatibilityAll={refreshCompatibilityAll}
/>
<AddCompatibilityQuestionButton refreshCompatibilityAll={refreshCompatibilityAll} />
</span>
)}
{shownAnswers.map((answer) => {
@@ -242,7 +255,7 @@ export function CompatibilityQuestionsDisplay(props: {
)}
</>
)}
{(fromSignup || otherQuestions.length >= 1 && isCurrentUser && !fromProfilePage) && (
{(fromSignup || (otherQuestions.length >= 1 && isCurrentUser && !fromProfilePage)) && (
<Row className={'w-full justify-center gap-8'}>
<AnswerCompatibilityQuestionButton
user={user}
@@ -250,7 +263,7 @@ export function CompatibilityQuestionsDisplay(props: {
refreshCompatibilityAll={refreshCompatibilityAll}
fromSignup={fromSignup}
/>
<CompatibilityPageButton/>
<CompatibilityPageButton />
</Row>
)}
{skippedQuestions.length > 0 && isCurrentUser && (
@@ -287,9 +300,13 @@ function CompatibilitySortWidget(props: {
const t = useT()
const sortToDisplay = {
'your-important': fromProfilePage
? t('answers.sort.important_to_user', 'Important to {name}', {name: fromProfilePage.user.name})
? t('answers.sort.important_to_user', 'Important to {name}', {
name: fromProfilePage.user.name,
})
: t('answers.sort.important_to_you', 'Important to you'),
'their-important': t('answers.sort.important_to_them', 'Important to {name}', {name: user.name}),
'their-important': t('answers.sort.important_to_them', 'Important to {name}', {
name: user.name,
}),
disagree: t('answers.sort.incompatible', 'Incompatible'),
'your-unanswered': t('answers.sort.unanswered_by_you', 'Unanswered by you'),
}
@@ -298,8 +315,7 @@ function CompatibilitySortWidget(props: {
'your-important',
'their-important',
'disagree',
(!fromProfilePage || fromProfilePage.user_id === currentUser?.id) &&
'your-unanswered'
(!fromProfilePage || fromProfilePage.user_id === currentUser?.id) && 'your-unanswered',
)
return (
@@ -314,7 +330,7 @@ function CompatibilitySortWidget(props: {
closeOnClick
buttonClass={'!text-ink-600 !hover:!text-ink-600'}
buttonContent={(open: boolean) => (
<DropdownButton content={sortToDisplay[sort]} open={open}/>
<DropdownButton content={sortToDisplay[sort]} open={open} />
)}
menuItemsClass={'bg-canvas-0'}
menuWidth="w-48"
@@ -347,11 +363,13 @@ export function CompatibilityAnswerBlock(props: {
const currentProfile = useProfile()
const t = useT()
const [newAnswer, setNewAnswer] = useState<CompatibilityAnswerSubmitType | undefined>(props.answer)
const [newAnswer, setNewAnswer] = useState<CompatibilityAnswerSubmitType | undefined>(
props.answer,
)
useEffect(() => {
setNewAnswer(props.answer)
}, [props.answer]);
}, [props.answer])
const comparedProfile = isCurrentUser
? null
@@ -359,28 +377,25 @@ export function CompatibilityAnswerBlock(props: {
? fromProfilePage
: {...currentProfile, user: currentUser}
if (
!question ||
!question.multiple_choice_options ||
answer && answer?.multiple_choice == null
)
if (!question || !question.multiple_choice_options || (answer && answer?.multiple_choice == null))
return null
const answerText = answer ? getStringKeyFromNumValue(
answer.multiple_choice,
question.multiple_choice_options as Record<string, number>
) : 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 =
answerText && !preferredAnswersText.includes(answerText)
const answerText = answer
? getStringKeyFromNumValue(
answer.multiple_choice,
question.multiple_choice_options as Record<string, number>,
)
: 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 = answerText && !preferredAnswersText.includes(answerText)
const isAnswered = answer && answer.multiple_choice > -1
const isSkipped = answer && answer.importance == -1
@@ -416,20 +431,19 @@ export function CompatibilityAnswerBlock(props: {
items={[
{
name: t('answers.menu.edit', 'Edit'),
icon: <PencilIcon className="h-5 w-5"/>,
icon: <PencilIcon className="h-5 w-5" />,
onClick: () => setEditOpen(true),
},
{
name: t('answers.menu.delete', 'Delete'),
icon: <TrashIcon className="h-5 w-5"/>,
icon: <TrashIcon className="h-5 w-5" />,
onClick: () => {
deleteCompatibilityAnswer(answer.id, user.id)
.then(() => refreshCompatibilityAll())
.catch((e) => {
toast.error(e.message)
})
.finally(() => {
})
.finally(() => {})
},
},
]}
@@ -444,7 +458,7 @@ export function CompatibilityAnswerBlock(props: {
items={[
{
name: t('answers.menu.skip', 'Skip'),
icon: <TrashIcon className="h-5 w-5"/>,
icon: <TrashIcon className="h-5 w-5" />,
onClick: () => {
submitCompatibilityAnswer(getEmptyAnswer(user.id, question.id))
.then(() => {
@@ -453,8 +467,7 @@ export function CompatibilityAnswerBlock(props: {
.catch((e) => {
toast.error(e.message)
})
.finally(() => {
})
.finally(() => {})
},
},
]}
@@ -465,13 +478,11 @@ export function CompatibilityAnswerBlock(props: {
)}
</Row>
</Row>
{answerText && <Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
{answerText}
</Row>}
{answerText && (
<Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">{answerText}</Row>
)}
<Row className="px-2 -mt-4">
{answer?.explanation && (
<Linkify className="" text={answer.explanation}/>
)}
{answer?.explanation && <Linkify className="" text={answer.explanation} />}
</Row>
{distinctPreferredAnswersText.length > 0 && (
<Col className="gap-2">
@@ -482,10 +493,7 @@ export function CompatibilityAnswerBlock(props: {
</div>
<Row className="flex-wrap gap-2 mt-0">
{distinctPreferredAnswersText.map((text) => (
<Row
key={text}
className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm"
>
<Row key={text} className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
{text}
</Row>
))}
@@ -494,27 +502,25 @@ export function CompatibilityAnswerBlock(props: {
)}
{!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>
))}
{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 && isAnswered && (
<Row className="w-full justify-end sm:hidden">
<CompatibilityDisplay
@@ -529,10 +535,7 @@ export function CompatibilityAnswerBlock(props: {
)}
{isCurrentUser && isAnswered && (
<Row className="w-full justify-end sm:hidden">
<ImportanceButton
importance={answer.importance}
onClick={() => setEditOpen(true)}
/>
<ImportanceButton importance={answer.importance} onClick={() => setEditOpen(true)} />
</Row>
)}
{/*{question.importance_score == 0 && <div className="text-ink-500 text-sm">Core Question</div>}*/}
@@ -566,20 +569,13 @@ function CompatibilityDisplay(props: {
currentUser: User | null | undefined
className?: string
}) {
const {
question,
profile1,
profile2,
answer1,
currentUserIsComparedProfile,
currentUser,
} = props
const {question, profile1, profile2, answer1, currentUserIsComparedProfile, currentUser} = props
const t = useT()
const [answer2, setAnswer2] = useState<
rowFor<'compatibility_answers'> | null | undefined
>(undefined)
const [answer2, setAnswer2] = useState<rowFor<'compatibility_answers'> | null | undefined>(
undefined,
)
async function getComparedProfileAnswer() {
db.from('compatibility_answers')
@@ -604,16 +600,14 @@ function CompatibilityDisplay(props: {
if (!profile1 || profile1.id === profile2.id) return null
const showCreateAnswer =
(!answer2 || answer2.importance == -1) &&
currentUserIsComparedProfile &&
!!currentUser
(!answer2 || answer2.importance == -1) && currentUserIsComparedProfile && !!currentUser
const isCurrentUser = currentUser?.id === profile2.user_id
const answerCompatibility = answer2
? getAnswerCompatibility(answer1, answer2)
: //getScoredAnswerCompatibility(answer1, answer2)
undefined
undefined
const user1 = profile1.user
const user2 = profile2.user
@@ -621,10 +615,7 @@ function CompatibilityDisplay(props: {
return (
<Row className="gap-2">
<ImportanceButton
importance={importanceScore}
onClick={() => setOpen(true)}
/>
<ImportanceButton importance={importanceScore} onClick={() => setOpen(true)} />
{showCreateAnswer || answerCompatibility === undefined || !answer2 ? (
<AnswerCompatibilityQuestionButton
@@ -641,10 +632,12 @@ function CompatibilityDisplay(props: {
'text-ink-1000 h-fit w-28 rounded-full px-2 py-0.5 text-xs transition-colors',
answerCompatibility
? 'bg-green-500/20 hover:bg-green-500/30'
: 'bg-red-500/20 hover:bg-red-500/30'
: 'bg-red-500/20 hover:bg-red-500/30',
)}
>
{answerCompatibility ? t('answers.compatible', 'Compatible') : t('answers.incompatible', 'Incompatible')}
{answerCompatibility
? t('answers.compatible', 'Compatible')
: t('answers.incompatible', 'Incompatible')}
</button>
</>
)}
@@ -653,17 +646,19 @@ function CompatibilityDisplay(props: {
<Subtitle>{question.question}</Subtitle>
<Col className={clsx('w-full gap-1', SCROLLABLE_MODAL_CLASS)}>
<div className="text-ink-600 items-center gap-2">
{t('answers.modal.preferred_of_user', "{name}'s preferred answers", {name: shortenName(user1.name)})}
{t('answers.modal.preferred_of_user', "{name}'s preferred answers", {
name: shortenName(user1.name),
})}
</div>
<div className="text-ink-500 text-sm">
{t('answers.modal.user_marked', '{name} marked this as ', {name: shortenName(user1.name)})}
{t('answers.modal.user_marked', '{name} marked this as ', {
name: shortenName(user1.name),
})}
<span className="font-semibold">
<ImportanceDisplay importance={answer1.importance}/>
<ImportanceDisplay importance={answer1.importance} />
</span>
</div>
{!answer2 && (
<PreferredListNoComparison question={question} answer={answer1}/>
)}
{!answer2 && <PreferredListNoComparison question={question} answer={answer1} />}
{answer2 && (
<>
<PreferredList
@@ -677,14 +672,18 @@ function CompatibilityDisplay(props: {
<div className="text-ink-600 mt-6 items-center gap-2">
{isCurrentUser
? t('answers.modal.your_preferred', 'Your preferred answers')
: t('answers.modal.preferred_of_user', "{name}'s preferred answers", {name: shortenName(user2.name)})}
: t('answers.modal.preferred_of_user', "{name}'s preferred answers", {
name: shortenName(user2.name),
})}
</div>
<div className="text-ink-500 text-sm">
{isCurrentUser
? t('answers.modal.you_marked', 'You marked this as ')
: t('answers.modal.user_marked', '{name} marked this as ', {name: shortenName(user2.name)})}
: t('answers.modal.user_marked', '{name} marked this as ', {
name: shortenName(user2.name),
})}
<span className="font-semibold">
<ImportanceDisplay importance={answer2.importance}/>
<ImportanceDisplay importance={answer2.importance} />
</span>
</div>
<PreferredList
@@ -702,21 +701,20 @@ function CompatibilityDisplay(props: {
)
}
function ImportanceDisplay(props: { importance: number }) {
function ImportanceDisplay(props: {importance: number}) {
const {importance} = props
const t = useT()
return (
<span className={clsx('w-fit')}>
{t(`answers.importance.${importance}`, getStringKeyFromNumValue(importance, IMPORTANCE_CHOICES) as string)}
{t(
`answers.importance.${importance}`,
getStringKeyFromNumValue(importance, IMPORTANCE_CHOICES) as string,
)}
</span>
)
}
function ImportanceButton(props: {
importance: number
onClick: () => void
className?: string
}) {
function ImportanceButton(props: {importance: number; onClick: () => void; className?: string}) {
const {importance, onClick, className} = props
return (
<button
@@ -726,18 +724,15 @@ function ImportanceButton(props: {
// Longer width for "Somewhat important"
importance === 1 ? 'w-36' : 'w-28',
IMPORTANCE_DISPLAY_COLORS[importance],
className
className,
)}
>
<ImportanceDisplay importance={importance}/>
<ImportanceDisplay importance={importance} />
</button>
)
}
function getStringKeyFromNumValue(
value: number,
map: Record<string, number>
): string | undefined {
function getStringKeyFromNumValue(value: number, map: Record<string, number>): string | undefined {
const choices = Object.keys(map) as (keyof typeof map)[]
return choices.find((choice) => map[choice] === value)
}

View File

@@ -1,17 +1,18 @@
import {ArrowLeftIcon, PlusIcon} from '@heroicons/react/outline'
import clsx from 'clsx'
import {User} from 'common/user'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {TbMessage} from 'react-icons/tb'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS,} from 'web/components/layout/modal'
import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {IndividualQuestionRow} from '../questions-form'
import {TbMessage} from 'react-icons/tb'
import {OtherProfileAnswers} from './other-profile-answers'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {useT} from 'web/lib/locale'
import {IndividualQuestionRow} from '../questions-form'
import {OtherProfileAnswers} from './other-profile-answers'
export function AddQuestionButton(props: {
isFirstQuestion?: boolean
questions: QuestionWithCountType[]
@@ -19,16 +20,13 @@ export function AddQuestionButton(props: {
refreshAnswers: () => void
}) {
const {questions, user, refreshAnswers} = props
const [openModal, setOpenModal] = usePersistentInMemoryState(
false,
`add-question-${user.id}`
)
const [openModal, setOpenModal] = usePersistentInMemoryState(false, `add-question-${user.id}`)
const t = useT()
return (
<>
<Button color={'gray-outline'} onClick={() => setOpenModal(true)}>
<Row className="items-center gap-1">
<PlusIcon className="h-4 w-4"/>
<PlusIcon className="h-4 w-4" />
{t('answers.free.add_free_response', 'Add Free Response')}
</Row>
</Button>
@@ -51,19 +49,17 @@ function AddQuestionModal(props: {
refreshAnswers: () => void
}) {
const {open, setOpen, questions, user, refreshAnswers} = props
const addableQuestions = questions.filter(
(q) => q.answer_type === 'free_response'
)
const addableQuestions = questions.filter((q) => q.answer_type === 'free_response')
const [selectedQuestion, setSelectedQuestion] =
usePersistentInMemoryState<QuestionWithCountType | null>(
null,
`selected-added-question-${user.id}}`
`selected-added-question-${user.id}}`,
)
const [expandedQuestion, setExpandedQuestion] =
usePersistentInMemoryState<QuestionWithCountType | null>(
null,
`selected-expanded-question-${user.id}}`
`selected-expanded-question-${user.id}}`,
)
const t = useT()
@@ -81,7 +77,7 @@ function AddQuestionModal(props: {
setExpandedQuestion(null)
}}
>
<ArrowLeftIcon className={'h-4 w-4'}/>
<ArrowLeftIcon className={'h-4 w-4'} />
</Button>
<span className="font-semibold">{expandedQuestion.question}</span>
</Row>
@@ -118,7 +114,7 @@ function AddQuestionModal(props: {
}}
>
{question.answer_count}
<TbMessage className="h-4 w-4"/>
<TbMessage className="h-4 w-4" />
</button>
</Row>
)
@@ -127,9 +123,7 @@ function AddQuestionModal(props: {
</>
) : (
<Col className="gap-4">
<div className="text-semibold text-lg">
{selectedQuestion.question}
</div>
<div className="text-semibold text-lg">{selectedQuestion.question}</div>
<IndividualQuestionRow
user={user}
row={selectedQuestion}

View File

@@ -1,55 +1,47 @@
import { PencilIcon } from '@heroicons/react/outline'
import { XIcon } from '@heroicons/react/outline'
import { Row as rowFor } from 'common/supabase/utils'
import { User } from 'common/user'
import { deleteAnswer } from 'web/lib/supabase/answers'
import { useState } from 'react'
import {PencilIcon, XIcon} from '@heroicons/react/outline'
import {Profile} from 'common/profiles/profile'
import {Row as rowFor} from 'common/supabase/utils'
import {User} from 'common/user'
import {partition} from 'lodash'
import {useState} from 'react'
import {TbMessage} from 'react-icons/tb'
import DropdownMenu from 'web/components/comments/dropdown-menu'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Linkify } from 'web/components/widgets/linkify'
import { IndividualQuestionRow } from '../questions-form'
import { Subtitle } from '../widgets/profile-subtitle'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {Linkify} from 'web/components/widgets/linkify'
import {shortenName} from 'web/components/widgets/user-link'
import {
QuestionWithCountType,
useFRQuestionsWithAnswerCount,
useUserAnswers,
} from 'web/hooks/use-questions'
import { TbMessage } from 'react-icons/tb'
import { OtherProfileAnswers } from './other-profile-answers'
import {
MODAL_CLASS,
Modal,
SCROLLABLE_MODAL_CLASS,
} from 'web/components/layout/modal'
import { partition } from 'lodash'
import { shortenName } from 'web/components/widgets/user-link'
import { AddQuestionButton } from './free-response-add-question'
import { Profile } from 'common/profiles/profile'
import {useT} from 'web/lib/locale'
import {deleteAnswer} from 'web/lib/supabase/answers'
import {IndividualQuestionRow} from '../questions-form'
import {Subtitle} from '../widgets/profile-subtitle'
import {AddQuestionButton} from './free-response-add-question'
import {OtherProfileAnswers} from './other-profile-answers'
export function FreeResponseDisplay(props: {
isCurrentUser: boolean
user: User
fromProfilePage: Profile | undefined
}) {
const { isCurrentUser, user, fromProfilePage } = props
const {isCurrentUser, user, fromProfilePage} = props
const t = useT()
const { refreshAnswers, answers: allAnswers } = useUserAnswers(user?.id)
const {refreshAnswers, answers: allAnswers} = useUserAnswers(user?.id)
const answers = allAnswers.filter(
(a) => a.free_response != null && a.free_response !== ''
)
const answers = allAnswers.filter((a) => a.free_response != null && a.free_response !== '')
const answerQuestionIds = new Set(answers.map((answer) => answer.question_id))
const FRquestionsWithCount = useFRQuestionsWithAnswerCount()
const [yourFRQuestions, otherFRQuestions] = partition(
FRquestionsWithCount,
(question) => answerQuestionIds.has(question.id)
const [yourFRQuestions, otherFRQuestions] = partition(FRquestionsWithCount, (question) =>
answerQuestionIds.has(question.id),
)
const noAnswers = answers.length < 1
@@ -64,7 +56,9 @@ export function FreeResponseDisplay(props: {
<Subtitle>
{isCurrentUser
? t('answers.free.your_title', 'Your Free Response')
: t('answers.free.user_title', "{name}'s Free Response", { name: shortenName(user.name) })}
: t('answers.free.user_title', "{name}'s Free Response", {
name: shortenName(user.name),
})}
</Subtitle>
</Row>
@@ -102,7 +96,7 @@ function AnswerBlock(props: {
user: User
refreshAnswers: () => void
}) {
const { answer, questions, isCurrentUser, user, refreshAnswers } = props
const {answer, questions, isCurrentUser, user, refreshAnswers} = props
const question = questions.find((q) => q.id === answer.question_id)
const [edit, setEdit] = useState(false)
const t = useT()
@@ -114,9 +108,7 @@ function AnswerBlock(props: {
return (
<Col
key={question.id}
className={
'bg-canvas-0 flex-grow whitespace-pre-line rounded-md px-3 py-2 leading-relaxed'
}
className={'bg-canvas-0 flex-grow whitespace-pre-line rounded-md px-3 py-2 leading-relaxed'}
>
<Row className="text-ink-600 justify-between text-sm">
{question.question}
@@ -131,11 +123,12 @@ function AnswerBlock(props: {
{
name: t('answers.menu.delete', 'Delete'),
icon: <XIcon className="h-5 w-5" />,
onClick: () =>
deleteAnswer(answer, user.id).then(() => refreshAnswers()),
onClick: () => deleteAnswer(answer, user.id).then(() => refreshAnswers()),
},
{
name: t('answers.free.see_others', 'See {count} other answers', { count: String(question.answer_count) }),
name: t('answers.free.see_others', 'See {count} other answers', {
count: String(question.answer_count),
}),
icon: <TbMessage className="h-5 w-5" />,
onClick: () => setOtherAnswerModal(true),
},
@@ -169,11 +162,7 @@ function AnswerBlock(props: {
<Modal open={otherAnswerModal} setOpen={setOtherAnswerModal}>
<Col className={MODAL_CLASS}>
<span className="font-semibold">{question.question}</span>
<OtherProfileAnswers
question={question}
user={user}
className={SCROLLABLE_MODAL_CLASS}
/>
<OtherProfileAnswers question={question} user={user} className={SCROLLABLE_MODAL_CLASS} />
</Col>
</Modal>
</Col>

View File

@@ -1,27 +1,27 @@
import { track } from 'web/lib/service/analytics'
import { PencilIcon } from '@heroicons/react/outline'
import Router from 'next/router'
import {PencilIcon} from '@heroicons/react/outline'
import clsx from 'clsx'
import { Row as rowFor } from 'common/supabase/utils'
import { capitalize, orderBy } from 'lodash'
import { Button } from 'web/components/buttons/button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Subtitle } from '../widgets/profile-subtitle'
import { BiTachometer } from 'react-icons/bi'
import {Row as rowFor} from 'common/supabase/utils'
import {capitalize, orderBy} from 'lodash'
import Router from 'next/router'
import {BiTachometer} from 'react-icons/bi'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
import {Subtitle} from '../widgets/profile-subtitle'
export function OpinionScale(props: {
multiChoiceAnswers: rowFor<'compatibility_answers_free'>[]
questions: rowFor<'compatibility_prompts'>[]
isCurrentUser: boolean
}) {
const { multiChoiceAnswers, questions, isCurrentUser } = props
const {multiChoiceAnswers, questions, isCurrentUser} = props
const t = useT()
const answeredMultiChoice = multiChoiceAnswers.filter(
(a) => a.multiple_choice != null && a.multiple_choice != -1
(a) => a.multiple_choice != null && a.multiple_choice != -1,
)
if (answeredMultiChoice.length < 1) {
@@ -59,17 +59,9 @@ export function OpinionScale(props: {
)}
</Row>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
{orderBy(answeredMultiChoice, (a) => a.multiple_choice, 'desc').map(
(answer) => {
return (
<OpinionScaleBlock
key={answer.id}
answer={answer}
questions={questions}
/>
)
}
)}
{orderBy(answeredMultiChoice, (a) => a.multiple_choice, 'desc').map((answer) => {
return <OpinionScaleBlock key={answer.id} answer={answer} questions={questions} />
})}
</div>
</Col>
)
@@ -79,7 +71,7 @@ function OpinionScaleBlock(props: {
answer: rowFor<'compatibility_answers_free'>
questions: rowFor<'compatibility_prompts'>[]
}) {
const { answer, questions } = props
const {answer, questions} = props
const question = questions.find((q) => q.id === answer.question_id)
const multiChoiceAnswer = answer.multiple_choice
if (!question) return null
@@ -96,13 +88,13 @@ function OpinionScaleBlock(props: {
multiChoiceAnswer == 0
? `bg-rose-400 dark:bg-rose-500 `
: multiChoiceAnswer == 1
? `bg-rose-300 dark:bg-rose-400 `
: multiChoiceAnswer == 2
? `bg-stone-400 dark:bg-stone-500`
: multiChoiceAnswer == 3
? `bg-teal-300 dark:bg-teal-200 `
: `bg-teal-400`,
'relative rounded bg-opacity-20 px-4 py-1 dark:bg-opacity-30'
? `bg-rose-300 dark:bg-rose-400 `
: multiChoiceAnswer == 2
? `bg-stone-400 dark:bg-stone-500`
: multiChoiceAnswer == 3
? `bg-teal-300 dark:bg-teal-200 `
: `bg-teal-400`,
'relative rounded bg-opacity-20 px-4 py-1 dark:bg-opacity-30',
)}
>
<div
@@ -111,12 +103,12 @@ function OpinionScaleBlock(props: {
multiChoiceAnswer == 0
? 'text-rose-700 dark:text-rose-300'
: multiChoiceAnswer == 1
? 'text-rose-500 dark:text-rose-300'
: multiChoiceAnswer == 2
? 'text-stone-500 dark:text-stone-400'
: multiChoiceAnswer == 3
? 'text-teal-500 '
: 'text-teal-600'
? 'text-rose-500 dark:text-rose-300'
: multiChoiceAnswer == 2
? 'text-stone-500 dark:text-stone-400'
: multiChoiceAnswer == 3
? 'text-teal-500 '
: 'text-teal-600',
)}
>
{capitalize(optionKey)}

View File

@@ -1,15 +1,15 @@
import clsx from 'clsx'
import {convertGender, Gender} from 'common/gender'
import {User} from 'common/user'
import {useOtherAnswers} from 'web/hooks/use-other-answers'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {capitalize} from 'lodash'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {Avatar} from 'web/components/widgets/avatar'
import {Linkify} from 'web/components/widgets/linkify'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {UserLink} from 'web/components/widgets/user-link'
import {convertGender, Gender} from 'common/gender'
import {capitalize} from 'lodash'
import clsx from 'clsx'
import {useOtherAnswers} from 'web/hooks/use-other-answers'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {shortenedFromNow} from 'web/lib/util/shortenedFromNow'
export function OtherProfileAnswers(props: {
@@ -20,15 +20,13 @@ export function OtherProfileAnswers(props: {
const {question, className} = props
const otherAnswers = useOtherAnswers(question.id)
const shownAnswers = otherAnswers?.filter(
(a) => a.multiple_choice != null || a.free_response || a.integer
(a) => a.multiple_choice != null || a.free_response || a.integer,
)
if (otherAnswers === undefined) return <CompassLoadingIndicator/>
if (otherAnswers === undefined) return <CompassLoadingIndicator />
if (
(otherAnswers === null ||
otherAnswers.length ||
!shownAnswers ||
shownAnswers.length === 0) === 0
(otherAnswers === null || otherAnswers.length || !shownAnswers || shownAnswers.length === 0) ===
0
)
return <div>No answers yet!</div>
@@ -37,24 +35,16 @@ export function OtherProfileAnswers(props: {
{shownAnswers?.map((otherAnswer) => {
const answerUser = otherAnswer.data
return (
<Col
key={answerUser.id}
className="bg-canvas-50 gap-2 rounded px-4 py-2"
>
<Col key={answerUser.id} className="bg-canvas-50 gap-2 rounded px-4 py-2">
<Row className="w-full justify-between">
<Row className="text-ink-500 items-center gap-2">
<Avatar
username={answerUser.username}
avatarUrl={answerUser.avatarUrl}
size="sm"
/>
<Avatar username={answerUser.username} avatarUrl={answerUser.avatarUrl} size="sm" />
<Col>
<span className="text-sm">
<UserLink user={answerUser}/>, {otherAnswer.age}
<UserLink user={answerUser} />, {otherAnswer.age}
</span>
<Row className="gap-1 text-xs">
{otherAnswer.city} {' '}
{capitalize(convertGender(otherAnswer.gender as Gender))}
{otherAnswer.city} {capitalize(convertGender(otherAnswer.gender as Gender))}
</Row>
</Col>
</Row>
@@ -64,11 +54,7 @@ export function OtherProfileAnswers(props: {
</Row>
<Linkify
className="text-sm"
text={
otherAnswer.free_response ??
otherAnswer.multiple_choice ??
otherAnswer.integer
}
text={otherAnswer.free_response ?? otherAnswer.multiple_choice ?? otherAnswer.integer}
/>
</Col>
)

View File

@@ -1,7 +1,8 @@
import {Profile} from 'common/profiles/profile'
import {User} from 'common/user'
import {Col} from 'web/components/layout/col'
import {CompatibilityQuestionsDisplay} from './compatibility-questions-display'
import {Profile} from 'common/profiles/profile'
export function ProfileAnswers(props: {
isCurrentUser: boolean

View File

@@ -1,26 +1,26 @@
'use client'
import {createContext, ReactNode, useEffect, useState} from 'react'
import {pickBy} from 'lodash'
import {onAuthStateChanged, onIdTokenChanged, User as FirebaseUser} from 'firebase/auth'
import {auth} from 'web/lib/firebase/users'
import {api} from 'web/lib/api'
import {randomString} from 'common/util/random'
import {useStateCheckEquality} from 'web/hooks/use-state-check-equality'
import {AUTH_COOKIE_NAME, TEN_YEARS_SECS} from 'common/envs/constants'
import {getCookie, setCookie} from 'web/lib/util/cookie'
import {type PrivateUser, type User, type UserAndPrivateUser,} from 'common/user'
import {safeLocalStorage} from 'web/lib/util/local'
import {type PrivateUser, type User, type UserAndPrivateUser} from 'common/user'
import {randomString} from 'common/util/random'
import {onAuthStateChanged, onIdTokenChanged, User as FirebaseUser} from 'firebase/auth'
import {pickBy} from 'lodash'
import {createContext, ReactNode, useEffect, useState} from 'react'
import {useEffectCheckEquality} from 'web/hooks/use-effect-check-equality'
import {getPrivateUserSafe, getUserSafe} from 'web/lib/supabase/users'
import {useStateCheckEquality} from 'web/hooks/use-state-check-equality'
import {useWebsocketPrivateUser, useWebsocketUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {auth} from 'web/lib/firebase/users'
import {identifyUser, setUserProperty} from 'web/lib/service/analytics'
import {getPrivateUserSafe, getUserSafe} from 'web/lib/supabase/users'
import {getCookie, setCookie} from 'web/lib/util/cookie'
import {safeLocalStorage} from 'web/lib/util/local'
// Either we haven't looked up the logged-in user yet (undefined), or we know
// the user is not logged in (null), or we know the user is logged in.
export type AuthUser =
| undefined
| null
| (UserAndPrivateUser & { authLoaded: boolean, firebaseUser: FirebaseUser })
| (UserAndPrivateUser & {authLoaded: boolean; firebaseUser: FirebaseUser})
const CACHED_USER_KEY = 'CACHED_USER_KEY_V2'
export const ensureDeviceToken = () => {
@@ -82,42 +82,38 @@ export const clearUserCookie = () => {
* which is important for reflecting `emailVerified` changes without a hard refresh.
*/
export function useAndSetupFirebaseUser() {
const [, forceRender] = useState(0);
const [firebaseUser, setFirebaseUser] = useState<FirebaseUser | null>(auth.currentUser);
const [, forceRender] = useState(0)
const [firebaseUser, setFirebaseUser] = useState<FirebaseUser | null>(auth.currentUser)
useEffect(() => {
const update = (u: FirebaseUser | null) => {
setFirebaseUser(u); // keep the real User instance
forceRender(v => v + 1); // force React to re-render
};
setFirebaseUser(u) // keep the real User instance
forceRender((v) => v + 1) // force React to re-render
}
const unsubAuth = onAuthStateChanged(auth, update);
const unsubToken = onIdTokenChanged(auth, update);
const unsubAuth = onAuthStateChanged(auth, update)
const unsubToken = onIdTokenChanged(auth, update)
return () => {
unsubAuth();
unsubToken();
};
}, []);
unsubAuth()
unsubToken()
}
}, [])
return firebaseUser;
return firebaseUser
}
export const AuthContext = createContext<AuthUser>(undefined)
export function AuthProvider(props: {
children: ReactNode
serverUser?: AuthUser
}) {
export function AuthProvider(props: {children: ReactNode; serverUser?: AuthUser}) {
const {children, serverUser} = props
const [user, setUser] = useStateCheckEquality<User | undefined | null>(
serverUser ? serverUser.user : serverUser
serverUser ? serverUser.user : serverUser,
)
const [privateUser, setPrivateUser] = useStateCheckEquality<PrivateUser | undefined>(
serverUser ? serverUser.privateUser : undefined,
)
const [privateUser, setPrivateUser] = useStateCheckEquality<
PrivateUser | undefined
>(serverUser ? serverUser.privateUser : undefined)
const [authLoaded, setAuthLoaded] = useState(false)
const firebaseUser = useAndSetupFirebaseUser()
@@ -125,7 +121,9 @@ export function AuthProvider(props: {
? user
: !privateUser
? privateUser
: firebaseUser ? {user, privateUser, authLoaded, firebaseUser} : undefined
: firebaseUser
? {user, privateUser, authLoaded, firebaseUser}
: undefined
useEffect(() => {
if (serverUser === undefined) {
@@ -149,11 +147,7 @@ export function AuthProvider(props: {
}
}, [authUser])
const onAuthLoad = (
fbUser: FirebaseUser,
user: User,
privateUser: PrivateUser
) => {
const onAuthLoad = (fbUser: FirebaseUser, user: User, privateUser: PrivateUser) => {
setUser(user)
setPrivateUser(privateUser)
setAuthLoaded(true)
@@ -204,7 +198,7 @@ export function AuthProvider(props: {
},
(e) => {
console.error(e)
}
},
)
}, [])
@@ -235,7 +229,5 @@ export function AuthProvider(props: {
if (authLoaded && listenPrivateUser) setPrivateUser(listenPrivateUser)
}, [authLoaded, listenPrivateUser])
return (
<AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
)
return <AuthContext.Provider value={authUser}>{children}</AuthContext.Provider>
}

View File

@@ -1,11 +1,11 @@
import { ArrowLeftIcon } from '@heroicons/react/solid'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import {ArrowLeftIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import { Button } from 'web/components/buttons/button'
import {useRouter} from 'next/navigation'
import {useEffect, useState} from 'react'
import {Button} from 'web/components/buttons/button'
export function BackButton(props: { className?: string }) {
const { className } = props
export function BackButton(props: {className?: string}) {
const {className} = props
const router = useRouter()
const [canGoBack, setCanGoBack] = useState(false)
@@ -17,11 +17,7 @@ export function BackButton(props: { className?: string }) {
if (!canGoBack) return null
return (
<Button
className={clsx('rounded-none', className)}
onClick={router.back}
color={'gray-white'}
>
<Button className={clsx('rounded-none', className)} onClick={router.back} color={'gray-white'}>
<ArrowLeftIcon className="h-5 w-5" aria-hidden />
<div className="sr-only">Back</div>
</Button>

View File

@@ -1,32 +1,35 @@
import {Editor} from '@tiptap/core'
import {MIN_BIO_LENGTH} from 'common/constants'
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {richTextToString} from 'common/util/parse'
import {tryCatch} from 'common/util/try-catch'
import {useRouter} from 'next/router'
import {useEffect, useState} from 'react'
import ReactMarkdown from 'react-markdown'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
import {updateProfile} from 'web/lib/api'
import {track} from 'web/lib/service/analytics'
import {useEffect, useState} from "react";
import ReactMarkdown from "react-markdown";
import {MIN_BIO_LENGTH} from "common/constants";
import {ShowMore} from 'web/components/widgets/show-more'
import {NewTabLink} from 'web/components/widgets/new-tab-link'
import {ShowMore} from 'web/components/widgets/show-more'
import {updateProfile} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {richTextToString} from 'common/util/parse'
import {useRouter} from "next/router";
import {track} from 'web/lib/service/analytics'
export function BioTips({onClick}: { onClick?: () => void }) {
const t = useT();
const tips = t('profile.bio.tips_list', `
export function BioTips({onClick}: {onClick?: () => void}) {
const t = useT()
const tips = t(
'profile.bio.tips_list',
`
- Your core values, interests, and activities
- Personality traits, what makes you unique and what you care about
- Connection goals (collaborative, friendship, romantic)
- Expectations and boundaries
- Availability, how to contact you or start a conversation (email, social media, etc.)
- Optional: romantic preferences, lifestyle habits, and conversation starters
`);
`,
)
let href = '/tips-bio'
@@ -41,31 +44,37 @@ export function BioTips({onClick}: { onClick?: () => void }) {
labelOpen={t('profile.bio.hide_info', 'Hide info')}
className={'custom-link text-sm'}
>
<p>{t('profile.bio.tips_intro', "Write a clear and engaging bio to help others understand who you are and the connections you seek. Include:")}</p>
<p>
{t(
'profile.bio.tips_intro',
'Write a clear and engaging bio to help others understand who you are and the connections you seek. Include:',
)}
</p>
<ReactMarkdown>{tips}</ReactMarkdown>
<NewTabLink
href={href}
onClick={onClick}>{t('profile.bio.tips_link', 'Read full tips for writing a high-quality bio')}</NewTabLink>
<NewTabLink href={href} onClick={onClick}>
{t('profile.bio.tips_link', 'Read full tips for writing a high-quality bio')}
</NewTabLink>
</ShowMore>
)
}
export function EditableBio(props: {
profile: Profile
onSave: () => void
onCancel?: () => void
}) {
export function EditableBio(props: {profile: Profile; onSave: () => void; onCancel?: () => void}) {
const {profile, onCancel, onSave} = props
const [editor, setEditor] = useState<any>(null)
const [textLength, setTextLength] = useState(0);
const t = useT();
const [textLength, setTextLength] = useState(0)
const t = useT()
const hideButtons = (textLength === 0) && !profile.bio
const hideButtons = textLength === 0 && !profile.bio
const saveBio = async () => {
if (!editor) return
// console.log(editor.getText().length)
const {error} = await tryCatch(updateProfile({bio: editor.getJSON(), bio_length: editor.getText().length}))
const {error} = await tryCatch(
updateProfile({
bio: editor.getJSON(),
bio_length: editor.getText().length,
}),
)
if (error) {
console.error(error)
@@ -80,11 +89,11 @@ export function EditableBio(props: {
<BaseBio
defaultValue={profile.bio}
onEditor={(e) => {
setEditor(e);
setEditor(e)
if (e) setTextLength(e.getText().length)
e?.on('update', () => {
setTextLength(e.getText().length);
});
setTextLength(e.getText().length)
})
}}
/>
{!hideButtons && (
@@ -155,12 +164,15 @@ interface BaseBioProps {
}
export function BaseBio({defaultValue, onBlur, onEditor, onClickTips}: BaseBioProps) {
const t = useT();
const t = useT()
const editor = useTextEditor({
// extensions: [StarterKit],
max: MAX_DESCRIPTION_LENGTH,
defaultValue: defaultValue,
placeholder: t('profile.bio.placeholder', "Tell us about yourself — and what you're looking for!"),
placeholder: t(
'profile.bio.placeholder',
"Tell us about yourself — and what you're looking for!",
),
})
const textLength = editor?.getText().length ?? 0
const remainingChars = MIN_BIO_LENGTH - textLength
@@ -171,19 +183,23 @@ export function BaseBio({defaultValue, onBlur, onEditor, onClickTips}: BaseBioPr
return (
<div>
{textLength < MIN_BIO_LENGTH &&
<p>
{remainingChars === 1
? t('profile.bio.add_characters_one', 'Add {count} more character so you can appear in search results—or take your time and start by exploring others.', {count: remainingChars})
: t('profile.bio.add_characters_many', 'Add {count} more characters so you can appear in search results—or take your time and start by exploring others.', {count: remainingChars})
}
</p>
}
<BioTips onClick={onClickTips}/>
<TextEditor
editor={editor}
onBlur={() => onBlur?.(editor)}
/>
{textLength < MIN_BIO_LENGTH && (
<p>
{remainingChars === 1
? t(
'profile.bio.add_characters_one',
'Add {count} more character so you can appear in search results—or take your time and start by exploring others.',
{count: remainingChars},
)
: t(
'profile.bio.add_characters_many',
'Add {count} more characters so you can appear in search results—or take your time and start by exploring others.',
{count: remainingChars},
)}
</p>
)}
<BioTips onClick={onClickTips} />
<TextEditor editor={editor} onBlur={() => onBlur?.(editor)} />
</div>
)
}

View File

@@ -1,17 +1,17 @@
import {PencilIcon, XIcon} from '@heroicons/react/outline'
import {JSONContent} from '@tiptap/core'
import clsx from 'clsx'
import {Profile} from 'common/profiles/profile'
import {tryCatch} from 'common/util/try-catch'
import DropdownMenu from 'web/components/comments/dropdown-menu'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {Content} from 'web/components/widgets/editor'
import {Tooltip} from 'web/components/widgets/tooltip'
import {updateProfile} from 'web/lib/api'
import {EditableBio} from './editable-bio'
import {tryCatch} from 'common/util/try-catch'
import {useT} from 'web/lib/locale'
import {Tooltip} from "web/components/widgets/tooltip";
import {EditableBio} from './editable-bio'
export function BioBlock(props: {
isCurrentUser: boolean
@@ -20,14 +20,14 @@ export function BioBlock(props: {
edit: boolean
setEdit: (edit: boolean) => void
}) {
const { isCurrentUser, refreshProfile, profile, edit, setEdit } = props
const {isCurrentUser, refreshProfile, profile, edit, setEdit} = props
const t = useT()
return (
<Col
className={clsx(
'bg-canvas-0 flex-grow whitespace-pre-line rounded-md leading-relaxed',
!edit && 'px-3 py-2'
!edit && 'px-3 py-2',
)}
>
<Row className="w-full" data-testid="profile-bio">
@@ -47,26 +47,30 @@ export function BioBlock(props: {
/>
)}
{isCurrentUser && !edit && (
<Tooltip text={t('more_options_user.edit_bio', 'Bio options')} noTap testId='profile-bio-options'>
<DropdownMenu
items={[
{
name: t('profile.bio.edit', 'Edit'),
icon: <PencilIcon className="h-5 w-5" />,
onClick: () => setEdit(true),
},
{
name: t('profile.bio.delete', 'Delete'),
icon: <XIcon className="h-5 w-5" />,
onClick: async () => {
const { error } = await tryCatch(updateProfile({ bio: null }))
if (error) console.error(error)
else refreshProfile()
<Tooltip
text={t('more_options_user.edit_bio', 'Bio options')}
noTap
testId="profile-bio-options"
>
<DropdownMenu
items={[
{
name: t('profile.bio.edit', 'Edit'),
icon: <PencilIcon className="h-5 w-5" />,
onClick: () => setEdit(true),
},
},
]}
closeOnClick
/>
{
name: t('profile.bio.delete', 'Delete'),
icon: <XIcon className="h-5 w-5" />,
onClick: async () => {
const {error} = await tryCatch(updateProfile({bio: null}))
if (error) console.error(error)
else refreshProfile()
},
},
]}
closeOnClick
/>
</Tooltip>
)}
</Row>

View File

@@ -1,32 +1,34 @@
import {QuestionMarkCircleIcon} from '@heroicons/react/outline'
import {JSONContent} from '@tiptap/core'
import {MAX_INT, MIN_BIO_LENGTH} from 'common/constants'
import {Profile} from 'common/profiles/profile'
import {useEffect, useState} from 'react'
import {Col} from 'web/components/layout/col'
import {useTextEditor} from 'web/components/widgets/editor'
import {Tooltip} from 'web/components/widgets/tooltip'
import {useT} from 'web/lib/locale'
import {Subtitle} from '../widgets/profile-subtitle'
import {BioBlock} from './profile-bio-block'
import {MAX_INT, MIN_BIO_LENGTH} from "common/constants";
import {useTextEditor} from "web/components/widgets/editor";
import {JSONContent} from "@tiptap/core"
import {useT} from "web/lib/locale";
import {Tooltip} from "web/components/widgets/tooltip";
import {QuestionMarkCircleIcon} from "@heroicons/react/outline";
export default function TooShortBio() {
const t = useT()
const text = t('profile.bio.too_short_tooltip', "Since your bio is too short, Compass' algorithm filters out your profile from search results (unless \"Include incomplete profiles\" is selected). This ensures searches show meaningful profiles.");
const text = t(
'profile.bio.too_short_tooltip',
'Since your bio is too short, Compass\' algorithm filters out your profile from search results (unless "Include incomplete profiles" is selected). This ensures searches show meaningful profiles.',
)
return (
<p className="text-red-600">
{t('profile.bio.too_short', "Bio too short. Profile may be filtered from search results.")}{" "}
{t('profile.bio.too_short', 'Bio too short. Profile may be filtered from search results.')}{' '}
<span className="inline-flex align-middle">
<Tooltip
text={text}>
<QuestionMarkCircleIcon className="w-5 h-5"/>
</Tooltip>
</span>
<Tooltip text={text}>
<QuestionMarkCircleIcon className="w-5 h-5" />
</Tooltip>
</span>
</p>
);
)
}
export function ProfileBio(props: {
isCurrentUser: boolean
profile: Profile
@@ -37,7 +39,7 @@ export function ProfileBio(props: {
const [edit, setEdit] = useState(false)
const editor = useTextEditor({defaultValue: ''})
const [textLength, setTextLength] = useState(MAX_INT)
const t = useT();
const t = useT()
useEffect(() => {
if (!editor) return
@@ -50,7 +52,7 @@ export function ProfileBio(props: {
return (
<Col>
{textLength < MIN_BIO_LENGTH && !edit && isCurrentUser && <TooShortBio/>}
{textLength < MIN_BIO_LENGTH && !edit && isCurrentUser && <TooShortBio />}
<Subtitle className="mb-4">{t('profile.bio.about_me', 'About Me')}</Subtitle>
<BioBlock
isCurrentUser={isCurrentUser}

View File

@@ -1,5 +1,5 @@
import {forwardRef, MouseEventHandler, ReactNode, Ref} from 'react'
import clsx from 'clsx'
import {forwardRef, MouseEventHandler, ReactNode, Ref} from 'react'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
export type SizeType = '2xs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
@@ -47,22 +47,13 @@ export function buttonClass(size: SizeType, color: ColorType) {
color === 'green' && [solid, 'bg-teal-500 hover:bg-teal-600'],
color === 'green-outline' && [outline, 'text-teal-500 hover:bg-teal-500'],
color === 'red' && [solid, 'bg-red-500 hover:bg-red-600'],
color === 'red-outline' && [
outline,
'text-scarlet-500 hover:bg-scarlet-500',
],
color === 'red-outline' && [outline, 'text-scarlet-500 hover:bg-scarlet-500'],
color === 'yellow' && [solid, 'bg-yellow-400 hover:bg-yellow-500'],
color === 'yellow-outline' && [
outline,
'text-yellow-500 hover:bg-yellow-500',
],
color === 'yellow-outline' && [outline, 'text-yellow-500 hover:bg-yellow-500'],
color === 'blue' && [solid, 'bg-blue-400 hover:bg-blue-500'],
color === 'sky-outline' && [outline, 'text-sky-500 hover:bg-sky-500'],
color === 'indigo' && [solid, 'bg-primary-500 hover:bg-primary-600'],
color === 'indigo-outline' && [
outline,
'text-primary-500 hover:bg-primary-500',
],
color === 'indigo-outline' && [outline, 'text-primary-500 hover:bg-primary-500'],
color === 'gray' &&
'bg-ink-300 text-ink-900 disabled:bg-ink-200 disabled:text-ink-500 hover:bg-ink-200 dark:enabled:hover:bg-ink-400 hover:text-ink-1000',
color === 'gray-outline' && [outline, 'text-ink-600 hover:bg-canvas-100'],
@@ -73,7 +64,7 @@ export function buttonClass(size: SizeType, color: ColorType) {
color === 'gold' && [
gradient,
'enabled:!bg-gradient-to-br from-yellow-400 via-yellow-100 to-yellow-300 dark:from-yellow-600 dark:via-yellow-200 dark:to-yellow-400 !text-gray-900',
]
],
)
}
@@ -85,7 +76,7 @@ export const Button = forwardRef(function Button(
type?: 'button' | 'reset' | 'submit'
loading?: boolean
} & JSX.IntrinsicElements['button'],
ref: Ref<HTMLButtonElement>
ref: Ref<HTMLButtonElement>,
) {
const {
children,
@@ -126,14 +117,7 @@ export function IconButton(props: {
type?: 'button' | 'reset' | 'submit'
disabled?: boolean
}) {
const {
children,
className,
onClick,
size = 'md',
type = 'button',
disabled = false,
} = props
const {children, className, onClick, size = 'md', type = 'button', disabled = false} = props
return (
<Button

View File

@@ -1,9 +1,10 @@
import clsx from 'clsx'
import { ReactNode, useState } from 'react'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { Button, ColorType, SizeType } from './button'
import {ReactNode, useState} from 'react'
import {Col} from '../layout/col'
import {Modal} from '../layout/modal'
import {Row} from '../layout/row'
import {Button, ColorType, SizeType} from './button'
export function ConfirmationButton(props: {
openModalBtn: {
@@ -65,10 +66,7 @@ export function ConfirmationButton(props: {
color={submitBtn?.color ?? 'blue'}
onClick={
onSubmitWithSuccess
? () =>
onSubmitWithSuccess().then((success) =>
updateOpen(!success)
)
? () => onSubmitWithSuccess().then((success) => updateOpen(!success))
: async () => {
await onSubmit?.()
updateOpen(false)
@@ -112,15 +110,7 @@ export function ResolveConfirmationButton(props: {
color: ColorType
disabled?: boolean
}) {
const {
onResolve,
isSubmitting,
openModalButtonClass,
color,
marketTitle,
label,
disabled,
} = props
const {onResolve, isSubmitting, openModalButtonClass, color, marketTitle, label, disabled} = props
return (
<ConfirmationButton
openModalBtn={{

View File

@@ -1,13 +1,14 @@
import {ComponentProps, useState} from 'react'
import {copyToClipboard} from 'web/lib/util/copy'
import {track} from 'web/lib/service/analytics'
import {Tooltip} from '../widgets/tooltip'
import {CheckIcon, ClipboardCopyIcon, DuplicateIcon} from '@heroicons/react/outline'
import {LinkIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import {Button, ColorType, IconButton, SizeType,} from 'web/components/buttons/button'
import {ComponentProps, useState} from 'react'
import toast from 'react-hot-toast'
import LinkIcon from 'web/lib/icons/link-icon.svg'
import {CheckIcon, ClipboardCopyIcon, DuplicateIcon,} from '@heroicons/react/outline'
import {useT} from "web/lib/locale";
import {Button, ColorType, IconButton, SizeType} from 'web/components/buttons/button'
import {useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
import {copyToClipboard} from 'web/lib/util/copy'
import {Tooltip} from '../widgets/tooltip'
export function CopyLinkOrShareButton(props: {
url: string
@@ -22,8 +23,7 @@ export function CopyLinkOrShareButton(props: {
contractId: string
}
}) {
const { url, size, children, className, iconClassName, tooltip, color } =
props
const {url, size, children, className, iconClassName, tooltip, color} = props
const [isSuccess, setIsSuccess] = useState(false)
const t = useT()
@@ -48,7 +48,7 @@ export function CopyLinkOrShareButton(props: {
className={clsx(
className,
'active:text-white',
isSuccess && 'text-green-500 duration-[25ms] hover:text-green-200'
isSuccess && 'text-green-500 duration-[25ms] hover:text-green-200',
)}
disabled={!url}
size={size}
@@ -73,9 +73,7 @@ export function CopyLinkOrShareButton(props: {
)
}
const ToolTipOrDiv = (
props: { hasChildren: boolean } & ComponentProps<typeof Tooltip>
) =>
const ToolTipOrDiv = (props: {hasChildren: boolean} & ComponentProps<typeof Tooltip>) =>
props.hasChildren ? (
<>{props.children}</>
) : (
@@ -91,7 +89,7 @@ export const CopyLinkRow = (props: {
linkBoxClassName?: string
linkButtonClassName?: string
}) => {
const { url, linkBoxClassName, linkButtonClassName } = props
const {url, linkBoxClassName, linkButtonClassName} = props
// "copied" success state animations
const [bgPressed, setBgPressed] = useState(false)
@@ -116,11 +114,9 @@ export const CopyLinkRow = (props: {
<button
className={clsx(
'border-ink-300 flex select-none items-center justify-between rounded border px-4 py-2 text-sm transition-colors duration-700',
bgPressed
? 'bg-primary-50 text-primary-500 transition-none'
: 'bg-canvas-50 text-ink-500',
bgPressed ? 'bg-primary-50 text-primary-500 transition-none' : 'bg-canvas-50 text-ink-500',
'disabled:h-9 disabled:animate-pulse',
linkBoxClassName
linkBoxClassName,
)}
disabled={!url}
onClick={onClick}
@@ -128,11 +124,7 @@ export const CopyLinkRow = (props: {
<div className={'select-all truncate'}>{displayUrl}</div>
{url && (
<div className={linkButtonClassName}>
{!iconPressed ? (
<DuplicateIcon className="h-5 w-5" />
) : (
<CheckIcon className="h-5 w-5" />
)}
{!iconPressed ? <DuplicateIcon className="h-5 w-5" /> : <CheckIcon className="h-5 w-5" />}
</div>
)}
</button>
@@ -145,7 +137,7 @@ export function SimpleCopyTextButton(props: {
tooltip?: string
className?: string
}) {
const { text, eventTrackingName, className, tooltip } = props
const {text, eventTrackingName, className, tooltip} = props
const t = useT()
const onClick = () => {
@@ -153,12 +145,16 @@ export function SimpleCopyTextButton(props: {
copyToClipboard(text)
toast.success(t('copy_link_button.link_copied', 'Link copied!'))
track(eventTrackingName, { text })
track(eventTrackingName, {text})
}
return (
<IconButton onClick={onClick} className={className} disabled={!text}>
<Tooltip text={tooltip ?? t('copy_link_button.copy_link', 'Copy link')} noTap placement="bottom">
<Tooltip
text={tooltip ?? t('copy_link_button.copy_link', 'Copy link')}
noTap
placement="bottom"
>
<ClipboardCopyIcon className={'h-5'} aria-hidden="true" />
</Tooltip>
</IconButton>

View File

@@ -1,4 +1,4 @@
import { useRef } from 'react'
import {useRef} from 'react'
/** button that opens file upload window */
export function FileUploadButton(props: {
@@ -7,7 +7,7 @@ export function FileUploadButton(props: {
children?: React.ReactNode
disabled?: boolean
}) {
const { onFiles, className, children, disabled } = props
const {onFiles, className, children, disabled} = props
const ref = useRef<HTMLInputElement>(null)
return (
<>

View File

@@ -1,10 +1,7 @@
import Link from "next/link";
import Link from 'next/link'
export const GeneralButton = (props: {
url: string
content: string
}) => {
const {url, content} = props;
export const GeneralButton = (props: {url: string; content: string}) => {
const {url, content} = props
return (
<div className="rounded-xl p-3 flex flex-col items-center group">
@@ -17,11 +14,9 @@ export const GeneralButton = (props: {
rel={url.startsWith('http') ? 'noopener noreferrer' : undefined}
>
<div className="flex items-center justify-center w-full h-full flex-col text-center">
<div className="flex items-center justify-center">
{content}
</div>
<div className="flex items-center justify-center">{content}</div>
</div>
</Link>
</div>
);
}
)
}

View File

@@ -1,27 +1,28 @@
import {usePrivateUser} from 'web/hooks/use-user'
import {Button} from 'web/components/buttons/button'
import {Modal} from 'web/components/layout/modal'
import {useState} from 'react'
import {Col} from 'web/components/layout/col'
import {User} from 'common/user'
import clsx from 'clsx'
import {DotsHorizontalIcon} from '@heroicons/react/outline'
import {useAdmin, useTrusted} from 'web/hooks/use-admin'
import clsx from 'clsx'
import {User} from 'common/user'
import {buildArray} from 'common/util/array'
import Router from 'next/router'
import {useState} from 'react'
import {toast} from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {SimpleCopyTextButton} from 'web/components/buttons/copy-link-button'
import {Col} from 'web/components/layout/col'
import {Modal} from 'web/components/layout/modal'
import {UncontrolledTabs} from 'web/components/layout/tabs'
import {BlockUser} from 'web/components/profile/block-user'
import {ReportUser} from 'web/components/profile/report-user'
import {Title} from 'web/components/widgets/title'
import {Row} from '../layout/row'
import {SimpleCopyTextButton} from 'web/components/buttons/copy-link-button'
import {Tooltip} from 'web/components/widgets/tooltip'
import {useAdmin, useTrusted} from 'web/hooks/use-admin'
import {usePrivateUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {buildArray} from 'common/util/array'
import {DeleteYourselfButton} from '../profile/delete-yourself'
import {toast} from "react-hot-toast";
import Router from "next/router";
import {useT} from 'web/lib/locale'
import {Tooltip} from "web/components/widgets/tooltip";
export function MoreOptionsUserButton(props: { user: User }) {
import {Row} from '../layout/row'
import {DeleteYourselfButton} from '../profile/delete-yourself'
export function MoreOptionsUserButton(props: {user: User}) {
const {user} = props
const {id: userId, name} = user
const currentPrivateUser = usePrivateUser()
@@ -30,7 +31,7 @@ export function MoreOptionsUserButton(props: { user: User }) {
const isTrusted = useTrusted()
const t = useT()
if (!currentPrivateUser) return <div/>
if (!currentPrivateUser) return <div />
const createdTime = new Date(user.createdTime).toLocaleDateString('en-us', {
year: 'numeric',
@@ -47,10 +48,7 @@ export function MoreOptionsUserButton(props: { user: User }) {
className="rounded-none px-6"
onClick={() => setIsModalOpen(true)}
>
<DotsHorizontalIcon
className={clsx('h-5 w-5 flex-shrink-0')}
aria-hidden="true"
/>
<DotsHorizontalIcon className={clsx('h-5 w-5 flex-shrink-0')} aria-hidden="true" />
</Button>
</Tooltip>
@@ -77,19 +75,19 @@ export function MoreOptionsUserButton(props: { user: User }) {
error: () => {
return t('more_options_user.error_banning', 'Error banning user')
},
}
},
)
}}
>
{user.isBannedFromPosting ? t('more_options_user.banned', 'Banned') : t('more_options_user.ban_user', 'Ban User')}
{user.isBannedFromPosting
? t('more_options_user.banned', 'Banned')
: t('more_options_user.ban_user', 'Ban User')}
</Button>
<Button
size="sm"
color="red"
onClick={() => {
api('remove-pinned-photo', {userId}).then(() =>
Router.back()
)
api('remove-pinned-photo', {userId}).then(() => Router.back())
}}
>
{t('more_options_user.delete_pinned_photo', 'Delete pinned photo')}
@@ -97,12 +95,10 @@ export function MoreOptionsUserButton(props: { user: User }) {
</Row>
)}
</div>
<Row
className={
'text-ink-600 flex-wrap items-center gap-x-3 gap-y-1 px-1'
}
>
<span className={'text-sm'}>{t('more_options_user.joined', 'Joined')} {createdTime}</span>
<Row className={'text-ink-600 flex-wrap items-center gap-x-3 gap-y-1 px-1'}>
<span className={'text-sm'}>
{t('more_options_user.joined', 'Joined')} {createdTime}
</span>
{isAdmin && (
<SimpleCopyTextButton
text={user.id}
@@ -118,36 +114,31 @@ export function MoreOptionsUserButton(props: { user: User }) {
// TODO: if isYou include a tab for users you've blocked?
isYou
? [
{
title: t('more_options_user.delete_account', 'Delete Account'),
content: (
<div className="flex min-h-[200px] items-center justify-center p-4">
<DeleteYourselfButton/>
</div>
),
},
]
{
title: t('more_options_user.delete_account', 'Delete Account'),
content: (
<div className="flex min-h-[200px] items-center justify-center p-4">
<DeleteYourselfButton />
</div>
),
},
]
: [
{
title: t('more_options_user.block', 'Block'),
content: (
<BlockUser
user={user}
currentUser={currentPrivateUser}
closeModal={() => setIsModalOpen(false)}
/>
),
},
{
title: t('more_options_user.report', 'Report'),
content: (
<ReportUser
user={user}
closeModal={() => setIsModalOpen(false)}
/>
),
},
],
{
title: t('more_options_user.block', 'Block'),
content: (
<BlockUser
user={user}
currentUser={currentPrivateUser}
closeModal={() => setIsModalOpen(false)}
/>
),
},
{
title: t('more_options_user.report', 'Report'),
content: <ReportUser user={user} closeModal={() => setIsModalOpen(false)} />,
},
],
])}
/>
</Col>

View File

@@ -1,19 +1,19 @@
import { useUser } from 'web/hooks/use-user'
import { Button } from 'web/components/buttons/button'
import { withTracking } from 'web/lib/service/analytics'
import { toast } from 'react-hot-toast'
import { Modal } from 'web/components/layout/modal'
import { Row } from 'web/components/layout/row'
import { useState } from 'react'
import { Col } from 'web/components/layout/col'
import { Title } from 'web/components/widgets/title'
import { capitalize } from 'lodash'
import { ReportProps } from 'common/report'
import { report as reportContent } from 'web/lib/api'
import {ReportProps} from 'common/report'
import {capitalize} from 'lodash'
import {useState} from 'react'
import {toast} from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Modal} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {Title} from 'web/components/widgets/title'
import {useUser} from 'web/hooks/use-user'
import {report as reportContent} from 'web/lib/api'
import {withTracking} from 'web/lib/service/analytics'
export function ReportButton(props: { report: ReportProps }) {
const { report } = props
const { contentOwnerId, contentType } = report
export function ReportButton(props: {report: ReportProps}) {
const {report} = props
const {contentOwnerId, contentType} = report
const currentUser = useUser()
const [isModalOpen, setIsModalOpen] = useState(false)
const label = contentType === 'contract' ? 'question' : contentType
@@ -46,16 +46,14 @@ export const ReportModal = (props: {
label: string
report: ReportProps
}) => {
const { label, report, setIsModalOpen, isModalOpen } = props
const {label, report, setIsModalOpen, isModalOpen} = props
const [isReported, setIsReported] = useState(false)
const onReport = async () => {
await toast.promise(reportContent(report), {
loading: 'Reporting...',
success: `${capitalize(
label
)} reported! Admins will take a look within 24 hours.`,
success: `${capitalize(label)} reported! Admins will take a look within 24 hours.`,
error: `Error reporting ${label}`,
})
setIsReported(true)

View File

@@ -1,31 +1,25 @@
import clsx from 'clsx'
import {firebaseLogin} from 'web/lib/firebase/users'
import {Button} from './button'
import {Col} from '../layout/col'
import {ButtonHTMLAttributes} from 'react'
import {FcGoogle} from 'react-icons/fc'
import {Row} from 'web/components/layout/row'
import {firebaseLogin} from 'web/lib/firebase/users'
import {ButtonHTMLAttributes} from "react"
import {FcGoogle} from "react-icons/fc"
import {Col} from '../layout/col'
import {Button} from './button'
export const SidebarSignUpButton = (props: { className?: string }) => {
export const SidebarSignUpButton = (props: {className?: string}) => {
const {className} = props
return (
<Col className={clsx('mt-4', className)}>
<Button
color="gradient"
size="xl"
onClick={firebaseLogin}
className="w-full"
>
<Button color="gradient" size="xl" onClick={firebaseLogin} className="w-full">
Sign up
</Button>
</Col>
)
}
export const GoogleSignInButton = (props: { onClick: () => any }) => {
export const GoogleSignInButton = (props: {onClick: () => any}) => {
return (
<Button
onClick={props.onClick}
@@ -34,13 +28,7 @@ export const GoogleSignInButton = (props: { onClick: () => any }) => {
className=" whitespace-nowrap shadow-sm outline-2 "
>
<Row className={'items-center gap-2 p-2'}>
<img
src="/google.svg"
alt=""
width={24}
height={24}
className="rounded-full bg-white"
/>
<img src="/google.svg" alt="" width={24} height={24} className="rounded-full bg-white" />
<span>Sign in with Google</span>
</Row>
</Button>
@@ -59,16 +47,15 @@ export function GoogleButton({onClick, isLoading = false, ...props}: GoogleButto
onClick={onClick}
disabled={isLoading}
className={clsx(
"w-full flex items-center justify-center gap-2 py-2 px-4 border border-gray-300",
"rounded-full shadow-sm text-sm font-medium",
"hover:bg-canvas-25 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500",
"disabled:opacity-70 disabled:cursor-not-allowed"
'w-full flex items-center justify-center gap-2 py-2 px-4 border border-gray-300',
'rounded-full shadow-sm text-sm font-medium',
'hover:bg-canvas-25 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500',
'disabled:opacity-70 disabled:cursor-not-allowed',
)}
{...props}
>
<FcGoogle className="w-5 h-5"/>
{isLoading ? "Loading..." : "Google"}
<FcGoogle className="w-5 h-5" />
{isLoading ? 'Loading...' : 'Google'}
</button>
)
}

View File

@@ -1,19 +1,19 @@
import {Col} from 'web/components/layout/col'
import clsx from 'clsx'
import {Row} from 'web/components/layout/row'
import {Avatar} from 'web/components/widgets/avatar'
import {RelativeTimestamp} from 'web/components/relative-timestamp'
import {Content} from 'web/components/widgets/editor'
import {DisplayUser} from 'common/api/user-types'
import {ChatMessage, PrivateChatMessage} from 'common/chat-message'
import {compassUserId} from 'common/profiles/constants'
import {first, last} from 'lodash'
import {Dispatch, memo, SetStateAction, useRef, useState} from 'react'
import {MultipleOrSingleAvatars} from 'web/components/multiple-or-single-avatars'
import {MessageActions} from 'web/components/chat/message-actions'
import {MessageReactions} from 'web/components/chat/message-reactions'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {MultipleOrSingleAvatars} from 'web/components/multiple-or-single-avatars'
import {RelativeTimestamp} from 'web/components/relative-timestamp'
import {Avatar} from 'web/components/widgets/avatar'
import {Content} from 'web/components/widgets/editor'
import {UserAvatarAndBadge} from 'web/components/widgets/user-link'
import {compassUserId} from 'common/profiles/constants'
import {DisplayUser} from 'common/api/user-types'
import {MessageActions} from "web/components/chat/message-actions"
import {MessageReactions} from "web/components/chat/message-reactions";
export function ChatMessageItem(props: {
chats: ChatMessage[]
@@ -75,7 +75,7 @@ export function ChatMessageItem(props: {
className={clsx(
'@container items-end justify-start gap-1',
isMe && 'flex-row-reverse',
firstOfUser ? 'mt-2' : 'mt-1'
firstOfUser ? 'mt-2' : 'mt-1',
)}
>
{!isMe && !hideAvatar && (
@@ -90,10 +90,7 @@ export function ChatMessageItem(props: {
<Col className="gap-1">
{chats.map((chat) => (
<div
className={clsx(
'group flex items-end gap-1',
isMe && 'flex-row-reverse'
)}
className={clsx('group flex items-end gap-1', isMe && 'flex-row-reverse')}
key={chat.id}
>
<div className="group relative">
@@ -106,7 +103,7 @@ export function ChatMessageItem(props: {
? 'bg-canvas-50 italic'
: isMe
? 'bg-primary-100 items-end self-end rounded-r-none group-first:rounded-tr-3xl'
: 'bg-canvas-0 items-start self-start rounded-l-none group-first:rounded-tl-3xl'
: 'bg-canvas-0 items-start self-start rounded-l-none group-first:rounded-tl-3xl',
)}
onMouseDown={() => startLongPress(chat.id)}
onMouseUp={cancelLongPress}
@@ -115,16 +112,11 @@ export function ChatMessageItem(props: {
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
>
<Content size={'sm'} content={chat.content} key={chat.id}/>
<Content size={'sm'} content={chat.content} key={chat.id} />
</div>
</Row>
{/* Hidden host for emoji picker, opened via long-press */}
<div
className={clsx(
'absolute -mt-2',
isMe ? 'right-40' : 'left-40',
)}
>
<div className={clsx('absolute -mt-2', isMe ? 'right-40' : 'left-40')}>
<MessageActions
message={{
id: chat.id,
@@ -143,16 +135,11 @@ export function ChatMessageItem(props: {
id: chat.id,
reactions: chat.reactions as Record<string, string[]> | undefined,
}}
className={clsx(
'ml-2',
isMe ? 'justify-end' : 'justify-start'
)}
className={clsx('ml-2', isMe ? 'justify-end' : 'justify-start')}
setMessages={setMessages}
/>
</div>
<Col
className="mb-2 mr-1 text-xs"
>
<Col className="mb-2 mr-1 text-xs">
{chat.visibility !== 'system_status' && (
<Row className={'items-center gap-3'}>
{/*{!isMe &&*/}
@@ -202,78 +189,69 @@ export function ChatMessageItem(props: {
)
}
export const SystemChatMessageItem = memo(
function SystemChatMessageItem(props: {
chats: ChatMessage[]
otherUsers: DisplayUser[] | undefined
}) {
const {chats, otherUsers} = props
const chat = last(chats)
const [showUsers, setShowUsers] = useState(false)
if (!chat) return null
const totalUsers = otherUsers?.length || 1
const hideAvatar =
chat.visibility === 'system_status' &&
chat.userId === compassUserId &&
chats.length === 1 ||
totalUsers < 2
return (
<Row className={clsx('flex-row-reverse items-center gap-1')}>
<Row className="grow"/>
<Col className={clsx('grow-y justify-end pb-2')}>
<RelativeTimestamp
time={chat.createdTime}
shortened
className="text-xs"
/>
export const SystemChatMessageItem = memo(function SystemChatMessageItem(props: {
chats: ChatMessage[]
otherUsers: DisplayUser[] | undefined
}) {
const {chats, otherUsers} = props
const chat = last(chats)
const [showUsers, setShowUsers] = useState(false)
if (!chat) return null
const totalUsers = otherUsers?.length || 1
const hideAvatar =
(chat.visibility === 'system_status' && chat.userId === compassUserId && chats.length === 1) ||
totalUsers < 2
return (
<Row className={clsx('flex-row-reverse items-center gap-1')}>
<Row className="grow" />
<Col className={clsx('grow-y justify-end pb-2')}>
<RelativeTimestamp time={chat.createdTime} shortened className="text-xs" />
</Col>
<Col className="max-w-[calc(100vw-6rem)] md:max-w-[80%]">
<Col className={clsx(' bg-canvas-50 px-1 py-2 text-sm italic')}>
{totalUsers > 1 ? (
<span>
{totalUsers} user{totalUsers > 1 ? 's' : ''} joined the chat!
</span>
) : (
<>
<Content content={chat.content} size={'sm'} />
{chat.visibility !== 'system_status' && (
<div className="invisible absolute right-0 top-0 -mt-2 flex translate-x-2 items-center opacity-0 transition-all group-hover:visible group-hover:translate-x-0 group-hover:opacity-100">
<MessageActions
message={{
id: chat.id,
userId: chat.userId,
content: chat.content,
isEdited: chat.isEdited,
reactions: chat.reactions,
}}
/>
</div>
)}
</>
)}
</Col>
<Col className="max-w-[calc(100vw-6rem)] md:max-w-[80%]">
<Col className={clsx(' bg-canvas-50 px-1 py-2 text-sm italic')}>
{totalUsers > 1 ? (
<span>
{totalUsers} user{totalUsers > 1 ? 's' : ''} joined the chat!
</span>
) : (
<>
<Content content={chat.content} size={'sm'}/>
{chat.visibility !== 'system_status' && (
<div
className="invisible absolute right-0 top-0 -mt-2 flex translate-x-2 items-center opacity-0 transition-all group-hover:visible group-hover:translate-x-0 group-hover:opacity-100">
<MessageActions
message={{
id: chat.id,
userId: chat.userId,
content: chat.content,
isEdited: chat.isEdited,
reactions: chat.reactions,
}}
/>
</div>
)}
</>
)}
</Col>
</Col>
{!hideAvatar && (
<MultipleOrSingleAvatars
size={'xs'}
spacing={0.3}
startLeft={0.6}
avatars={otherUsers || []}
onClick={() => setShowUsers(true)}
/>
)}
{showUsers && (
<MultiUserModal
showUsers={showUsers}
setShowUsers={setShowUsers}
otherUsers={otherUsers ?? []}
/>
)}
</Row>
)
}
)
</Col>
{!hideAvatar && (
<MultipleOrSingleAvatars
size={'xs'}
spacing={0.3}
startLeft={0.6}
avatars={otherUsers || []}
onClick={() => setShowUsers(true)}
/>
)}
{showUsers && (
<MultiUserModal
showUsers={showUsers}
setShowUsers={setShowUsers}
otherUsers={otherUsers ?? []}
/>
)}
</Row>
)
})
export const MultiUserModal = (props: {
showUsers: boolean
setShowUsers: (show: boolean) => void
@@ -284,11 +262,8 @@ export const MultiUserModal = (props: {
<Modal open={showUsers} setOpen={setShowUsers}>
<Col className={clsx(MODAL_CLASS)}>
{otherUsers?.map((user) => (
<Row
key={user.id}
className={'w-full items-center justify-start gap-2'}
>
<UserAvatarAndBadge user={user}/>
<Row key={user.id} className={'w-full items-center justify-start gap-2'}>
<UserAvatarAndBadge user={user} />
</Row>
))}
</Col>
@@ -307,10 +282,10 @@ function MessageAvatar(props: {
<Col
className={clsx(
beforeSameUser ? 'pointer-events-none invisible' : '',
'grow-y justify-end pb-2 pr-1'
'grow-y justify-end pb-2 pr-1',
)}
>
<Avatar avatarUrl={userAvatarUrl} username={username} size="xs"/>
<Avatar avatarUrl={userAvatarUrl} username={username} size="xs" />
</Col>
)
}

View File

@@ -1,18 +1,18 @@
import {DotsHorizontalIcon, PencilIcon, TrashIcon} from '@heroicons/react/outline'
import {EmojiHappyIcon} from '@heroicons/react/solid'
import {JSONContent} from '@tiptap/react'
import clsx from 'clsx'
import {PrivateChatMessage} from 'common/chat-message'
import {Dispatch, SetStateAction, useEffect, useRef, useState} from 'react'
import {useUser} from 'web/hooks/use-user'
import {toast} from 'react-hot-toast'
import {api} from "web/lib/api"
import clsx from "clsx"
import DropdownMenu, {DropdownItem} from "web/components/comments/dropdown-menu"
import {JSONContent} from "@tiptap/react"
import {handleReaction} from "web/lib/util/message-reactions"
import {useClickOutside} from "web/hooks/use-click-outside"
import {PrivateChatMessage} from "common/chat-message";
import {updateReactionUI} from "web/lib/supabase/chat-messages";
import {useIsMobile} from "web/hooks/use-is-mobile";
import DropdownMenu, {DropdownItem} from 'web/components/comments/dropdown-menu'
import {useClickOutside} from 'web/hooks/use-click-outside'
import {useIsMobile} from 'web/hooks/use-is-mobile'
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale'
import {updateReactionUI} from 'web/lib/supabase/chat-messages'
import {handleReaction} from 'web/lib/util/message-reactions'
const REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '👎']
@@ -52,7 +52,8 @@ export function MessageActions(props: {
}, [openEmojiPickerKey])
const handleDelete = async () => {
if (!confirm(t('messages.delete_confirm', 'Are you sure you want to delete this message?'))) return
if (!confirm(t('messages.delete_confirm', 'Are you sure you want to delete this message?')))
return
const messageId = message.id
try {
await api('delete-message', {messageId})
@@ -74,8 +75,7 @@ export function MessageActions(props: {
ref={emojiPickerRef}
className={clsx(
'absolute mb-2 rounded-lg bg-canvas-100 p-2 shadow-lg pr-10 z-10 max-w-[250px]',
isMobile ? 'left-1/2 transform -translate-x-1/2'
: isOwner ? 'right-20' : 'left-20'
isMobile ? 'left-1/2 transform -translate-x-1/2' : isOwner ? 'right-20' : 'left-20',
)}
>
<div className="grid grid-cols-6 gap-8">
@@ -97,27 +97,29 @@ export function MessageActions(props: {
)}
{!hideTrigger && (
<DropdownMenu
items={[
isOwner && {
name: t('messages.action.edit', 'Edit'),
icon: <PencilIcon className="h-4 w-4"/>,
onClick: onRequestEdit,
},
isOwner && {
name: t('messages.action.delete', 'Delete'),
icon: <TrashIcon className="h-4 w-4"/>,
onClick: handleDelete,
},
{
name: t('messages.action.add_reaction', 'Add Reaction'),
icon: <EmojiHappyIcon className="h-4 w-4"/>,
onClick: () => {
setShowEmojiPicker(!showEmojiPicker)
items={
[
isOwner && {
name: t('messages.action.edit', 'Edit'),
icon: <PencilIcon className="h-4 w-4" />,
onClick: onRequestEdit,
},
},
].filter(Boolean) as DropdownItem[]}
isOwner && {
name: t('messages.action.delete', 'Delete'),
icon: <TrashIcon className="h-4 w-4" />,
onClick: handleDelete,
},
{
name: t('messages.action.add_reaction', 'Add Reaction'),
icon: <EmojiHappyIcon className="h-4 w-4" />,
onClick: () => {
setShowEmojiPicker(!showEmojiPicker)
},
},
].filter(Boolean) as DropdownItem[]
}
closeOnClick={true}
icon={<DotsHorizontalIcon className="h-5 w-5 text-gray-500"/>}
icon={<DotsHorizontalIcon className="h-5 w-5 text-gray-500" />}
menuWidth="w-40"
className="text-ink-500 hover:text-ink-700 rounded-full p-1 hover:bg-canvas-50"
/>

View File

@@ -1,9 +1,9 @@
import {useUser} from 'web/hooks/use-user'
import clsx from 'clsx'
import {PrivateChatMessage} from 'common/chat-message'
import {Dispatch, SetStateAction, useMemo} from 'react'
import clsx from "clsx";
import {handleReaction} from "web/lib/util/message-reactions";
import {updateReactionUI} from "web/lib/supabase/chat-messages";
import {PrivateChatMessage} from "common/chat-message";
import {useUser} from 'web/hooks/use-user'
import {updateReactionUI} from 'web/lib/supabase/chat-messages'
import {handleReaction} from 'web/lib/util/message-reactions'
interface MessageReactionsProps {
message: {
@@ -11,20 +11,16 @@ interface MessageReactionsProps {
reactions?: Record<string, string[]>
}
className?: string
setMessages?: Dispatch<SetStateAction<PrivateChatMessage[] | undefined>>,
setMessages?: Dispatch<SetStateAction<PrivateChatMessage[] | undefined>>
}
export function MessageReactions({
message,
className,
setMessages,
}: MessageReactionsProps) {
export function MessageReactions({message, className, setMessages}: MessageReactionsProps) {
const user = useUser()
const reactions = message.reactions || {}
// console.log(reactions)
const reactionGroups = useMemo(() => {
const groups: { emoji: string; users: string[] }[] = []
const groups: {emoji: string; users: string[]}[] = []
// Group reactions by emoji
for (const [emoji, userIds] of Object.entries(reactions)) {
@@ -56,7 +52,7 @@ export function MessageReactions({
'flex items-center gap-1 rounded-full border px-2 py-0.5 text-sm',
hasReacted
? 'bg-primary-50 border-primary-200 text-primary-600'
: 'bg-canvas-50 border-ink-200 text-ink-600 hover:bg-ink-50'
: 'bg-canvas-50 border-ink-200 text-ink-600 hover:bg-ink-50',
)}
>
<span className="text-base">{emoji}</span>

View File

@@ -1,45 +1,44 @@
import { Comment } from 'common/comment'
import { run } from 'common/supabase/utils'
import { db } from 'web/lib/supabase/db'
import { useEffect, useState } from 'react'
import { Modal } from 'web/components/layout/modal'
import { Col } from 'web/components/layout/col'
import { Title } from 'web/components/widgets/title'
import { Content } from 'web/components/widgets/editor'
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
import { formatTimeShort } from 'web/lib/util/time'
import { shortenedFromNow } from 'web/lib/util/shortenedFromNow'
import { useIsClient } from 'web/hooks/use-is-client'
import { DateTimeTooltip } from '../widgets/datetime-tooltip'
import {Comment} from 'common/comment'
import {run} from 'common/supabase/utils'
import {useEffect, useState} from 'react'
import {Col} from 'web/components/layout/col'
import {Modal} from 'web/components/layout/modal'
import {Content} from 'web/components/widgets/editor'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Title} from 'web/components/widgets/title'
import {useIsClient} from 'web/hooks/use-is-client'
import {db} from 'web/lib/supabase/db'
import {shortenedFromNow} from 'web/lib/util/shortenedFromNow'
import {formatTimeShort} from 'web/lib/util/time'
import {DateTimeTooltip} from '../widgets/datetime-tooltip'
type EditHistory = Comment & {
editCreatedTime: number
}
export const CommentEditHistoryButton = (props: { comment: Comment }) => {
const { comment } = props
export const CommentEditHistoryButton = (props: {comment: Comment}) => {
const {comment} = props
const [showEditHistory, setShowEditHistory] = useState(false)
const [edits, setEdits] = useState<EditHistory[] | undefined>(undefined)
const isClient = useIsClient()
const loadEdits = async () => {
const { data } = await run(
const {data} = await run(
db
.from('contract_comment_edits')
.select('*')
.eq('comment_id', comment.id)
.order('created_time', { ascending: false })
.order('created_time', {ascending: false}),
)
// created_time is the time the row is created, but the row's content is the content before the edit, aka created_time is when the content is deleted and replaced
const comments = data.map((edit, i) => {
const comment = edit.data as Comment
const editCreatedTime =
i === data.length - 1
? comment.createdTime
: new Date(data[i + 1].created_time).valueOf()
i === data.length - 1 ? comment.createdTime : new Date(data[i + 1].created_time).valueOf()
return { ...comment, editCreatedTime }
return {...comment, editCreatedTime}
})
setEdits(comments)
@@ -73,18 +72,11 @@ export const CommentEditHistoryButton = (props: { comment: Comment }) => {
) : (
<Col className="gap-4">
{edits.map((edit) => (
<Col
key={edit.id}
className={'bg-ink-100 gap-2 rounded-xl rounded-tl-none p-2'}
>
<Col key={edit.id} className={'bg-ink-100 gap-2 rounded-xl rounded-tl-none p-2'}>
<div className="text-ink-500 text-sm">
{formatTimeShort(edit.editCreatedTime)}
</div>
<Content
size="sm"
className="mt-1 grow"
content={edit.content || edit.text}
/>
<Content size="sm" className="mt-1 grow" content={edit.content || edit.text} />
</Col>
))}
</Col>

View File

@@ -1,23 +1,23 @@
import { PaperAirplaneIcon } from '@heroicons/react/solid'
import { Editor } from '@tiptap/react'
import {PaperAirplaneIcon} from '@heroicons/react/solid'
import {Editor} from '@tiptap/react'
import clsx from 'clsx'
import { User } from 'common/user'
import { useEffect, useState } from 'react'
import { useUser } from 'web/hooks/use-user'
import { MAX_COMMENT_LENGTH } from 'common/comment'
import { Avatar } from '../widgets/avatar'
import { TextEditor, useTextEditor } from '../widgets/editor'
import { ReplyToUserInfo } from 'common/comment'
import { Row } from '../layout/row'
import { LoadingIndicator } from '../widgets/loading-indicator'
import { safeLocalStorage } from 'web/lib/util/local'
import {MAX_COMMENT_LENGTH, ReplyToUserInfo} from 'common/comment'
import {User} from 'common/user'
import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
import { BiRepost } from 'react-icons/bi'
import { Tooltip } from 'web/components/widgets/tooltip'
import { track } from 'web/lib/service/analytics'
import { firebaseLogin } from 'web/lib/firebase/users'
import { useEvent } from 'web/hooks/use-event'
import {useT} from "web/lib/locale";
import {BiRepost} from 'react-icons/bi'
import {Tooltip} from 'web/components/widgets/tooltip'
import {useEvent} from 'web/hooks/use-event'
import {useUser} from 'web/hooks/use-user'
import {firebaseLogin} from 'web/lib/firebase/users'
import {useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
import {safeLocalStorage} from 'web/lib/util/local'
import {Row} from '../layout/row'
import {Avatar} from '../widgets/avatar'
import {TextEditor, useTextEditor} from '../widgets/editor'
import {LoadingIndicator} from '../widgets/loading-indicator'
export function CommentInput(props: {
replyToUserInfo?: ReplyToUserInfo
@@ -70,7 +70,7 @@ export function CommentInput(props: {
if (editor.state.selection.empty) {
editor.commands.insertContent(' ')
const endPos = editor.state.selection.from
editor.commands.deleteRange({ from: endPos - 1, to: endPos })
editor.commands.deleteRange({from: endPos - 1, to: endPos})
}
try {
@@ -108,7 +108,7 @@ export function CommentInput(props: {
)
}
const emojiMenuActive = (view: { state: any }) => {
const emojiMenuActive = (view: {state: any}) => {
const regex = /^emoji\$.*$/ // emoji$ can have random numbers following it....❤️ tiptap
let active = false
@@ -125,7 +125,7 @@ const emojiMenuActive = (view: { state: any }) => {
export type CommentType = 'comment' | 'repost'
export function CommentInputTextArea(props: {
user: User | undefined | null
replyTo?: { id: string; username: string }
replyTo?: {id: string; username: string}
editor: Editor | null
submit?: (type: CommentType) => void
cancelEditing?: () => void
@@ -186,10 +186,10 @@ export function CommentInputTextArea(props: {
.chain()
.setContent({
type: 'mention',
attrs: { label: replyTo.username, id: replyTo.id },
attrs: {label: replyTo.username, id: replyTo.id},
})
.insertContent(' ')
.focus(undefined, { scrollIntoView: false })
.focus(undefined, {scrollIntoView: false})
.run()
}
}, [replyTo, editor])
@@ -198,10 +198,7 @@ export function CommentInputTextArea(props: {
<TextEditor editor={editor} simple hideEmbed>
<Row className={''}>
{user && !isSubmitting && submit && commentTypes.includes('repost') && (
<Tooltip
text={'Post question & comment to your followers'}
className={'mt-2'}
>
<Tooltip text={'Post question & comment to your followers'} className={'mt-2'}>
<button
disabled={!editor || editor.isEmpty}
className="text-ink-500 hover:text-ink-700 active:bg-ink-300 disabled:text-ink-300 px-2 transition-colors"
@@ -219,7 +216,7 @@ export function CommentInputTextArea(props: {
className="text-primary-600 hover:underline"
onClick={cancelEditing}
>
{t("comment.cancel", "Cancel")}
{t('comment.cancel', 'Cancel')}
</button>
</Row>
)}
@@ -234,11 +231,7 @@ export function CommentInputTextArea(props: {
)}
{submit && isSubmitting && (
<LoadingIndicator
size={'md'}
className={'px-4'}
spinnerClassName="border-ink-500"
/>
<LoadingIndicator size={'md'} className={'px-4'} spinnerClassName="border-ink-500" />
)}
</Row>
</TextEditor>

View File

@@ -1,10 +1,10 @@
import { DotsHorizontalIcon } from '@heroicons/react/solid'
import { ReactNode, useState } from 'react'
import { Popover } from '@headlessui/react'
import {Popover} from '@headlessui/react'
import {DotsHorizontalIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import { usePopper } from 'react-popper'
import { Col } from 'web/components/layout/col'
import { AnimationOrNothing } from 'web/components/comments/dropdown-menu'
import {ReactNode, useState} from 'react'
import {usePopper} from 'react-popper'
import {AnimationOrNothing} from 'web/components/comments/dropdown-menu'
import {Col} from 'web/components/layout/col'
export default function DropdownMenu(props: {
items: ReactNode[]
@@ -27,26 +27,20 @@ export default function DropdownMenu(props: {
withinOverflowContainer,
buttonContent,
} = props
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>()
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>()
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>()
const { styles, attributes } = usePopper(referenceElement, popperElement, {
const {styles, attributes} = usePopper(referenceElement, popperElement, {
strategy: withinOverflowContainer ? 'fixed' : 'absolute',
})
const icon = props.icon ?? (
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
)
const icon = props.icon ?? <DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
return (
<Popover className={clsx('relative inline-block text-left', className)}>
{({ open }) => (
{({open}) => (
<>
<Popover.Button
ref={setReferenceElement}
className={clsx(
'text-ink-500 hover:text-ink-800 flex items-center',
buttonClass
)}
className={clsx('text-ink-500 hover:text-ink-800 flex items-center', buttonClass)}
onClick={(e: any) => {
e.stopPropagation()
}}
@@ -65,7 +59,7 @@ export default function DropdownMenu(props: {
'bg-canvas-0 ring-ink-1000 z-30 mt-2 rounded-md shadow-lg ring-1 ring-opacity-5 focus:outline-none',
menuWidth ?? 'w-34',
menuItemsClass,
'p-1'
'p-1',
)}
>
<Col className={'gap-1'}>{items.map((item) => item)}</Col>

View File

@@ -1,8 +1,8 @@
import {Popover, Transition} from '@headlessui/react'
import {DotsHorizontalIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import {Fragment, ReactNode, useState} from 'react'
import {usePopper} from 'react-popper'
import {Popover, Transition} from '@headlessui/react'
import clsx from 'clsx'
export type DropdownItem = {
name: string
@@ -35,25 +35,19 @@ export default function DropdownMenu(props: {
withinOverflowContainer,
buttonContent,
} = props
const [referenceElement, setReferenceElement] =
useState<HTMLButtonElement | null>()
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>()
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>()
const { styles, attributes } = usePopper(referenceElement, popperElement, {
const {styles, attributes} = usePopper(referenceElement, popperElement, {
strategy: withinOverflowContainer ? 'fixed' : 'absolute',
})
const icon = props.icon ?? (
<DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
)
const icon = props.icon ?? <DotsHorizontalIcon className="h-5 w-5" aria-hidden="true" />
return (
<Popover className={clsx('relative inline-block text-left', className)}>
{({ open, close }) => (
{({open, close}) => (
<>
<Popover.Button
ref={setReferenceElement}
className={clsx(
'text-ink-500 hover-bold flex items-center',
buttonClass
)}
className={clsx('text-ink-500 hover-bold flex items-center', buttonClass)}
onClick={(e: any) => {
e.stopPropagation()
}}
@@ -72,7 +66,7 @@ export default function DropdownMenu(props: {
'bg-canvas-0 ring-ink-1000 z-30 mt-2 rounded-md shadow-lg ring-1 ring-opacity-5 focus:outline-none',
menuWidth ?? 'w-34',
menuItemsClass,
'py-1'
'py-1',
)}
>
{items.map((item) => (
@@ -91,7 +85,7 @@ export default function DropdownMenu(props: {
? 'bg-primary-100'
: 'hover:bg-canvas-100 hover:text-ink-900',
'text-ink-700',
'flex w-full gap-2 px-4 py-2 text-left text-sm rounded-md'
'flex w-full gap-2 px-4 py-2 text-left text-sm rounded-md',
)}
>
{item.icon && <div className="w-5">{item.icon}</div>}

View File

@@ -6,7 +6,7 @@ export function ReplyToggle(props: {
numComments: number
onSeeReplyClick?: () => void
}) {
const { seeReplies, numComments, onSeeReplyClick } = props
const {seeReplies, numComments, onSeeReplyClick} = props
if (numComments === 0) return null
@@ -15,16 +15,9 @@ export function ReplyToggle(props: {
className="text-ink-500 hover:text-primary-500 flex items-center gap-2 text-sm transition-colors"
onClick={onSeeReplyClick}
>
<div
className={clsx(
numComments === 0 ? 'hidden' : 'flex select-none items-center gap-1'
)}
>
<div className={clsx(numComments === 0 ? 'hidden' : 'flex select-none items-center gap-1')}>
<TriangleDownFillIcon
className={clsx(
'h-2 w-2 transition-transform',
seeReplies ? '' : 'rotate-[-60deg]'
)}
className={clsx('h-2 w-2 transition-transform', seeReplies ? '' : 'rotate-[-60deg]')}
/>
{numComments} {numComments === 1 ? 'reply' : 'replies'}
</div>

View File

@@ -1,15 +1,15 @@
import {JSONContent} from '@tiptap/core'
import {formLink} from 'common/constants'
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
import Link from 'next/link'
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
import {Title} from 'web/components/widgets/title'
import {useUser} from 'web/hooks/use-user'
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
import {JSONContent} from "@tiptap/core";
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
import {Button} from "web/components/buttons/button";
import {api} from "web/lib/api";
import {Title} from "web/components/widgets/title";
import toast from "react-hot-toast";
import Link from "next/link";
import {formLink} from "common/constants";
import {api} from 'web/lib/api'
import {useT} from 'web/lib/locale' // added
export function ContactComponent() {
@@ -28,17 +28,18 @@ export function ContactComponent() {
<Col className="mx-2">
<Title className="!mb-2 text-3xl">{t('contact.title', 'Contact')}</Title>
<p className={'custom-link mb-4'}>
{t('contact.intro_prefix', "You can also contact us through this ")}
{t('contact.intro_prefix', 'You can also contact us through this ')}
<Link href={formLink}>{t('contact.form_link', 'feedback form')}</Link>
{t('contact.intro_middle', ' or any of our ')}
<Link href={'/social'}>{t('contact.socials', 'socials')}</Link>
{t('contact.intro_suffix', ". Feel free to give your contact information if you'd like us to get back to you.")}
{t(
'contact.intro_suffix',
". Feel free to give your contact information if you'd like us to get back to you.",
)}
</p>
<Col>
<div className={'mb-2'}>
<TextEditor
editor={editor}
/>
<TextEditor editor={editor} />
</div>
{showButton && (
<Row className="right-1 justify-between gap-2">
@@ -49,9 +50,11 @@ export function ContactComponent() {
const data = {
content: editor.getJSON() as JSONContent,
userId: user?.id,
};
}
const result = await api('contact', data).catch(() => {
toast.error(t('contact.toast.failed', 'Failed to contact — try again or contact us...'))
toast.error(
t('contact.toast.failed', 'Failed to contact — try again or contact us...'),
)
})
if (!result) return
editor.commands.clearContent()

View File

@@ -1,13 +1,14 @@
import { Editor } from '@tiptap/react'
import { DOMAIN } from 'common/envs/constants'
import { useState } from 'react'
import { Button } from '../buttons/button'
import { Col } from '../layout/col'
import { Modal } from '../layout/modal'
import { Row } from '../layout/row'
import { Spacer } from '../layout/spacer'
import {Editor} from '@tiptap/react'
import {DOMAIN} from 'common/envs/constants'
import {useState} from 'react'
import toast from 'react-hot-toast'
import {Button} from '../buttons/button'
import {Col} from '../layout/col'
import {Modal} from '../layout/modal'
import {Row} from '../layout/row'
import {Spacer} from '../layout/spacer'
type EmbedPattern = {
// Regex should have a single capture group.
regex: RegExp
@@ -17,19 +18,16 @@ type EmbedPattern = {
const embedPatterns: EmbedPattern[] = [
{
regex: /^https?:\/\/manifold\.markets\/([^\/]+\/[^\/]+)/,
rewrite: (slug) =>
`<iframe src="https://manifold.markets/embed/${slug}"></iframe>`,
rewrite: (slug) => `<iframe src="https://manifold.markets/embed/${slug}"></iframe>`,
},
{
regex: /^https?:\/\/www\.youtube\.com\/watch\?v=([^&]+)/,
rewrite: (id) =>
`<iframe src="https://www.youtube.com/embed/${id}"></iframe>`,
rewrite: (id) => `<iframe src="https://www.youtube.com/embed/${id}"></iframe>`,
},
// Also rewrite youtube links like `https://youtu.be/IOlKZDgyQRQ`
{
regex: /^https?:\/\/youtu\.be\/([^&]+)/,
rewrite: (id) =>
`<iframe src="https://www.youtube.com/embed/${id}"></iframe>`,
rewrite: (id) => `<iframe src="https://www.youtube.com/embed/${id}"></iframe>`,
},
// Twitch is a bit annoying, since it requires the `&parent=DOMAIN` to match
{
@@ -47,8 +45,7 @@ const embedPatterns: EmbedPattern[] = [
{
// Tiktok: https://www.tiktok.com/@tiktok/video/6959980000000000001
regex: /^https?:\/\/www\.tiktok\.com\/@[^\/]+\/video\/(\d+)/,
rewrite: (id) =>
`<iframe src="https://www.tiktok.com/embed/v2/${id}"></iframe>`,
rewrite: (id) => `<iframe src="https://www.tiktok.com/embed/v2/${id}"></iframe>`,
},
]
@@ -68,17 +65,14 @@ export function EmbedModal(props: {
open: boolean
setOpen: (open: boolean) => void
}) {
const { editor, open, setOpen } = props
const {editor, open, setOpen} = props
const [input, setInput] = useState('')
const embed = embedCode(input)
return (
<Modal open={open} setOpen={setOpen}>
<Col className="bg-canvas-0 gap-2 rounded p-6">
<label
htmlFor="embed"
className="text-ink-700 block text-sm font-medium"
>
<label htmlFor="embed" className="text-ink-700 block text-sm font-medium">
Embed a Youtube video
</label>
<input
@@ -91,22 +85,20 @@ export function EmbedModal(props: {
onChange={(e) => setInput(e.target.value)}
/>
{embed && <div dangerouslySetInnerHTML={{ __html: embed }}></div>}
{embed && <div dangerouslySetInnerHTML={{__html: embed}}></div>}
<Spacer h={2} />
<Row className="gap-2">
<Button
color={embed ? 'indigo' : 'gray'}
style={{ cursor: embed ? 'pointer' : 'not-allowed' }}
style={{cursor: embed ? 'pointer' : 'not-allowed'}}
onClick={() => {
if (editor && embed) {
editor.chain().insertContent(embed).run()
setInput('')
setOpen(false)
} else {
toast.error(
`We only allow embeds from a few sites. Please open a pull request.`
)
toast.error(`We only allow embeds from a few sites. Please open a pull request.`)
}
}}
>

View File

@@ -1,11 +1,12 @@
import { Extension } from '@tiptap/core'
import {Extension} from '@tiptap/core'
import Suggestion from '@tiptap/suggestion'
import { emojiSuggestion } from './emoji-suggestion'
import {emojiSuggestion} from './emoji-suggestion'
export const EmojiExtension = Extension.create({
name: 'emoji',
addProseMirrorPlugins() {
return [Suggestion({ editor: this.editor, ...emojiSuggestion })]
return [Suggestion({editor: this.editor, ...emojiSuggestion})]
},
})

View File

@@ -1,67 +1,63 @@
import type {SuggestionProps} from '@tiptap/suggestion'
import clsx from 'clsx'
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react'
import type { SuggestionProps } from '@tiptap/suggestion'
import { EmojiData } from './emoji-suggestion'
import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
import {EmojiData} from './emoji-suggestion'
// copied from https://tiptap.dev/api/nodes/mention#usage and https://tiptap.dev/api/nodes/emoji
export const EmojiList = forwardRef(
(props: SuggestionProps<EmojiData>, ref) => {
const { items, command } = props
export const EmojiList = forwardRef((props: SuggestionProps<EmojiData>, ref) => {
const {items, command} = props
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => setSelectedIndex(0), [items])
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => setSelectedIndex(0), [items])
const selectItem = (index: number) => {
const item = items[index]
if (item) command(item)
}
const onUp = () =>
setSelectedIndex((i) => (i + items.length - 1) % items.length)
const onDown = () => setSelectedIndex((i) => (i + 1) % items.length)
const onEnter = () => selectItem(selectedIndex)
useEffect(() => setSelectedIndex(0), [items])
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: any) => {
if (event.key === 'ArrowUp') {
onUp()
return true
}
if (event.key === 'ArrowDown') {
onDown()
return true
}
if (event.key === 'Enter') {
onEnter()
return true
}
return false
},
}))
return (
<div className="w-42 bg-canvas-0 ring-ink-1000 absolute z-10 overflow-x-hidden rounded-md py-1 shadow-lg ring-1 ring-opacity-5 focus:outline-none">
{!items.length ? (
<span className="text-ink-900 m-1 whitespace-nowrap">No results</span>
) : (
items.map((item, i) => (
<button
className={clsx(
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
selectedIndex === i
? 'text-ink-0 bg-primary-500'
: 'text-ink-900'
)}
key={item.codePoint}
onClick={() => selectItem(i)}
>
{item.character} {item.shortcodes[0]}
</button>
))
)}
</div>
)
const selectItem = (index: number) => {
const item = items[index]
if (item) command(item)
}
)
const onUp = () => setSelectedIndex((i) => (i + items.length - 1) % items.length)
const onDown = () => setSelectedIndex((i) => (i + 1) % items.length)
const onEnter = () => selectItem(selectedIndex)
useEffect(() => setSelectedIndex(0), [items])
useImperativeHandle(ref, () => ({
onKeyDown: ({event}: any) => {
if (event.key === 'ArrowUp') {
onUp()
return true
}
if (event.key === 'ArrowDown') {
onDown()
return true
}
if (event.key === 'Enter') {
onEnter()
return true
}
return false
},
}))
return (
<div className="w-42 bg-canvas-0 ring-ink-1000 absolute z-10 overflow-x-hidden rounded-md py-1 shadow-lg ring-1 ring-opacity-5 focus:outline-none">
{!items.length ? (
<span className="text-ink-900 m-1 whitespace-nowrap">No results</span>
) : (
items.map((item, i) => (
<button
className={clsx(
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
selectedIndex === i ? 'text-ink-0 bg-primary-500' : 'text-ink-900',
)}
key={item.codePoint}
onClick={() => selectItem(i)}
>
{item.character} {item.shortcodes[0]}
</button>
))
)}
</div>
)
})

View File

@@ -1,10 +1,11 @@
import { SuggestionOptions } from '@tiptap/suggestion'
import { beginsWith, searchInAny } from 'common/util/parse'
import { EmojiList } from './emoji-list'
import { invertBy, orderBy } from 'lodash'
import {PluginKey} from '@tiptap/pm/state'
import {SuggestionOptions} from '@tiptap/suggestion'
import {beginsWith, searchInAny} from 'common/util/parse'
import {invertBy, orderBy} from 'lodash'
import {makeMentionRender} from '../user-mention/mention-suggestion'
import {EmojiList} from './emoji-list'
import shortcodes from './github-shortcodes.json' // from https://api.github.com/emojis
import { PluginKey } from '@tiptap/pm/state'
import { makeMentionRender } from '../user-mention/mention-suggestion'
type Suggestion = Omit<SuggestionOptions, 'editor'>
@@ -23,40 +24,33 @@ const rank = (c: string) => {
return r < 0 ? 101 : r
}
const emojiArr = Object.entries(invertBy(shortcodes)).map(
([codePoint, shortcodes]) => ({
codePoint,
shortcodes,
character: String.fromCodePoint(
...codePoint
.split(' ')
.flatMap((s) => [Number(`0x${s}`), 0x200d]) // interleave zero-width joiner
.slice(0, -1) // remove last joiner
),
})
)
const emojiArr = Object.entries(invertBy(shortcodes)).map(([codePoint, shortcodes]) => ({
codePoint,
shortcodes,
character: String.fromCodePoint(
...codePoint
.split(' ')
.flatMap((s) => [Number(`0x${s}`), 0x200d]) // interleave zero-width joiner
.slice(0, -1), // remove last joiner
),
}))
// copied from mention-suggestion.ts, which is copied from https://tiptap.dev/api/nodes/mention#usage
export const emojiSuggestion: Suggestion = {
char: ':',
pluginKey: new PluginKey('emoji'),
allowedPrefixes: [' '],
items: async ({ query }) => {
const items = emojiArr.filter((item) =>
searchInAny(query, ...item.shortcodes)
)
items: async ({query}) => {
const items = emojiArr.filter((item) => searchInAny(query, ...item.shortcodes))
return orderBy(
items,
[
(item) => item.shortcodes.some((s) => beginsWith(s, query)),
(item) => rank(item.character),
],
['desc', 'asc']
[(item) => item.shortcodes.some((s) => beginsWith(s, query)), (item) => rank(item.character)],
['desc', 'asc'],
).slice(0, 5)
},
render: makeMentionRender(EmojiList),
command: ({ editor, range, props }) => {
command: ({editor, range, props}) => {
editor
.chain()
.focus()

View File

@@ -3,8 +3,8 @@ import {Editor} from '@tiptap/core'
import {BubbleMenu} from '@tiptap/react'
import clsx from 'clsx'
import {getUrl} from 'common/util/parse'
import {useState} from 'react'
import {Bold, Italic, Type} from 'lucide-react'
import {useState} from 'react'
// see https://tiptap.dev/guide/menus
@@ -13,7 +13,7 @@ export function FloatingFormatMenu(props: {
/** show more formatting options */
advanced?: boolean
}) {
const { editor, advanced } = props
const {editor, advanced} = props
const [url, setUrl] = useState<string | null>(null)
@@ -22,34 +22,27 @@ export function FloatingFormatMenu(props: {
const setLink = () => {
const href = url && getUrl(url)
if (href) {
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
editor.chain().focus().extendMarkRange('link').setLink({href}).run()
}
}
const unsetLink = () => editor.chain().focus().unsetLink().run()
return (
<BubbleMenu
editor={editor}
className="text-ink-0 bg-ink-700 flex gap-2 rounded-sm p-1"
>
<BubbleMenu editor={editor} className="text-ink-0 bg-ink-700 flex gap-2 rounded-sm p-1">
{url === null ? (
<>
{advanced && (
<>
<IconButton
icon={Type}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
isActive={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({level: 1}).run()}
isActive={editor.isActive('heading', {level: 1})}
/>
<IconButton
icon={Type}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
isActive={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({level: 2}).run()}
isActive={editor.isActive('heading', {level: 2})}
className="!h-4"
/>
<Divider />
@@ -98,12 +91,10 @@ const IconButton = (props: {
isActive?: boolean
className?: string
}) => {
const { icon: Icon, onClick, isActive, className } = props
const {icon: Icon, onClick, isActive, className} = props
return (
<button onClick={onClick} type="button">
<Icon
className={clsx('h-5', isActive && 'text-primary-200', className)}
/>
<Icon className={clsx('h-5', isActive && 'text-primary-200', className)} />
</button>
)
}

View File

@@ -1,11 +1,9 @@
import { Image } from '@tiptap/extension-image'
import {Image} from '@tiptap/extension-image'
import clsx from 'clsx'
import { useState } from 'react'
import {useState} from 'react'
export const BasicImage = Image.extend({
renderReact: (attrs: any) => (
<img loading="lazy" {...attrs} alt={attrs.alt ?? ''} />
),
renderReact: (attrs: any) => <img loading="lazy" {...attrs} alt={attrs.alt ?? ''} />,
})
export const DisplayImage = Image.extend({
@@ -16,14 +14,9 @@ export const MediumDisplayImage = Image.extend({
renderReact: (attrs: any) => <ExpandingImage size={'md'} {...attrs} />,
})
function ExpandingImage(props: {
src: string
alt?: string
title?: string
size?: 'md'
}) {
function ExpandingImage(props: {src: string; alt?: string; title?: string; size?: 'md'}) {
const [expanded, setExpanded] = useState(false)
const { size, alt, ...rest } = props
const {size, alt, ...rest} = props
return (
<>
@@ -34,7 +27,7 @@ function ExpandingImage(props: {
onClick={() => setExpanded(true)}
className={clsx(
'cursor-pointer object-contain',
size === 'md' ? 'max-h-[400px]' : 'h-[128px]'
size === 'md' ? 'max-h-[400px]' : 'h-[128px]',
)}
height={size === 'md' ? 400 : 128}
/>
@@ -43,11 +36,7 @@ function ExpandingImage(props: {
className="bg-opacity fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60"
onClick={() => setExpanded(false)}
>
<img
alt={alt ?? ''}
{...rest}
className="max-h-full cursor-pointer object-contain"
/>
<img alt={alt ?? ''} {...rest} className="max-h-full cursor-pointer object-contain" />
</div>
)}
</>

View File

@@ -1,7 +1,8 @@
import { Extensions } from '@tiptap/core'
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
import {Extensions} from '@tiptap/core'
import {NodeViewWrapper, ReactNodeViewRenderer} from '@tiptap/react'
import clsx from 'clsx'
import { getField } from './utils'
import {getField} from './utils'
export const nodeViewMiddleware = (extensions: Extensions) => {
return extensions.map((e) => {
@@ -12,18 +13,14 @@ export const nodeViewMiddleware = (extensions: Extensions) => {
addNodeView: () =>
ReactNodeViewRenderer((props: any) => (
<NodeViewWrapper
className={clsx(
e.name,
'contents',
props.selected && '[&>*]:outline-dotted'
)}
className={clsx(e.name, 'contents', props.selected && '[&>*]:outline-dotted')}
>
{renderReact(
{
...props.node.attrs,
deleteNode: props.deleteNode,
},
props.children
props.children,
)}
</NodeViewWrapper>
)),

View File

@@ -2,13 +2,14 @@ import {EmojiHappyIcon} from '@heroicons/react/outline'
import {CodeIcon, PhotographIcon} from '@heroicons/react/solid'
import {Editor} from '@tiptap/react'
import {MouseEventHandler, useState} from 'react'
import {Row} from 'web/components/layout/row'
import {useT} from 'web/lib/locale'
import {FileUploadButton} from '../buttons/file-upload-button'
import {LoadingIndicator} from '../widgets/loading-indicator'
import {Tooltip} from '../widgets/tooltip'
import {EmbedModal} from './embed-modal'
import type {UploadMutation} from './upload-extension'
import {Row} from 'web/components/layout/row'
import {Tooltip} from '../widgets/tooltip'
import {useT} from 'web/lib/locale'
/* Toolbar, with buttons for images and embeds */
export function StickyFormatMenu(props: {
@@ -24,7 +25,7 @@ export function StickyFormatMenu(props: {
return (
<Row className="text-ink-600 h-8 items-center">
<UploadButton key={'upload-button'} upload={upload}/>
<UploadButton key={'upload-button'} upload={upload} />
{!hideEmbed && (
<ToolbarButton
key={'embed-button'}
@@ -35,7 +36,7 @@ export function StickyFormatMenu(props: {
setIframeOpen(true)
}}
>
<CodeIcon className="h-5 w-5" aria-hidden="true"/>
<CodeIcon className="h-5 w-5" aria-hidden="true" />
</ToolbarButton>
)}
<ToolbarButton
@@ -43,17 +44,17 @@ export function StickyFormatMenu(props: {
label={t('sticky_format_menu.add_emoji', 'Add emoji')}
onClick={() => insertEmoji(editor)}
>
<EmojiHappyIcon className="h-5 w-5"/>
<EmojiHappyIcon className="h-5 w-5" />
</ToolbarButton>
<EmbedModal editor={editor} open={iframeOpen} setOpen={setIframeOpen}/>
<div className="grow"/>
<EmbedModal editor={editor} open={iframeOpen} setOpen={setIframeOpen} />
<div className="grow" />
{children}
</Row>
)
}
function UploadButton(props: { upload: UploadMutation }) {
function UploadButton(props: {upload: UploadMutation}) {
const {upload} = props
const t = useT()
@@ -68,7 +69,7 @@ function UploadButton(props: { upload: UploadMutation }) {
className="hover:text-ink-700 disabled:text-ink-300 active:text-ink-800 text-ink-400 relative flex rounded px-3 py-1 pl-4 transition-colors"
>
<Row className={'items-center justify-start gap-2'}>
<PhotographIcon className="h-5 w-5" aria-hidden="true"/>
<PhotographIcon className="h-5 w-5" aria-hidden="true" />
{upload?.isLoading && (
<LoadingIndicator
className="absolute bottom-0 left-0 right-0 top-0"

View File

@@ -1,12 +1,12 @@
import { Editor, Extension } from '@tiptap/core'
import {Editor, Extension} from '@tiptap/core'
import toast from 'react-hot-toast'
import { useMutation } from 'web/hooks/use-mutation'
import { uploadImage } from 'web/lib/firebase/storage'
import {useMutation} from 'web/hooks/use-mutation'
import {uploadImage} from 'web/lib/firebase/storage'
export const Upload = Extension.create({
name: 'upload',
addStorage: () => ({ mutation: {} }),
addStorage: () => ({mutation: {}}),
})
export const useUploadMutation = (editor: Editor | null) =>
@@ -19,7 +19,7 @@ export const useUploadMutation = (editor: Editor | null) =>
if (!editor || !urls.length) return
let trans = editor.chain().focus()
urls.forEach((src) => {
trans = trans.setImage({ src })
trans = trans.setImage({src})
trans = trans.createParagraphNear()
})
trans.run()
@@ -27,7 +27,7 @@ export const useUploadMutation = (editor: Editor | null) =>
onError(error: any) {
toast.error(error.message ?? error)
},
}
},
)
export type UploadMutation = ReturnType<typeof useUploadMutation>

View File

@@ -1,7 +1,8 @@
import Mention from '@tiptap/extension-mention'
import { mergeAttributes } from '@tiptap/react'
import { mentionSuggestion } from './mention-suggestion'
import { UserMention } from './user-mention'
import {mergeAttributes} from '@tiptap/react'
import {mentionSuggestion} from './mention-suggestion'
import {UserMention} from './user-mention'
const name = 'mention-component'
@@ -11,11 +12,7 @@ const name = 'mention-component'
* https://tiptap.dev/guide/node-views/react#render-a-react-component
*/
export const DisplayMention = Mention.extend({
parseHTML: () => [{ tag: name }, { tag: `a[data-type="${name}"]` }],
renderHTML: ({ HTMLAttributes }) => [
name,
mergeAttributes({ HTMLAttributes }),
0,
],
parseHTML: () => [{tag: name}, {tag: `a[data-type="${name}"]`}],
renderHTML: ({HTMLAttributes}) => [name, mergeAttributes({HTMLAttributes}), 0],
renderReact: (attrs: any) => <UserMention userName={attrs.label} />,
}).configure({ suggestion: mentionSuggestion })
}).configure({suggestion: mentionSuggestion})

View File

@@ -1,67 +1,63 @@
import { SuggestionProps } from '@tiptap/suggestion'
import {SuggestionProps} from '@tiptap/suggestion'
import clsx from 'clsx'
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
import { Avatar } from '../../widgets/avatar'
import { DisplayUser } from 'web/lib/supabase/users'
import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
import {DisplayUser} from 'web/lib/supabase/users'
import {Avatar} from '../../widgets/avatar'
// copied from https://tiptap.dev/api/nodes/mention#usage
export const MentionList = forwardRef(
(props: SuggestionProps<DisplayUser>, ref) => {
const { items: users, command } = props
export const MentionList = forwardRef((props: SuggestionProps<DisplayUser>, ref) => {
const {items: users, command} = props
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => setSelectedIndex(0), [users])
const [selectedIndex, setSelectedIndex] = useState(0)
useEffect(() => setSelectedIndex(0), [users])
const submitUser = (index: number) => {
const user = users[index]
if (user) command({ id: user.id, label: user.username } as any)
}
const onUp = () =>
setSelectedIndex((i) => (i + users.length - 1) % users.length)
const onDown = () => setSelectedIndex((i) => (i + 1) % users.length)
const onEnter = () => submitUser(selectedIndex)
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: any) => {
if (event.key === 'ArrowUp') {
onUp()
return true
}
if (event.key === 'ArrowDown') {
onDown()
return true
}
if (event.key === 'Enter') {
onEnter()
return true
}
return false
},
}))
return (
<div className="w-42 bg-canvas-0 ring-ink-1000 absolute z-10 overflow-x-hidden rounded-md py-1 shadow-lg ring-1 ring-opacity-5 focus:outline-none">
{!users.length ? (
<span className="m-1 whitespace-nowrap">No results...</span>
) : (
users.map((user, i) => (
<button
className={clsx(
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
selectedIndex === i
? 'text-ink-0 bg-primary-500'
: 'text-ink-900'
)}
onClick={() => submitUser(i)}
key={user.id}
>
<Avatar avatarUrl={user.avatarUrl} size="xs" noLink />
{user.username}
</button>
))
)}
</div>
)
const submitUser = (index: number) => {
const user = users[index]
if (user) command({id: user.id, label: user.username} as any)
}
)
const onUp = () => setSelectedIndex((i) => (i + users.length - 1) % users.length)
const onDown = () => setSelectedIndex((i) => (i + 1) % users.length)
const onEnter = () => submitUser(selectedIndex)
useImperativeHandle(ref, () => ({
onKeyDown: ({event}: any) => {
if (event.key === 'ArrowUp') {
onUp()
return true
}
if (event.key === 'ArrowDown') {
onDown()
return true
}
if (event.key === 'Enter') {
onEnter()
return true
}
return false
},
}))
return (
<div className="w-42 bg-canvas-0 ring-ink-1000 absolute z-10 overflow-x-hidden rounded-md py-1 shadow-lg ring-1 ring-opacity-5 focus:outline-none">
{!users.length ? (
<span className="m-1 whitespace-nowrap">No results...</span>
) : (
users.map((user, i) => (
<button
className={clsx(
'flex h-8 w-full cursor-pointer select-none items-center gap-2 truncate px-4',
selectedIndex === i ? 'text-ink-0 bg-primary-500' : 'text-ink-900',
)}
onClick={() => submitUser(i)}
key={user.id}
>
<Avatar avatarUrl={user.avatarUrl} size="xs" noLink />
{user.username}
</button>
))
)}
</div>
)
})

View File

@@ -1,10 +1,11 @@
import type { MentionOptions } from '@tiptap/extension-mention'
import { ReactRenderer } from '@tiptap/react'
import { beginsWith } from 'common/util/parse'
import { sortBy } from 'lodash'
import type {MentionOptions} from '@tiptap/extension-mention'
import {ReactRenderer} from '@tiptap/react'
import {beginsWith} from 'common/util/parse'
import {sortBy} from 'lodash'
import tippy from 'tippy.js'
import { searchUsers } from 'web/lib/supabase/users'
import { MentionList } from './mention-list'
import {searchUsers} from 'web/lib/supabase/users'
import {MentionList} from './mention-list'
type Render = Suggestion['render']
type Suggestion = MentionOptions['suggestion']
@@ -12,9 +13,9 @@ type Suggestion = MentionOptions['suggestion']
// copied from https://tiptap.dev/api/nodes/mention#usage
export const mentionSuggestion: Suggestion = {
allowedPrefixes: [' '],
items: async ({ query }) =>
items: async ({query}) =>
sortBy(await searchUsers(query, 6), (u) =>
[u.name, u.username].some((s) => beginsWith(s, query)) ? -1 : 0
[u.name, u.username].some((s) => beginsWith(s, query)) ? -1 : 0,
),
render: makeMentionRender(MentionList),
}

View File

@@ -1,8 +1,8 @@
import Link from 'next/link'
import { linkClass } from 'web/components/widgets/site-link'
import {linkClass} from 'web/components/widgets/site-link'
export const UserMention = (props: { userName: string }) => {
const { userName } = props
export const UserMention = (props: {userName: string}) => {
const {userName} = props
return (
<Link href={`/${userName}`} className={linkClass}>
@{userName}

View File

@@ -1,13 +1,13 @@
import {
Editor,
AnyExtension,
Content,
JSONContent,
Editor,
Extensions,
getExtensionField,
AnyExtension,
JSONContent,
} from '@tiptap/react'
import { keyBy } from 'lodash'
import { createElement, Fragment, ReactNode } from 'react'
import {keyBy} from 'lodash'
import {createElement, Fragment, ReactNode} from 'react'
export function insertContent(editor: Editor | null, ...contents: Content[]) {
if (!editor) {
@@ -26,22 +26,14 @@ export function insertContent(editor: Editor | null, ...contents: Content[]) {
type ProsemirrorDOM =
| 0
| string
| [
tag: string,
attrs: Record<string, string | number | undefined>,
...content: ProsemirrorDOM[]
]
| [tag: string, attrs: Record<string, string | number | undefined>, ...content: ProsemirrorDOM[]]
const pmdToJSX = (dom: ProsemirrorDOM, children: ReactNode): ReactNode => {
if (Array.isArray(dom)) {
const [tag, attrs, ...content] = dom
const { class: className, ...rest } = attrs
const {class: className, ...rest} = attrs
return createElement(
tag,
{ className, ...rest },
...content.map((c) => pmdToJSX(c, children))
)
return createElement(tag, {className, ...rest}, ...content.map((c) => pmdToJSX(c, children)))
} else if (dom === 0) {
if (Array.isArray(children)) {
// wrap in fragment to stop missing key warnings
@@ -54,8 +46,8 @@ const pmdToJSX = (dom: ProsemirrorDOM, children: ReactNode): ReactNode => {
}
export function getField(extension: AnyExtension, field: string) {
const { name, options, storage } = extension
return getExtensionField(extension, field, { name, options, storage })
const {name, options, storage} = extension
return getExtensionField(extension, field, {name, options, storage})
}
/**
@@ -70,7 +62,7 @@ export const generateReact = (doc: JSONContent, extensions: Extensions) => {
}
const extensionsIncludingStarterKit = extensions.flatMap(
(e) => getField(e, 'addExtensions')?.() ?? e
(e) => getField(e, 'addExtensions')?.() ?? e,
)
const exts = keyBy(extensionsIncludingStarterKit, 'name')
@@ -97,7 +89,7 @@ export const generateReact = (doc: JSONContent, extensions: Extensions) => {
children = renderReact(m.attrs, children)
} else {
const renderHTML = getField(e, 'renderHTML')
children = pmdToJSX(renderHTML({ HTMLAttributes: m.attrs }), children)
children = pmdToJSX(renderHTML({HTMLAttributes: m.attrs}), children)
}
})
}
@@ -111,10 +103,10 @@ export const generateReact = (doc: JSONContent, extensions: Extensions) => {
return pmdToJSX(
renderHTML?.({
node: { attrs: content.attrs },
node: {attrs: content.attrs},
HTMLAttributes: content.attrs,
}) ?? 0,
children
children,
)
}

View File

@@ -1,9 +1,9 @@
import toast from 'react-hot-toast'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {useFirebaseUser} from 'web/hooks/use-firebase-user'
import {sendVerificationEmail} from 'web/lib/firebase/email-verification'
import {useT} from 'web/lib/locale'
import {Col} from "web/components/layout/col"
import toast from "react-hot-toast";
import {useFirebaseUser} from "web/hooks/use-firebase-user";
export function EmailVerificationButton() {
const user = useFirebaseUser()
@@ -12,18 +12,18 @@ export function EmailVerificationButton() {
const isEmailVerified = user?.emailVerified
async function reload() {
if (!user) return false;
if (!user) return false
// Refresh user record from Firebase
await user.reload();
await user.reload()
if (user.emailVerified) {
// IMPORTANT: force a new ID token with updated claims
await user.getIdToken(true)
console.log("User email verified")
console.log('User email verified')
return true
} else {
toast.error(t('', "Email still not verified..."))
toast.error(t('', 'Email still not verified...'))
}
}
@@ -39,15 +39,13 @@ export function EmailVerificationButton() {
? t('settings.email.verified', 'Email Verified ✔️')
: t('settings.email.send_verification', 'Send verification email')}
</Button>
{!isEmailVerified && <div className={'custom-link'}>
<button
type="button"
onClick={reload}
className={'w-fit mx-2'}
>
{t('settings.email.just_verified', 'I verified my email')}
</button>
</div>}
{!isEmailVerified && (
<div className={'custom-link'}>
<button type="button" onClick={reload} className={'w-fit mx-2'}>
{t('settings.email.just_verified', 'I verified my email')}
</button>
</div>
)}
</Col>
)
}

View File

@@ -1,16 +1,16 @@
'use client'
import 'react-datepicker/dist/react-datepicker.css'
import clsx from 'clsx'
import {APIError} from 'common/api/utils'
import {useEffect, useState} from 'react'
import DatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS, SCROLLABLE_MODAL_CLASS} from 'web/components/layout/modal'
import {api} from 'web/lib/api'
import {APIError} from "common/api/utils";
import clsx from "clsx";
import {Col} from "web/components/layout/col";
import {useLocale, useT} from 'web/lib/locale';
import {Event} from 'web/hooks/use-events'
import {api} from 'web/lib/api'
import {useLocale, useT} from 'web/lib/locale'
export function CreateEventModal(props: {
open: boolean
@@ -43,7 +43,7 @@ export function CreateEventModal(props: {
setFormData({
title: event.title || '',
description: event.description || '',
locationType: event.location_type || 'in_person' as 'in_person' | 'online',
locationType: event.location_type || ('in_person' as 'in_person' | 'online'),
locationAddress: event.location_address || '',
locationUrl: event.location_url || '',
eventStartTime: event.event_start_time ? new Date(event.event_start_time) : null,
@@ -77,12 +77,11 @@ export function CreateEventModal(props: {
title: formData.title,
description: formData.description || undefined,
locationType: formData.locationType,
locationAddress: formData.locationType === 'in_person' && formData.locationAddress || undefined,
locationUrl: formData.locationType === 'online' && formData.locationUrl || undefined,
locationAddress:
(formData.locationType === 'in_person' && formData.locationAddress) || undefined,
locationUrl: (formData.locationType === 'online' && formData.locationUrl) || undefined,
eventStartTime: formData.eventStartTime!.toISOString(),
eventEndTime: formData.eventEndTime
? formData.eventEndTime.toISOString()
: undefined,
eventEndTime: formData.eventEndTime ? formData.eventEndTime.toISOString() : undefined,
maxParticipants: formData.maxParticipants
? parseInt(formData.maxParticipants)
: undefined,
@@ -92,12 +91,11 @@ export function CreateEventModal(props: {
title: formData.title,
description: formData.description || undefined,
locationType: formData.locationType,
locationAddress: formData.locationType === 'in_person' && formData.locationAddress || undefined,
locationUrl: formData.locationType === 'online' && formData.locationUrl || undefined,
locationAddress:
(formData.locationType === 'in_person' && formData.locationAddress) || undefined,
locationUrl: (formData.locationType === 'online' && formData.locationUrl) || undefined,
eventStartTime: formData.eventStartTime!.toISOString(),
eventEndTime: formData.eventEndTime
? formData.eventEndTime.toISOString()
: undefined,
eventEndTime: formData.eventEndTime ? formData.eventEndTime.toISOString() : undefined,
maxParticipants: formData.maxParticipants
? parseInt(formData.maxParticipants)
: undefined,
@@ -131,21 +129,24 @@ export function CreateEventModal(props: {
}
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
) => {
const {name, value} = e.target
setFormData((prev) => ({...prev, [name]: value}))
}
const dateFormat = locale === 'en' ? "MMM d, yyyy h:mm aa" : "dd MMM yyyy, HH:mm"
const timeFormat = "HH:mm"
const dateFormat = locale === 'en' ? 'MMM d, yyyy h:mm aa' : 'dd MMM yyyy, HH:mm'
const timeFormat = 'HH:mm'
return (
<Modal open={open} setOpen={setOpen} onClose={onClose} size="lg">
<Col className={clsx("", MODAL_CLASS)}>
<form onSubmit={handleSubmit} className={clsx("space-y-4 pr-4", SCROLLABLE_MODAL_CLASS)}>
<h3>{isEditing ? t('events.edit_event', 'Edit Event') : t('events.create_new_event', 'Create New Event')}</h3>
<Col className={clsx('', MODAL_CLASS)}>
<form onSubmit={handleSubmit} className={clsx('space-y-4 pr-4', SCROLLABLE_MODAL_CLASS)}>
<h3>
{isEditing
? t('events.edit_event', 'Edit Event')
: t('events.create_new_event', 'Create New Event')}
</h3>
<div>
<label htmlFor="title" className="mb-1 block text-sm font-medium min-w-[300px]">
{t('events.event_title', 'Event Title')} *
@@ -243,10 +244,15 @@ export function CreateEventModal(props: {
onChange={(date: Date | null) => {
if (!date) return
setFormData((prev) => {
const newEndTime = (!prev.eventEndTime || prev.eventEndTime < date)
? new Date(date.getTime() + 60 * 60 * 1000)
: prev.eventEndTime
return {...prev, eventStartTime: date, eventEndTime: newEndTime}
const newEndTime =
!prev.eventEndTime || prev.eventEndTime < date
? new Date(date.getTime() + 60 * 60 * 1000)
: prev.eventEndTime
return {
...prev,
eventStartTime: date,
eventEndTime: newEndTime,
}
})
}}
showTimeSelect
@@ -271,7 +277,11 @@ export function CreateEventModal(props: {
const startTime = prev.eventStartTime
if (startTime && date && date <= startTime) {
// If end time is before or equal to start, set start to 1 hour before end
return {...prev, eventStartTime: new Date(date.getTime() - 60 * 60 * 1000), eventEndTime: date}
return {
...prev,
eventStartTime: new Date(date.getTime() - 60 * 60 * 1000),
eventEndTime: date,
}
}
return {...prev, eventEndTime: date}
})
@@ -303,9 +313,7 @@ export function CreateEventModal(props: {
/>
</div>
{error && (
<div className="text-red-800 rounded-md p-3 text-sm">{error}</div>
)}
{error && <div className="text-red-800 rounded-md p-3 text-sm">{error}</div>}
<div className="flex justify-end gap-3 pt-4 pb-4">
<button
@@ -320,7 +328,13 @@ export function CreateEventModal(props: {
disabled={loading}
className="bg-primary-500 hover:bg-primary-600 text-white rounded-md px-4 py-2 text-sm font-medium disabled:opacity-50"
>
{loading ? (isEditing ? t('events.updating', 'Updating...') : t('events.creating', 'Creating...')) : (isEditing ? t('events.update_event', 'Update Event') : t('events.create_event', 'Create Event'))}
{loading
? isEditing
? t('events.updating', 'Updating...')
: t('events.creating', 'Creating...')
: isEditing
? t('events.update_event', 'Update Event')
: t('events.create_event', 'Create Event')}
</button>
</div>
</form>

View File

@@ -1,13 +1,14 @@
import clsx from 'clsx'
import {HOUR_MS} from 'common/util/time'
import dayjs from 'dayjs'
import {capitalize} from 'lodash'
import Link from 'next/link'
import {Event} from 'web/hooks/use-events'
import {useUser} from 'web/hooks/use-user'
import {useLocale, useT} from 'web/lib/locale'
import Link from 'next/link'
import dayjs from 'dayjs'
import {formatTimeShort, fromNow} from 'web/lib/util/time'
import {UserLink, UserLinkFromId} from './user-link'
import {formatTimeShort, fromNow} from "web/lib/util/time";
import {capitalize} from "lodash";
import {HOUR_MS} from "common/util/time";
export function EventCard(props: {
event: Event
@@ -22,28 +23,29 @@ export function EventCard(props: {
const t = useT()
const {locale} = useLocale()
const isRsvped = user && event.participants.includes(user.id)
const isMaybe = user && event.maybe.includes(user.id)
const isCreator = user && event.creator_id === user.id
const isPast = new Date(event.event_start_time) < new Date()
const formattedDate = formatTimeShort(event.event_start_time, locale)
const formattedEnd = event.event_end_time && formatTimeShort(
event.event_end_time,
locale,
dayjs(event.event_end_time).isSame(event.event_start_time, 'day')
)
const formattedEnd =
event.event_end_time &&
formatTimeShort(
event.event_end_time,
locale,
dayjs(event.event_end_time).isSame(event.event_start_time, 'day'),
)
let timeAgo = fromNow(event.event_start_time, false, t, locale)
const assumedEnd = new Date(event.event_end_time ?? new Date(event.event_start_time).getTime() + 24 * HOUR_MS)
if (isPast && assumedEnd > new Date()) timeAgo = t('events.started', 'Started {time}', {time: timeAgo})
const assumedEnd = new Date(
event.event_end_time ?? new Date(event.event_start_time).getTime() + 24 * HOUR_MS,
)
if (isPast && assumedEnd > new Date())
timeAgo = t('events.started', 'Started {time}', {time: timeAgo})
return (
<div
className={clsx(
'bg-canvas-0 border-canvas-200 rounded-lg border p-4 shadow-sm',
className
)}
className={clsx('bg-canvas-0 border-canvas-200 rounded-lg border p-4 shadow-sm', className)}
>
{/* Header */}
<div className="mb-3">
@@ -51,14 +53,16 @@ export function EventCard(props: {
{event.creator && (
<div className="text-ink-500 mt-1 flex items-center gap-2 text-sm">
<span>{t('events.organized_by', 'Organized by')}</span>
<UserLink user={event.creator}/>
<UserLink user={event.creator} />
</div>
)}
</div>
{/* Date & Time */}
<div className="mb-3">
<p className="text-ink-700 font-medium">{formattedDate} - {formattedEnd}</p>
<p className="text-ink-700 font-medium">
{formattedDate} - {formattedEnd}
</p>
<p className="text-ink-500 text-sm">{capitalize(timeAgo)}</p>
</div>
@@ -66,7 +70,9 @@ export function EventCard(props: {
{event.description && (
<div className="text-ink-600 mb-3">
{event.description.split('\n').map((line, index) => (
<p key={index} className={index > 0 ? 'mt-2' : ''}>{line}</p>
<p key={index} className={index > 0 ? 'mt-2' : ''}>
{line}
</p>
))}
</div>
)}
@@ -75,10 +81,24 @@ export function EventCard(props: {
<div className="mb-3">
{event.location_type === 'in_person' && event.location_address ? (
<div className="text-ink-600 flex items-start gap-2">
<svg className="mt-0.5 h-5 w-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
<svg
className="mt-0.5 h-5 w-5 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span>{event.location_address}</span>
</div>
@@ -90,8 +110,12 @@ export function EventCard(props: {
className="text-primary-600 hover:text-primary-700 flex items-center gap-2"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
<span>{t('events.online_event_join_link', 'Online Event - Join Link')}</span>
</a>
@@ -101,15 +125,26 @@ export function EventCard(props: {
{/* Participants */}
<div className="mb-3">
<p className="text-ink-500 text-sm">
{t('events.participants_count', '{count} going', {count: event.participants.length})}
{event.max_participants && t('events.participants_max', ' / {max} max', {max: event.max_participants})}
{event.maybe.length > 0 && t('events.maybe_count', ' ({count} maybe)', {count: event.maybe.length})}
{t('events.participants_count', '{count} going', {
count: event.participants.length,
})}
{event.max_participants &&
t('events.participants_max', ' / {max} max', {
max: event.max_participants,
})}
{event.maybe.length > 0 &&
t('events.maybe_count', ' ({count} maybe)', {
count: event.maybe.length,
})}
</p>
{event.participants.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{event.participants.map((participantId: string) => (
<span key={participantId} className="bg-canvas-100 text-ink-700 px-2 py-1 rounded text-xs">
<UserLinkFromId userId={participantId}/>
<span
key={participantId}
className="bg-canvas-100 text-ink-700 px-2 py-1 rounded text-xs"
>
<UserLinkFromId userId={participantId} />
</span>
))}
</div>
@@ -124,10 +159,12 @@ export function EventCard(props: {
'inline-flex items-center rounded-full px-2 py-1 text-xs font-medium',
event.status === 'cancelled'
? 'bg-red-100 text-red-800'
: 'bg-gray-100 text-gray-800'
: 'bg-gray-100 text-gray-800',
)}
>
{event.status === 'cancelled' ? t('events.cancelled', 'Cancelled') : t('events.completed', 'Completed')}
{event.status === 'cancelled'
? t('events.cancelled', 'Cancelled')
: t('events.completed', 'Completed')}
</span>
</div>
)}
@@ -139,9 +176,11 @@ export function EventCard(props: {
<>
<span className="text-green-600 flex items-center gap-1 text-sm font-medium">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"/>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
{t('events.going', 'Going')}
</span>
@@ -157,9 +196,11 @@ export function EventCard(props: {
<>
<span className="text-yellow-600 flex items-center gap-1 text-sm font-medium">
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"/>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
{t('events.maybe', 'Maybe')}
</span>
@@ -173,18 +214,22 @@ export function EventCard(props: {
)}
{(!isRsvped || !isMaybe) && (
<div className="flex items-center gap-2">
{!isRsvped && <button
{!isRsvped && (
<button
onClick={() => onRsvp?.(event.id, 'going')}
className="bg-primary-500 hover:bg-primary-600 text-white rounded-md px-3 py-1.5 text-sm font-medium"
>
{t('events.going', 'Going')}
</button>}
{!isMaybe && <button
>
{t('events.going', 'Going')}
</button>
)}
{!isMaybe && (
<button
onClick={() => onRsvp?.(event.id, 'maybe')}
className="bg-canvas-100 hover:bg-canvas-200 text-ink-700 rounded-md px-3 py-1.5 text-sm font-medium"
>
{t('events.maybe', 'Maybe')}
</button>}
>
{t('events.maybe', 'Maybe')}
</button>
)}
</div>
)}
</div>
@@ -195,7 +240,14 @@ export function EventCard(props: {
<div className="mt-3 pt-3 border-t border-canvas-200 flex gap-2">
<button
onClick={() => {
if (confirm(t('events.cancel_event_confirmation', 'Are you sure you want to cancel this event? This action cannot be undone.'))) {
if (
confirm(
t(
'events.cancel_event_confirmation',
'Are you sure you want to cancel this event? This action cannot be undone.',
),
)
) {
onCancelEvent?.(event.id)
}
}}

View File

@@ -1,4 +1,5 @@
import {Event} from 'web/hooks/use-events'
import {EventCard} from './event-card'
export function EventsList(props: {
@@ -11,7 +12,8 @@ export function EventsList(props: {
onEdit?: (event: Event) => void
className?: string
}) {
const {events, title, emptyMessage, onRsvp, onCancelRsvp, onCancelEvent, onEdit, className} = props
const {events, title, emptyMessage, onRsvp, onCancelRsvp, onCancelEvent, onEdit, className} =
props
return (
<div className={className}>

View File

@@ -1,5 +1,5 @@
import Link from 'next/link'
import {useUserInStore} from "web/hooks/use-user-supabase";
import {useUserInStore} from 'web/hooks/use-user-supabase'
type User = {
id: string
@@ -8,7 +8,10 @@ type User = {
avatar_url?: string | null
}
export function UserLink({user, className = ""}: {
export function UserLink({
user,
className = '',
}: {
user: User | null | undefined
className?: string
}) {
@@ -20,21 +23,14 @@ export function UserLink({user, className = ""}: {
className={`hover:text-primary-600 flex items-center gap-1 ${className}`}
>
{user.avatar_url && (
<img
src={user.avatar_url}
alt={user.name}
className="h-5 w-5 rounded-full"
/>
<img src={user.avatar_url} alt={user.name} className="h-5 w-5 rounded-full" />
)}
<span>{user.name}</span>
</Link>
)
}
export function UserLinkFromId({userId, className = ""}: {
userId: string
className?: string
}) {
export function UserLinkFromId({userId, className = ''}: {userId: string; className?: string}) {
const user = useUserInStore(userId)
return <UserLink user={user} className={className}/>
return <UserLink user={user} className={className} />
}

View File

@@ -1,6 +1,6 @@
import clsx from 'clsx'
import {FilterFields} from 'common/filters'
import {RangeSlider} from 'web/components/widgets/slider'
import {FilterFields} from "common/filters"
import {useT} from 'web/lib/locale'
export const PREF_AGE_MIN = 18
@@ -8,7 +8,7 @@ export const PREF_AGE_MAX = 100
export function getNoMinMaxAge(
pref_age_min: number | null | undefined,
pref_age_max: number | null | undefined
pref_age_max: number | null | undefined,
) {
const noMinAge = !pref_age_min || pref_age_min <= PREF_AGE_MIN
const noMaxAge = !pref_age_max || pref_age_max >= PREF_AGE_MAX
@@ -20,14 +20,16 @@ export function AgeFilterText(props: {
pref_age_max: number | null | undefined
highlightedClass?: string
}) {
const { pref_age_min, pref_age_max, highlightedClass } = props
const {pref_age_min, pref_age_max, highlightedClass} = props
const [noMinAge, noMaxAge] = getNoMinMaxAge(pref_age_min, pref_age_max)
const t = useT()
if (noMinAge && noMaxAge) {
return (
<span>
<span className={clsx('text-semibold', highlightedClass)}>{t('filter.age.any', 'Any')}</span>{' '}
<span className={clsx('text-semibold', highlightedClass)}>
{t('filter.age.any', 'Any')}
</span>{' '}
<span className="">{t('filter.age.age', 'age')}</span>
</span>
)
@@ -66,13 +68,13 @@ export function AgeFilterText(props: {
)
}
const FILTER_MAX = 60;
const FILTER_MAX = 60
export function AgeFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
}) {
const { filters, updateFilter } = props
const {filters, updateFilter} = props
return (
<RangeSlider
lowValue={filters.pref_age_min ?? PREF_AGE_MIN}
@@ -86,7 +88,7 @@ export function AgeFilter(props: {
min={PREF_AGE_MIN}
max={FILTER_MAX}
marks={[
{ value: 0, label: `${PREF_AGE_MIN}` },
{value: 0, label: `${PREF_AGE_MIN}`},
{
value: ((30 - PREF_AGE_MIN) / (FILTER_MAX - PREF_AGE_MIN)) * 100,
label: `30`,
@@ -99,7 +101,7 @@ export function AgeFilter(props: {
value: ((50 - PREF_AGE_MIN) / (FILTER_MAX - PREF_AGE_MIN)) * 100,
label: `50`,
},
{ value: 100, label: `${FILTER_MAX}+` },
{value: 100, label: `${FILTER_MAX}+`},
]}
/>
)

View File

@@ -1,6 +1,6 @@
import clsx from 'clsx'
import {RangeSlider} from 'web/components/widgets/slider'
import {FilterFields} from 'common/filters'
import {RangeSlider} from 'web/components/widgets/slider'
import {useT} from 'web/lib/locale'
export const BIG5_MIN = 0
@@ -40,19 +40,14 @@ export function hasAnyBig5Filter(filters: Partial<FilterFields>) {
)
}
export function Big5FilterText(props: {
filters: Partial<FilterFields>
highlightedClass?: string
}) {
export function Big5FilterText(props: {filters: Partial<FilterFields>; highlightedClass?: string}) {
const {filters, highlightedClass} = props
const t = useT()
const hasAny = hasAnyBig5Filter(filters)
if (!hasAny) {
return (
<span className={clsx(!hasAny && 'text-ink-600')}>
{t('filter.big5.any', 'Any Big 5')}
</span>
<span className={clsx(!hasAny && 'text-ink-600')}>{t('filter.big5.any', 'Any Big 5')}</span>
)
}
@@ -89,7 +84,7 @@ export function Big5SliderRow(props: {
setValues={(low, high) => {
onChange(
low > BIG5_MIN ? Math.round(low) : undefined,
high < BIG5_MAX ? Math.round(high) : undefined
high < BIG5_MAX ? Math.round(high) : undefined,
)
}}
marks={[
@@ -128,10 +123,7 @@ export function Big5Filters(props: {
}
/>
<Big5SliderRow
label={t(
'profile.big5_conscientiousness',
'Conscientiousness'
)}
label={t('profile.big5_conscientiousness', 'Conscientiousness')}
minValue={filters.big5_conscientiousness_min}
maxValue={filters.big5_conscientiousness_max}
onChange={(min, max) =>
@@ -177,5 +169,3 @@ export function Big5Filters(props: {
</div>
)
}

View File

@@ -1,48 +1,49 @@
import {ChevronDownIcon, ChevronUpIcon} from '@heroicons/react/outline'
import {DietType, RelationshipType, RomanticType} from 'web/lib/util/convert-types'
import {ReactNode} from 'react'
import {FaUserGroup} from 'react-icons/fa6'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {CustomizeableDropdown} from 'web/components/widgets/customizeable-dropdown'
import {FilterFields} from 'common/filters'
import {Gender} from 'common/gender'
import {AgeFilter, AgeFilterText} from './age-filter'
import {GenderFilter, GenderFilterText} from './gender-filter'
import {LocationFilter, LocationFilterProps, LocationFilterText,} from './location-filter'
import {RelationshipFilter, RelationshipFilterText,} from './relationship-filter'
import {MyMatchesToggle} from './my-matches-toggle'
import {hasKidsLabels} from 'common/has-kids'
import {OptionTableKey} from 'common/profiles/constants'
import {Profile} from 'common/profiles/profile'
import {FilterFields} from "common/filters";
import {ShortBioToggle} from "web/components/filters/short-bio-toggle";
import DropdownMenu from "web/components/comments/dropdown-menu";
import {KidsLabel, useWantsKidsLabelsWithIcon} from "web/components/filters/wants-kids-filter";
import {hasKidsLabels} from "common/has-kids";
import {HasKidsLabel} from "web/components/filters/has-kids-filter";
import {RomanticFilter, RomanticFilterText} from "web/components/filters/romantic-filter";
import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa";
import {DietFilter, DietFilterText} from "web/components/filters/diet-filter";
import {PoliticalFilter, PoliticalFilterText} from "web/components/filters/political-filter";
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 {BsPersonHeart, BsPersonVcard} from "react-icons/bs";
import {LuCigarette, LuGraduationCap} from "react-icons/lu";
import {DrinksFilter, DrinksFilterText} from "web/components/filters/drinks-filter";
import {ReactNode} from 'react'
import {BsPersonHeart, BsPersonVcard} from 'react-icons/bs'
import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from 'react-icons/fa'
import {FaUserGroup} from 'react-icons/fa6'
import {GiFruitBowl} from 'react-icons/gi'
import {LuCigarette, LuGraduationCap} from 'react-icons/lu'
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 {PiHandsPrayingBold} from 'react-icons/pi'
import {RiScales3Line} from 'react-icons/ri'
import DropdownMenu from 'web/components/comments/dropdown-menu'
import {Big5Filters, Big5FilterText, hasAnyBig5Filter} from 'web/components/filters/big5-filter'
import {DietFilter, DietFilterText} from 'web/components/filters/diet-filter'
import {DrinksFilter, DrinksFilterText} from 'web/components/filters/drinks-filter'
import {EducationFilter, EducationFilterText} from 'web/components/filters/education-filter'
import {HasKidsLabel} from 'web/components/filters/has-kids-filter'
import {InterestFilter, InterestFilterText} from 'web/components/filters/interest-filter'
import {LanguageFilter, LanguageFilterText} from 'web/components/filters/language-filter'
import {MbtiFilter, MbtiFilterText} from 'web/components/filters/mbti-filter'
import {PoliticalFilter, PoliticalFilterText} from 'web/components/filters/political-filter'
import {
RelationshipStatusFilter,
RelationshipStatusFilterText
} from "web/components/filters/relationship-status-filter";
import {InterestFilter, InterestFilterText} from "web/components/filters/interest-filter";
import {OptionTableKey} from "common/profiles/constants";
import {useT} from "web/lib/locale";
import {ResetFiltersButton} from "web/components/searches/button";
import {Big5Filters, Big5FilterText, hasAnyBig5Filter} from "web/components/filters/big5-filter";
RelationshipStatusFilterText,
} from 'web/components/filters/relationship-status-filter'
import {ReligionFilter, ReligionFilterText} from 'web/components/filters/religion-filter'
import {RomanticFilter, RomanticFilterText} from 'web/components/filters/romantic-filter'
import {ShortBioToggle} from 'web/components/filters/short-bio-toggle'
import {SmokerFilter, SmokerFilterText} from 'web/components/filters/smoker-filter'
import {KidsLabel, useWantsKidsLabelsWithIcon} from 'web/components/filters/wants-kids-filter'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {ResetFiltersButton} from 'web/components/searches/button'
import {CustomizeableDropdown} from 'web/components/widgets/customizeable-dropdown'
import {useT} from 'web/lib/locale'
import {DietType, RelationshipType, RomanticType} from 'web/lib/util/convert-types'
import {AgeFilter, AgeFilterText} from './age-filter'
import {GenderFilter, GenderFilterText} from './gender-filter'
import {LocationFilter, LocationFilterProps, LocationFilterText} from './location-filter'
import {MyMatchesToggle} from './my-matches-toggle'
import {RelationshipFilter, RelationshipFilterText} from './relationship-filter'
export function DesktopFilters(props: {
filters: Partial<FilterFields>
@@ -72,10 +73,7 @@ export function DesktopFilters(props: {
return (
<>
<ResetFiltersButton
clearFilters={clearFilters}
/>
<ResetFiltersButton clearFilters={clearFilters} />
<MyMatchesToggle
setYourFilters={setYourFilters}
@@ -85,11 +83,7 @@ export function DesktopFilters(props: {
/>
{/* Short Bios */}
<ShortBioToggle
updateFilter={updateFilter}
filters={filters}
hidden={false}
/>
<ShortBioToggle updateFilter={updateFilter} filters={filters} hidden={false} />
{/* CONNECTION */}
<CustomizeableDropdown
@@ -98,22 +92,16 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<FaUserGroup className="h-4 w-4"/>
<FaUserGroup className="h-4 w-4" />
<RelationshipFilterText
relationship={
filters.pref_relation_styles as
| RelationshipType[]
| undefined
}
relationship={filters.pref_relation_styles as RelationshipType[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<RelationshipFilter filters={filters} updateFilter={updateFilter}/>
}
dropdownMenuContent={<RelationshipFilter filters={filters} updateFilter={updateFilter} />}
popoverClassName="bg-canvas-50"
menuWidth="w-50"
/>
@@ -125,13 +113,9 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<BsPersonHeart className="h-4 w-4"/>
<BsPersonHeart className="h-4 w-4" />
<RelationshipStatusFilterText
options={
filters.relationship_status as
| string[]
| undefined
}
options={filters.relationship_status as string[] | undefined}
defaultLabel={t('filter.relationship_status.any', 'Any relationship status')}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
@@ -140,7 +124,7 @@ export function DesktopFilters(props: {
/>
)}
dropdownMenuContent={
<RelationshipStatusFilter filters={filters} updateFilter={updateFilter}/>
<RelationshipStatusFilter filters={filters} updateFilter={updateFilter} />
}
popoverClassName="bg-canvas-50"
menuWidth="w-50"
@@ -162,10 +146,7 @@ export function DesktopFilters(props: {
/>
)}
dropdownMenuContent={
<LocationFilter
youProfile={youProfile}
locationFilterProps={locationFilterProps}
/>
<LocationFilter youProfile={youProfile} locationFilterProps={locationFilterProps} />
}
popoverClassName="bg-canvas-50"
menuWidth="w-80"
@@ -187,7 +168,7 @@ export function DesktopFilters(props: {
)}
dropdownMenuContent={
<Col className="mx-2 mb-4">
<AgeFilter filters={filters} updateFilter={updateFilter}/>
<AgeFilter filters={filters} updateFilter={updateFilter} />
</Col>
}
popoverClassName="bg-canvas-50"
@@ -209,7 +190,7 @@ export function DesktopFilters(props: {
)}
dropdownMenuContent={
<Col>
<GenderFilter filters={filters} updateFilter={updateFilter}/>
<GenderFilter filters={filters} updateFilter={updateFilter} />
</Col>
}
popoverClassName="bg-canvas-50"
@@ -236,127 +217,119 @@ export function DesktopFilters(props: {
{/* popoverClassName="bg-canvas-50"*/}
{/*/>*/}
{includeRelationshipFilters && <>
{/* RELATIONSHIP STYLE */}
{includeRelationshipFilters && (
<>
{/* RELATIONSHIP STYLE */}
<CustomizeableDropdown
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<FaHeart className="h-4 w-4"/>
<RomanticFilterText
relationship={
filters.pref_romantic_styles as
| RomanticType[]
| undefined
}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<RomanticFilter filters={filters} updateFilter={updateFilter}/>
}
popoverClassName="bg-canvas-50"
menuWidth="w-50"
/>
{/* WANTS KIDS */}
<DropdownMenu
items={[
{
name: wantsKidsLabelsWithIcon.no_preference.name,
icon: wantsKidsLabelsWithIcon.no_preference.icon,
onClick: () => {
updateFilter({
wants_kids_strength: wantsKidsLabelsWithIcon.no_preference.strength,
})
},
},
{
name: wantsKidsLabelsWithIcon.wants_kids.name,
icon: wantsKidsLabelsWithIcon.wants_kids.icon,
onClick: () => {
updateFilter({
wants_kids_strength: wantsKidsLabelsWithIcon.wants_kids.strength,
})
},
},
{
name: wantsKidsLabelsWithIcon.doesnt_want_kids.name,
icon: wantsKidsLabelsWithIcon.doesnt_want_kids.icon,
onClick: () => {
updateFilter({
wants_kids_strength: wantsKidsLabelsWithIcon.doesnt_want_kids.strength,
})
},
},
]}
closeOnClick
buttonClass={'!text-ink-600 !hover:!text-ink-600'}
buttonContent={(open: boolean) => (
<DropdownButton
content={
<KidsLabel
strength={
filters.wants_kids_strength ??
wantsKidsLabelsWithIcon.no_preference.strength
}
highlightedClass={open ? 'text-primary-500' : ''}
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<FaHeart className="h-4 w-4" />
<RomanticFilterText
relationship={filters.pref_romantic_styles as RomanticType[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
}
open={open}
/>
)}
menuItemsClass={'bg-canvas-50'}
menuWidth="w-48"
</Row>
}
/>
)}
dropdownMenuContent={<RomanticFilter filters={filters} updateFilter={updateFilter} />}
popoverClassName="bg-canvas-50"
menuWidth="w-50"
/>
{/* HAS KIDS */}
{/* WANTS KIDS */}
<DropdownMenu
items={[
{
name: t("profile.has_kids.no_preference", hasKidsLabels.no_preference.name),
onClick: () => {
updateFilter({has_kids: hasKidsLabels.no_preference.value})
},
items={[
{
name: wantsKidsLabelsWithIcon.no_preference.name,
icon: wantsKidsLabelsWithIcon.no_preference.icon,
onClick: () => {
updateFilter({
wants_kids_strength: wantsKidsLabelsWithIcon.no_preference.strength,
})
},
{
name: t("profile.has_kids.doesnt_have_kids", hasKidsLabels.doesnt_have_kids.name),
onClick: () => {
updateFilter({has_kids: hasKidsLabels.doesnt_have_kids.value})
},
},
{
name: wantsKidsLabelsWithIcon.wants_kids.name,
icon: wantsKidsLabelsWithIcon.wants_kids.icon,
onClick: () => {
updateFilter({
wants_kids_strength: wantsKidsLabelsWithIcon.wants_kids.strength,
})
},
{
name: t("profile.has_kids.has_kids", hasKidsLabels.has_kids.name),
onClick: () => {
updateFilter({has_kids: hasKidsLabels.has_kids.value})
},
},
{
name: wantsKidsLabelsWithIcon.doesnt_want_kids.name,
icon: wantsKidsLabelsWithIcon.doesnt_want_kids.icon,
onClick: () => {
updateFilter({
wants_kids_strength: wantsKidsLabelsWithIcon.doesnt_want_kids.strength,
})
},
]}
closeOnClick
buttonClass={'!text-ink-600 !hover:!text-ink-600'}
buttonContent={(open: boolean) => (
<DropdownButton
content={
<HasKidsLabel
has_kids={filters.has_kids ?? -1}
highlightedClass={open ? 'text-primary-500' : ''}
/>
}
open={open}
/>
)}
menuItemsClass="bg-canvas-50"
menuWidth="w-40"
},
]}
closeOnClick
buttonClass={'!text-ink-600 !hover:!text-ink-600'}
buttonContent={(open: boolean) => (
<DropdownButton
content={
<KidsLabel
strength={
filters.wants_kids_strength ?? wantsKidsLabelsWithIcon.no_preference.strength
}
highlightedClass={open ? 'text-primary-500' : ''}
/>
}
open={open}
/>
)}
menuItemsClass={'bg-canvas-50'}
menuWidth="w-48"
/>
</>
}
{/* HAS KIDS */}
<DropdownMenu
items={[
{
name: t('profile.has_kids.no_preference', hasKidsLabels.no_preference.name),
onClick: () => {
updateFilter({has_kids: hasKidsLabels.no_preference.value})
},
},
{
name: t('profile.has_kids.doesnt_have_kids', hasKidsLabels.doesnt_have_kids.name),
onClick: () => {
updateFilter({has_kids: hasKidsLabels.doesnt_have_kids.value})
},
},
{
name: t('profile.has_kids.has_kids', hasKidsLabels.has_kids.name),
onClick: () => {
updateFilter({has_kids: hasKidsLabels.has_kids.value})
},
},
]}
closeOnClick
buttonClass={'!text-ink-600 !hover:!text-ink-600'}
buttonContent={(open: boolean) => (
<DropdownButton
content={
<HasKidsLabel
has_kids={filters.has_kids ?? -1}
highlightedClass={open ? 'text-primary-500' : ''}
/>
}
open={open}
/>
)}
menuItemsClass="bg-canvas-50"
menuWidth="w-40"
/>
</>
)}
{/* DIET */}
<CustomizeableDropdown
@@ -365,22 +338,16 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<GiFruitBowl className="h-4 w-4"/>
<GiFruitBowl className="h-4 w-4" />
<DietFilterText
options={
filters.diet as
| DietType[]
| undefined
}
options={filters.diet as DietType[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<DietFilter filters={filters} updateFilter={updateFilter}/>
}
dropdownMenuContent={<DietFilter filters={filters} updateFilter={updateFilter} />}
popoverClassName="bg-canvas-50"
menuWidth="w-50"
/>
@@ -392,7 +359,7 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<MdLocalBar className="h-4 w-4"/>
<MdLocalBar className="h-4 w-4" />
<DrinksFilterText
drinks_min={filters.drinks_min}
drinks_max={filters.drinks_max}
@@ -404,7 +371,7 @@ export function DesktopFilters(props: {
)}
dropdownMenuContent={
<Col className="mx-2 mb-4">
<DrinksFilter filters={filters} updateFilter={updateFilter}/>
<DrinksFilter filters={filters} updateFilter={updateFilter} />
</Col>
}
popoverClassName="bg-canvas-50"
@@ -418,7 +385,7 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<LuCigarette className="h-4 w-4"/>
<LuCigarette className="h-4 w-4" />
<SmokerFilterText
is_smoker={filters.is_smoker}
highlightedClass={open ? 'text-primary-500' : undefined}
@@ -429,7 +396,7 @@ export function DesktopFilters(props: {
)}
dropdownMenuContent={
<Col className="mx-2 mb-4">
<SmokerFilter filters={filters} updateFilter={updateFilter}/>
<SmokerFilter filters={filters} updateFilter={updateFilter} />
</Col>
}
popoverClassName="bg-canvas-50"
@@ -443,41 +410,31 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<MdLanguage className="h-4 w-4"/>
<MdLanguage className="h-4 w-4" />
<LanguageFilterText
options={
filters.languages as
| string[]
| undefined
}
options={filters.languages as string[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<LanguageFilter filters={filters} updateFilter={updateFilter}/>
}
dropdownMenuContent={<LanguageFilter filters={filters} updateFilter={updateFilter} />}
popoverClassName="bg-canvas-50 col-span-full max-h-80 overflow-y-auto"
menuWidth="w-50"
/>
{/* Interests */}
<CustomizeableDropdown
newBadgeClassName={"-top-3 -left-2"}
newBadgeClassName={'-top-3 -left-2'}
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<FaStar className="h-4 w-4"/>
<FaStar className="h-4 w-4" />
<InterestFilterText
options={
filters.interests as
| string[]
| undefined
}
options={filters.interests as string[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
label={'interests'}
/>
@@ -499,19 +456,15 @@ export function DesktopFilters(props: {
{/* Causes */}
<CustomizeableDropdown
newBadgeClassName={"-top-3 -left-2"}
newBadgeClassName={'-top-3 -left-2'}
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<FaHandsHelping className="h-4 w-4"/>
<FaHandsHelping className="h-4 w-4" />
<InterestFilterText
options={
filters.causes as
| string[]
| undefined
}
options={filters.causes as string[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
label={'causes'}
/>
@@ -533,19 +486,15 @@ export function DesktopFilters(props: {
{/* Work */}
<CustomizeableDropdown
newBadgeClassName={"-top-3 -left-2"}
newBadgeClassName={'-top-3 -left-2'}
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<FaBriefcase className="h-4 w-4"/>
<FaBriefcase className="h-4 w-4" />
<InterestFilterText
options={
filters.work as
| string[]
| undefined
}
options={filters.work as string[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
label={'work'}
/>
@@ -572,22 +521,16 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<RiScales3Line className="h-4 w-4"/>
<RiScales3Line className="h-4 w-4" />
<PoliticalFilterText
options={
filters.political_beliefs as
| string[]
| undefined
}
options={filters.political_beliefs as string[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<PoliticalFilter filters={filters} updateFilter={updateFilter}/>
}
dropdownMenuContent={<PoliticalFilter filters={filters} updateFilter={updateFilter} />}
popoverClassName="bg-canvas-50"
menuWidth="w-50"
/>
@@ -599,13 +542,9 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<PiHandsPrayingBold className="h-4 w-4"/>
<PiHandsPrayingBold className="h-4 w-4" />
<ReligionFilterText
options={
filters.religion as
| string[]
| undefined
}
options={filters.religion as string[] | undefined}
highlightedClass={open ? 'text-primary-500' : undefined}
/>
</Row>
@@ -640,9 +579,7 @@ export function DesktopFilters(props: {
}
/>
)}
dropdownMenuContent={
<MbtiFilter filters={filters} updateFilter={updateFilter} />
}
dropdownMenuContent={<MbtiFilter filters={filters} updateFilter={updateFilter} />}
popoverClassName="bg-canvas-50"
menuWidth="w-[400px]"
/>
@@ -654,10 +591,12 @@ export function DesktopFilters(props: {
open={open}
content={
<Row className="items-center gap-1">
<BsPersonVcard className="h-4 w-4"/>
<BsPersonVcard className="h-4 w-4" />
<Big5FilterText
filters={filters}
highlightedClass={open || hasAnyBig5Filter(filters) ? 'text-primary-500' : undefined}
highlightedClass={
open || hasAnyBig5Filter(filters) ? 'text-primary-500' : undefined
}
/>
</Row>
}
@@ -665,7 +604,7 @@ export function DesktopFilters(props: {
)}
dropdownMenuContent={
<Col className="mx-2 mb-4">
<Big5Filters filters={filters} updateFilter={updateFilter}/>
<Big5Filters filters={filters} updateFilter={updateFilter} />
</Col>
}
popoverClassName="bg-canvas-50"
@@ -678,7 +617,7 @@ export function DesktopFilters(props: {
<DropdownButton
content={
<Row className="items-center gap-1">
<LuGraduationCap className="h-4 w-4"/>
<LuGraduationCap className="h-4 w-4" />
<EducationFilterText
options={filters.education_levels as string[]}
highlightedClass={open ? 'text-primary-500' : undefined}
@@ -690,7 +629,7 @@ export function DesktopFilters(props: {
)}
dropdownMenuContent={
<Col>
<EducationFilter filters={filters} updateFilter={updateFilter}/>
<EducationFilter filters={filters} updateFilter={updateFilter} />
</Col>
}
popoverClassName="bg-canvas-50"
@@ -700,17 +639,13 @@ export function DesktopFilters(props: {
)
}
export function DropdownButton(props: { open: boolean; content: ReactNode }) {
export function DropdownButton(props: {open: boolean; content: ReactNode}) {
const {open, content} = props
return (
<Row className="hover:text-ink-700 items-center gap-0.5 transition-all">
{content}
<span className="text-ink-400">
{open ? (
<ChevronUpIcon className="h-4 w-4"/>
) : (
<ChevronDownIcon className="h-4 w-4"/>
)}
{open ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
</span>
</Row>
)

View File

@@ -1,11 +1,10 @@
import clsx from 'clsx'
import {convertDietTypes, DietType,} 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 {DIET_CHOICES} from 'common/choices'
import {FilterFields} from 'common/filters'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {convertDietTypes, DietType} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function DietFilterText(props: {
options: DietType[] | undefined
@@ -24,9 +23,7 @@ export function DietFilterText(props: {
)
}
const convertedTypes = options.map((r) =>
convertDietTypes(r)
)
const convertedTypes = options.map((r) => convertDietTypes(r))
if (length > 1) {
return (

View File

@@ -1,6 +1,6 @@
import clsx from 'clsx'
import {RangeSlider} from 'web/components/widgets/slider'
import {FilterFields} from 'common/filters'
import {RangeSlider} from 'web/components/widgets/slider'
import {useT} from 'web/lib/locale'
export const DRINKS_MIN = 0
@@ -8,7 +8,7 @@ export const DRINKS_MAX = 20
export function getNoMinMaxDrinks(
drinks_min: number | null | undefined,
drinks_max: number | null | undefined
drinks_max: number | null | undefined,
) {
const noMin = drinks_min == null || drinks_min <= DRINKS_MIN
const noMax = drinks_max == null || drinks_max >= DRINKS_MAX
@@ -25,7 +25,6 @@ export function DrinksFilterText(props: {
const [noMin, noMax] = getNoMinMaxDrinks(drinks_min, drinks_max)
const t = useT()
if (drinks_max === DRINKS_MIN) {
return (
<span className="font-semibold">

View File

@@ -1,13 +1,12 @@
import clsx from 'clsx'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {FilterFields} from "common/filters";
import {EDUCATION_CHOICES} from "common/choices";
import {convertEducationTypes} from "web/lib/util/convert-types";
import stringOrStringArrayToText from "web/lib/util/string-or-string-array-to-text";
import {getSortedOptions} from 'common/util/sorting'
import {useT} from 'web/lib/locale'
import {EDUCATION_CHOICES} from 'common/choices'
import {FilterFields} from 'common/filters'
import {toKey} from 'common/parsing'
import {getSortedOptions} from 'common/util/sorting'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {convertEducationTypes} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function EducationFilterText(props: {
options: string[] | undefined
@@ -28,7 +27,7 @@ export function EducationFilterText(props: {
const sortedOptions = getSortedOptions(options, EDUCATION_CHOICES)
const convertedTypes = sortedOptions.map((r) =>
t(`profile.education.${toKey(r)}`, convertEducationTypes(r as any))
t(`profile.education.${toKey(r)}`, convertEducationTypes(r as any)),
)
return (

View File

@@ -1,23 +1,23 @@
import clsx from 'clsx'
import GenderIcon from '../gender-icon'
import {GENDERS_PLURAL} from 'common/choices'
import {FilterFields} from 'common/filters'
import {Gender} from 'common/gender'
import {Row} from 'web/components/layout/row'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {FilterFields} from "common/filters";
import {GENDERS_PLURAL} from "common/choices";
export function GenderFilterText(props: {
gender: Gender[] | undefined
highlightedClass?: string
}) {
const { gender, highlightedClass } = props
import GenderIcon from '../gender-icon'
export function GenderFilterText(props: {gender: Gender[] | undefined; highlightedClass?: string}) {
const {gender, highlightedClass} = props
const t = useT()
if (!gender || gender.length < 1) {
return (
<span>
<span className={clsx('text-semibold', highlightedClass)}>{t('filter.gender.any', 'Any')}</span>{' '}
<span className={clsx('text-semibold', highlightedClass)}>
{t('filter.gender.any', 'Any')}
</span>{' '}
<span className="">{t('filter.gender.gender', 'gender')}</span>
</span>
)
@@ -25,12 +25,12 @@ export function GenderFilterText(props: {
return (
<Row className="items-center gap-0.5 font-semibold">
{gender.map((g) => {
return (
<GenderIcon key={g} gender={g} className={clsx('h-4 w-4')} hasColor />
)
return <GenderIcon key={g} gender={g} className={clsx('h-4 w-4')} hasColor />
})}{' '}
<span className="hidden sm:inline">
{gender.length > 1 ? t('filter.gender.genders', 'genders') : t('filter.gender.gender', 'gender')}
{gender.length > 1
? t('filter.gender.genders', 'genders')
: t('filter.gender.gender', 'gender')}
</span>
</Row>
)
@@ -40,7 +40,7 @@ export function GenderFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
}) {
const { filters, updateFilter } = props
const {filters, updateFilter} = props
return (
<>
<MultiCheckbox
@@ -48,7 +48,7 @@ export function GenderFilter(props: {
choices={GENDERS_PLURAL}
translationPrefix={'profile.gender.plural'}
onChange={(c) => {
updateFilter({ genders: c })
updateFilter({genders: c})
}}
/>
</>

View File

@@ -1,12 +1,11 @@
import clsx from 'clsx'
import {Row} from 'web/components/layout/row'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {FaChild} from 'react-icons/fa6'
import {FilterFields} from 'common/filters'
import {generateChoicesMap, hasKidsLabels} from 'common/has-kids'
import {FaChild} from 'react-icons/fa6'
import {Row} from 'web/components/layout/row'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {useT} from 'web/lib/locale'
export function HasKidsLabel(props: {
has_kids: number
highlightedClass?: string
@@ -28,10 +27,8 @@ export function HasKidsLabel(props: {
}
return (
<Row className="items-center gap-0.5">
<FaChild className="h-4 w-4"/>
<span
className={clsx(highlightedClass, has_kids !== -1 && 'font-semibold')}
>
<FaChild className="h-4 w-4" />
<span className={clsx(highlightedClass, has_kids !== -1 && 'font-semibold')}>
{t(`profile.has_kids.${labelKey}`, labelValue)}
</span>
</Row>

View File

@@ -1,11 +1,11 @@
import clsx from 'clsx'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {FilterFields} from "common/filters";
import {FilterFields} from 'common/filters'
import {OptionTableKey} from 'common/profiles/constants'
import {invert} from 'lodash'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useChoices} from 'web/hooks/use-choices'
import {useLocale, useT} from 'web/lib/locale'
import {useChoices} from "web/hooks/use-choices";
import {invert} from "lodash";
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function InterestFilterText(props: {
options: string[] | undefined
@@ -39,7 +39,7 @@ export function InterestFilterText(props: {
<div>
<span className={clsx('font-semibold', highlightedClass)}>
{stringOrStringArrayToText({
text: options.map(id => choices[id]),
text: options.map((id) => choices[id]),
capitalizeFirstLetterOption: true,
t: t,
})}{' '}
@@ -57,9 +57,7 @@ export function InterestFilter(props: {
const {filters, updateFilter, choices, label} = props
const {locale} = useLocale()
const sortedChoices = Object.fromEntries(
Object.entries(invert(choices)).sort((a, b) =>
a[0].localeCompare(b[0], locale)
)
Object.entries(invert(choices)).sort((a, b) => a[0].localeCompare(b[0], locale)),
)
return (
<MultiCheckbox

View File

@@ -1,12 +1,11 @@
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 'common/choices'
import {FilterFields} from 'common/filters'
import {getSortedOptions} from 'common/util/sorting'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {convertLanguageTypes} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function LanguageFilterText(props: {
options: string[] | undefined
@@ -37,7 +36,7 @@ export function LanguageFilterText(props: {
const sortedOptions = getSortedOptions(options, LANGUAGE_CHOICES)
const convertedTypes = sortedOptions.map((r) =>
t(`profile.language.${r}`, convertLanguageTypes(r as any))
t(`profile.language.${r}`, convertLanguageTypes(r as any)),
)
return (

View File

@@ -1,19 +1,20 @@
import clsx from 'clsx'
import {Col} from 'web/components/layout/col'
import {Slider} from 'web/components/widgets/slider'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {Row} from 'web/components/layout/row'
import {City, CityRow, originToCity, profileToCity, useCitySearch,} from '../search-location'
import {Profile} from 'common/profiles/profile'
import {useEffect, useState} from 'react'
import {Input} from 'web/components/widgets/input'
import {useT} from 'web/lib/locale'
import {XIcon} from '@heroicons/react/solid'
import {uniqBy} from 'lodash'
import {buildArray} from 'common/util/array'
import clsx from 'clsx'
import {OriginLocation} from 'common/filters'
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import {formatDistance, kmToMiles, milesToKm} from 'common/measurement-utils'
import {Profile} from 'common/profiles/profile'
import {buildArray} from 'common/util/array'
import {uniqBy} from 'lodash'
import {useEffect, useState} from 'react'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {Input} from 'web/components/widgets/input'
import {Slider} from 'web/components/widgets/slider'
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {useT} from 'web/lib/locale'
import {City, CityRow, originToCity, profileToCity, useCitySearch} from '../search-location'
export function LocationFilterText(props: {
location: OriginLocation | undefined | null
@@ -80,7 +81,7 @@ export function LocationFilter(props: {
const [lastCity, setLastCity] = usePersistentInMemoryState<City>(
location ? originToCity(location) : youCity || DEFAULT_LAST_CITY,
'last-used-city'
'last-used-city',
)
const setCity = (city: City | undefined) => {
@@ -100,10 +101,9 @@ export function LocationFilter(props: {
// search results
const {cities, loading, query, setQuery} = useCitySearch()
const listedCities = uniqBy(
buildArray(cities, lastCity, youCity),
'geodb_city_id'
).filter((c) => !location || location.id !== c.geodb_city_id)
const listedCities = uniqBy(buildArray(cities, lastCity, youCity), 'geodb_city_id').filter(
(c) => !location || location.id !== c.geodb_city_id,
)
return (
<Col className={clsx('w-full gap-3')}>
@@ -118,7 +118,7 @@ export function LocationFilter(props: {
/>
</Row>
{location && <DistanceSlider radius={radius} setRadius={setRadius}/>}
{location && <DistanceSlider radius={radius} setRadius={setRadius} />}
<LocationResults
showAny={!!location && query === ''}
@@ -134,10 +134,7 @@ export function LocationFilter(props: {
)
}
function DistanceSlider(props: {
radius: number
setRadius: (radius: number) => void
}) {
function DistanceSlider(props: {radius: number; setRadius: (radius: number) => void}) {
const {radius, setRadius} = props
const {measurementSystem} = useMeasurementSystem()
@@ -145,7 +142,7 @@ function DistanceSlider(props: {
const snapToValue = (value: number) => {
const closest = snapValues.reduce((prev, curr) =>
Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev
Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev,
)
// Convert back to miles if needed for internal storage
const closestMiles = measurementSystem === 'metric' ? kmToMiles(closest) : closest
@@ -199,10 +196,7 @@ function LocationResults(props: {
className="hover:bg-primary-200 hover:text-ink-950 cursor-pointer px-4 py-2 transition-colors"
>
<Row className="items-center gap-2">
<XIcon
className="h-4 w-4 text-ink-400"
aria-label={t('common.close', 'Close')}
/>
<XIcon className="h-4 w-4 text-ink-400" aria-label={t('common.close', 'Close')} />
<span>{t('filter.location.set_any_city', 'Set to Any City')}</span>
</Row>
</button>
@@ -220,8 +214,8 @@ function LocationResults(props: {
})}
{debouncedLoading && (
<div className="flex flex-col gap-2 px-4 py-2">
<div className="bg-ink-600 h-4 w-1/3 animate-pulse rounded-full"/>
<div className="bg-ink-400 h-4 w-2/3 animate-pulse rounded-full"/>
<div className="bg-ink-600 h-4 w-1/3 animate-pulse rounded-full" />
<div className="bg-ink-400 h-4 w-2/3 animate-pulse rounded-full" />
</div>
)}
</Col>

View File

@@ -3,8 +3,8 @@ import {MBTI_CHOICES} from 'common/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'
import {useT} from 'web/lib/locale'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function MbtiFilterText(props: {
options: string[] | undefined
@@ -18,9 +18,7 @@ export function MbtiFilterText(props: {
const label = defaultLabel || t('filter.any', 'Any')
if (!options || length < 1) {
return (
<span className={clsx('text-semibold', highlightedClass)}>{label}</span>
)
return <span className={clsx('text-semibold', highlightedClass)}>{label}</span>
}
if (length > 2) {

View File

@@ -1,50 +1,51 @@
import {ChevronDownIcon, ChevronUpIcon} from '@heroicons/react/outline'
import clsx from 'clsx'
import {ReactNode, useState} from 'react'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {AgeFilter, AgeFilterText, getNoMinMaxAge} from './age-filter'
import {GenderFilter, GenderFilterText} from './gender-filter'
import {LocationFilter, LocationFilterProps, LocationFilterText,} from './location-filter'
import {RelationshipFilter, RelationshipFilterText,} from './relationship-filter'
import {MyMatchesToggle} from './my-matches-toggle'
import {Profile} from 'common/profiles/profile'
import {FilterFields} from 'common/filters'
import {Gender} from 'common/gender'
import {DietType, RelationshipType, RomanticType} from 'web/lib/util/convert-types'
import {FilterFields} from "common/filters";
import {ShortBioToggle} from "web/components/filters/short-bio-toggle";
import {KidsLabel, WantsKidsFilter} from "web/components/filters/wants-kids-filter";
import {wantsKidsLabels} from "common/wants-kids";
import {HasKidsFilter, HasKidsLabel} from "./has-kids-filter"
import {hasKidsLabels} from "common/has-kids";
import {RomanticFilter, RomanticFilterText} from "web/components/filters/romantic-filter";
import {DietFilter, DietFilterText} from "web/components/filters/diet-filter";
import {PoliticalFilter, PoliticalFilterText} from "web/components/filters/political-filter";
import {EducationFilter, EducationFilterText} from "web/components/filters/education-filter";
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 {hasKidsLabels} from 'common/has-kids'
import {OptionTableKey} from 'common/profiles/constants'
import {Profile} from 'common/profiles/profile'
import {wantsKidsLabels} from 'common/wants-kids'
import {ReactNode, useState} from 'react'
import {BsPersonHeart, BsPersonVcard} from 'react-icons/bs'
import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from 'react-icons/fa'
import {FaUserGroup} from 'react-icons/fa6'
import {GiFruitBowl} from 'react-icons/gi'
import {LuCigarette, LuGraduationCap} from 'react-icons/lu'
import {MdLanguage, MdLocalBar} from 'react-icons/md'
import {PiHandsPrayingBold} from 'react-icons/pi'
import {RiScales3Line} from 'react-icons/ri'
import {Big5Filters, Big5FilterText, hasAnyBig5Filter} from 'web/components/filters/big5-filter'
import {DietFilter, DietFilterText} from 'web/components/filters/diet-filter'
import {EducationFilter, EducationFilterText} from 'web/components/filters/education-filter'
import {InterestFilter, InterestFilterText} from 'web/components/filters/interest-filter'
import {LanguageFilter, LanguageFilterText} from 'web/components/filters/language-filter'
import {MbtiFilter, MbtiFilterText} from 'web/components/filters/mbti-filter'
import {PoliticalFilter, PoliticalFilterText} from 'web/components/filters/political-filter'
import {
RelationshipStatusFilter,
RelationshipStatusFilterText
} from "web/components/filters/relationship-status-filter";
import {MbtiFilter, MbtiFilterText} from "web/components/filters/mbti-filter";
import {InterestFilter, InterestFilterText} from "web/components/filters/interest-filter";
import {OptionTableKey} from "common/profiles/constants";
import {NewBadge} from "web/components/new-badge";
import {useT} from "web/lib/locale";
import {FaUserGroup} from "react-icons/fa6";
import {BsPersonHeart, BsPersonVcard} from "react-icons/bs";
import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa";
import {GiFruitBowl} from "react-icons/gi";
import {MdLanguage, MdLocalBar} from "react-icons/md";
import {LuCigarette, LuGraduationCap} from "react-icons/lu";
import {RiScales3Line} from "react-icons/ri";
import {PiHandsPrayingBold} from "react-icons/pi";
import {ResetFiltersButton} from "web/components/searches/button";
import {Big5Filters, Big5FilterText, hasAnyBig5Filter} from "web/components/filters/big5-filter";
import {FilterGuide} from "web/components/guidance";
RelationshipStatusFilterText,
} from 'web/components/filters/relationship-status-filter'
import {ReligionFilter, ReligionFilterText} from 'web/components/filters/religion-filter'
import {RomanticFilter, RomanticFilterText} from 'web/components/filters/romantic-filter'
import {ShortBioToggle} from 'web/components/filters/short-bio-toggle'
import {KidsLabel, WantsKidsFilter} from 'web/components/filters/wants-kids-filter'
import {FilterGuide} from 'web/components/guidance'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {NewBadge} from 'web/components/new-badge'
import {ResetFiltersButton} from 'web/components/searches/button'
import {useT} from 'web/lib/locale'
import {DietType, RelationshipType, RomanticType} from 'web/lib/util/convert-types'
import {AgeFilter, AgeFilterText, getNoMinMaxAge} from './age-filter'
import {DrinksFilter, DrinksFilterText, getNoMinMaxDrinks} from './drinks-filter'
import {GenderFilter, GenderFilterText} from './gender-filter'
import {HasKidsFilter, HasKidsLabel} from './has-kids-filter'
import {LocationFilter, LocationFilterProps, LocationFilterText} from './location-filter'
import {MyMatchesToggle} from './my-matches-toggle'
import {RelationshipFilter, RelationshipFilterText} from './relationship-filter'
import {SmokerFilter, SmokerFilterText} from './smoker-filter'
function MobileFilters(props: {
filters: Partial<FilterFields>
@@ -76,15 +77,11 @@ function MobileFilters(props: {
return !!filterArray && filterArray.length > 0
}
const [noMinAge, noMaxAge] = getNoMinMaxAge(
filters.pref_age_min,
filters.pref_age_max
)
const [noMinAge, noMaxAge] = getNoMinMaxAge(filters.pref_age_min, filters.pref_age_max)
return (
<Col className="mb-[calc(20px+env(safe-area-inset-bottom))] mt-[calc(20px+env(safe-area-inset-top))]">
<FilterGuide className={'justify-between px-4 py-2'}/>
<FilterGuide className={'justify-between px-4 py-2'} />
<Row className="justify-between px-4">
<Col className="py-2">
@@ -95,39 +92,31 @@ function MobileFilters(props: {
hidden={!youProfile}
/>
</Col>
<ResetFiltersButton
clearFilters={clearFilters}
/>
<ResetFiltersButton clearFilters={clearFilters} />
</Row>
{/* Short Bios */}
<Col className="p-4 pb-2">
<ShortBioToggle
updateFilter={updateFilter}
filters={filters}
hidden={false}
/>
<ShortBioToggle updateFilter={updateFilter} filters={filters} hidden={false} />
</Col>
{/* CONNECTION */}
<MobileFilterSection
title={t('profile.seeking', "Seeking")}
title={t('profile.seeking', 'Seeking')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.pref_relation_styles)}
icon={<FaUserGroup className="h-4 w-4"/>}
icon={<FaUserGroup className="h-4 w-4" />}
selection={
<RelationshipFilterText
relationship={filters.pref_relation_styles as RelationshipType[]}
highlightedClass={
hasAny(filters.pref_relation_styles)
? 'text-primary-600'
: 'text-ink-900'
hasAny(filters.pref_relation_styles) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<RelationshipFilter filters={filters} updateFilter={updateFilter}/>
<RelationshipFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* Relationship Status */}
@@ -136,25 +125,23 @@ function MobileFilters(props: {
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.relationship_status || undefined)}
icon={<BsPersonHeart className="h-4 w-4"/>}
icon={<BsPersonHeart className="h-4 w-4" />}
selection={
<RelationshipStatusFilterText
options={filters.relationship_status as string[]}
defaultLabel={t('filter.relationship_status.any', 'Any relationship status')}
highlightedClass={
hasAny(filters.relationship_status || undefined)
? 'text-primary-600'
: 'text-ink-900'
hasAny(filters.relationship_status || undefined) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<RelationshipStatusFilter filters={filters} updateFilter={updateFilter}/>
<RelationshipStatusFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* LOCATION */}
<MobileFilterSection
title={t("profile.optional.location", "Location")}
title={t('profile.optional.location', 'Location')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={!!locationFilterProps.location}
@@ -163,23 +150,16 @@ function MobileFilters(props: {
location={locationFilterProps.location}
radius={locationFilterProps.radius}
youProfile={youProfile}
highlightedClass={
!locationFilterProps.location
? 'text-ink-900'
: 'text-primary-600'
}
highlightedClass={!locationFilterProps.location ? 'text-ink-900' : 'text-primary-600'}
/>
}
>
<LocationFilter
youProfile={youProfile}
locationFilterProps={locationFilterProps}
/>
<LocationFilter youProfile={youProfile} locationFilterProps={locationFilterProps} />
</MobileFilterSection>
{/* AGE RANGE */}
<MobileFilterSection
title={t("profile.optional.age", "Age")}
title={t('profile.optional.age', 'Age')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
childrenClassName={'pb-6'}
@@ -188,31 +168,27 @@ function MobileFilters(props: {
<AgeFilterText
pref_age_min={filters.pref_age_min}
pref_age_max={filters.pref_age_max}
highlightedClass={
noMinAge && noMaxAge ? 'text-ink-900' : 'text-primary-600'
}
highlightedClass={noMinAge && noMaxAge ? 'text-ink-900' : 'text-primary-600'}
/>
}
>
<AgeFilter filters={filters} updateFilter={updateFilter}/>
<AgeFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* GENDER */}
<MobileFilterSection
title={t("profile.optional.gender", "Gender")}
title={t('profile.optional.gender', 'Gender')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.genders)}
selection={
<GenderFilterText
gender={filters.genders as Gender[]}
highlightedClass={
hasAny(filters.genders) ? 'text-primary-600' : 'text-ink-900'
}
highlightedClass={hasAny(filters.genders) ? 'text-primary-600' : 'text-ink-900'}
/>
}
>
<GenderFilter filters={filters} updateFilter={updateFilter}/>
<GenderFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* PREFERRED GENDER */}
@@ -233,131 +209,125 @@ function MobileFilters(props: {
{/* <PrefGenderFilter filters={filters} updateFilter={updateFilter}/>*/}
{/*</MobileFilterSection>*/}
{includeRelationshipFilters && <>
{/* ROMANTIC STYLE */}
{includeRelationshipFilters && (
<>
{/* ROMANTIC STYLE */}
<MobileFilterSection
title={t("profile.romantic.style", "Style")}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.pref_romantic_styles || undefined)}
icon={<FaHeart className="h-4 w-4"/>}
selection={
<RomanticFilterText
relationship={filters.pref_romantic_styles as RomanticType[]}
highlightedClass={
hasAny(filters.pref_romantic_styles || undefined)
? 'text-primary-600'
: 'text-ink-900'
}
/>
}
title={t('profile.romantic.style', 'Style')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.pref_romantic_styles || undefined)}
icon={<FaHeart className="h-4 w-4" />}
selection={
<RomanticFilterText
relationship={filters.pref_romantic_styles as RomanticType[]}
highlightedClass={
hasAny(filters.pref_romantic_styles || undefined)
? 'text-primary-600'
: 'text-ink-900'
}
/>
}
>
<RomanticFilter filters={filters} updateFilter={updateFilter}/>
<RomanticFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* WANTS KIDS */}
{/* WANTS KIDS */}
<MobileFilterSection
title={t('filter.wants_kids.wants_kids', 'Wants kids')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={
filters.wants_kids_strength != null &&
filters.wants_kids_strength !== -1
}
title={t('filter.wants_kids.wants_kids', 'Wants kids')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={filters.wants_kids_strength != null && filters.wants_kids_strength !== -1}
// icon={<WantsKidsIcon strength={filters.wants_kids_strength ?? -1}/>}
selection={
<KidsLabel
strength={filters.wants_kids_strength ?? -1}
highlightedClass={
(filters.wants_kids_strength ?? -1) ==
wantsKidsLabels.no_preference.strength
? 'text-ink-900'
: 'text-primary-600'
}
mobile
/>
}
selection={
<KidsLabel
strength={filters.wants_kids_strength ?? -1}
highlightedClass={
(filters.wants_kids_strength ?? -1) == wantsKidsLabels.no_preference.strength
? 'text-ink-900'
: 'text-primary-600'
}
mobile
/>
}
>
<WantsKidsFilter filters={filters} updateFilter={updateFilter}/>
<WantsKidsFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* HAS KIDS */}
{/* HAS KIDS */}
<MobileFilterSection
title={t("profile.has_kids", "Has kids")}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={filters.has_kids != null && filters.has_kids !== -1}
title={t('profile.has_kids', 'Has kids')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={filters.has_kids != null && filters.has_kids !== -1}
// icon={<FaChild className="text-ink-900 h-4 w-4"/>}
selection={
<HasKidsLabel
has_kids={filters.has_kids ?? -1}
highlightedClass={
(filters.has_kids ?? -1) == hasKidsLabels.no_preference.value
? 'text-ink-900'
: 'text-primary-600'
}
mobile
/>
}
selection={
<HasKidsLabel
has_kids={filters.has_kids ?? -1}
highlightedClass={
(filters.has_kids ?? -1) == hasKidsLabels.no_preference.value
? 'text-ink-900'
: 'text-primary-600'
}
mobile
/>
}
>
<HasKidsFilter filters={filters} updateFilter={updateFilter}/>
<HasKidsFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
</>}
</>
)}
{/* DIET */}
<MobileFilterSection
title={t("profile.optional.diet", "Diet")}
title={t('profile.optional.diet', 'Diet')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.diet || undefined)}
icon={<GiFruitBowl className="h-4 w-4"/>}
icon={<GiFruitBowl className="h-4 w-4" />}
selection={
<DietFilterText
options={filters.diet as DietType[]}
highlightedClass={
hasAny(filters.diet || undefined)
? 'text-primary-600'
: 'text-ink-900'
hasAny(filters.diet || undefined) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<DietFilter filters={filters} updateFilter={updateFilter}/>
<DietFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* DRINKS PER MONTH */}
<MobileFilterSection
title={t("profile.drinks", "Drinks")}
title={t('profile.drinks', 'Drinks')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={(() => {
const [noMin, noMax] = getNoMinMaxDrinks(filters.drinks_min, filters.drinks_max);
const [noMin, noMax] = getNoMinMaxDrinks(filters.drinks_min, filters.drinks_max)
return !noMin || !noMax
})()}
icon={<MdLocalBar className="h-4 w-4"/>}
icon={<MdLocalBar className="h-4 w-4" />}
selection={
<DrinksFilterText
drinks_min={filters.drinks_min}
drinks_max={filters.drinks_max}
highlightedClass={(() => {
const [noMin, noMax] = getNoMinMaxDrinks(filters.drinks_min, filters.drinks_max);
return (noMin && noMax) ? 'text-ink-900' : 'text-primary-600'
const [noMin, noMax] = getNoMinMaxDrinks(filters.drinks_min, filters.drinks_max)
return noMin && noMax ? 'text-ink-900' : 'text-primary-600'
})()}
/>
}
>
<DrinksFilter filters={filters} updateFilter={updateFilter}/>
<DrinksFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* SMOKER */}
<MobileFilterSection
title={t("profile.smokes", "Smoker")}
title={t('profile.smokes', 'Smoker')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={filters.is_smoker != null}
icon={<LuCigarette className="h-4 w-4"/>}
icon={<LuCigarette className="h-4 w-4" />}
selection={
<SmokerFilterText
is_smoker={filters.is_smoker}
@@ -366,46 +336,42 @@ function MobileFilters(props: {
/>
}
>
<SmokerFilter filters={filters} updateFilter={updateFilter}/>
<SmokerFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* LANGUAGES */}
<MobileFilterSection
title={t("profile.optional.languages", "Languages")}
title={t('profile.optional.languages', 'Languages')}
// className="col-span-full max-h-80 overflow-y-auto"
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.languages || undefined)}
icon={<MdLanguage className="h-4 w-4"/>}
icon={<MdLanguage className="h-4 w-4" />}
selection={
<LanguageFilterText
options={filters.languages as string[]}
highlightedClass={
hasAny(filters.languages || undefined)
? 'text-primary-600'
: 'text-ink-900'
hasAny(filters.languages || undefined) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<LanguageFilter filters={filters} updateFilter={updateFilter}/>
<LanguageFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* INTERESTS */}
<MobileFilterSection
newBadgeClassName={"-top-0 -left-0"}
title={t("profile.optional.interests", "Interests")}
newBadgeClassName={'-top-0 -left-0'}
title={t('profile.optional.interests', 'Interests')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.interests || undefined)}
icon={<FaStar className="h-4 w-4"/>}
icon={<FaStar className="h-4 w-4" />}
selection={
<InterestFilterText
options={filters.interests as string[]}
highlightedClass={
hasAny(filters.interests || undefined)
? 'text-primary-600'
: 'text-ink-900'
hasAny(filters.interests || undefined) ? 'text-primary-600' : 'text-ink-900'
}
label={'interests'}
/>
@@ -421,19 +387,17 @@ function MobileFilters(props: {
{/* CAUSES */}
<MobileFilterSection
newBadgeClassName={"-top-0 -left-0"}
title={t("profile.optional.causes", "Causes")}
newBadgeClassName={'-top-0 -left-0'}
title={t('profile.optional.causes', 'Causes')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.causes || undefined)}
icon={<FaHandsHelping className="h-4 w-4"/>}
icon={<FaHandsHelping className="h-4 w-4" />}
selection={
<InterestFilterText
options={filters.causes as string[]}
highlightedClass={
hasAny(filters.causes || undefined)
? 'text-primary-600'
: 'text-ink-900'
hasAny(filters.causes || undefined) ? 'text-primary-600' : 'text-ink-900'
}
label={'causes'}
/>
@@ -449,19 +413,17 @@ function MobileFilters(props: {
{/* WORK */}
<MobileFilterSection
newBadgeClassName={"-top-0 -left-0"}
title={t("profile.optional.work", "Work")}
newBadgeClassName={'-top-0 -left-0'}
title={t('profile.optional.work', 'Work')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.work || undefined)}
icon={<FaBriefcase className="h-4 w-4"/>}
icon={<FaBriefcase className="h-4 w-4" />}
selection={
<InterestFilterText
options={filters.work as string[]}
highlightedClass={
hasAny(filters.work || undefined)
? 'text-primary-600'
: 'text-ink-900'
hasAny(filters.work || undefined) ? 'text-primary-600' : 'text-ink-900'
}
label={'work'}
/>
@@ -477,32 +439,30 @@ function MobileFilters(props: {
{/* POLITICS */}
<MobileFilterSection
title={t("profile.optional.political_beliefs", "Politics")}
title={t('profile.optional.political_beliefs', 'Politics')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.political_beliefs || undefined)}
icon={<RiScales3Line className="h-4 w-4"/>}
icon={<RiScales3Line className="h-4 w-4" />}
selection={
<PoliticalFilterText
options={filters.political_beliefs as string[]}
highlightedClass={
hasAny(filters.political_beliefs || undefined)
? 'text-primary-600'
: 'text-ink-900'
hasAny(filters.political_beliefs || undefined) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<PoliticalFilter filters={filters} updateFilter={updateFilter}/>
<PoliticalFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* RELIGION */}
<MobileFilterSection
title={t("profile.optional.religious_beliefs", "Religion")}
title={t('profile.optional.religious_beliefs', 'Religion')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.religion || undefined)}
icon={<PiHandsPrayingBold className="h-4 w-4"/>}
icon={<PiHandsPrayingBold className="h-4 w-4" />}
selection={
<ReligionFilterText
options={filters.religion as string[]}
@@ -512,7 +472,7 @@ function MobileFilters(props: {
/>
}
>
<ReligionFilter filters={filters} updateFilter={updateFilter}/>
<ReligionFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* MBTI */}
@@ -521,18 +481,16 @@ function MobileFilters(props: {
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.mbti)}
icon={<BsPersonVcard className="h-4 w-4"/>}
icon={<BsPersonVcard className="h-4 w-4" />}
selection={
<MbtiFilterText
options={filters.mbti as string[]}
highlightedClass={
hasAny(filters.mbti) ? 'text-primary-600' : 'text-ink-900'
}
highlightedClass={hasAny(filters.mbti) ? 'text-primary-600' : 'text-ink-900'}
defaultLabel={t('filter.any_mbti', 'Any MBTI')}
/>
}
>
<MbtiFilter filters={filters} updateFilter={updateFilter}/>
<MbtiFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* BIG FIVE PERSONALITY */}
@@ -541,26 +499,24 @@ function MobileFilters(props: {
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAnyBig5Filter(filters)}
icon={<BsPersonVcard className="h-4 w-4"/>}
icon={<BsPersonVcard className="h-4 w-4" />}
selection={
<Big5FilterText
filters={filters}
highlightedClass={
hasAnyBig5Filter(filters) ? 'text-primary-600' : 'text-ink-900'
}
highlightedClass={hasAnyBig5Filter(filters) ? 'text-primary-600' : 'text-ink-900'}
/>
}
>
<Big5Filters filters={filters} updateFilter={updateFilter}/>
<Big5Filters filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
{/* EDUCATION */}
<MobileFilterSection
title={t("profile.education.short_name", "Education")}
title={t('profile.education.short_name', 'Education')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.education_levels)}
icon={<LuGraduationCap className="h-4 w-4"/>}
icon={<LuGraduationCap className="h-4 w-4" />}
selection={
<EducationFilterText
options={filters.education_levels as string[]}
@@ -570,7 +526,7 @@ function MobileFilters(props: {
/>
}
>
<EducationFilter filters={filters} updateFilter={updateFilter}/>
<EducationFilter filters={filters} updateFilter={updateFilter} />
</MobileFilterSection>
</Col>
)
@@ -612,32 +568,22 @@ export function MobileFilterSection(props: {
<button
className={clsx(
'text-ink-600 flex w-full flex-row justify-between px-4 pt-4 relative',
isOpen ? 'pb-2' : 'pb-4'
isOpen ? 'pb-2' : 'pb-4',
)}
onClick={() =>
isOpen ? setOpenFilter(undefined) : setOpenFilter(title)
}
onClick={() => (isOpen ? setOpenFilter(undefined) : setOpenFilter(title))}
>
{showNewBadge && <NewBadge classes={newBadgeClassName}/>}
<Row
className={clsx('items-center gap-2', isActive && 'font-semibold')}
>
{showNewBadge && <NewBadge classes={newBadgeClassName} />}
<Row className={clsx('items-center gap-2', isActive && 'font-semibold')}>
{icon}
{selection}
{/*{title}: {selection}*/}
</Row>
<div className="text-ink-900">
{isOpen ? (
<ChevronUpIcon className="h-5 w-5"/>
) : (
<ChevronDownIcon className="h-5 w-5"/>
)}
{isOpen ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
</div>
</button>
{isOpen && (
<div className={clsx('bg-canvas-50 px-4 py-2', childrenClassName)}>
{children}
</div>
<div className={clsx('bg-canvas-50 px-4 py-2', childrenClassName)}>{children}</div>
)}
</Col>
)

View File

@@ -1,7 +1,7 @@
import {Row} from 'web/components/layout/row'
import clsx from 'clsx'
import {Profile} from 'common/profiles/profile'
import {useT} from "web/lib/locale";
import {Row} from 'web/components/layout/row'
import {useT} from 'web/lib/locale'
export function MyMatchesToggle(props: {
setYourFilters: (checked: boolean) => void

View File

@@ -1,13 +1,12 @@
import clsx from 'clsx'
import {convertPoliticalTypes,} 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 {POLITICAL_CHOICES} from 'common/choices'
import {FilterFields} from 'common/filters'
import {getSortedOptions} from 'common/util/sorting'
import {useT} from 'web/lib/locale'
import {toKey} from 'common/parsing'
import {getSortedOptions} from 'common/util/sorting'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {convertPoliticalTypes} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function PoliticalFilterText(props: {
options: string[] | undefined
@@ -38,7 +37,7 @@ export function PoliticalFilterText(props: {
const sortedOptions = getSortedOptions(options, POLITICAL_CHOICES)
const convertedTypes = sortedOptions.map((r) =>
t(`profile.political.${toKey(r)}`, convertPoliticalTypes(r as any))
t(`profile.political.${toKey(r)}`, convertPoliticalTypes(r as any)),
)
return (

View File

@@ -1,11 +1,12 @@
import clsx from 'clsx'
import GenderIcon from '../gender-icon'
import {GENDERS_PLURAL} from 'common/choices'
import {FilterFields} from 'common/filters'
import {Gender} from 'common/gender'
import {Row} from 'web/components/layout/row'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {FilterFields} from 'common/filters'
import {useT} from 'web/lib/locale'
import {GENDERS_PLURAL} from "common/choices";
import GenderIcon from '../gender-icon'
export function PrefGenderFilterText(props: {
pref_gender: Gender[] | undefined
@@ -18,12 +19,7 @@ export function PrefGenderFilterText(props: {
return (
<span>
<span className="">{t('filter.gender.they_seek', 'Gender they seek')}: </span>
<span
className={clsx(
'text-semibold capitalize sm:normal-case',
highlightedClass
)}
>
<span className={clsx('text-semibold capitalize sm:normal-case', highlightedClass)}>
{t('filter.any', 'any')}
</span>
</span>
@@ -33,14 +29,7 @@ export function PrefGenderFilterText(props: {
<Row className="items-center gap-0.5 font-semibold">
<span className="">{t('filter.gender.they_seek', 'Gender they seek')}: </span>
{pref_gender.map((gender) => {
return (
<GenderIcon
key={gender}
gender={gender}
className={clsx('h-4 w-4')}
hasColor
/>
)
return <GenderIcon key={gender} gender={gender} className={clsx('h-4 w-4')} hasColor />
})}
</Row>
)

View File

@@ -1,11 +1,10 @@
import clsx from 'clsx'
import {convertRelationshipType, RelationshipType,} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {RELATIONSHIP_CHOICES} from 'common/choices'
import {FilterFields} from 'common/filters'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {RELATIONSHIP_CHOICES} from "common/choices";
import {FilterFields} from "common/filters";
import {convertRelationshipType, RelationshipType} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function RelationshipFilterText(props: {
relationship: RelationshipType[] | undefined
@@ -24,7 +23,7 @@ export function RelationshipFilterText(props: {
}
const convertedRelationships = relationship?.map((r) =>
t(`profile.relationship.${r}`, convertRelationshipType(r))
t(`profile.relationship.${r}`, convertRelationshipType(r)),
)
if (relationshipLength > 1) {

View File

@@ -1,12 +1,11 @@
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 {RELATIONSHIP_STATUS_CHOICES} from 'common/choices'
import {FilterFields} from 'common/filters'
import {getSortedOptions} from 'common/util/sorting'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {RELATIONSHIP_STATUS_CHOICES} from "common/choices"
import {FilterFields} from "common/filters"
import {getSortedOptions} from "common/util/sorting"
import {useT} from 'web/lib/locale'
import {convertRelationshipStatusTypes} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function RelationshipStatusFilterText(props: {
options: string[] | undefined
@@ -20,9 +19,7 @@ export function RelationshipStatusFilterText(props: {
const label = defaultLabel || t('filter.any', 'Any')
if (!options || length < 1) {
return (
<span className={clsx('text-semibold', highlightedClass)}>{label}</span>
)
return <span className={clsx('text-semibold', highlightedClass)}>{label}</span>
}
if (length > 2) {
@@ -37,7 +34,7 @@ export function RelationshipStatusFilterText(props: {
const sortedOptions = getSortedOptions(options, RELATIONSHIP_STATUS_CHOICES)
const convertedTypes = sortedOptions.map((r) =>
t(`profile.relationship_status.${r}`, convertRelationshipStatusTypes(r))
t(`profile.relationship_status.${r}`, convertRelationshipStatusTypes(r)),
)
return (

View File

@@ -1,14 +1,12 @@
import clsx from 'clsx'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {FilterFields} from 'common/filters'
import {RELIGION_CHOICES} from 'common/choices'
import {FilterFields} from 'common/filters'
import {toKey} from 'common/parsing'
import {getSortedOptions} from 'common/util/sorting'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {convertReligionTypes} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {getSortedOptions} from 'common/util/sorting'
import {useT} from 'web/lib/locale'
import {toKey} from 'common/parsing'
export function ReligionFilterText(props: {
options: string[] | undefined
@@ -39,7 +37,7 @@ export function ReligionFilterText(props: {
const sortedOptions = getSortedOptions(options, RELIGION_CHOICES)
const convertedTypes = sortedOptions.map((r) =>
t(`profile.religion.${toKey(r)}`, convertReligionTypes(r as any))
t(`profile.religion.${toKey(r)}`, convertReligionTypes(r as any)),
)
return (

View File

@@ -1,12 +1,11 @@
import clsx from 'clsx'
import {convertRomanticTypes, RomanticType,} 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 {ROMANTIC_CHOICES} from 'common/choices'
import {FilterFields} from 'common/filters'
import {toKey} from 'common/parsing'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import {toKey} from "common/parsing";
import {convertRomanticTypes, RomanticType} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function RomanticFilterText(props: {
relationship: RomanticType[] | undefined
@@ -25,7 +24,7 @@ export function RomanticFilterText(props: {
}
const convertedTypes = relationship.map((r) =>
t(`profile.romantic.${toKey(r)}`, convertRomanticTypes(r))
t(`profile.romantic.${toKey(r)}`, convertRomanticTypes(r)),
)
if (length > 1) {

View File

@@ -1,127 +1,128 @@
import {QuestionMarkCircleIcon} from '@heroicons/react/outline'
import {DisplayUser} from 'common/api/user-types'
import {FilterFields} from 'common/filters'
import {Profile} from 'common/profiles/profile'
import {forwardRef, useEffect, useRef, useState} from 'react'
import toast from 'react-hot-toast'
import {IoFilterSharp} from 'react-icons/io5'
import {Button} from 'web/components/buttons/button'
import {FilterGuide} from 'web/components/guidance'
import {Col} from 'web/components/layout/col'
import {RightModal} from 'web/components/layout/right-modal'
import {Row} from 'web/components/layout/row'
import {BookmarkSearchButton, BookmarkStarButton} from 'web/components/searches/button'
import {Input} from 'web/components/widgets/input'
import {Select} from 'web/components/widgets/select'
import {Tooltip} from 'web/components/widgets/tooltip'
import {BookmarkedSearchesType} from 'web/hooks/use-bookmarked-searches'
import {useChoices} from 'web/hooks/use-choices'
import {useIsClearedFilters} from 'web/hooks/use-is-cleared-filters'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {submitBookmarkedSearch} from 'web/lib/supabase/searches'
import {DesktopFilters} from './desktop-filters'
import {LocationFilterProps} from './location-filter'
import MobileFilters from './mobile-filters'
import {BookmarkSearchButton, BookmarkStarButton} from "web/components/searches/button";
import {BookmarkedSearchesType} from "web/hooks/use-bookmarked-searches";
import {submitBookmarkedSearch} from "web/lib/supabase/searches";
import {useUser} from "web/hooks/use-user";
import toast from "react-hot-toast";
import {FilterFields} from "common/filters";
import {DisplayUser} from "common/api/user-types";
import {useChoices} from "web/hooks/use-choices";
import {useT} from "web/lib/locale";
import {Tooltip} from "web/components/widgets/tooltip";
import {QuestionMarkCircleIcon} from "@heroicons/react/outline";
import {useIsClearedFilters} from "web/hooks/use-is-cleared-filters";
import {FilterGuide} from "web/components/guidance";
function isOrderBy(input: string): input is FilterFields['orderBy'] {
return ['last_online_time', 'created_time', 'compatibility_score'].includes(
input
)
return ['last_online_time', 'created_time', 'compatibility_score'].includes(input)
}
const TYPING_SPEED = 100; // ms per character
const HOLD_TIME = 2000; // ms to hold the full word before deleting or switching
const TYPING_SPEED = 100 // ms per character
const HOLD_TIME = 2000 // ms to hold the full word before deleting or switching
export const WORDS: string[] = [
// Values
"Minimalism",
"Sustainability",
"Veganism",
"Meditation",
"Climate",
"Animal",
"Community living",
"Open source",
"Spirituality",
'Minimalism',
'Sustainability',
'Veganism',
'Meditation',
'Climate',
'Animal',
'Community living',
'Open source',
'Spirituality',
// Intellectual interests
"Philosophy",
"AI safety",
"Psychology",
'Philosophy',
'AI safety',
'Psychology',
// Arts & culture
"Indie film",
"Jazz",
"Contemporary art",
"Folk music",
"Poetry",
"Sci-fi",
"Board games",
'Indie film',
'Jazz',
'Contemporary art',
'Folk music',
'Poetry',
'Sci-fi',
'Board games',
// Relationship intentions
"Study buddy",
"Co-founder",
'Study buddy',
'Co-founder',
// Lifestyle
"Digital nomad",
"Permaculture",
"Yoga",
'Digital nomad',
'Permaculture',
'Yoga',
// Random human quirks (to make it feel alive)
"Chess",
"Rock climbing",
"Stargazing",
'Chess',
'Rock climbing',
'Stargazing',
// Other
"Feminism",
"Coding",
"ENFP",
"INTP",
"Therapy",
"Science",
"Camus",
"Running",
"Writing",
"Reading",
"Anime",
"Drawing",
"Photography",
"Linux",
"History",
"Graphics design",
"Math",
"Ethereum",
"Finance",
'Feminism',
'Coding',
'ENFP',
'INTP',
'Therapy',
'Science',
'Camus',
'Running',
'Writing',
'Reading',
'Anime',
'Drawing',
'Photography',
'Linux',
'History',
'Graphics design',
'Math',
'Ethereum',
'Finance',
]
function getRandomPair(words = WORDS, count = 3): string {
const shuffled = [...words].sort(() => 0.5 - Math.random())
return shuffled.slice(0, count).join(", ")
return shuffled.slice(0, count).join(', ')
}
const MAX_BOOKMARKED_SEARCHES = 10
const MAX_BOOKMARKED_SEARCHES = 10;
export const Search = forwardRef<HTMLInputElement, {
youProfile: Profile | undefined | null
starredUsers: DisplayUser[]
refreshStars: () => void
// filter props
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
clearFilters: () => void
setYourFilters: (checked: boolean) => void
isYourFilters: boolean
locationFilterProps: LocationFilterProps
bookmarkedSearches: BookmarkedSearchesType[]
refreshBookmarkedSearches: () => void
profileCount: number | undefined
openFilters?: () => void
openFiltersModal?: boolean
highlightFilters?: boolean
highlightSort?: boolean
setOpenFiltersModal?: (open: boolean) => void
}>((props, ref) => {
export const Search = forwardRef<
HTMLInputElement,
{
youProfile: Profile | undefined | null
starredUsers: DisplayUser[]
refreshStars: () => void
// filter props
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
clearFilters: () => void
setYourFilters: (checked: boolean) => void
isYourFilters: boolean
locationFilterProps: LocationFilterProps
bookmarkedSearches: BookmarkedSearchesType[]
refreshBookmarkedSearches: () => void
profileCount: number | undefined
openFilters?: () => void
openFiltersModal?: boolean
highlightFilters?: boolean
highlightSort?: boolean
setOpenFiltersModal?: (open: boolean) => void
}
>((props, ref) => {
const {
youProfile,
updateFilter,
@@ -173,15 +174,15 @@ export const Search = forwardRef<HTMLInputElement, {
}
}, [highlightSort])
const [placeholder, setPlaceholder] = useState('');
const [textToType, setTextToType] = useState(getRandomPair());
const [_, setCharIndex] = useState(0);
const [isHolding, setIsHolding] = useState(false);
const [bookmarked, setBookmarked] = useState(false);
const [loadingBookmark, setLoadingBookmark] = useState(false);
const [placeholder, setPlaceholder] = useState('')
const [textToType, setTextToType] = useState(getRandomPair())
const [_, setCharIndex] = useState(0)
const [isHolding, setIsHolding] = useState(false)
const [bookmarked, setBookmarked] = useState(false)
const [loadingBookmark, setLoadingBookmark] = useState(false)
const t = useT()
const [openBookmarks, setOpenBookmarks] = useState(false);
const [openStarBookmarks, setOpenStarBookmarks] = useState(false);
const [openBookmarks, setOpenBookmarks] = useState(false)
const [openStarBookmarks, setOpenStarBookmarks] = useState(false)
const user = useUser()
const youSeekingRelationship = youProfile?.pref_relation_styles?.includes('relationship')
const isClearedFilters = useIsClearedFilters(filters)
@@ -195,33 +196,33 @@ export const Search = forwardRef<HTMLInputElement, {
}
useEffect(() => {
if (isHolding) return;
if (isHolding) return
const interval = setInterval(() => {
setCharIndex((prev) => {
if (prev < textToType.length) {
setPlaceholder(textToType.slice(0, prev + 1));
return prev + 1;
setPlaceholder(textToType.slice(0, prev + 1))
return prev + 1
} else {
setIsHolding(true);
clearInterval(interval);
setIsHolding(true)
clearInterval(interval)
setTimeout(() => {
setPlaceholder('');
setCharIndex(0);
setTextToType(getRandomPair(Object.values(interestChoices))); // pick new pair
setIsHolding(false);
}, HOLD_TIME);
return prev;
setPlaceholder('')
setCharIndex(0)
setTextToType(getRandomPair(Object.values(interestChoices))) // pick new pair
setIsHolding(false)
}, HOLD_TIME)
return prev
}
});
}, TYPING_SPEED);
})
}, TYPING_SPEED)
return () => clearInterval(interval);
}, [textToType, isHolding]);
return () => clearInterval(interval)
}, [textToType, isHolding])
useEffect(() => {
setTimeout(() => setBookmarked(false), 2000);
}, [bookmarked]);
setTimeout(() => setBookmarked(false), 2000)
}, [bookmarked])
return (
<Col className={'text-ink-600 w-full gap-2 py-2 text-sm main-font'}>
@@ -256,16 +257,16 @@ export const Search = forwardRef<HTMLInputElement, {
<option value="last_online_time">{t('common.active', 'Active')}</option>
</Select>
<Button
color={highlightFilters ? "blue" : "none"}
color={highlightFilters ? 'blue' : 'none'}
size="sm"
className={`border-ink-300 border sm:hidden${highlightFilters ? " border-blue-500" : ""}`}
className={`border-ink-300 border sm:hidden${highlightFilters ? ' border-blue-500' : ''}`}
onClick={handleOpenFilters}
>
<IoFilterSharp className="h-5 w-5"/>
<IoFilterSharp className="h-5 w-5" />
</Button>
</Row>
</Row>
<FilterGuide className={'hidden sm:inline'}/>
<FilterGuide className={'hidden sm:inline'} />
<Row
className={
'border-ink-300 dark:border-ink-300 hidden flex-wrap items-center gap-4 pb-4 pt-1 sm:inline-flex'
@@ -307,18 +308,19 @@ export const Search = forwardRef<HTMLInputElement, {
loading={loadingBookmark}
onClick={() => {
if (bookmarkedSearches.length >= MAX_BOOKMARKED_SEARCHES) {
toast.error(`You can bookmark maximum ${MAX_BOOKMARKED_SEARCHES} searches; please delete one first.`)
toast.error(
`You can bookmark maximum ${MAX_BOOKMARKED_SEARCHES} searches; please delete one first.`,
)
setOpenBookmarks(true)
return
}
setLoadingBookmark(true)
submitBookmarkedSearch(filters, locationFilterProps, user?.id)
.finally(() => {
setLoadingBookmark(false)
setBookmarked(true)
refreshBookmarkedSearches()
setOpenBookmarks(true)
})
submitBookmarkedSearch(filters, locationFilterProps, user?.id).finally(() => {
setLoadingBookmark(false)
setBookmarked(true)
refreshBookmarkedSearches()
setOpenBookmarks(true)
})
}}
size={'xs'}
color={'none'}
@@ -330,8 +332,7 @@ export const Search = forwardRef<HTMLInputElement, {
? ''
: isClearedFilters
? t('common.notified_any', 'Get notified for any new profile')
: t('common.notified', 'Get notified for selected filters')
}
: t('common.notified', 'Get notified for selected filters')}
</Button>
<BookmarkSearchButton
@@ -353,11 +354,22 @@ export const Search = forwardRef<HTMLInputElement, {
</Row>
{(profileCount ?? 0) > 0 && (
<Row className="text-sm text-ink-500 gap-2">
<p>{profileCount} {(profileCount ?? 0) > 1 ? t('common.people', 'people') : t('common.person', 'person')}</p>
{!filters.shortBio && <Tooltip
text={t('search.include_short_bios_tooltip', 'To list all the profiles, tick "Include incomplete profiles"')}>
<QuestionMarkCircleIcon className="w-5 h-5"/>
</Tooltip>}
<p>
{profileCount}{' '}
{(profileCount ?? 0) > 1
? t('common.people', 'people')
: t('common.person', 'person')}
</p>
{!filters.shortBio && (
<Tooltip
text={t(
'search.include_short_bios_tooltip',
'To list all the profiles, tick "Include incomplete profiles"',
)}
>
<QuestionMarkCircleIcon className="w-5 h-5" />
</Tooltip>
)}
</Row>
)}
</Row>

View File

@@ -1,7 +1,7 @@
import {Row} from 'web/components/layout/row'
import clsx from 'clsx'
import {FilterFields} from "common/filters";
import {useT} from "web/lib/locale";
import {FilterFields} from 'common/filters'
import {Row} from 'web/components/layout/row'
import {useT} from 'web/lib/locale'
export function ShortBioToggle(props: {
filters: Partial<FilterFields>

View File

@@ -1,7 +1,7 @@
import clsx from 'clsx'
import {FilterFields} from 'common/filters'
import {Row} from 'web/components/layout/row'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {FilterFields} from 'common/filters'
import {useT} from 'web/lib/locale'
export function SmokerFilterText(props: {
@@ -17,8 +17,12 @@ export function SmokerFilterText(props: {
{is_smoker == null
? t('common.either', 'Either')
: is_smoker
? mobile ? t('common.yes', 'Yes') : t('profile.smoker.yes', 'Smoker')
: mobile ? t('common.no', 'No') : t('profile.smoker.no', 'Non-smoker')}
? mobile
? t('common.yes', 'Yes')
: t('profile.smoker.yes', 'Smoker')
: mobile
? t('common.no', 'No')
: t('profile.smoker.no', 'Non-smoker')}
</span>
</Row>
)

View File

@@ -1,18 +1,22 @@
import {Profile} from "common/profiles/profile";
import {useIsLooking} from "web/hooks/use-is-looking";
import {usePersistentLocalState} from "web/hooks/use-persistent-local-state";
import {useCallback, useEffect} from "react";
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";
import {MAX_INT, MIN_INT} from 'common/constants'
import {FilterFields, initialFilters, OriginLocation} from 'common/filters'
import {logger} from 'common/logging'
import {Profile} from 'common/profiles/profile'
import {
wantsKidsDatabase,
wantsKidsDatabaseToWantsKidsFilter,
wantsKidsToHasKidsFilter,
} from 'common/wants-kids'
import {debounce, isEqual} from 'lodash'
import {useCallback, useEffect} from 'react'
import {useIsLooking} from 'web/hooks/use-is-looking'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
export const useFilters = (you: Profile | undefined) => {
const isLooking = useIsLooking()
const [filters, setFilters] = usePersistentLocalState<Partial<FilterFields>>(
isLooking ? initialFilters : {...initialFilters, orderBy: 'created_time'},
'profile-filters-4'
'profile-filters-4',
)
// logger.log('filters', filters)
@@ -24,24 +28,18 @@ export const useFilters = (you: Profile | undefined) => {
}
const clearFilters = () => {
setFilters(
isLooking
? initialFilters
: {...initialFilters, orderBy: 'created_time'}
)
setFilters(isLooking ? initialFilters : {...initialFilters, orderBy: 'created_time'})
setLocation(undefined)
}
const [radius, setRadius] = usePersistentLocalState<number>(
100,
'search-radius'
)
const [radius, setRadius] = usePersistentLocalState<number>(100, 'search-radius')
const debouncedSetRadius = useCallback(debounce(setRadius, 200), [setRadius])
const [location, setLocation] = usePersistentLocalState<
OriginLocation | undefined | null
>(undefined, 'nearby-origin-location')
const [location, setLocation] = usePersistentLocalState<OriginLocation | undefined | null>(
undefined,
'nearby-origin-location',
)
// const nearbyCities = useNearbyCities(location?.id, radius)
//
@@ -55,7 +53,7 @@ export const useFilters = (you: Profile | undefined) => {
} else {
updateFilter({lat: undefined, lon: undefined, radius: undefined})
}
}, [location?.id, radius]);
}, [location?.id, radius])
const locationFilterProps = {
location,
@@ -82,36 +80,46 @@ export const useFilters = (you: Profile | 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
),
has_kids: wantsKidsToHasKidsFilter(
(you?.wants_kids_strength ?? 2) as wantsKidsDatabase
(you?.wants_kids_strength ?? 2) as wantsKidsDatabase,
),
has_kids: wantsKidsToHasKidsFilter((you?.wants_kids_strength ?? 2) as wantsKidsDatabase),
is_smoker: you?.is_smoker,
}
logger.debug(you, yourFilters)
const isYourFilters =
!!you
&& (!location || location.id === you.geodb_city_id)
&& 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.interests), new Set(you.interests))
&& isEqual(new Set(filters.causes), new Set(you.causes))
&& isEqual(new Set(filters.work), new Set(you.work))
&& 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
&& filters.wants_kids_strength == yourFilters.wants_kids_strength
&& filters.is_smoker == yourFilters.is_smoker
!!you &&
(!location || location.id === you.geodb_city_id) &&
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.interests), new Set(you.interests)) &&
isEqual(new Set(filters.causes), new Set(you.causes)) &&
isEqual(new Set(filters.work), new Set(you.work)) &&
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 &&
filters.wants_kids_strength == yourFilters.wants_kids_strength &&
filters.is_smoker == yourFilters.is_smoker
const setYourFilters = (checked: boolean) => {
if (checked) {
@@ -119,7 +127,12 @@ export const useFilters = (you: Profile | undefined) => {
setRadius(100)
debouncedSetRadius(100) // clear any pending debounced sets
if (you?.geodb_city_id && you.city && you.city_latitude && you.city_longitude) {
setLocation({id: you?.geodb_city_id, name: you?.city, lat: you?.city_latitude, lon: you?.city_longitude})
setLocation({
id: you?.geodb_city_id,
name: you?.city,
lat: you?.city_latitude,
lon: you?.city_longitude,
})
}
} else {
clearFilters()
@@ -139,4 +152,4 @@ export const useFilters = (you: Profile | undefined) => {
// const alternateWomenAndMen = (profiles: Profile[]) => {
// const [women, nonWomen] = partition(profiles, (l) => l.gender === 'female')
// return filterDefined(zip(women, nonWomen).flat())
// }
// }

View File

@@ -1,12 +1,11 @@
import clsx from 'clsx'
import {FilterFields} from 'common/filters'
import {generateChoicesMap, KidLabel, wantsKidsLabels} from 'common/wants-kids'
import {ReactNode} from 'react'
import {MdNoStroller, MdOutlineStroller, MdStroller} from 'react-icons/md'
import {Row} from 'web/components/layout/row'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import clsx from 'clsx'
import {FilterFields} from "common/filters";
import {generateChoicesMap, KidLabel, wantsKidsLabels} from "common/wants-kids"
import {useT} from "web/lib/locale";
import {useT} from 'web/lib/locale'
interface KidLabelWithIcon extends KidLabel {
icon: ReactNode
@@ -23,28 +22,27 @@ export const useWantsKidsLabelsWithIcon = () => {
...wantsKidsLabels.no_preference,
name: t('filter.wants_kids.any_preference', 'Any preference'),
shortName: t('filter.wants_kids.either', 'Either'),
icon: <MdOutlineStroller className="h-4 w-4"/>,
icon: <MdOutlineStroller className="h-4 w-4" />,
},
wants_kids: {
...wantsKidsLabels.wants_kids,
name: t('filter.wants_kids.wants_kids', 'Wants kids'),
shortName: t('common.yes', 'Yes'),
icon: <MdStroller className="h-4 w-4"/>,
icon: <MdStroller className="h-4 w-4" />,
},
doesnt_want_kids: {
...wantsKidsLabels.doesnt_want_kids,
name: t('filter.wants_kids.doesnt_want_kids', "Doesn't want kids"),
shortName: t('common.no', 'No'),
icon: <MdNoStroller className="h-4 w-4"/>,
icon: <MdNoStroller className="h-4 w-4" />,
},
} as KidsLabelsMapWithIcon
}
export function WantsKidsIcon(props: { strength: number; className?: string }) {
export function WantsKidsIcon(props: {strength: number; className?: string}) {
const {strength, className} = props
const wantsKidsLabelsWithIcon = useWantsKidsLabelsWithIcon()
return (
<span className={className}>
{strength == wantsKidsLabelsWithIcon.no_preference.strength
@@ -56,21 +54,17 @@ export function WantsKidsIcon(props: { strength: number; className?: string }) {
)
}
export function KidsLabel(props: {
strength: number
highlightedClass?: string
mobile?: boolean
}) {
export function KidsLabel(props: {strength: number; highlightedClass?: string; mobile?: boolean}) {
const {strength, highlightedClass} = props
const wantsKidsLabelsWithIcon = useWantsKidsLabelsWithIcon()
return (
<Row className="items-center gap-0.5">
<WantsKidsIcon strength={strength} className={clsx('')}/>
<WantsKidsIcon strength={strength} className={clsx('')} />
<span
className={clsx(
strength != wantsKidsLabelsWithIcon.no_preference.strength && 'font-semibold',
highlightedClass
highlightedClass,
)}
>
{strength == wantsKidsLabelsWithIcon.no_preference.strength

View File

@@ -7,12 +7,12 @@ import {useT} from 'web/lib/locale'
const FONT_OPTIONS: FontOption[] = ['atkinson', 'system-sans', 'classic-serif']
const EN_TRANSLATIONS = {
"atkinson": "Atkinson Hyperlegible",
"system-sans": "Sytem Sans-serif",
"classic-serif": "Classic Serif"
atkinson: 'Atkinson Hyperlegible',
'system-sans': 'Sytem Sans-serif',
'classic-serif': 'Classic Serif',
}
export function FontPicker(props: { className?: string } = {}) {
export function FontPicker(props: {className?: string} = {}) {
const {className} = props
const {font, setFont} = useFontPreference()
const t = useT()
@@ -22,7 +22,10 @@ export function FontPicker(props: { className?: string } = {}) {
id="font-picker"
value={font}
onChange={(e) => setFont(e.target.value as FontOption)}
className={clsx('rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50', className)}
className={clsx(
'rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50',
className,
)}
>
{FONT_OPTIONS.map((option) => (
<option key={option} value={option}>

View File

@@ -1,75 +1,53 @@
import clsx from 'clsx'
import { BsFillPersonFill } from 'react-icons/bs'
import {type Gender} from 'common/gender'
import {BsFillPersonFill} from 'react-icons/bs'
import {
PiGenderFemaleBold,
PiGenderMaleBold,
PiGenderNonbinaryBold,
PiGenderTransgenderBold,
} from 'react-icons/pi'
import { type Gender } from 'common/gender'
export default function GenderIcon(props: {
gender: Gender
className: string
hasColor?: boolean
}) {
const { gender, className, hasColor } = props
export default function GenderIcon(props: {gender: Gender; className: string; hasColor?: boolean}) {
const {gender, className, hasColor} = props
if (gender == 'male') {
return (
<PiGenderMaleBold
className={clsx(
className,
hasColor ? 'text-blue-500 dark:text-blue-300' : ''
)}
className={clsx(className, hasColor ? 'text-blue-500 dark:text-blue-300' : '')}
/>
)
}
if (gender == 'female') {
return (
<PiGenderFemaleBold
className={clsx(
className,
hasColor ? 'text-pink-500 dark:text-pink-300' : ''
)}
className={clsx(className, hasColor ? 'text-pink-500 dark:text-pink-300' : '')}
/>
)
}
if (gender == 'non-binary') {
return (
<PiGenderNonbinaryBold
className={clsx(
className,
hasColor ? 'text-purple-500 dark:text-purple-300' : ''
)}
className={clsx(className, hasColor ? 'text-purple-500 dark:text-purple-300' : '')}
/>
)
}
if (gender == 'trans-female') {
return (
<PiGenderTransgenderBold
className={clsx(
className,
hasColor ? 'text-pink-500 dark:text-pink-300' : ''
)}
className={clsx(className, hasColor ? 'text-pink-500 dark:text-pink-300' : '')}
/>
)
}
if (gender == 'trans-male') {
return (
<PiGenderTransgenderBold
className={clsx(
className,
hasColor ? 'text-blue-500 dark:text-blue-300' : ''
)}
className={clsx(className, hasColor ? 'text-blue-500 dark:text-blue-300' : '')}
/>
)
}
return (
<BsFillPersonFill
className={clsx(
className,
hasColor ? 'text-blue-500 dark:text-blue-300' : ''
)}
className={clsx(className, hasColor ? 'text-blue-500 dark:text-blue-300' : '')}
/>
)
}

View File

@@ -1,16 +1,18 @@
import {useRouter} from "next/router";
import clsx from "clsx";
import {useT} from "web/lib/locale";
import clsx from 'clsx'
import {useRouter} from 'next/router'
import {useT} from 'web/lib/locale'
export function FilterGuide(props: {
className?: string
}) {
export function FilterGuide(props: {className?: string}) {
const {className} = props
const router = useRouter()
const {query} = router
const fromSignup = query.fromSignup === 'true'
const t = useT()
return fromSignup && <p className={clsx("guidance", className)}>
{t('profiles.filter_guide', 'Filter below by intent, age, location, and more')}
</p>;
}
return (
fromSignup && (
<p className={clsx('guidance', className)}>
{t('profiles.filter_guide', 'Filter below by intent, age, location, and more')}
</p>
)
)
}

View File

@@ -1,20 +1,15 @@
import {useEffect} from "react";
import {Col} from "web/components/layout/col";
import {SignUpButton} from "web/components/nav/sidebar";
import {useUser} from "web/hooks/use-user";
import {useT} from "web/lib/locale";
import {useEffect} from 'react'
import {Col} from 'web/components/layout/col'
import {SignUpButton} from 'web/components/nav/sidebar'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
export function AboutBox(props: {
title: string
text: string
}) {
export function AboutBox(props: {title: string; text: string}) {
const {title, text} = props
return (
<div className="space-y-2">
<h3 className="text-lg font-bold">{title}</h3>
<p className="text-gray-600 dark:text-gray-400">
{text}
</p>
<p className="text-gray-600 dark:text-gray-400">{text}</p>
</div>
)
}
@@ -26,70 +21,94 @@ export function LoggedOutHome() {
const typewriterText = t('home.typewriter', 'Search.')
useEffect(() => {
const text = typewriterText;
const el = document.getElementById("typewriter");
if (!el) return;
const text = typewriterText
const el = document.getElementById('typewriter')
if (!el) return
let i = 0;
let timeoutId: any;
el.textContent = "";
let i = 0
let timeoutId: any
el.textContent = ''
function typeWriter() {
if (i < text.length && el) {
el.textContent = text.substring(0, i + 1);
i++;
timeoutId = setTimeout(typeWriter, 150);
el.textContent = text.substring(0, i + 1)
i++
timeoutId = setTimeout(typeWriter, 150)
}
}
const startId = setTimeout(typeWriter, 500);
const startId = setTimeout(typeWriter, 500)
return () => {
clearTimeout(timeoutId);
clearTimeout(startId);
if (el) el.textContent = text;
};
}, [typewriterText]);
clearTimeout(timeoutId)
clearTimeout(startId)
if (el) el.textContent = text
}
}, [typewriterText])
return (
<>
{user === null && <Col className="mb-4 gap-2 lg:hidden">
{user === null && (
<Col className="mb-4 gap-2 lg:hidden">
<SignUpButton
className="mt-4 flex-1 fixed bottom-[calc(55px+env(safe-area-inset-bottom))] w-full left-0 right-0 z-10 mx-auto px-4"
size="xl"
text={t('home.sign_up','Sign up')}
className="mt-4 flex-1 fixed bottom-[calc(55px+env(safe-area-inset-bottom))] w-full left-0 right-0 z-10 mx-auto px-4"
size="xl"
text={t('home.sign_up', 'Sign up')}
/>
{/*<SignUpAsMatchmaker className="flex-1"/>*/}
</Col>}
<h1
className="pt-12 pb-2 text-7xl md:text-8xl xs:text-6xl font-extrabold leading-tight xl:whitespace-nowrap md:whitespace-nowrap text-center">
{t('home.title', "Don't Swipe.")}<br/>
{/*<SignUpAsMatchmaker className="flex-1"/>*/}
</Col>
)}
<h1 className="pt-12 pb-2 text-7xl md:text-8xl xs:text-6xl font-extrabold leading-tight xl:whitespace-nowrap md:whitespace-nowrap text-center">
{t('home.title', "Don't Swipe.")}
<br />
<span id="typewriter"></span>
<span id="cursor" className="animate-pulse">|</span>
<span id="cursor" className="animate-pulse">
|
</span>
</h1>
<div className="py-8"></div>
<h3
className="text-2xl font-bold text-center">
{t('home.subtitle', 'Find people who share your values, ideas, and intentions — not just your photos.')}
<h3 className="text-2xl font-bold text-center">
{t(
'home.subtitle',
'Find people who share your values, ideas, and intentions — not just your photos.',
)}
</h3>
<div className="w-full py-8 mt-20">
<div className="max-w-6xl mx-auto px-4">
<div className="grid md:grid-cols-3 gap-8 text-center">
<AboutBox title={t('home.feature1.title','Radically Transparent')}
text={t('home.feature1.text','No algorithms. Every profile searchable. You decide who to discover.')}/>
<AboutBox title={t('home.feature2.title','Built for Depth')}
text={t('home.feature2.text','Search and filter by values, interests, goals, and keywords — from “stoicism” to “sustainable living.” Surface the connections that truly matter.')}/>
<AboutBox title={t('home.feature3.title','Community Owned & Open Source')}
text={t('home.feature3.text','Free forever. No ads, no subscriptions. Built by the people who use it, for the benefit of everyone.')}/>
<AboutBox
title={t('home.feature1.title', 'Radically Transparent')}
text={t(
'home.feature1.text',
'No algorithms. Every profile searchable. You decide who to discover.',
)}
/>
<AboutBox
title={t('home.feature2.title', 'Built for Depth')}
text={t(
'home.feature2.text',
'Search and filter by values, interests, goals, and keywords — from “stoicism” to “sustainable living.” Surface the connections that truly matter.',
)}
/>
<AboutBox
title={t('home.feature3.title', 'Community Owned & Open Source')}
text={t(
'home.feature3.text',
'Free forever. No ads, no subscriptions. Built by the people who use it, for the benefit of everyone.',
)}
/>
</div>
<div className="mt-10 max-w-xl mx-auto">
<p className="text-center">
{t('home.bottom', 'Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to browsing — a public digital good designed to serve people, not profit.')}
{t(
'home.bottom',
'Compass is to human connection what Linux is to software, Wikipedia is to knowledge, and Firefox is to browsing — a public digital good designed to serve people, not profit.',
)}
</p>
</div>
</div>
</div>
<div className="block lg:hidden h-12"></div>
</>
);
)
}

View File

@@ -1,11 +1,10 @@
'use client'
import {useLocale} from "web/lib/locale";
import {LOCALES} from "common/constants";
import clsx from "clsx";
import clsx from 'clsx'
import {LOCALES} from 'common/constants'
import {useLocale} from 'web/lib/locale'
export function LanguagePicker(props: { className?: string } = {}) {
export function LanguagePicker(props: {className?: string} = {}) {
const {className} = props
const {locale, setLocale} = useLocale()
@@ -14,7 +13,10 @@ export function LanguagePicker(props: { className?: string } = {}) {
id="locale-picker"
value={locale}
onChange={(e) => setLocale(e.target.value)}
className={clsx("rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50", className)}
className={clsx(
'rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50',
className,
)}
>
{Object.entries(LOCALES).map(([key, v]) => (
<option key={key} value={key}>

View File

@@ -1,11 +1,11 @@
import clsx from 'clsx'
import { forwardRef } from 'react'
import {forwardRef} from 'react'
export const Col = forwardRef(function Col(
props: JSX.IntrinsicElements['div'],
ref: React.Ref<HTMLDivElement>
ref: React.Ref<HTMLDivElement>,
) {
const { children, className, ...rest } = props
const {children, className, ...rest} = props
return (
<div className={clsx(className, 'flex flex-col')} ref={ref} {...rest}>

View File

@@ -5,8 +5,7 @@ import {Fragment, ReactNode, useEffect, useRef} from 'react'
export const MODAL_CLASS =
'items-center gap-4 rounded-md bg-canvas-0 sm:px-8 px-4 pt-6 pb-2 text-ink-1000 h-[calc(100dvh-var(--hloss)-120px)] sm:h-[calc(95dvh-var(--hloss)-120px)] '
export const SCROLLABLE_MODAL_CLASS =
'!overflow-auto'
export const SCROLLABLE_MODAL_CLASS = '!overflow-auto'
// From https://tailwindui.com/components/application-ui/overlays/modals
export function Modal(props: {
@@ -18,15 +17,7 @@ export function Modal(props: {
className?: string
onClose?: () => void
}) {
const {
children,
position = 'center',
open,
setOpen,
size = 'md',
className,
onClose,
} = props
const {children, position = 'center', open, setOpen, size = 'md', className, onClose} = props
const sizeClass = {
sm: 'sm:max-w-sm',
@@ -81,19 +72,14 @@ export function Modal(props: {
leaveTo="opacity-0 sm:scale-95"
>
<div className="fixed inset-0 overflow-y-auto pt-20 sm:p-0">
<div
className={clsx(
'flex min-h-full items-end justify-center',
positionClass
)}
>
<div className={clsx('flex min-h-full items-end justify-center', positionClass)}>
<div
className={clsx(
'relative w-full transform transition-all',
'lg:mx-6 lg:my-8 mb-[var(--bnh)]',
'max-h-[90vh] min-h-[60vh]', // 👈 add this
sizeClass,
className
className,
)}
>
<div className="sr-only" tabIndex={0}>
@@ -105,8 +91,7 @@ export function Modal(props: {
onClick={() => setOpen(false)}
className={clsx(
'text-ink-700 bottom-50 hover:text-primary-400 focus:text-primary-400 absolute -top-4 right-4 -translate-y-full cursor-pointer outline-none sm:right-0',
position === 'top' &&
'sm:-bottom-4 sm:top-auto sm:translate-y-full'
position === 'top' && 'sm:-bottom-4 sm:top-auto sm:translate-y-full',
)}
>
<XIcon className="h-8 w-8" />

View File

@@ -1,6 +1,6 @@
import { Dialog, Transition } from '@headlessui/react'
import {Dialog, Transition} from '@headlessui/react'
import clsx from 'clsx'
import { Fragment, ReactNode } from 'react'
import {Fragment, ReactNode} from 'react'
// From https://tailwindui.com/components/application-ui/overlays/modals
export function RightModal(props: {
children: ReactNode
@@ -10,7 +10,7 @@ export function RightModal(props: {
noAutoFocus?: boolean
className?: string
}) {
const { children, open, setOpen, className, noAutoFocus } = props
const {children, open, setOpen, className, noAutoFocus} = props
return (
<Transition.Root show={open} as={Fragment}>
@@ -43,14 +43,8 @@ export function RightModal(props: {
leaveTo="opacity-0 sm:scale-95"
>
<div className="fixed inset-0 p-0">
<div
className={clsx(
'flex h-full flex-row justify-end overflow-hidden'
)}
>
<Dialog.Panel
className={clsx('grow-y transform transition-all ', className)}
>
<div className={clsx('flex h-full flex-row justify-end overflow-hidden')}>
<Dialog.Panel className={clsx('grow-y transform transition-all ', className)}>
{/* Hack to capture focus b/c headlessui dialog always focuses first element
and we don't want it to.
*/}

View File

@@ -1,11 +1,11 @@
import clsx from 'clsx'
import { forwardRef } from 'react'
import {forwardRef} from 'react'
export const Row = forwardRef(function Row(
props: JSX.IntrinsicElements['div'],
ref: React.Ref<HTMLDivElement>
ref: React.Ref<HTMLDivElement>,
) {
const { children, className, ...rest } = props
const {children, className, ...rest} = props
return (
<div className={clsx(className, 'flex flex-row')} ref={ref} {...rest}>
{children}

View File

@@ -1,8 +1,8 @@
export function Spacer(props: { className?: string; w?: number; h?: number }) {
const { w, h, className } = props
export function Spacer(props: {className?: string; w?: number; h?: number}) {
const {w, h, className} = props
const width = w === undefined ? undefined : w * 0.25 + 'rem'
const height = h === undefined ? undefined : h * 0.25 + 'rem'
return <div style={{ width, height, flexShrink: 0 }} className={className} />
return <div style={{width, height, flexShrink: 0}} className={className} />
}

View File

@@ -1,16 +1,16 @@
import clsx from 'clsx'
import { usePathname, ReadonlyURLSearchParams } from 'next/navigation'
import { useRouter } from 'next/router'
import {ReadonlyURLSearchParams, usePathname} from 'next/navigation'
import {useRouter} from 'next/router'
import {ReactNode, useEffect, useRef} from 'react'
import {Row} from 'web/components/layout/row'
import {Carousel} from 'web/components/widgets/carousel'
import {Tooltip} from 'web/components/widgets/tooltip'
import {useDefinedSearchParams} from 'web/hooks/use-defined-search-params'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {track} from 'web/lib/service/analytics'
import { ReactNode, useEffect, useRef } from 'react'
import { track } from 'web/lib/service/analytics'
import { Col } from './col'
import { Tooltip } from 'web/components/widgets/tooltip'
import { Row } from 'web/components/layout/row'
import { Carousel } from 'web/components/widgets/carousel'
import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state'
import { useDefinedSearchParams } from 'web/hooks/use-defined-search-params'
import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'
import {Col} from './col'
export type Tab = {
title: string
@@ -35,36 +35,22 @@ type TabProps = {
name?: string // a unique identifier for the tabs, used for caching
}
export function MinimalistTabs(props: TabProps & { activeIndex: number }) {
const {
tabs,
activeIndex,
labelClassName,
onClick,
className,
renderAllTabs,
trackingName,
} = props
export function MinimalistTabs(props: TabProps & {activeIndex: number}) {
const {tabs, activeIndex, labelClassName, onClick, className, renderAllTabs, trackingName} = props
const hasRenderedIndexRef = useRef(new Set<number>())
hasRenderedIndexRef.current.add(activeIndex)
return (
<>
<Carousel
className={clsx('border-ink-200 border-b pb-1', className)}
aria-label="Tabs"
>
<Carousel className={clsx('border-ink-200 border-b pb-1', className)} aria-label="Tabs">
{tabs.map((tab, i) => (
<a
href="#"
key={tab.queryString ?? tab.title}
onClick={(e) => {
e.preventDefault()
onClick?.(
tab.queryString?.toLowerCase() ?? tab.title.toLowerCase(),
i
)
onClick?.(tab.queryString?.toLowerCase() ?? tab.title.toLowerCase(), i)
if (trackingName) {
track(trackingName, {
tab: tab.title,
@@ -73,11 +59,9 @@ export function MinimalistTabs(props: TabProps & { activeIndex: number }) {
}}
aria-current={activeIndex === i ? 'page' : undefined}
className={clsx(
activeIndex === i
? 'text-primary-600'
: 'text-ink-400 hover:text-ink-700',
activeIndex === i ? 'text-primary-600' : 'text-ink-400 hover:text-ink-700',
'cursor-pointer whitespace-nowrap text-lg ',
labelClassName
labelClassName,
)}
>
<Tooltip text={tab.tooltip}>
@@ -87,19 +71,10 @@ export function MinimalistTabs(props: TabProps & { activeIndex: number }) {
))}
</Carousel>
{tabs
.map((tab, i) => ({ tab, i }))
.filter(
({ tab, i }) =>
renderAllTabs || tab.prerender || hasRenderedIndexRef.current.has(i)
)
.map(({ tab, i }) => (
<div
key={i}
className={clsx(
i === activeIndex ? 'contents' : 'hidden',
tab.className
)}
>
.map((tab, i) => ({tab, i}))
.filter(({tab, i}) => renderAllTabs || tab.prerender || hasRenderedIndexRef.current.has(i))
.map(({tab, i}) => (
<div key={i} className={clsx(i === activeIndex ? 'contents' : 'hidden', tab.className)}>
{tab.content}
</div>
))}
@@ -107,7 +82,7 @@ export function MinimalistTabs(props: TabProps & { activeIndex: number }) {
)
}
export function ControlledTabs(props: TabProps & { activeIndex: number }) {
export function ControlledTabs(props: TabProps & {activeIndex: number}) {
const {
tabs,
activeIndex,
@@ -136,10 +111,7 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
onClick={(e) => {
e.preventDefault()
onClick?.(
tab.queryString?.toLowerCase() ?? tab.title.toLowerCase(),
i
)
onClick?.(tab.queryString?.toLowerCase() ?? tab.title.toLowerCase(), i)
if (trackingName) {
track(trackingName, {
@@ -153,23 +125,24 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
: 'text-ink-500 hover:border-ink-300 hover:text-ink-700 border-transparent',
'mr-4 inline-flex cursor-pointer flex-row gap-1 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium ',
labelClassName,
'flex-shrink-0'
'flex-shrink-0',
)}
aria-current={activeIndex === i ? 'page' : undefined}
>
<Col
className={clsx()
// tab.stackedTabIcon && (activeIndex !== i) && 'opacity-85'
className={
clsx()
// tab.stackedTabIcon && (activeIndex !== i) && 'opacity-85'
}
>
<Tooltip text={tab.tooltip}>
{tab.stackedTabIcon && (
<Row className="justify-center">{tab.stackedTabIcon}</Row>
)}
{tab.stackedTabIcon && <Row className="justify-center">{tab.stackedTabIcon}</Row>}
<Row className={'items-center'}>
<Col>
{tab.title.split('\n').map((line, i) => (
<Row className={'items-center justify-center'} key={i}>{line}</Row>
<Row className={'items-center justify-center'} key={i}>
{line}
</Row>
))}
</Col>
{tab.inlineTabIcon}
@@ -180,19 +153,10 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
))}
</Carousel>
{tabs
.map((tab, i) => ({ tab, i }))
.filter(
({ tab, i }) =>
renderAllTabs || tab.prerender || hasRenderedIndexRef.current.has(i)
)
.map(({ tab, i }) => (
<div
key={i}
className={clsx(
i === activeIndex ? 'contents' : 'hidden',
tab.className
)}
>
.map((tab, i) => ({tab, i}))
.filter(({tab, i}) => renderAllTabs || tab.prerender || hasRenderedIndexRef.current.has(i))
.map(({tab, i}) => (
<div key={i} className={clsx(i === activeIndex ? 'contents' : 'hidden', tab.className)}>
{tab.content}
</div>
))}
@@ -200,11 +164,11 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
)
}
export function UncontrolledTabs(props: TabProps & { defaultIndex?: number }) {
const { defaultIndex, onClick, ...rest } = props
export function UncontrolledTabs(props: TabProps & {defaultIndex?: number}) {
const {defaultIndex, onClick, ...rest} = props
const [activeIndex, setActiveIndex] = usePersistentInMemoryState(
defaultIndex ?? 0,
`tab-${props.trackingName}-${props.name ?? 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')
@@ -222,16 +186,10 @@ export function UncontrolledTabs(props: TabProps & { defaultIndex?: number }) {
)
}
const isTabSelected = (
params: ReadonlyURLSearchParams,
queryParam: string,
tab: Tab
) => {
const isTabSelected = (params: ReadonlyURLSearchParams, queryParam: string, tab: Tab) => {
const selected = params.get(queryParam)
if (typeof selected === 'string') {
return (
(tab.queryString?.toLowerCase() ?? tab.title.toLowerCase()) === selected
)
return (tab.queryString?.toLowerCase() ?? tab.title.toLowerCase()) === selected
} else {
return false
}
@@ -243,37 +201,24 @@ export function QueryUncontrolledTabs(
scrollToTop?: boolean
minimalist?: boolean
saveTabInLocalStorageKey?: string
}
},
) {
const {
tabs,
minimalist,
onClick,
scrollToTop,
saveTabInLocalStorageKey,
...rest
} = props
const {tabs, minimalist, onClick, scrollToTop, saveTabInLocalStorageKey, ...rest} = props
const router = useRouter()
const pathName = usePathname()
const { searchParams, createQueryString } = useDefinedSearchParams()
const selectedIdx = tabs.findIndex((t) =>
isTabSelected(searchParams, 'tab', t)
const {searchParams, createQueryString} = useDefinedSearchParams()
const selectedIdx = tabs.findIndex((t) => isTabSelected(searchParams, 'tab', t))
const [savedTabIndex, setSavedTabIndex] = usePersistentLocalState<number | undefined>(
undefined,
saveTabInLocalStorageKey ?? '',
)
const [savedTabIndex, setSavedTabIndex] = usePersistentLocalState<
number | undefined
>(undefined, saveTabInLocalStorageKey ?? '')
const defaultIndex =
(saveTabInLocalStorageKey ? savedTabIndex : undefined) ??
props.defaultIndex ??
0
(saveTabInLocalStorageKey ? savedTabIndex : undefined) ?? props.defaultIndex ?? 0
const activeIndex = selectedIdx !== -1 ? selectedIdx : defaultIndex
useEffect(() => {
if (onClick) {
onClick(
tabs[activeIndex].queryString ?? tabs[activeIndex].title,
activeIndex
)
onClick(tabs[activeIndex].queryString ?? tabs[activeIndex].title, activeIndex)
}
if (saveTabInLocalStorageKey) setSavedTabIndex(activeIndex)
}, [activeIndex])
@@ -284,12 +229,10 @@ export function QueryUncontrolledTabs(
tabs={tabs}
activeIndex={activeIndex}
onClick={(title) => {
if (scrollToTop) window.scrollTo({ top: 0 })
router.replace(
pathName + '?' + createQueryString('tab', title),
undefined,
{ shallow: true }
)
if (scrollToTop) window.scrollTo({top: 0})
router.replace(pathName + '?' + createQueryString('tab', title), undefined, {
shallow: true,
})
}}
/>
)
@@ -300,12 +243,8 @@ export function QueryUncontrolledTabs(
tabs={tabs}
activeIndex={activeIndex}
onClick={(title) => {
if (scrollToTop) window.scrollTo({ top: 0 })
router.replace(
pathName + '?' + createQueryString('tab', title),
undefined,
{ shallow: true }
)
if (scrollToTop) window.scrollTo({top: 0})
router.replace(pathName + '?' + createQueryString('tab', title), undefined, {shallow: true})
}}
/>
)

View File

@@ -1,6 +1,6 @@
import Link from "next/link";
import Link from 'next/link'
export const CustomLink = ({href, children}: { href?: string; children: React.ReactNode }) => {
export const CustomLink = ({href, children}: {href?: string; children: React.ReactNode}) => {
if (!href) return <>{children}</>
// If href is internal, use Next.js Link
@@ -14,4 +14,4 @@ export const CustomLink = ({href, children}: { href?: string; children: React.Re
{children}
</a>
)
}
}

View File

@@ -1,11 +1,11 @@
import {PageBase} from "web/components/page-base";
import {Col} from "web/components/layout/col";
import ReactMarkdown from "react-markdown";
import {SEO} from "web/components/SEO";
import {capitalize} from "lodash";
import {CustomLink} from "web/components/links";
import {BackButton} from "web/components/back-button";
import {useRouter} from "next/router";
import {capitalize} from 'lodash'
import {useRouter} from 'next/router'
import ReactMarkdown from 'react-markdown'
import {BackButton} from 'web/components/back-button'
import {Col} from 'web/components/layout/col'
import {CustomLink} from 'web/components/links'
import {PageBase} from 'web/components/page-base'
import {SEO} from 'web/components/SEO'
export const MD_PATHS = [
'constitution',
@@ -17,18 +17,20 @@ export const MD_PATHS = [
] as const
type Props = {
content: string;
filename: typeof MD_PATHS[number];
};
content: string
filename: (typeof MD_PATHS)[number]
}
export const CustomMarkdown = ({children}: { children: string }) => {
return <ReactMarkdown
components={{
a: ({node: _node, children, ...props}) => <CustomLink {...props}>{children}</CustomLink>
}}
>
{children}
</ReactMarkdown>
export const CustomMarkdown = ({children}: {children: string}) => {
return (
<ReactMarkdown
components={{
a: ({node: _node, children, ...props}) => <CustomLink {...props}>{children}</CustomLink>,
}}
>
{children}
</ReactMarkdown>
)
}
export default function MarkdownPage({content, filename}: Props) {
@@ -37,29 +39,27 @@ export default function MarkdownPage({content, filename}: Props) {
const {query} = router
const fromSignup = query.fromSignup === 'true'
const backButton = fromSignup && <BackButton className="-ml-2 self-start"/>
const backButton = fromSignup && <BackButton className="-ml-2 self-start" />
const formattedContent = <Col className="items-center">
<Col className="items-center justify-center mb-8 max-w-5xl">
<Col className='w-full rounded px-3 py-4 sm:px-6 space-y-4'>
{backButton}
<div className={'custom-link !mt-0 prose prose-neutral dark:prose-invert'}>
<CustomMarkdown>{content}</CustomMarkdown>
</div>
const formattedContent = (
<Col className="items-center">
<Col className="items-center justify-center mb-8 max-w-5xl">
<Col className="w-full rounded px-3 py-4 sm:px-6 space-y-4">
{backButton}
<div className={'custom-link !mt-0 prose prose-neutral dark:prose-invert'}>
<CustomMarkdown>{content}</CustomMarkdown>
</div>
</Col>
</Col>
</Col>
</Col>
)
if (fromSignup) return formattedContent
return (
<PageBase trackPageView={filename} className={'col-span-8'}>
<SEO
title={title}
description={title}
url={`/` + filename}
/>
<SEO title={title} description={title} url={`/` + filename} />
{formattedContent}
</PageBase>
)
}
}

View File

@@ -1,17 +1,16 @@
import { Row } from 'web/components/layout/row'
import { HeartIcon } from '@heroicons/react/solid'
import { Profile } from 'common/profiles/profile'
import Image from 'next/image'
import { Col } from 'web/components/layout/col'
import { UserIcon } from '@heroicons/react/solid'
import {HeartIcon, UserIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import {Profile} from 'common/profiles/profile'
import Image from 'next/image'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
export function MatchAvatars(props: {
profileProfile: Profile
matchedProfile: Profile
className?: string
}) {
const { profileProfile, matchedProfile, className } = props
const {profileProfile, matchedProfile, className} = props
return (
<Row className={clsx(className, 'mx-auto items-center gap-1')}>

View File

@@ -1,10 +1,10 @@
import {Switch} from '@headlessui/react'
import {useT} from 'web/lib/locale'
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import clsx from 'clsx'
import {Row} from 'web/components/layout/row'
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import {useT} from 'web/lib/locale'
export default function MeasurementSystemToggle(props: { className?: string }) {
export default function MeasurementSystemToggle(props: {className?: string}) {
const {className} = props
const {measurementSystem, setMeasurementSystem} = useMeasurementSystem()
const t = useT()
@@ -13,33 +13,27 @@ export default function MeasurementSystemToggle(props: { className?: string }) {
return (
<Row className={clsx('items-center gap-2', className)}>
<span
className={clsx('text-sm', !isEnabled ? 'font-bold' : 'text-ink-500')}
>
<span className={clsx('text-sm', !isEnabled ? 'font-bold' : 'text-ink-500')}>
{t('settings.measurement.imperial', 'Imperial')}
</span>
<Switch
checked={isEnabled}
onChange={(enabled: boolean) =>
setMeasurementSystem(enabled ? 'metric' : 'imperial')
}
onChange={(enabled: boolean) => setMeasurementSystem(enabled ? 'metric' : 'imperial')}
className={clsx(
isEnabled ? 'bg-primary-500' : 'bg-ink-300',
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none'
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none',
)}
>
<span
className={clsx(
isEnabled ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform'
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
)}
/>
</Switch>
<span
className={clsx('text-sm', isEnabled ? 'font-bold' : 'text-ink-500')}
>
<span className={clsx('text-sm', isEnabled ? 'font-bold' : 'text-ink-500')}>
{t('settings.measurement.metric', 'Metric')}
</span>
</Row>

View File

@@ -1,21 +1,22 @@
import {Col} from 'web/components/layout/col'
import clsx from 'clsx'
import {EmailVerificationButton} from "web/components/email-verification-button";
import {EmailVerificationButton} from 'web/components/email-verification-button'
import {Col} from 'web/components/layout/col'
interface EmailVerificationPromptProps {
t: any
className?: string
}
export function EmailVerificationPrompt(
{
t,
className
}: EmailVerificationPromptProps) {
export function EmailVerificationPrompt({t, className}: EmailVerificationPromptProps) {
return (
<Col className={clsx('gap-4 max-w-xl', className)}>
<h3>{t('messaging.email_verification_required', "You must verify your email to message people.")}</h3>
<EmailVerificationButton/>
<h3>
{t(
'messaging.email_verification_required',
'You must verify your email to message people.',
)}
</h3>
<EmailVerificationButton />
</Col>
)
}

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