mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-25 01:51:37 -04:00
1104 lines
41 KiB
TypeScript
1104 lines
41 KiB
TypeScript
import {PlusIcon, XMarkIcon} from '@heroicons/react/24/solid'
|
||
import {Editor} from '@tiptap/react'
|
||
import clsx from 'clsx'
|
||
import {
|
||
DIET_CHOICES,
|
||
EDUCATION_CHOICES,
|
||
GENDERS,
|
||
LANGUAGE_CHOICES,
|
||
MBTI_CHOICES,
|
||
POLITICAL_CHOICES,
|
||
RACE_CHOICES,
|
||
RELATIONSHIP_CHOICES,
|
||
RELATIONSHIP_STATUS_CHOICES,
|
||
RELIGION_CHOICES,
|
||
ROMANTIC_CHOICES,
|
||
} from 'common/choices'
|
||
import {debug} from 'common/logger'
|
||
import {MultipleChoiceOptions} from 'common/profiles/multiple-choice'
|
||
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
|
||
import {PLATFORM_LABELS, type Site, SITE_ORDER, Socials} from 'common/socials'
|
||
import {BaseUser} from 'common/user'
|
||
import {range} from 'lodash'
|
||
import {Fragment, useEffect, useRef, useState} from 'react'
|
||
import Textarea from 'react-expanding-textarea'
|
||
import toast from 'react-hot-toast'
|
||
import {AddOptionEntry} from 'web/components/add-option-entry'
|
||
import {SignupBio} from 'web/components/bio/editable-bio'
|
||
import {Button, IconButton} from 'web/components/buttons/button'
|
||
import {Col} from 'web/components/layout/col'
|
||
import {Row} from 'web/components/layout/row'
|
||
import {MultiCheckbox} from 'web/components/multi-checkbox'
|
||
import {City, CityRow, profileToCity, useCitySearch} from 'web/components/search-location'
|
||
import {Carousel} from 'web/components/widgets/carousel'
|
||
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
|
||
import {Input} from 'web/components/widgets/input'
|
||
import {PlatformSelect} from 'web/components/widgets/platform-select'
|
||
import {RadioToggleGroup} from 'web/components/widgets/radio-toggle-group'
|
||
import {Select} from 'web/components/widgets/select'
|
||
import {Slider} from 'web/components/widgets/slider'
|
||
import {Title} from 'web/components/widgets/title'
|
||
import {fetchChoices} from 'web/hooks/use-choices'
|
||
import {useLocale, useT} from 'web/lib/locale'
|
||
import {track} from 'web/lib/service/analytics'
|
||
import {db} from 'web/lib/supabase/db'
|
||
import {colClassName, labelClassName} from 'web/pages/signup'
|
||
|
||
import {SocialIcon} from './user/social'
|
||
import {AddPhotosWidget} from './widgets/add-photos'
|
||
|
||
export const OptionalProfileUserForm = (props: {
|
||
profile: ProfileWithoutUser
|
||
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
|
||
user: BaseUser
|
||
buttonLabel?: string
|
||
bottomNavBarVisible?: boolean
|
||
onSubmit: () => Promise<void>
|
||
}) => {
|
||
const {profile, user, buttonLabel, setProfile, onSubmit, bottomNavBarVisible = true} = props
|
||
|
||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||
const [lookingRelationship, setLookingRelationship] = useState(
|
||
(profile.pref_relation_styles || []).includes('relationship'),
|
||
)
|
||
const [ageError, setAgeError] = useState<string | null>(null)
|
||
const t = useT()
|
||
const [heightFeet, setHeightFeet] = useState<number | undefined>(
|
||
profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) / 12) : undefined,
|
||
)
|
||
const [heightInches, setHeightInches] = useState<number | undefined>(
|
||
profile.height_in_inches ? Math.floor((profile['height_in_inches'] ?? 0) % 12) : undefined,
|
||
)
|
||
|
||
const [newLinkPlatform, setNewLinkPlatform] = useState('')
|
||
const [newLinkValue, setNewLinkValue] = useState('')
|
||
const [interestChoices, setInterestChoices] = useState({})
|
||
const [causeChoices, setCauseChoices] = useState({})
|
||
const [workChoices, setWorkChoices] = useState({})
|
||
const {locale} = useLocale()
|
||
|
||
const [keywordsString, setKeywordsString] = useState<string>(profile.keywords?.join(', ') || '')
|
||
|
||
useEffect(() => {
|
||
fetchChoices('interests', locale).then(setInterestChoices)
|
||
fetchChoices('causes', locale).then(setCauseChoices)
|
||
fetchChoices('work', locale).then(setWorkChoices)
|
||
}, [db])
|
||
|
||
const errorToast = () => {
|
||
toast.error(t('profile.optional.error.invalid_fields', 'Some fields are incorrect...'))
|
||
}
|
||
|
||
const handleSubmit = async () => {
|
||
// Validate age before submitting
|
||
if (profile['age'] !== null && profile['age'] !== undefined) {
|
||
if (profile['age'] < 18) {
|
||
setAgeError(t('profile.optional.age.error_min', 'You must be at least 18 years old'))
|
||
setIsSubmitting(false)
|
||
errorToast()
|
||
return
|
||
}
|
||
if (profile['age'] > 100) {
|
||
setAgeError(t('profile.optional.age.error_max', 'Please enter a valid age'))
|
||
setIsSubmitting(false)
|
||
errorToast()
|
||
return
|
||
}
|
||
}
|
||
|
||
setIsSubmitting(true)
|
||
|
||
track('submit optional profile')
|
||
|
||
await onSubmit()
|
||
|
||
setIsSubmitting(false)
|
||
}
|
||
|
||
const updateUserLink = (platform: string, value: string | null) => {
|
||
setProfile('links', {...((profile.links as Socials) ?? {}), [platform]: value})
|
||
}
|
||
|
||
const addNewLink = () => {
|
||
if (newLinkPlatform && newLinkValue) {
|
||
updateUserLink(newLinkPlatform.toLowerCase().trim(), newLinkValue.trim())
|
||
setNewLinkPlatform('')
|
||
setNewLinkValue('')
|
||
}
|
||
}
|
||
|
||
function setProfileCity(inputCity: City | undefined) {
|
||
if (!inputCity) {
|
||
setProfile('geodb_city_id', null)
|
||
setProfile('city', '')
|
||
setProfile('region_code', null)
|
||
setProfile('country', null)
|
||
setProfile('city_latitude', null)
|
||
setProfile('city_longitude', null)
|
||
} else {
|
||
const {
|
||
geodb_city_id,
|
||
city,
|
||
region_code,
|
||
country,
|
||
latitude: city_latitude,
|
||
longitude: city_longitude,
|
||
} = inputCity
|
||
setProfile('geodb_city_id', geodb_city_id)
|
||
setProfile('city', city)
|
||
setProfile('region_code', region_code)
|
||
setProfile('country', country)
|
||
setProfile('city_latitude', city_latitude)
|
||
setProfile('city_longitude', city_longitude)
|
||
}
|
||
}
|
||
|
||
function profileToRaisedInCity(profile: Profile): City | undefined {
|
||
if (profile.raised_in_geodb_city_id && profile.raised_in_lat && profile.raised_in_lon) {
|
||
return {
|
||
geodb_city_id: profile.raised_in_geodb_city_id,
|
||
city: profile.raised_in_city ?? null,
|
||
region_code: profile.raised_in_region_code ?? '',
|
||
country: profile.raised_in_country ?? '',
|
||
country_code: '',
|
||
latitude: profile.raised_in_lat,
|
||
longitude: profile.raised_in_lon,
|
||
}
|
||
}
|
||
return undefined
|
||
}
|
||
|
||
function setProfileRaisedInCity(inputCity: City | undefined) {
|
||
if (!inputCity) {
|
||
setProfile('raised_in_geodb_city_id', null)
|
||
setProfile('raised_in_city', null)
|
||
setProfile('raised_in_region_code', null)
|
||
setProfile('raised_in_country', null)
|
||
setProfile('raised_in_lat', null)
|
||
setProfile('raised_in_lon', null)
|
||
} else {
|
||
const {geodb_city_id, city, region_code, country, latitude, longitude} = inputCity
|
||
setProfile('raised_in_geodb_city_id', geodb_city_id)
|
||
setProfile('raised_in_city', city)
|
||
setProfile('raised_in_region_code', region_code)
|
||
setProfile('raised_in_country', country)
|
||
setProfile('raised_in_lat', latitude)
|
||
setProfile('raised_in_lon', longitude)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{/*<Row className={'justify-end'}>*/}
|
||
{/* <Button*/}
|
||
{/* disabled={isSubmitting}*/}
|
||
{/* loading={isSubmitting}*/}
|
||
{/* onClick={handleSubmit}*/}
|
||
{/* >*/}
|
||
{/* {buttonLabel ?? t('common.next', 'Next')} / {t('common.skip', 'Skip')}*/}
|
||
{/* </Button>*/}
|
||
{/*</Row>*/}
|
||
|
||
<Title>{t('profile.optional.subtitle', 'Optional information')}</Title>
|
||
|
||
<Col className={'gap-8'}>
|
||
<Category
|
||
title={t('profile.optional.category.personal_info', 'Personal Information')}
|
||
className={'mt-0'}
|
||
/>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.location', 'Location')}
|
||
</label>
|
||
{profile.city ? (
|
||
<Row className="border-primary-500 w-full justify-between rounded border px-4 py-2">
|
||
<CityRow
|
||
city={profileToCity(profile)}
|
||
onSelect={() => {}}
|
||
className="pointer-events-none"
|
||
/>
|
||
<button
|
||
className="text-ink-700 hover:text-primary-700 text-sm underline"
|
||
onClick={() => {
|
||
setProfileCity(undefined)
|
||
}}
|
||
>
|
||
{t('common.change', 'Change')}
|
||
</button>
|
||
</Row>
|
||
) : (
|
||
<CitySearchBox
|
||
onCitySelected={(city: City | undefined) => {
|
||
setProfileCity(city)
|
||
}}
|
||
/>
|
||
)}
|
||
</Col>
|
||
|
||
<Row className={'items-center gap-2'}>
|
||
<Col className={'gap-1'}>
|
||
<label className={clsx(labelClassName)}>{t('profile.optional.gender', 'Gender')}</label>
|
||
<ChoicesToggleGroup
|
||
currentChoice={profile['gender']}
|
||
choicesMap={
|
||
Object.fromEntries(
|
||
Object.entries(GENDERS).map(([k, v]) => [t(`profile.gender.${v}`, k), v]),
|
||
) as any
|
||
}
|
||
setChoice={(c) => setProfile('gender', c)}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>{t('profile.optional.age', 'Age')}</label>
|
||
<Input
|
||
type="number"
|
||
placeholder={t('profile.optional.age', 'Age')}
|
||
value={profile['age'] ?? undefined}
|
||
min={18}
|
||
max={100}
|
||
error={!!ageError}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value ? Number(e.target.value) : null
|
||
if (value !== null && value < 18) {
|
||
setAgeError(
|
||
t('profile.optional.age.error_min', 'You must be at least 18 years old'),
|
||
)
|
||
} else if (value !== null && value > 100) {
|
||
setAgeError(t('profile.optional.age.error_max', 'Please enter a valid age'))
|
||
} else {
|
||
setAgeError(null)
|
||
}
|
||
setProfile('age', value)
|
||
}}
|
||
/>
|
||
{ageError && <p className="text-error text-sm mt-1">{ageError}</p>}
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>{t('profile.optional.height', 'Height')}</label>
|
||
<Row className={'gap-2'}>
|
||
<Col>
|
||
<span>{t('profile.optional.feet', 'Feet')}</span>
|
||
<Input
|
||
type="number"
|
||
data-testid="height-feet"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.value === '') {
|
||
setHeightFeet(undefined)
|
||
} else {
|
||
setHeightFeet(Number(e.target.value))
|
||
const heightInInches = Number(e.target.value) * 12 + (heightInches ?? 0)
|
||
setProfile('height_in_inches', heightInInches)
|
||
}
|
||
}}
|
||
className={'w-16'}
|
||
value={typeof heightFeet === 'number' ? Math.floor(heightFeet) : ''}
|
||
min={0}
|
||
/>
|
||
</Col>
|
||
<Col>
|
||
<span>{t('profile.optional.inches', 'Inches')}</span>
|
||
<Input
|
||
type="number"
|
||
data-testid="height-inches"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.value === '') {
|
||
setHeightInches(undefined)
|
||
} else {
|
||
setHeightInches(Number(e.target.value))
|
||
const heightInInches = Number(e.target.value) + 12 * (heightFeet ?? 0)
|
||
setProfile('height_in_inches', heightInInches)
|
||
}
|
||
}}
|
||
className={'w-16'}
|
||
value={typeof heightInches === 'number' ? Math.floor(heightInches) : ''}
|
||
min={0}
|
||
/>
|
||
</Col>
|
||
<div className="self-end mb-2 text-ink-700 mx-2">
|
||
{t('common.or', 'OR').toUpperCase()}
|
||
</div>
|
||
<Col>
|
||
<span>{t('profile.optional.centimeters', 'Centimeters')}</span>
|
||
<Input
|
||
type="number"
|
||
data-testid="height-centimeters"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.value === '') {
|
||
setHeightFeet(undefined)
|
||
setHeightInches(undefined)
|
||
setProfile('height_in_inches', null)
|
||
} else {
|
||
// Convert cm to inches
|
||
const totalInches = Number(e.target.value) / 2.54
|
||
setHeightFeet(Math.floor(totalInches / 12))
|
||
setHeightInches(totalInches % 12)
|
||
setProfile('height_in_inches', totalInches)
|
||
}
|
||
}}
|
||
className={'w-20'}
|
||
value={
|
||
heightFeet !== undefined && profile['height_in_inches']
|
||
? Math.round(profile['height_in_inches'] * 2.54)
|
||
: ''
|
||
}
|
||
min={0}
|
||
/>
|
||
</Col>
|
||
</Row>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.ethnicity', 'Ethnicity/origin')}
|
||
</label>
|
||
<MultiCheckbox
|
||
choices={RACE_CHOICES}
|
||
translationPrefix={'profile.race'}
|
||
selected={profile['ethnicity'] ?? []}
|
||
onChange={(selected) => setProfile('ethnicity', selected)}
|
||
/>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.raised_in', 'Place you grew up')}
|
||
</label>
|
||
<label className={clsx('guidance')}>
|
||
{t(
|
||
'profile.optional.raised_in_hint',
|
||
'Especially useful if you grew up in a different country than where you live now—and if it reflects your cultural references, values, and life experiences.',
|
||
)}
|
||
</label>
|
||
{profile.raised_in_geodb_city_id ? (
|
||
<Row className="border-primary-500 w-full justify-between rounded border px-4 py-2">
|
||
<CityRow
|
||
city={profileToRaisedInCity(profile as Profile)!}
|
||
onSelect={() => {}}
|
||
className="pointer-events-none"
|
||
/>
|
||
<button
|
||
className="text-ink-700 hover:text-primary-700 text-sm underline"
|
||
onClick={() => {
|
||
setProfileRaisedInCity(undefined)
|
||
}}
|
||
>
|
||
{t('common.change', 'Change')}
|
||
</button>
|
||
</Row>
|
||
) : (
|
||
<CitySearchBox
|
||
onCitySelected={(city: City | undefined) => {
|
||
setProfileRaisedInCity(city)
|
||
}}
|
||
/>
|
||
)}
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.og_card', 'Profile Card')} className={'mt-0'} />
|
||
|
||
<label className={clsx('guidance')}>
|
||
{t(
|
||
'profile.optional.headline_description',
|
||
'What will appear on your profile card when others view it.',
|
||
)}
|
||
</label>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.headline', 'Headline')}
|
||
</label>
|
||
<label className={clsx('guidance')}>
|
||
{t(
|
||
'profile.optional.headline_hint',
|
||
"2-3 sentences that describe you and what you are looking for (max 250 characters). You'll be able to create a long document later in the profile bio.",
|
||
)}
|
||
</label>
|
||
<Textarea
|
||
data-testid="headline"
|
||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||
setProfile('headline', e.target.value)
|
||
}
|
||
className={'w-full md:w-[700px] bg-canvas-0 border rounded-md p-2'}
|
||
value={profile['headline'] ?? undefined}
|
||
maxLength={250}
|
||
/>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.keywords', 'Keywords')}
|
||
</label>
|
||
<label className={clsx('guidance')}>
|
||
{t(
|
||
'profile.optional.keywords_hint',
|
||
'Add 3-5 main keywords separated by commas that will be very visible on your profile (identity, interests, causes, politics, etc.). You can add more keywords later in the interests, causes and work sections.',
|
||
)}
|
||
</label>
|
||
<Input
|
||
data-testid="keywords"
|
||
type="text"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
setKeywordsString(e.target.value)
|
||
const keywords = e.target.value
|
||
.split(',')
|
||
.map((k) => k.trim())
|
||
.filter(Boolean)
|
||
setProfile('keywords', keywords)
|
||
}}
|
||
className={'w-full sm:w-96'}
|
||
value={keywordsString}
|
||
placeholder={t(
|
||
'profile.optional.keywords_placeholder',
|
||
'e.g., hiking, climate, progressive',
|
||
)}
|
||
/>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.category.interested_in', "Who I'm looking for")} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.interested_in', 'Interested in connecting with')}
|
||
</label>
|
||
<MultiCheckbox
|
||
choices={{
|
||
Women: 'female',
|
||
Men: 'male',
|
||
Other: 'other',
|
||
}}
|
||
translationPrefix={'profile.gender.plural'}
|
||
selected={profile['pref_gender'] || []}
|
||
onChange={(selected) => setProfile('pref_gender', selected)}
|
||
/>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.age_range', 'Who are aged between')}
|
||
</label>
|
||
<Row className={'gap-2'}>
|
||
<Col>
|
||
<span>{t('common.min', 'Min')}</span>
|
||
<Select
|
||
data-testid="pref-age-min"
|
||
value={profile['pref_age_min'] ?? ''}
|
||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
const newMin = e.target.value ? Number(e.target.value) : 18
|
||
const currentMax = profile['pref_age_max'] ?? 100
|
||
setProfile('pref_age_min', Math.min(newMin, currentMax))
|
||
}}
|
||
className={'w-18 border-ink-300 rounded-md'}
|
||
>
|
||
<option key={''} value={''}></option>
|
||
{range(18, (profile['pref_age_max'] ?? 100) + 1).map((m) => (
|
||
<option key={m} value={m}>
|
||
{m}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</Col>
|
||
<Col>
|
||
<span>{t('common.max', 'Max')}</span>
|
||
<Select
|
||
data-testid="pref-age-max"
|
||
value={profile['pref_age_max'] ?? ''}
|
||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
const newMax = e.target.value ? Number(e.target.value) : 100
|
||
const currentMin = profile['pref_age_min'] ?? 18
|
||
setProfile('pref_age_max', Math.max(newMax, currentMin))
|
||
}}
|
||
className={'w-18 border-ink-300 rounded-md'}
|
||
>
|
||
<option key={''} value={''}></option>
|
||
{range(profile['pref_age_min'] ?? 18, 100).map((m) => (
|
||
<option key={m} value={m}>
|
||
{m}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</Col>
|
||
</Row>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.connection_type', 'Connection type')}
|
||
</label>
|
||
<MultiCheckbox
|
||
choices={RELATIONSHIP_CHOICES}
|
||
selected={profile['pref_relation_styles'] || []}
|
||
translationPrefix={'profile.relationship'}
|
||
onChange={(selected) => {
|
||
setProfile('pref_relation_styles', selected)
|
||
setLookingRelationship((selected || []).includes('relationship'))
|
||
}}
|
||
/>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.category.relationships', 'Relationships')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.relationship_status', 'Relationship status')}
|
||
</label>
|
||
<MultiCheckbox
|
||
choices={RELATIONSHIP_STATUS_CHOICES}
|
||
translationPrefix={'profile.relationship_status'}
|
||
selected={profile['relationship_status'] ?? []}
|
||
onChange={(selected) => setProfile('relationship_status', selected)}
|
||
/>
|
||
</Col>
|
||
|
||
{lookingRelationship && (
|
||
<>
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.relationship_style', 'Relationship style')}
|
||
</label>
|
||
<MultiCheckbox
|
||
choices={ROMANTIC_CHOICES}
|
||
translationPrefix={'profile.romantic'}
|
||
selected={profile['pref_romantic_styles'] || []}
|
||
onChange={(selected) => {
|
||
setProfile('pref_romantic_styles', selected)
|
||
}}
|
||
/>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.category.family', 'Family')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.num_kids', 'Current number of kids')}
|
||
</label>
|
||
<Input
|
||
data-testid="current-number-of-kids"
|
||
type="number"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value === '' ? null : Number(e.target.value)
|
||
setProfile('has_kids', value)
|
||
}}
|
||
className={'w-20'}
|
||
min={0}
|
||
value={profile['has_kids'] ?? undefined}
|
||
/>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.want_kids', 'I would like to have kids')}
|
||
</label>
|
||
<RadioToggleGroup
|
||
className={'w-44'}
|
||
choicesMap={Object.fromEntries(
|
||
Object.entries(MultipleChoiceOptions).map(([k, v]) => [
|
||
t(`profile.wants_kids_${v}`, k),
|
||
v,
|
||
]),
|
||
)}
|
||
setChoice={(choice) => {
|
||
setProfile('wants_kids_strength', choice)
|
||
}}
|
||
currentChoice={profile.wants_kids_strength ?? -1}
|
||
/>
|
||
</Col>
|
||
</>
|
||
)}
|
||
|
||
<Category title={t('profile.optional.interests', 'Interests')} />
|
||
<AddOptionEntry
|
||
// title={t('profile.optional.interests', 'Interests')}
|
||
choices={interestChoices}
|
||
setChoices={setInterestChoices}
|
||
profile={profile}
|
||
setProfile={setProfile}
|
||
label={'interests'}
|
||
/>
|
||
|
||
<Category title={t('profile.optional.category.morality', 'Morality')} />
|
||
<AddOptionEntry
|
||
title={t('profile.optional.causes', 'Causes')}
|
||
choices={causeChoices}
|
||
setChoices={setCauseChoices}
|
||
profile={profile}
|
||
setProfile={setProfile}
|
||
label={'causes'}
|
||
/>
|
||
|
||
<Category title={t('profile.optional.category.education', 'Education')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.education_level', 'Highest completed education level')}
|
||
</label>
|
||
<Carousel className="max-w-full">
|
||
<ChoicesToggleGroup
|
||
currentChoice={profile['education_level']}
|
||
choicesMap={Object.fromEntries(
|
||
Object.entries(EDUCATION_CHOICES).map(([k, v]) => [
|
||
t(`profile.education.${v}`, k),
|
||
v,
|
||
]) as any,
|
||
)}
|
||
setChoice={(c) => setProfile('education_level', c)}
|
||
/>
|
||
</Carousel>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.university', 'University')}
|
||
</label>
|
||
<Input
|
||
data-testid="university"
|
||
type="text"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setProfile('university', e.target.value)
|
||
}
|
||
className={'w-52'}
|
||
value={profile['university'] ?? undefined}
|
||
/>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.category.work', 'Work')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{profile['company']
|
||
? t('profile.optional.job_title_at_company', 'Job title at {company}', {
|
||
company: profile['company'],
|
||
})
|
||
: t('profile.optional.job_title', 'Job title')}
|
||
</label>
|
||
<Input
|
||
data-testid="job-title"
|
||
type="text"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setProfile('occupation_title', e.target.value)
|
||
}
|
||
className={'w-52'}
|
||
value={profile['occupation_title'] ?? undefined}
|
||
/>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>{t('profile.optional.company', 'Company')}</label>
|
||
<Input
|
||
data-testid="company"
|
||
type="text"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setProfile('company', e.target.value)
|
||
}
|
||
className={'w-52'}
|
||
value={profile['company'] ?? undefined}
|
||
/>
|
||
</Col>
|
||
|
||
<AddOptionEntry
|
||
title={t('profile.optional.work', 'Work Area')}
|
||
choices={workChoices}
|
||
setChoices={setWorkChoices}
|
||
profile={profile}
|
||
setProfile={setProfile}
|
||
label={'work'}
|
||
/>
|
||
|
||
<Category title={t('profile.optional.political_beliefs', 'Political beliefs')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
{/*<label className={clsx(labelClassName)}>*/}
|
||
{/* {t('profile.optional.political_beliefs', 'Political beliefs')}*/}
|
||
{/*</label>*/}
|
||
<MultiCheckbox
|
||
choices={POLITICAL_CHOICES}
|
||
selected={profile['political_beliefs'] ?? []}
|
||
translationPrefix={'profile.political'}
|
||
onChange={(selected) => setProfile('political_beliefs', selected)}
|
||
/>
|
||
<p>{t('profile.optional.details', 'Details')}</p>
|
||
<Input
|
||
data-testid="political-belief-details"
|
||
type="text"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setProfile('political_details', e.target.value)
|
||
}
|
||
className={'w-full sm:w-96'}
|
||
value={profile['political_details'] ?? undefined}
|
||
/>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.religious_beliefs', 'Religious beliefs')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
{/*<label className={clsx(labelClassName)}>*/}
|
||
{/* {t('profile.optional.religious_beliefs', 'Religious beliefs')}*/}
|
||
{/*</label>*/}
|
||
<MultiCheckbox
|
||
choices={RELIGION_CHOICES}
|
||
selected={profile['religion'] ?? []}
|
||
translationPrefix={'profile.religion'}
|
||
onChange={(selected) => setProfile('religion', selected)}
|
||
/>
|
||
<p>{t('profile.optional.details', 'Details')}</p>
|
||
<Input
|
||
data-testid="religious-belief-details"
|
||
type="text"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
setProfile('religious_beliefs', e.target.value)
|
||
}
|
||
className={'w-full sm:w-96'}
|
||
value={profile['religious_beliefs'] ?? undefined}
|
||
/>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.category.psychology', 'Psychology')} />
|
||
<Col className={clsx(colClassName, 'max-w-[550px]')}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.mbti', 'MBTI Personality Type')}
|
||
</label>
|
||
<ChoicesToggleGroup
|
||
currentChoice={profile['mbti'] ?? ''}
|
||
choicesMap={MBTI_CHOICES}
|
||
setChoice={(c) => setProfile('mbti', c)}
|
||
className="grid grid-cols-4 xs:grid-cols-8"
|
||
/>
|
||
</Col>
|
||
|
||
{/* Big Five personality traits (0–100) */}
|
||
<Col className={clsx(colClassName, 'max-w-[550px]')}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.big5', 'Big Five Personality Traits')}
|
||
</label>
|
||
<div className="space-y-4">
|
||
<Big5Slider
|
||
label={t('profile.big5_openness', 'Openness')}
|
||
value={profile.big5_openness ?? 50}
|
||
onChange={(v) => setProfile('big5_openness', v)}
|
||
/>
|
||
<Big5Slider
|
||
label={t('profile.big5_conscientiousness', 'Conscientiousness')}
|
||
value={profile.big5_conscientiousness ?? 50}
|
||
onChange={(v) => setProfile('big5_conscientiousness', v)}
|
||
/>
|
||
<Big5Slider
|
||
label={t('profile.big5_extraversion', 'Extraversion')}
|
||
value={profile.big5_extraversion ?? 50}
|
||
onChange={(v) => setProfile('big5_extraversion', v)}
|
||
/>
|
||
<Big5Slider
|
||
label={t('profile.big5_agreeableness', 'Agreeableness')}
|
||
value={profile.big5_agreeableness ?? 50}
|
||
onChange={(v) => setProfile('big5_agreeableness', v)}
|
||
/>
|
||
<Big5Slider
|
||
label={t('profile.big5_neuroticism', 'Neuroticism')}
|
||
value={profile.big5_neuroticism ?? 50}
|
||
onChange={(v) => setProfile('big5_neuroticism', v)}
|
||
/>
|
||
<p className="text-sm text-ink-500">
|
||
{t(
|
||
'profile.big5_hint',
|
||
'Drag each slider to set where you see yourself on these traits (0 = low, 100 = high).',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.diet', 'Diet')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
{/*<label className={clsx(labelClassName)}>*/}
|
||
{/* {t('profile.optional.diet', 'Diet')}*/}
|
||
{/*</label>*/}
|
||
<MultiCheckbox
|
||
choices={DIET_CHOICES}
|
||
selected={profile['diet'] ?? []}
|
||
translationPrefix={'profile.diet'}
|
||
onChange={(selected) => setProfile('diet', selected)}
|
||
/>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.category.substances', 'Substances')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.smoke', 'Do you smoke?')}
|
||
</label>
|
||
<ChoicesToggleGroup
|
||
currentChoice={profile['is_smoker'] ?? undefined}
|
||
choicesMap={Object.fromEntries(
|
||
Object.entries({
|
||
Yes: true,
|
||
No: false,
|
||
}).map(([k, v]) => [t(`common.${k.toLowerCase()}`, k), v]),
|
||
)}
|
||
setChoice={(c) => setProfile('is_smoker', c)}
|
||
/>
|
||
</Col>
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>
|
||
{t('profile.optional.drinks_per_month', 'Alcoholic beverages consumed per month')}
|
||
</label>
|
||
<Input
|
||
data-testid="alcohol-consumed-per-month"
|
||
type="number"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const value = e.target.value === '' ? null : Number(e.target.value)
|
||
setProfile('drinks_per_month', value)
|
||
}}
|
||
className={'w-20'}
|
||
min={0}
|
||
value={profile['drinks_per_month'] ?? undefined}
|
||
/>
|
||
</Col>
|
||
|
||
<Category title={t('profile.optional.languages', 'Languages')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
{/*<label className={clsx(labelClassName)}>*/}
|
||
{/* {t('profile.optional.languages', 'Languages')}*/}
|
||
{/*</label>*/}
|
||
<div className="grid grid-cols-1 gap-4">
|
||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||
<div className="col-span-full max-h-60 overflow-y-auto w-full">
|
||
<MultiCheckbox
|
||
choices={LANGUAGE_CHOICES}
|
||
selected={profile.languages || []}
|
||
translationPrefix={'profile.language'}
|
||
onChange={(selected) => setProfile('languages', selected)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Col>
|
||
|
||
{/* <Col className={clsx(colClassName)}>
|
||
<label className={clsx(labelClassName)}>Birthplace</label>
|
||
<Input
|
||
type="text"
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setProfileState('born_in_location', e.target.value)}
|
||
className={'w-52'}
|
||
value={profile['born_in_location'] ?? undefined}
|
||
/>
|
||
</Col> */}
|
||
|
||
{/*<Col className={clsx(colClassName)}>*/}
|
||
{/* <label className={clsx(labelClassName)}>Looking for a relationship?</label>*/}
|
||
{/* <ChoicesToggleGroup*/}
|
||
{/* currentChoice={lookingRelationship}*/}
|
||
{/* choicesMap={{Yes: true, No: false}}*/}
|
||
{/* setChoice={(c) => setLookingRelationship(c)}*/}
|
||
{/* />*/}
|
||
{/*</Col>*/}
|
||
|
||
<Category title={t('profile.optional.socials', 'Socials')} />
|
||
|
||
<Col className={clsx(colClassName, 'pb-4')}>
|
||
{/*<label className={clsx(labelClassName)}>*/}
|
||
{/* {t('profile.optional.socials', 'Socials')}*/}
|
||
{/*</label>*/}
|
||
|
||
<div className="grid w-full grid-cols-[8rem_1fr_auto] gap-2">
|
||
{Object.entries((profile.links ?? {}) as Socials)
|
||
.filter(([_, value]) => value != null)
|
||
.map(([platform, value]) => (
|
||
<Fragment key={platform}>
|
||
<div className="col-span-3 mt-2 flex items-center gap-2 self-center sm:col-span-1">
|
||
<SocialIcon site={platform as any} className="text-primary-700 h-4 w-4" />
|
||
{PLATFORM_LABELS[platform as Site] ?? platform}
|
||
</div>
|
||
<Input
|
||
type="text"
|
||
value={value!}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||
updateUserLink(platform, e.target.value)
|
||
}
|
||
className="col-span-2 sm:col-span-1"
|
||
/>
|
||
<IconButton onClick={() => updateUserLink(platform, null)}>
|
||
<XMarkIcon className="h-6 w-6" />
|
||
<div className="sr-only">{t('common.remove', 'Remove')}</div>
|
||
</IconButton>
|
||
</Fragment>
|
||
))}
|
||
|
||
{/* Spacer */}
|
||
<div className="col-span-3 h-4" />
|
||
|
||
<PlatformSelect
|
||
value={newLinkPlatform}
|
||
onChange={setNewLinkPlatform}
|
||
className="h-full !w-full"
|
||
/>
|
||
<Input
|
||
type="text"
|
||
placeholder={
|
||
SITE_ORDER.includes(newLinkPlatform as any) && newLinkPlatform != 'site'
|
||
? t('profile.optional.username_or_url', 'Username or URL')
|
||
: t('profile.optional.site_url', 'Site URL')
|
||
}
|
||
value={newLinkValue}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewLinkValue(e.target.value)}
|
||
// disable password managers
|
||
autoComplete="off"
|
||
data-1p-ignore
|
||
data-lpignore="true"
|
||
data-bwignore="true"
|
||
data-protonpass-ignore="true"
|
||
className="w-full"
|
||
/>
|
||
<Button
|
||
color="gray-outline"
|
||
onClick={addNewLink}
|
||
disabled={!newLinkPlatform || !newLinkValue}
|
||
>
|
||
<PlusIcon className="h-6 w-6" />
|
||
<div className="sr-only">{t('common.add', 'Add')}</div>
|
||
</Button>
|
||
</div>
|
||
</Col>
|
||
|
||
<Category title={t('profile.basics.bio', 'Bio')} className={'mt-0'} />
|
||
<label className={clsx('guidance')}>
|
||
{t(
|
||
'profile.optional.bio_description',
|
||
'Here you can write a long document about who you are and what you are looking for. It includes nice formatting like headers, bold, italic, lists, links, embedded images, and more.',
|
||
)}
|
||
</label>
|
||
<SignupBio
|
||
profile={profile}
|
||
onChange={(e: Editor) => {
|
||
debug('bio changed', e, profile.bio)
|
||
setProfile('bio', e.getJSON())
|
||
setProfile('bio_length', e.getText().length)
|
||
}}
|
||
/>
|
||
|
||
<Category title={t('profile.optional.photos', 'Photos')} />
|
||
|
||
<Col className={clsx(colClassName)}>
|
||
{/*<label className={clsx(labelClassName)}>*/}
|
||
{/* {t('profile.optional.photos', 'Photos')}*/}
|
||
{/*</label>*/}
|
||
|
||
{/*<div className="mb-1">*/}
|
||
{/* A real or stylized photo of you is required.*/}
|
||
{/*</div>*/}
|
||
|
||
<AddPhotosWidget
|
||
username={user.username}
|
||
photo_urls={profile.photo_urls}
|
||
pinned_url={profile.pinned_url}
|
||
setPhotoUrls={(urls) => setProfile('photo_urls', urls)}
|
||
setPinnedUrl={(url) => setProfile('pinned_url', url)}
|
||
setDescription={(url, description) =>
|
||
setProfile('image_descriptions', {
|
||
...((profile?.image_descriptions as Record<string, string>) ?? {}),
|
||
[url]: description,
|
||
})
|
||
}
|
||
image_descriptions={profile.image_descriptions as Record<string, string>}
|
||
/>
|
||
</Col>
|
||
|
||
<Row className={'justify-end'}>
|
||
<Button
|
||
className={clsx(
|
||
'fixed lg:bottom-6 right-4 lg:right-32 z-50 text-xl',
|
||
bottomNavBarVisible
|
||
? 'bottom-[calc(90px+var(--bnh))]'
|
||
: 'bottom-[calc(30px+var(--bnh))]',
|
||
)}
|
||
disabled={isSubmitting}
|
||
loading={isSubmitting}
|
||
onClick={handleSubmit}
|
||
color={'gray'}
|
||
>
|
||
{buttonLabel ?? t('common.next', 'Next')}
|
||
</Button>
|
||
</Row>
|
||
</Col>
|
||
</>
|
||
)
|
||
}
|
||
|
||
const CitySearchBox = (props: {onCitySelected: (city: City | undefined) => void}) => {
|
||
// search results
|
||
const {cities, query, setQuery} = useCitySearch()
|
||
const [focused, setFocused] = useState(false)
|
||
const t = useT()
|
||
|
||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||
|
||
return (
|
||
<>
|
||
<Input
|
||
value={query}
|
||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
||
placeholder={t('profile.optional.search_city', 'Search city...')}
|
||
onFocus={() => setFocused(true)}
|
||
onBlur={(e) => {
|
||
// Do not hide the dropdown if clicking inside the dropdown
|
||
if (dropdownRef.current && !dropdownRef.current.contains(e.relatedTarget)) {
|
||
setFocused(false)
|
||
}
|
||
// Set to the best guess (first city) if no option selected
|
||
if (cities.length > 0) props.onCitySelected(cities[0])
|
||
}}
|
||
/>
|
||
<div className="relative w-full" ref={dropdownRef}>
|
||
<Col className="bg-canvas-50 absolute left-0 right-0 top-1 z-10 w-full overflow-hidden rounded-md">
|
||
{focused &&
|
||
cities.map((c) => (
|
||
<CityRow
|
||
key={c.geodb_city_id}
|
||
city={c}
|
||
onSelect={() => {
|
||
props.onCitySelected(c)
|
||
setQuery('')
|
||
}}
|
||
className="hover:bg-primary-200 justify-between gap-1 px-4 py-2 transition-colors"
|
||
/>
|
||
))}
|
||
</Col>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
function Category({title, className}: {title: string; className?: string}) {
|
||
return <h3 className={clsx('text-xl font-semibold mb-[-8px]', className)}>{title}</h3>
|
||
}
|
||
|
||
const Big5Slider = (props: {label: string; value: number; onChange: (v: number) => void}) => {
|
||
const {label, value, onChange} = props
|
||
return (
|
||
<div>
|
||
<div className="mb-1 flex items-center justify-between text-sm text-ink-600">
|
||
<span>{label}</span>
|
||
<span className="font-semibold text-ink-700" data-testid={`${label.toLowerCase()}-value`}>
|
||
{Math.round(value)}
|
||
</span>
|
||
</div>
|
||
<Slider
|
||
amount={value}
|
||
min={0}
|
||
max={100}
|
||
onChange={(v) => onChange(Math.round(v))}
|
||
marks={[
|
||
{value: 0, label: '0'},
|
||
{value: 25, label: '25'},
|
||
{value: 50, label: '50'},
|
||
{value: 75, label: '75'},
|
||
{value: 100, label: '100'},
|
||
]}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|