Files
Compass/web/components/optional-profile-form.tsx
2026-03-07 00:24:22 +01:00

1104 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (0100) */}
<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>
)
}