Files
Compass/web/components/optional-profile-form.tsx

1751 lines
69 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 {CameraIcon, InformationCircleIcon} from '@heroicons/react/24/outline'
import * as Sentry from '@sentry/node'
import {JSONContent} from '@tiptap/core'
import {Editor} from '@tiptap/react'
import clsx from 'clsx'
import {
CANNABIS_CHOICES,
DEFAULT_NEUROTYPES,
DEFAULT_ORIENTATIONS,
DIET_CHOICES,
EDUCATION_CHOICES,
EXTRA_NEUROTYPES,
GENDERS,
GENDERS_PLURAL,
LANGUAGE_CHOICES,
MBTI_CHOICES,
NEUROTYPE_CHOICES,
ORIENTATION_CHOICES,
POLITICAL_CHOICES,
PSYCHEDELICS_CHOICES,
RACE_CHOICES,
RELATIONSHIP_CHOICES,
RELATIONSHIP_STATUS_CHOICES,
RELIGION_CHOICES,
ROMANTIC_CHOICES,
SUBSTANCE_INTENTION_CHOICES,
SUBSTANCE_PREFERENCE_CHOICES,
} from 'common/choices'
import {DEFAULT_GENDERS, EXTRA_GENDERS} from 'common/gender'
import {debug} from 'common/logger'
import {isUrl} from 'common/parsing'
import {MultipleChoiceOptions} from 'common/profiles/multiple-choice'
import {Profile, ProfileWithoutUser} from 'common/profiles/profile'
import {BaseUser} from 'common/user'
import {removeNullOrUndefinedProps} from 'common/util/object'
import {concatJSONContent, parseJsonContentToText} from 'common/util/parse'
import {urlize} from 'common/util/string'
import {MINUTE_MS, sleep} from 'common/util/time'
import {invert, range} from 'lodash'
import {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} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {CustomLink} from 'web/components/links'
import {LLMExtractSection} from 'web/components/llm-extract-section'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {City, CityRow, profileToCity, useCitySearch} from 'web/components/search-location'
import {SocialLinksSection} from 'web/components/social-links-section'
import {VoiceAutofillSection} from 'web/components/voice-autofill-section'
import {Carousel} from 'web/components/widgets/carousel'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {Input} from 'web/components/widgets/input'
import {RadioToggleGroup} from 'web/components/widgets/radio-toggle-group'
import {Select} from 'web/components/widgets/select'
import {Slider} from 'web/components/widgets/slider'
import {ChoiceMap, ChoiceSetter, useChoicesContext} from 'web/hooks/use-choices'
import {api} from 'web/lib/api'
import {useLocale, useT} from 'web/lib/locale'
import {track} from 'web/lib/service/analytics'
import {blobToBase64} from 'web/lib/util/blob'
import {scrollIntoViewCentered} from 'web/lib/util/scroll'
import {colClassName, labelClassName} from 'web/pages/signup'
import {AddPhotosWidget} from './widgets/add-photos'
const DEFAULT_PREF_GENDER_VALUES = ['female', 'male']
const BIG5_KEYS = [
'big5_openness',
'big5_conscientiousness',
'big5_extraversion',
'big5_agreeableness',
'big5_neuroticism',
] as const
function PrefGenderCheckbox(props: {
profile: ProfileWithoutUser
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
t: (key: string, fallback: string) => string
}) {
const {profile, setProfile, t} = props
const selected = profile['pref_gender'] || []
const hasExtended = selected.some((v) => !DEFAULT_PREF_GENDER_VALUES.includes(v))
const [showAll, setShowAll] = useState(hasExtended)
const visibleChoices = Object.fromEntries(
Object.entries(GENDERS_PLURAL as Record<string, string>).filter(
([, v]) => showAll || DEFAULT_PREF_GENDER_VALUES.includes(v) || selected.includes(v),
),
)
return (
<>
<MultiCheckbox
choices={visibleChoices}
translationPrefix={'profile.gender'}
selected={selected}
onChange={(selected) => setProfile('pref_gender', selected)}
/>
{!showAll && (
<button
type="button"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAll(true)}
>
{t('profile.gender.show_more', 'Show more options')}
</button>
)}
</>
)
}
export const OptionalProfileUserForm = (props: {
profile: ProfileWithoutUser
setProfile: <K extends keyof ProfileWithoutUser>(key: K, value: ProfileWithoutUser[K]) => void
user: BaseUser
buttonLabel?: string
bottomNavBarVisible?: boolean
onSubmit: (profile?: ProfileWithoutUser) => Promise<void>
}) => {
const {profile, user, buttonLabel, setProfile, onSubmit, bottomNavBarVisible = true} = props
const [isSubmitting, setIsSubmitting] = useState(false)
const [uploadingImages, setUploadingImages] = useState(false)
const [ageError, setAgeError] = useState<string | null>(null)
const [showAllGenders, setShowAllGenders] = useState(
() => !!profile.gender && EXTRA_GENDERS.includes(profile.gender as any),
)
const [showAllOrientations, setShowAllOrientations] = useState(false)
const [showAllNeurotypes, setShowAllNeurotypes] = useState(
() =>
!!(profile as any).neurotype &&
((profile as any).neurotype as string[]).some((v) => EXTRA_NEUROTYPES.includes(v as any)),
)
const t = useT()
const {locale} = useLocale()
const choices = useChoicesContext()
const [interestChoices, setInterestChoices] = useState(choices.interests)
const [causeChoices, setCauseChoices] = useState(choices.causes)
const [workChoices, setWorkChoices] = useState(choices.work)
const [keywordsString, setKeywordsString] = useState<string>(profile.keywords?.join(', ') || '')
const lookingRelationship = (profile.pref_relation_styles || []).includes('relationship')
const heightFeet =
typeof profile.height_in_inches === 'number'
? Math.floor(profile.height_in_inches / 12)
: undefined
const heightInches =
typeof profile.height_in_inches === 'number' ? profile.height_in_inches % 12 : undefined
const anyBig5Set = BIG5_KEYS.some((k) => typeof profile[k] === 'number')
const resetBig5 = () => BIG5_KEYS.forEach((k) => setProfile(k, null))
const [isExtracting, setIsExtracting] = useState(false)
const [autofillMode, setAutofillMode] = useState<'voice' | 'link'>('voice')
const [parsingEditor, setParsingEditor] = useState<any>(null)
const [extractionProgress, setExtractionProgress] = useState(0)
const [extractionError, setExtractionError] = useState<string | null>(null)
// Auto-fill can populate every field on the page, which makes the profile *look* finished — but it
// can never supply a photo. Set on a successful extraction; the banner itself also checks that
// there is still no photo, so it disappears the moment one is added.
const [remindAboutPhotos, setRemindAboutPhotos] = useState(false)
const photosRef = useRef<HTMLDivElement>(null)
const hasPhotos = !!profile.photo_urls?.length || !!profile.pinned_url
const showPhotoReminder = remindAboutPhotos && !hasPhotos
const runExtraction = async (input: {
content?: string
url?: string
source: 'text' | 'url' | 'voice'
}): Promise<Partial<ProfileWithoutUser>> => {
setIsExtracting(true)
setExtractionProgress(0)
setExtractionError(null)
const startTime = Date.now()
// Was leaking: the interval was never cleared, so every extraction left another timer behind
// driving the progress bar. Now that a user can extract several times in a row (record, review,
// fill, record again) that compounds, so it is cleared in `finally`.
const progressInterval = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000
if (elapsed < 20) {
setExtractionProgress((elapsed / 30) * 100)
} else if (elapsed < 60) {
setExtractionProgress((2 / 3) * 100 + ((elapsed - 20) / 150) * 100)
}
}, 100)
const payload = {locale, ...input}
try {
let extractedProfile: Partial<ProfileWithoutUser> = {}
let status: string | undefined = 'pending'
while (status === 'pending') {
const elapsedMs = Date.now() - startTime
if (elapsedMs > 10 * MINUTE_MS) {
throw new Error('Extraction timed out after 10 minutes')
}
const response = await api('llm-extract-profile', payload)
status = response.status
debug(response)
if (status === 'pending') {
await sleep(1000)
}
extractedProfile = response.profile
}
if (status !== 'success') {
throw new Error('Failed to extract profile')
}
extractedProfile = removeNullOrUndefinedProps(extractedProfile)
for (const data of Object.entries(extractedProfile)) {
const key = data[0] as keyof ProfileWithoutUser
let value = data[1]
let choices: ChoiceMap | undefined
let setChoices: ChoiceSetter | undefined
if (key === 'interests') {
choices = interestChoices
setChoices = setInterestChoices
} else if (key === 'causes') {
choices = causeChoices
setChoices = setCauseChoices
} else if (key === 'work') {
choices = workChoices
setChoices = setWorkChoices
}
if (choices && setChoices) {
const newFields: string[] = []
const converter = invert(choices)
value = (value as string[]).map((interest: string) => {
if (!converter[interest]) newFields.push(interest)
return converter[interest] ?? interest
})
if (newFields.length) {
setChoices((prev: any) => ({
...prev,
...Object.fromEntries(newFields.map((e) => [e, e])),
}))
}
debug({value, converter})
} else if (key === 'keywords') setKeywordsString((value as string[]).join(', '))
;(extractedProfile as Record<string, unknown>)[key] = value
}
// Pasted text *is* the bio, so it is stored verbatim. A URL's bio comes back from the fetched
// page, and a voice bio is written by the LLM — a raw speech transcript reads terribly.
if (input.source === 'text') extractedProfile.bio = parsingEditor?.getJSON?.()
// A follow-up is an addition, not a correction: someone who remembered one more
// thing to say should not lose everything they already had. Only the bio behaves this way —
// the structured fields still take the newest answer, since those are single-valued.
if (extractedProfile.bio) {
const existingBio = profile.bio as JSONContent | null | undefined
if (parseJsonContentToText(existingBio).trim()) {
extractedProfile.bio = concatJSONContent(existingBio, extractedProfile.bio as JSONContent)
}
}
debug({
text: parsingEditor?.getText?.(),
json: parsingEditor?.getJSON?.(),
extracted: extractedProfile,
})
for (const key of Object.keys(extractedProfile) as (keyof ProfileWithoutUser)[]) {
setProfile(key, extractedProfile[key] as ProfileWithoutUser[typeof key])
}
parsingEditor?.commands?.clearContent?.()
toast.success(
t('profile.llm.extract.success', 'Profile data extracted! Please review below.'),
)
setRemindAboutPhotos(true)
setExtractionProgress(100)
return extractedProfile
} catch (error) {
console.error(error)
setExtractionError(
t(
'profile.llm.extract.error',
'Profile extraction failed. No worries — you can fill in your profile manually, or save it now and come back to extract later.',
),
)
Sentry.captureException(error, {
user, // shows in the User section
// contexts: {'Error Info': {}}, // only strings as values (not nested objects)
extra: {payload}, // for the rest (nested, etc.)
})
} finally {
clearInterval(progressInterval)
setIsExtracting(false)
}
return {}
}
const handleLLMExtract = async (): Promise<Partial<ProfileWithoutUser>> => {
const llmContent = parsingEditor?.getText?.() ?? ''
if (!llmContent) {
toast.error(t('profile.llm.extract.error_empty', 'Please enter content to extract from'))
return {}
}
return isUrl(llmContent)
? runExtraction({url: urlize(llmContent).trim(), source: 'url'})
: runExtraction({content: llmContent.trim(), source: 'text'})
}
const handleTranscribe = async (blob: Blob): Promise<string | null> => {
setExtractionError(null)
try {
const audio = await blobToBase64(blob)
const {transcript} = await api('transcribe-audio', {
audio,
mimeType: blob.type || 'audio/webm',
locale,
})
return transcript
} catch (error) {
console.error(error)
setExtractionError(
t(
'profile.voice.error.transcription',
'We could not transcribe your recording. Your recording is still here — try again, or fill in your profile manually.',
),
)
Sentry.captureException(error, {
user,
extra: {mimeType: blob.type, size: blob.size},
})
return null
}
}
const errorToast = () => {
toast.error(t('profile.optional.error.invalid_fields', 'Some fields are incorrect...'))
}
const handleSubmit = async () => {
let finalProfile = profile
// Not gated on the visible tab: the editor stays mounted either way, so text typed here is real
// content the user would not expect us to drop just because they switched tabs before saving.
if (parsingEditor?.getText?.()?.trim()) {
const extractedProfile = await handleLLMExtract()
finalProfile = {...profile, ...extractedProfile}
}
// Validate age before submitting
if (typeof finalProfile['age'] === 'number') {
if (finalProfile['age'] < 18) {
setAgeError(t('profile.optional.age.error_min', 'You must be at least 18 years old'))
setIsSubmitting(false)
errorToast()
return
}
if (finalProfile['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(finalProfile)
choices.refreshInterests()
choices.refreshCauses()
choices.refreshWork()
setIsSubmitting(false)
}
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 (
<>
{/* `pb-32`: the bio editor is the last element on the page, so when it is focused there is
nothing below it to scroll into — the document simply ends, and no amount of
`scrollIntoView` can lift its toolbar off the bottom edge. This reserves scrollable room
below the form so the toolbar can clear the viewport edge (and the floating submit button). */}
<Col className={'gap-8 max-w-3xl pb-32'} data-profile-form>
{/* This sentence is the single most abandonment-reducing thing on the page — it tells you the
eighteen sections below are all skippable. It was rendered in `.guidance`: 14px at 70%
opacity, i.e. styled like fine print, which is exactly backwards. */}
<div className="border-primary-400 flex items-start gap-3 border-l-2 pl-4">
<InformationCircleIcon
className="w-5 h-5 text-primary-600 shrink-0 mt-0.5"
strokeWidth={1.8}
/>
<p className="text-sm text-ink-600 leading-relaxed">
{t(
'profile.optional.subtitle',
'Although all the fields below are optional, they will help people better understand you and connect with you.',
)}
</p>
</div>
<Category title={t('profile.llm.extract.title', 'Auto-fill')} className={'mt-0'} />
{/* Two ways in, one at a time: showing both a recorder and a text editor at once reads as
two things to do rather than a choice between them. */}
{/* Underlined tabs rather than a segmented pill. The selected segment was `bg-canvas-0` —
pure black in the dark ramp — so the active tab read as a hole punched in the page rather
than the raised one. An underline needs no fill to say which is selected. */}
<Row className="border-canvas-300 w-full gap-6 border-b">
{(
[
['voice', t('profile.autofill.by_voice', 'By voice')],
['link', t('profile.autofill.by_link', 'By link or text')],
] as const
).map(([mode, label]) => (
<button
key={mode}
type="button"
onClick={() => setAutofillMode(mode)}
aria-pressed={autofillMode === mode}
disabled={isExtracting}
className={clsx(
'-mb-px border-b-2 px-1 pb-3 text-sm transition-colors disabled:cursor-not-allowed',
autofillMode === mode
? 'border-primary-500 text-ink-1000'
: 'border-transparent text-ink-500 hover:text-ink-800',
)}
>
{label}
</button>
))}
</Row>
{/* Both panels stay mounted and the inactive one is hidden, rather than swapped out.
Unmounting the recorder would tear down an in-progress recording and drop any transcript
corrections the moment someone peeked at the other tab. `display: none` also keeps the
hidden panel out of the flex layout and away from assistive tech. */}
<div className={clsx(autofillMode !== 'voice' && 'hidden')}>
<VoiceAutofillSection
onTranscribe={handleTranscribe}
onExtract={async (transcript) => {
await runExtraction({content: transcript, source: 'voice'})
}}
isExtracting={isExtracting}
isSubmitting={isSubmitting}
progress={extractionProgress}
/>
</div>
<div className={clsx(autofillMode !== 'link' && 'hidden')}>
<LLMExtractSection
parsingEditor={parsingEditor}
setParsingEditor={setParsingEditor}
isExtracting={isExtracting}
isSubmitting={isSubmitting}
onExtract={handleLLMExtract}
progress={extractionProgress}
/>
</div>
{extractionError && (
<p className="border rounded-xl border-red-900 text-red-600 text-sm p-2">
{extractionError}
</p>
)}
{showPhotoReminder && (
<div
role="status"
className="flex items-start gap-3 rounded-xl bg-primary-100/60 ring-1 ring-primary-200 p-4"
>
<CameraIcon className="w-5 h-5 text-primary-700 shrink-0 mt-0.5" strokeWidth={1.8} />
<Col className="gap-2">
<p className="text-sm text-ink-700 leading-relaxed">
{t(
'profile.optional.photo_reminder',
'One thing we cannot fill in for you: your photos. They are still an important thing people look at (at least just to visually identify you), so do not leave without adding one or two.',
)}
</p>
<button
type="button"
className="text-primary-700 self-start text-sm font-medium hover:underline"
onClick={() => {
if (photosRef.current) scrollIntoViewCentered(photosRef.current)
}}
>
{t('profile.optional.photo_reminder.cta', 'Take me to photos')}
</button>
</Col>
</div>
)}
{/* The rule that used to sit here (`border border-b`, which draws two) is now owned by
`Category` itself, so every section boundary is drawn the same way. */}
<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)
.filter(
([, v]) =>
showAllGenders ||
DEFAULT_GENDERS.includes(v as any) ||
profile['gender'] === v,
)
.map(([k, v]) => [t(`profile.gender.${v}`, k), v]),
) as any
}
setChoice={(c) => setProfile('gender', c)}
/>
{!showAllGenders && (
<button
type="button"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAllGenders(true)}
>
{t('profile.gender.show_more', 'Show more options')}
</button>
)}
{showAllGenders && (
<>
<p className={clsx(labelClassName, 'mt-1')}>
{t('profile.optional.details', 'Details')}
</p>
<Input
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('gender_details', e.target.value)
}
className={'w-full'}
value={(profile as any)['gender_details'] ?? undefined}
placeholder={t(
'profile.gender.details_placeholder',
'Any details about your gender identity…',
)}
/>
</>
)}
</Col>
</Row>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>{t('profile.optional.age', 'Age')}</label>
<Input
type="number"
className={'!w-24'}
placeholder={t('profile.optional.age', 'Age')}
value={profile['age'] ?? undefined}
min={18}
max={100}
step={1}
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 className={clsx(labelClassName, 'mb-1')}>
{t('profile.optional.feet', 'Feet')}
</span>
<Input
type="number"
data-testid="height-feet"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const heightInInches = Number(e.target.value || 0) * 12 + (heightInches ?? 0)
setProfile('height_in_inches', heightInInches)
}}
className={'!w-20'}
value={typeof heightFeet === 'number' && heightFeet ? Math.floor(heightFeet) : ''}
min={0}
step={1}
/>
</Col>
<Col>
<span className={clsx(labelClassName, 'mb-1')}>
{t('profile.optional.inches', 'Inches')}
</span>
<Input
type="number"
data-testid="height-inches"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
const heightInInches = Number(e.target.value || 0) + 12 * (heightFeet ?? 0)
setProfile('height_in_inches', heightInInches)
}}
className={'!w-20'}
value={
typeof heightInches === 'number' && heightInches ? Math.floor(heightInches) : ''
}
min={0}
step={1}
/>
</Col>
<div className="self-end mb-2 text-ink-700 mx-2">
{t('common.or', 'OR').toUpperCase()}
</div>
<Col>
<span className={clsx(labelClassName, 'mb-1')}>
{t('profile.optional.centimeters', 'Centimeters')}
</span>
<Input
type="number"
data-testid="height-centimeters"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === '') {
setProfile('height_in_inches', null)
} else {
// Convert cm to inches
const totalInches = Number(e.target.value) / 2.54
setProfile('height_in_inches', totalInches)
}
}}
className={'!w-24'}
value={
heightFeet !== undefined && profile['height_in_inches']
? Math.round(profile['height_in_inches'] * 2.54)
: ''
}
min={0}
step={1}
/>
</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>
{/* Its own section rather than the tail of Personal Information. Photos are a hero field —
the first thing anyone looks at on the finished profile — and as the last row of a
section about height and ethnicity they were something you scrolled past on the way
somewhere else, with no entry in the index to come back to. */}
<Category title={t('profile.optional.category.photos', 'Photos')} />
<Col className={clsx(colClassName)} ref={photosRef}>
{/*<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>}
onUpload={(uploading) => setUploadingImages(uploading)}
/>
</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={
'text-ink-700 w-full bg-canvas-50 border border-canvas-300 rounded-xl p-3 transition-all duration-150 hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/25'
}
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'}
value={keywordsString}
placeholder={t(
'profile.optional.keywords_placeholder',
'e.g., hiking, climate, progressive',
)}
/>
</Col>
{/* Third, not last. Bio is the longest thing anyone writes and the third thing anyone reads on
the finished profile — burying it under fifty attribute fields meant most people met it
with the form already exhausted. It follows the hero fields it belongs with: photos,
headline, keywords. */}
<Category title={t('profile.basics.bio', 'Bio')} />
<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.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>
<PrefGenderCheckbox profile={profile} setProfile={setProfile} t={t} />
</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 className={clsx(labelClassName, 'mb-1')}>{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 className={clsx(labelClassName, 'mb-1')}>{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)
}}
/>
</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.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.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 && (
<>
<Row className={'items-center gap-2'}>
<Col className={'gap-1'}>
<label className={clsx(labelClassName)}>
{t('profile.optional.orientation', 'Orientation')}
</label>
<MultiCheckbox
choices={
Object.fromEntries(
Object.entries(ORIENTATION_CHOICES).filter(
([, v]) =>
showAllOrientations ||
DEFAULT_ORIENTATIONS.includes(v as any) ||
(profile['orientation'] ?? []).includes(v),
),
) as any
}
selected={profile['orientation'] ?? []}
translationPrefix={'profile.orientation'}
onChange={(selected) => setProfile('orientation', selected)}
/>
{!showAllOrientations && (
<button
type="button"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAllOrientations(true)}
>
{t('profile.orientation.show_more', 'Show more options')}
</button>
)}
{showAllOrientations && (
<>
<p className={clsx(labelClassName, 'mt-1')}>
{t('profile.optional.details', 'Details')}
</p>
<Input
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('orientation_details', e.target.value)
}
className={'w-full'}
value={(profile as any)['orientation_details'] ?? undefined}
placeholder={t(
'profile.orientation.details_placeholder',
'Any details about your sexual orientation…',
)}
/>
</>
)}
</Col>
</Row>
<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.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 className={clsx(labelClassName)}>{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'}
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 className={clsx(labelClassName)}>{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'}
value={profile['religious_beliefs'] ?? undefined}
/>
</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.morality', 'Morality')} />
<AddOptionEntry
title={t('profile.optional.causes', 'Causes')}
choices={causeChoices}
setChoices={setCauseChoices}
profile={profile}
setProfile={setProfile}
label={'causes'}
/>
<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.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>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.psychedelics', 'Psychedelics / plant medicine')}
</label>
<Select
value={profile['psychedelics'] ?? ''}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setProfile('psychedelics', e.target.value || null)
}
className={'w-full sm:w-80'}
>
<option value=""></option>
{Object.entries(PSYCHEDELICS_CHOICES).map(([label, value]) => (
<option key={value} value={value}>
{t(`profile.psychedelics.${value}`, label)}
</option>
))}
</Select>
</Col>
{profile['psychedelics'] && profile['psychedelics'] !== 'never_not_interested' && (
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.psychedelics_intention', 'Intention (psychedelics)')}
</label>
<MultiCheckbox
choices={SUBSTANCE_INTENTION_CHOICES}
selected={profile['psychedelics_intention'] ?? []}
translationPrefix={'profile.substance_intention'}
onChange={(selected) => setProfile('psychedelics_intention', selected)}
/>
</Col>
)}
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.psychedelics_pref', 'Preference for partner (psychedelics)')}
</label>
<MultiCheckbox
choices={SUBSTANCE_PREFERENCE_CHOICES}
selected={profile['psychedelics_pref'] ?? []}
translationPrefix={'profile.substance_pref'}
onChange={(selected) => setProfile('psychedelics_pref', selected)}
/>
</Col>
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.cannabis', 'Cannabis')}
</label>
<Select
value={profile['cannabis'] ?? ''}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setProfile('cannabis', e.target.value || null)
}
className={'w-full sm:w-80'}
>
<option value=""></option>
{Object.entries(CANNABIS_CHOICES).map(([label, value]) => (
<option key={value} value={value}>
{t(`profile.cannabis.${value}`, label)}
</option>
))}
</Select>
</Col>
{profile['cannabis'] && profile['cannabis'] !== 'never_not_interested' && (
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.cannabis_intention', 'Intention (cannabis)')}
</label>
<MultiCheckbox
choices={SUBSTANCE_INTENTION_CHOICES}
selected={profile['cannabis_intention'] ?? []}
translationPrefix={'profile.substance_intention'}
onChange={(selected) => setProfile('cannabis_intention', selected)}
/>
</Col>
)}
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.cannabis_pref', 'Preference for partner (cannabis)')}
</label>
<MultiCheckbox
choices={SUBSTANCE_PREFERENCE_CHOICES}
selected={profile['cannabis_pref'] ?? []}
translationPrefix={'profile.substance_pref'}
onChange={(selected) => setProfile('cannabis_pref', selected)}
/>
</Col>
<Category title={t('profile.optional.category.psychology', 'Psychology')} />
{/* Sits with MBTI/Big Five rather than under Relationships: it describes how someone's mind
works, and it is just as relevant to friendship and collaboration as to dating. */}
<Col className={clsx(colClassName)}>
<label className={clsx(labelClassName)}>
{t('profile.optional.neurotype', 'Neurotype')}
</label>
<MultiCheckbox
choices={
Object.fromEntries(
Object.entries(NEUROTYPE_CHOICES).filter(
([, v]) =>
showAllNeurotypes ||
DEFAULT_NEUROTYPES.includes(v as any) ||
((profile as any)['neurotype'] ?? []).includes(v),
),
) as any
}
selected={(profile as any)['neurotype'] ?? []}
translationPrefix={'profile.neurotype'}
onChange={(selected) => setProfile('neurotype' as any, selected)}
/>
{!showAllNeurotypes && (
<button
type="button"
className="text-primary-700 mt-1 text-sm font-medium hover:underline"
onClick={() => setShowAllNeurotypes(true)}
>
{t('profile.neurotype.show_more', 'Show more options')}
</button>
)}
{showAllNeurotypes && (
<>
<p className={clsx(labelClassName, 'mt-1')}>
{t('profile.optional.details', 'Details')}
</p>
<Input
type="text"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setProfile('neurotype_details' as any, e.target.value)
}
className={'w-full'}
value={(profile as any)['neurotype_details'] ?? undefined}
placeholder={t(
'profile.neurotype.details_placeholder',
'Anything helpful to know about how you think or communicate…',
)}
/>
</>
)}
</Col>
<Col className={clsx(colClassName, 'max-w-[550px]')}>
<label className={clsx(labelClassName)}>
{t('profile.optional.mbti', 'MBTI Personality Type')}
</label>
<ChoicesToggleGroup
currentChoice={profile['mbti'] as any}
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)}>
<Row className="w-full items-center justify-between gap-3">
<label className={clsx(labelClassName)}>
{t('profile.big5', 'Big Five Personality Traits')}
</label>
{/* A slider cannot express "unset": drag one and there is no gesture that puts it back,
so before this the first accidental touch permanently committed a score. Clearing
writes `null` rather than `undefined` on purpose — the edit path
(`pages/profile.tsx`) strips undefined but persists null, so undefined would silently
leave the old value in the database. Signup strips both, which is also correct there:
an unset trait should not be sent at all. */}
{anyBig5Set && (
<button
type="button"
onClick={resetBig5}
className="text-sm font-medium text-primary-700 hover:underline shrink-0"
>
{t('profile.big5_reset', 'Reset')}
</button>
)}
</Row>
<p className="guidance custom-link">
{t(
'profile.big5_guidance',
'The Big Five personality trait model is a scientific model that groups variation in personality into five separate factors (',
)}
{/* CustomLink ships no colour of its own, so this inherited a 2.70:1 amber. */}
<CustomLink
href={'https://en.wikipedia.org/wiki/Big_Five_personality_traits'}
className="text-primary-700 font-medium hover:underline"
>
{t('profile.big5_wikipedia_link', 'Wikipedia article')}
</CustomLink>
{t(
'profile.big5_guidance_suffix',
'). You can take a free well-cited public-domain approximate test ',
)}
<CustomLink href={'https://emilywilloughby.com/research/bfas'}>
{t('profile.big5_test_link', 'here')}
</CustomLink>
{t('profile.big5_guidance_end', '.')}
</p>
<div className={clsx('space-y-4', 'w-full max-w-[550px]')}>
<Big5Slider
label={t('profile.big5_openness', 'Openness')}
value={profile.big5_openness ?? 50}
isSet={typeof profile.big5_openness === 'number'}
onChange={(v) => setProfile('big5_openness', v)}
/>
<Big5Slider
label={t('profile.big5_conscientiousness', 'Conscientiousness')}
value={profile.big5_conscientiousness ?? 50}
isSet={typeof profile.big5_conscientiousness === 'number'}
onChange={(v) => setProfile('big5_conscientiousness', v)}
/>
<Big5Slider
label={t('profile.big5_extraversion', 'Extraversion')}
value={profile.big5_extraversion ?? 50}
isSet={typeof profile.big5_extraversion === 'number'}
onChange={(v) => setProfile('big5_extraversion', v)}
/>
<Big5Slider
label={t('profile.big5_agreeableness', 'Agreeableness')}
value={profile.big5_agreeableness ?? 50}
isSet={typeof profile.big5_agreeableness === 'number'}
onChange={(v) => setProfile('big5_agreeableness', v)}
/>
<Big5Slider
label={t('profile.big5_neuroticism', 'Neuroticism')}
value={profile.big5_neuroticism ?? 50}
isSet={typeof profile.big5_neuroticism === 'number'}
onChange={(v) => setProfile('big5_neuroticism', v)}
/>
</div>
<p className="text-sm text-ink-700">
{t(
'profile.big5_hint',
'Drag each slider to set where you see yourself on these traits (0 = low, 100 = high).',
)}
</p>
</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="scrollbar-visible col-span-full max-h-60 overflow-y-auto w-full pr-2">
<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.accessibility_notes', 'Accessibility')} />
{/* Free text on purpose, not a checkbox list of conditions: a taxonomy exists mainly to be
filtered on, and filtering people out by disability is what this field must not enable.
Free text leaves the framing and the depth of disclosure with the member. */}
<Col className={clsx(colClassName)}>
<label className={clsx('guidance')}>
{t(
'profile.optional.accessibility_notes_hint',
'Optional, and public like the rest of your profile. Anything practical that helps someone meet you well — access needs, energy levels, sensory preferences, quieter venues.',
)}
</label>
<Textarea
data-testid="accessibility_notes"
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setProfile('accessibility_notes' as any, e.target.value)
}
className={
'text-ink-700 w-full bg-canvas-50 border border-canvas-300 rounded-xl p-3 transition-all duration-150 hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/25'
}
value={(profile as any)['accessibility_notes'] ?? undefined}
maxLength={500}
placeholder={t(
'profile.optional.accessibility_notes_placeholder',
'e.g. I use a wheelchair, so step-free venues work best. Happy to answer questions.',
)}
/>
</Col>
<Category title={t('profile.optional.socials', 'Socials')} />
<SocialLinksSection profile={profile} setProfile={setProfile} />
<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 || uploadingImages}
loading={isSubmitting}
onClick={handleSubmit}
size={'xl'}
// Was `primary` — the *tinted secondary* variant. This is the button that actually
// creates the account at the end of a 7,500px form; it should not be the quietest
// control on screen.
color={'cta'}
>
{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...')}
className={'w-full sm:w-[420px]'}
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])
}}
searchIcon
/>
<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>
</>
)
}
/**
* Section header for the long optional-profile form.
*
* This form is ~7,500px of a single flat column holding eighteen of these. Previously each was a bare
* `text-xl` heading pulled *closer* to its fields by a `mb-[-8px]` hack, so there was no visual
* boundary between one section and the next — "Work" ended and "Political beliefs" began with nothing
* between them but a slightly bolder line of text, and a filler had no way to see how the form was
* organised or where they were in it.
*
* A rule plus real leading space does the grouping without restructuring 1,300 lines of field markup:
* the header now closes the previous section as much as it opens its own. The negative margin is gone
* — the parent `Col` already supplies `gap-8`, which is the spacing this was fighting.
*/
function slugifySectionTitle(title: string) {
return (
'form-section-' +
title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
)
}
function Category({title, className}: {title: string; className?: string}) {
return (
// Label above the rule, not below it — the same object as a section heading on the rendered
// profile, so the form is built in the visual language of the thing it produces. `mt-6` on top of
// the parent's `gap-8` keeps the heading closer to the section it opens than to the one it closes.
//
// `data-form-section` is what ProfileFormNav reads to build its index: the markup stays the source
// of truth for which sections exist and in what order. `scroll-mt-24` keeps a jumped-to heading
// clear of the sticky header rather than tucked under it.
<div
id={slugifySectionTitle(title)}
data-form-section={title}
className={clsx('w-full mt-6 scroll-mt-24', className)}
>
{/* `!mt-0` / `!mb-0`: the global h1h6 rule sets Newsreader at weight 700 with a 24px margin, so
a heading element has to opt out of all three explicitly or it opts out of none. */}
<h3
className="font-dm-sans text-ink-400 !mt-0 !mb-3 font-normal uppercase !leading-none"
style={{fontSize: '10px', letterSpacing: '0.18em'}}
>
{title}
</h3>
<div className="border-canvas-300 border-t" />
</div>
)
}
/**
* `isSet` distinguishes "I am a 50" from "I have not answered this".
*
* The slider has no null position — an untouched trait has to render *somewhere*, and it renders at
* the midpoint. Without a readout that says otherwise, an untouched form looks like five deliberate
* 50s, and submitting it would claim five personality scores the user never gave.
*/
const Big5Slider = (props: {
label: string
value: number
isSet: boolean
onChange: (v: number) => void
}) => {
const {label, value, isSet, onChange} = props
return (
<div>
<div className="mb-1 flex items-center justify-between text-sm text-ink-700">
<span>{label}</span>
<span
className={clsx('font-semibold', isSet ? 'text-ink-700' : 'text-ink-600')}
data-testid={`${label.toLowerCase()}-value`}
>
{isSet ? 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>
)
}