import * as Sentry from '@sentry/node' import {Editor} from '@tiptap/react' import clsx from 'clsx' import { CANNABIS_CHOICES, DEFAULT_ORIENTATIONS, DIET_CHOICES, EDUCATION_CHOICES, GENDERS, GENDERS_PLURAL, LANGUAGE_CHOICES, MBTI_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 {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 {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 {colClassName, labelClassName} from 'web/pages/signup' import {AddPhotosWidget} from './widgets/add-photos' const DEFAULT_PREF_GENDER_VALUES = ['female', 'male'] function PrefGenderCheckbox(props: { profile: ProfileWithoutUser setProfile: (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).filter( ([, v]) => showAll || DEFAULT_PREF_GENDER_VALUES.includes(v) || selected.includes(v), ), ) return ( <> setProfile('pref_gender', selected)} /> {!showAll && ( )} ) } export const OptionalProfileUserForm = (props: { profile: ProfileWithoutUser setProfile: (key: K, value: ProfileWithoutUser[K]) => void user: BaseUser buttonLabel?: string bottomNavBarVisible?: boolean onSubmit: (profile?: ProfileWithoutUser) => Promise }) => { const {profile, user, buttonLabel, setProfile, onSubmit, bottomNavBarVisible = true} = props const [isSubmitting, setIsSubmitting] = useState(false) const [uploadingImages, setUploadingImages] = useState(false) const [ageError, setAgeError] = useState(null) const [showAllGenders, setShowAllGenders] = useState( () => !!profile.gender && EXTRA_GENDERS.includes(profile.gender as any), ) const [showAllOrientations, setShowAllOrientations] = useState(false) 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(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 [isExtracting, setIsExtracting] = useState(false) const [parsingEditor, setParsingEditor] = useState(null) const [extractionProgress, setExtractionProgress] = useState(0) const [extractionError, setExtractionError] = useState(null) const handleLLMExtract = async (): Promise> => { const llmContent = parsingEditor?.getText?.() ?? '' if (!llmContent) { toast.error(t('profile.llm.extract.error_empty', 'Please enter content to extract from')) return {} } setIsExtracting(true) setExtractionProgress(0) setExtractionError(null) const startTime = Date.now() 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 isInputUrl = isUrl(llmContent) const payload = { locale, ...(isInputUrl ? {url: urlize(llmContent).trim()} : {content: llmContent.trim()}), } try { let extractedProfile: Partial = {} 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)[key] = value } if (!isInputUrl) extractedProfile.bio = parsingEditor?.getJSON?.() 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.'), ) // clearInterval(progressInterval) 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 errorToast = () => { toast.error(t('profile.optional.error.invalid_fields', 'Some fields are incorrect...')) } const handleSubmit = async () => { let finalProfile = profile 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 ( <>

{t( 'profile.optional.subtitle', 'Although all the fields below are optional, they will help people better understand you and connect with you.', )}

{extractionError && (

{extractionError}

)}
{profile.city ? ( {}} className="pointer-events-none" /> ) : ( { setProfileCity(city) }} /> )} 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 && ( )} {showAllGenders && ( <>

{t('profile.optional.details', 'Details')}

) => setProfile('gender_details', e.target.value) } className={'w-full sm:w-[700px]'} value={(profile as any)['gender_details'] ?? undefined} placeholder={t( 'profile.gender.details_placeholder', 'Any details about your gender identity…', )} /> )}
) => { 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 &&

{ageError}

} {t('profile.optional.feet', 'Feet')} ) => { 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} /> {t('profile.optional.inches', 'Inches')} ) => { 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} />
{t('common.or', 'OR').toUpperCase()}
{t('profile.optional.centimeters', 'Centimeters')} ) => { 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} />
setProfile('ethnicity', selected)} /> {profile.raised_in_geodb_city_id ? ( {}} className="pointer-events-none" /> ) : ( { setProfileRaisedInCity(city) }} /> )}