mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
1751 lines
69 KiB
TypeScript
1751 lines
69 KiB
TypeScript
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 (0–100) */}
|
||
<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 h1–h6 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>
|
||
)
|
||
}
|