mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-05-18 13:47:08 -04:00
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:
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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})]
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
)),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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}+`},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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})
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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' : '')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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.
|
||||
*/}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user