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

575 lines
17 KiB
TypeScript

import {ClockIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {
INVERTED_DIET_CHOICES,
INVERTED_EDUCATION_CHOICES,
INVERTED_LANGUAGE_CHOICES,
INVERTED_MBTI_CHOICES,
INVERTED_POLITICAL_CHOICES,
INVERTED_RELATIONSHIP_STATUS_CHOICES,
INVERTED_RELIGION_CHOICES,
} from 'common/choices'
import {MAX_INT, MIN_INT} from 'common/constants'
import {convertGenderPlural, Gender} from 'common/gender'
import {getLocationText} from 'common/geodb'
import {formatHeight, MeasurementSystem} from 'common/measurement-utils'
import {Profile} from 'common/profiles/profile'
import {Socials} from 'common/socials'
import {UserActivity} from 'common/user'
import {Home} from 'lucide-react'
import React, {ReactNode} from 'react'
import {BiSolidDrink} from 'react-icons/bi'
import {BsPersonHeart, BsPersonVcard} from 'react-icons/bs'
import {FaBriefcase, FaHandsHelping, FaHeart, FaStar, FaUsers} from 'react-icons/fa'
import {FaChild} from 'react-icons/fa6'
import {FiUser} from 'react-icons/fi'
import {GiFruitBowl, GiRing} from 'react-icons/gi'
import {HiOutlineGlobe} from 'react-icons/hi'
import {LuBriefcase, LuCigarette, LuCigaretteOff, LuGraduationCap} from 'react-icons/lu'
import {MdLanguage, MdNoDrinks, MdOutlineChildFriendly} from 'react-icons/md'
import {PiHandsPrayingBold, PiMagnifyingGlassBold} from 'react-icons/pi'
import {RiScales3Line} from 'react-icons/ri'
import {TbBulb, TbCheck, TbMoodSad, TbUsers} from 'react-icons/tb'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {UserHandles} from 'web/components/user/user-handles'
import {useChoices} from 'web/hooks/use-choices'
import {useLocale, useT} from 'web/lib/locale'
import {getSeekingGenderText} from 'web/lib/profile/seeking'
import {convertRace, type RelationshipType} from 'web/lib/util/convert-types'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {fromNow} from 'web/lib/util/time'
export function AboutRow(props: {
icon: ReactNode
text?: string | null | string[]
preText?: string
suffix?: string | null
testId?: string
}) {
const {icon, text, preText, suffix, testId} = props
const t = useT()
if (!text?.length && !preText && !suffix) {
return <></>
}
let formattedText = ''
if (preText) {
formattedText += preText
}
if (text?.length) {
formattedText += stringOrStringArrayToText({
text: text,
preText: preText,
asSentence: false,
capitalizeFirstLetterOption: true,
t: t,
})
}
if (suffix) {
formattedText += formattedText ? ` (${suffix})` : suffix
}
return (
<Row className="items-center gap-2" data-testid={testId}>
<div className="text-ink-600 w-5">{icon}</div>
<div>{formattedText}</div>
</Row>
)
}
export default function ProfileAbout(props: {
profile: Profile
userActivity?: UserActivity
isCurrentUser: boolean
}) {
const {profile, userActivity, isCurrentUser} = props
const t = useT()
const {choices: interestsById} = useChoices('interests')
const {choices: causesById} = useChoices('causes')
const {choices: workById} = useChoices('work')
const {locale} = useLocale()
return (
<Col className={clsx('bg-canvas-0 relative gap-3 overflow-hidden rounded px-4')}>
<Seeking profile={profile} />
<RelationshipType profile={profile} />
<RelationshipStatus profile={profile} />
<Education profile={profile} />
<Occupation profile={profile} />
<AboutRow
icon={<FaBriefcase className="h-5 w-5" />}
text={
profile.work
?.map((id) => workById[id])
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, locale)) as string[]
}
testId="profile-about-work-area"
/>
<AboutRow
icon={<RiScales3Line className="h-5 w-5" />}
text={profile.political_beliefs?.map((belief) =>
t(`profile.political.${belief}`, INVERTED_POLITICAL_CHOICES[belief]),
)}
suffix={profile.political_details}
testId="profile-about-political"
/>
<AboutRow
icon={<PiHandsPrayingBold className="h-5 w-5" />}
text={profile.religion?.map((belief) =>
t(`profile.religion.${belief}`, INVERTED_RELIGION_CHOICES[belief]),
)}
suffix={profile.religious_beliefs}
testId="profile-about-religious"
/>
<AboutRow
icon={<FaStar className="h-5 w-5" />}
text={
profile.interests
?.map((id) => interestsById[id])
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, locale)) as string[]
}
testId="profile-about-interests"
/>
<AboutRow
icon={<FaHandsHelping className="h-5 w-5" />}
text={
profile.causes
?.map((id) => causesById[id])
.filter(Boolean)
.sort((a, b) => a.localeCompare(b, locale)) as string[]
}
testId="profile-about-causes"
/>
<AboutRow
icon={<BsPersonVcard className="h-5 w-5" />}
text={profile.mbti ? INVERTED_MBTI_CHOICES[profile.mbti] : null}
testId="profile-about-personality"
/>
<Big5Traits profile={profile} />
<AboutRow
icon={<HiOutlineGlobe className="h-5 w-5" />}
text={profile.ethnicity
?.filter((r) => r !== 'other')
?.map((r: any) => t(`profile.race.${r}`, convertRace(r)))}
testId="profile-about-ethnicity"
/>
<RaisedIn profile={profile} />
<Smoker profile={profile} />
<Drinks profile={profile} />
<AboutRow
icon={<GiFruitBowl className="h-5 w-5" />}
text={profile.diet?.map((e) => t(`profile.diet.${e}`, INVERTED_DIET_CHOICES[e]))}
testId="profile-about-diet"
/>
<AboutRow
icon={<MdLanguage className="h-5 w-5" />}
text={profile.languages?.map((v) =>
t(`profile.language.${v}`, INVERTED_LANGUAGE_CHOICES[v]),
)}
testId="profile-about-languages"
/>
<HasKids profile={profile} />
<WantsKids profile={profile} />
{!isCurrentUser && <LastOnline lastOnlineTime={userActivity?.last_online_time} />}
<UserHandles links={(profile.links ?? {}) as Socials} />
</Col>
)
}
function Seeking(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const prefGender = profile.pref_gender
const min = profile.pref_age_min
const max = profile.pref_age_max
const seekingGenderText = stringOrStringArrayToText({
text:
prefGender?.length == 5
? ['people']
: prefGender?.map((gender) =>
t(
`profile.gender.plural.${gender}`,
convertGenderPlural(gender as Gender),
).toLowerCase(),
),
preText: t('profile.interested_in', 'Interested in'),
asSentence: true,
capitalizeFirstLetterOption: false,
t: t,
})
const noMin = (min ?? MIN_INT) <= 18
const noMax = (max ?? MAX_INT) >= 99
const ageRangeText =
noMin && noMax
? t('profile.age_any', 'of any age')
: min == max
? t('profile.age_exact', 'exactly {min} years old', {min})
: noMax
? t('profile.age_older_than', 'older than {min}', {min})
: noMin
? t('profile.age_younger_than', 'younger than {max}', {max})
: t('profile.age_between', 'between {min} - {max} years old', {
min,
max,
})
if (!prefGender || prefGender.length < 1) {
return <></>
}
return (
<AboutRow
icon={<PiMagnifyingGlassBold className="h-5 w-5" />}
text={`${seekingGenderText} ${ageRangeText}`}
testId="profile-about-seeking"
/>
)
}
function RelationshipType(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const seekingGenderText = getSeekingGenderText(profile, t)
return (
<AboutRow
icon={<BsPersonHeart className="h-5 w-5" />}
text={seekingGenderText}
testId="profile-about-relationship-type"
/>
)
}
function RelationshipStatus(props: {profile: Profile}) {
const {profile} = props
const t = useT()
const relationship_status = profile.relationship_status ?? []
if (relationship_status.length === 0) return
const key = relationship_status[0] as keyof typeof RELATIONSHIP_ICONS
const icon = RELATIONSHIP_ICONS[key] ?? FaHeart
return (
<AboutRow
icon={icon ? React.createElement(icon, {className: 'h-5 w-5'}) : null}
text={relationship_status?.map((v) =>
t(`profile.relationship_status.${v}`, INVERTED_RELATIONSHIP_STATUS_CHOICES[v]),
)}
testId="profile-about-relationship-status"
/>
)
}
function Education(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const educationLevel = profile.education_level
const university = profile.university
let text = ''
if (educationLevel) {
text += capitalizeAndRemoveUnderscores(
t(`profile.education.${educationLevel}`, INVERTED_EDUCATION_CHOICES[educationLevel]),
)
}
if (university) {
if (educationLevel) text += ` ${t('profile.at', 'at')} `
text += capitalizeAndRemoveUnderscores(university)
}
if (text.length === 0) {
return <></>
}
return (
<AboutRow
icon={<LuGraduationCap className="h-5 w-5" />}
text={text}
testId="profile-about-education"
/>
)
}
function Occupation(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const occupation_title = profile.occupation_title
const company = profile.company
if (!company && !occupation_title) {
return <></>
}
const occupationText = `${
occupation_title ? capitalizeAndRemoveUnderscores(occupation_title) : ''
}${occupation_title && company ? ` ${t('profile.at', 'at')} ` : ''}${
company ? capitalizeAndRemoveUnderscores(company) : ''
}`
return (
<AboutRow
icon={<LuBriefcase className="h-5 w-5" />}
text={occupationText}
testId="profile-about-occupation"
/>
)
}
function Smoker(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const isSmoker = profile.is_smoker
if (isSmoker == null) return null
if (isSmoker) {
return (
<AboutRow icon={<LuCigarette className="h-5 w-5" />} text={t('profile.smokes', 'Smokes')} />
)
}
return (
<AboutRow
icon={<LuCigaretteOff className="h-5 w-5" />}
text={t('profile.doesnt_smoke', "Doesn't smoke")}
testId="profile-about-smoker"
/>
)
}
function Drinks(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const drinksPerMonth = profile.drinks_per_month
if (drinksPerMonth == null) return null
if (drinksPerMonth === 0) {
return (
<AboutRow
icon={<MdNoDrinks className="h-5 w-5" />}
text={t('profile.doesnt_drink', "Doesn't drink")}
testId="profile-about-not-drink"
/>
)
}
return (
<AboutRow
icon={<BiSolidDrink className="h-5 w-5" />}
text={
drinksPerMonth === 1
? t('profile.drinks_one', '1 drink per month')
: t('profile.drinks_many', '{count} drinks per month', {
count: drinksPerMonth,
})
}
testId="profile-about-drinker"
/>
)
}
function WantsKids(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const wantsKidsStrength = profile.wants_kids_strength
if (wantsKidsStrength == null || wantsKidsStrength < 0) return null
const wantsKidsText =
wantsKidsStrength == 0
? t('profile.wants_kids_0', 'Does not want children')
: wantsKidsStrength == 1
? t('profile.wants_kids_1', 'Prefers not to have children')
: wantsKidsStrength == 2
? t('profile.wants_kids_2', 'Neutral or open to having children')
: wantsKidsStrength == 3
? t('profile.wants_kids_3', 'Leaning towards wanting children')
: t('profile.wants_kids_4', 'Wants children')
return (
<AboutRow
icon={<MdOutlineChildFriendly className="h-5 w-5" />}
text={wantsKidsText}
testId="profile-about-wants-kids"
/>
)
}
function LastOnline(props: {lastOnlineTime?: string}) {
const t = useT()
const {locale} = useLocale()
const {lastOnlineTime} = props
if (!lastOnlineTime) return null
return (
<AboutRow
icon={<ClockIcon className="h-5 w-5" />}
text={t('profile.last_online', 'Active {time}', {
time: fromNow(lastOnlineTime, true, t, locale),
})}
testId="profile-about-last-online"
/>
)
}
function Big5Traits(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const traits = [
{
key: 'big5_openness',
icon: <TbBulb className="h-5 w-5" />,
label: t('profile.big5_openness', 'Openness'),
value: profile.big5_openness,
},
{
key: 'big5_conscientiousness',
icon: <TbCheck className="h-5 w-5" />,
label: t('profile.big5_conscientiousness', 'Conscientiousness'),
value: profile.big5_conscientiousness,
},
{
key: 'big5_extraversion',
icon: <TbUsers className="h-5 w-5" />,
label: t('profile.big5_extraversion', 'Extraversion'),
value: profile.big5_extraversion,
},
{
key: 'big5_agreeableness',
icon: <FaHeart className="h-5 w-5" />,
label: t('profile.big5_agreeableness', 'Agreeableness'),
value: profile.big5_agreeableness,
},
{
key: 'big5_neuroticism',
icon: <TbMoodSad className="h-5 w-5" />,
label: t('profile.big5_neuroticism', 'Neuroticism'),
value: profile.big5_neuroticism,
},
]
const hasAnyTraits = traits.some((trait) => trait.value !== null && trait.value !== undefined)
if (!hasAnyTraits) {
return <></>
}
return (
<Col className="gap-2" data-testid="profile-about-big-five-personality-traits">
<div className="text-ink-600 font-medium">
{t('profile.big5', 'Big Five personality traits')}:
</div>
<div className="ml-6">
{traits.map((trait) => {
if (trait.value === null || trait.value === undefined) return null
let levelText: string
if (trait.value <= 20) {
levelText = t('profile.big5_very_low', 'Very low')
} else if (trait.value <= 40) {
levelText = t('profile.big5_low', 'Low')
} else if (trait.value <= 60) {
levelText = t('profile.big5_average', 'Average')
} else if (trait.value <= 80) {
levelText = t('profile.big5_high', 'High')
} else {
levelText = t('profile.big5_very_high', 'Very high')
}
return (
<Row key={trait.key} className="items-center gap-2">
<div className="text-ink-600 w-5">{trait.icon}</div>
<div>
{trait.label}: {levelText} ({trait.value})
</div>
</Row>
)
})}
</div>
</Col>
)
}
function HasKids(props: {profile: Profile}) {
const t = useT()
const {profile} = props
if (typeof profile.has_kids !== 'number') return null
const hasKidsText =
profile.has_kids == 0
? t('profile.has_kids.doesnt_have_kids', 'Does not have children')
: profile.has_kids > 1
? t('profile.has_kids_many', 'Has {count} kids', {
count: profile.has_kids,
})
: t('profile.has_kids_one', 'Has {count} kid', {
count: profile.has_kids,
})
const faChild = <FaChild className="h-5 w-5" />
const icon =
profile.has_kids === 0 ? (
<div className="relative h-5 w-5">
{faChild}
<div className="absolute inset-0">
{/*<div className="absolute top-1/2 left-0 h-0.5 w-full -translate-y-1/2 rotate-45 transform bg-ink-500"/>*/}
<div className="absolute top-1/2 left-0 h-0.5 w-full -translate-y-1/2 -rotate-45 transform bg-ink-1000" />
</div>
</div>
) : (
faChild
)
return <AboutRow icon={icon} text={hasKidsText} testId={'profile-about-has-kids'} />
}
function RaisedIn(props: {profile: Profile}) {
const t = useT()
const locationText = getLocationText(props.profile, 'raised_in_')
if (!locationText) {
return null
}
return (
<AboutRow
icon={<Home className="h-5 w-5" />}
text={t('profile.about.raised_in', `Raised in ${locationText}`, {location: locationText})}
/>
)
}
export const formatProfileValue = (
key: string,
value: any,
measurementSystem: MeasurementSystem = 'imperial',
) => {
if (Array.isArray(value)) {
return value.join(', ')
}
switch (key) {
case 'created_time':
case 'last_online_time':
return fromNow(new Date(value).valueOf())
case 'is_smoker':
case 'diet':
case 'has_pets':
return value ? 'Yes' : 'No'
case 'height_in_inches':
return formatHeight(value, measurementSystem)
case 'pref_age_max':
case 'pref_age_min':
return null // handle this in a special case
case 'wants_kids_strength':
return renderAgreementScale(value)
default:
return value
}
}
const renderAgreementScale = (value: number) => {
if (value == 1) return 'Strongly disagree'
if (value == 2) return 'Disagree'
if (value == 3) return 'Neutral'
if (value == 4) return 'Agree'
if (value == 5) return 'Strongly agree'
return ''
}
const capitalizeAndRemoveUnderscores = (str: string) => {
const withSpaces = str.replace(/_/g, ' ')
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1)
}
export const RELATIONSHIP_ICONS = {
single: FiUser,
married: GiRing,
casual: FaHeart,
long_term: FaHeart,
open: FaUsers,
} as const