Add Big 5 profile field

This commit is contained in:
MartinBraquet
2026-02-13 15:23:54 +01:00
parent ca55a93d5f
commit 7734b689a3
15 changed files with 576 additions and 11 deletions

View File

@@ -0,0 +1,181 @@
import clsx from 'clsx'
import {RangeSlider} from 'web/components/widgets/slider'
import {FilterFields} from 'common/filters'
import {useT} from 'web/lib/locale'
export const BIG5_MIN = 0
export const BIG5_MAX = 100
export type Big5Key =
| 'big5_openness'
| 'big5_conscientiousness'
| 'big5_extraversion'
| 'big5_agreeableness'
| 'big5_neuroticism'
export type Big5MinMaxKey =
| 'big5_openness_min'
| 'big5_openness_max'
| 'big5_conscientiousness_min'
| 'big5_conscientiousness_max'
| 'big5_extraversion_min'
| 'big5_extraversion_max'
| 'big5_agreeableness_min'
| 'big5_agreeableness_max'
| 'big5_neuroticism_min'
| 'big5_neuroticism_max'
export function hasAnyBig5Filter(filters: Partial<FilterFields>) {
return (
filters.big5_openness_min != null ||
filters.big5_openness_max != null ||
filters.big5_conscientiousness_min != null ||
filters.big5_conscientiousness_max != null ||
filters.big5_extraversion_min != null ||
filters.big5_extraversion_max != null ||
filters.big5_agreeableness_min != null ||
filters.big5_agreeableness_max != null ||
filters.big5_neuroticism_min != null ||
filters.big5_neuroticism_max != null
)
}
export function Big5FilterText(props: {
filters: Partial<FilterFields>
highlightedClass?: string
}) {
const {filters, highlightedClass} = props
const t = useT()
const hasAny = hasAnyBig5Filter(filters)
if (!hasAny) {
return (
<span className={clsx(!hasAny && 'text-ink-600')}>
{t('filter.big5.any', 'Any Big 5')}
</span>
)
}
return (
<span className={clsx('font-semibold', highlightedClass)}>
{t('filter.big5.custom', 'Custom Big 5')}
</span>
)
}
export function Big5SliderRow(props: {
label: string
minValue: number | null | undefined
maxValue: number | null | undefined
onChange: (min: number | undefined, max: number | undefined) => void
}) {
const {label, minValue, maxValue, onChange} = props
return (
<div className="mb-4">
<div className="mb-1 flex items-center justify-between text-sm text-ink-600">
<span>{label}</span>
<span className="font-semibold text-ink-700">
{minValue == null && maxValue == null
? '0 100'
: `${minValue ?? BIG5_MIN} ${maxValue ?? BIG5_MAX}`}
</span>
</div>
<RangeSlider
lowValue={minValue ?? BIG5_MIN}
highValue={maxValue ?? BIG5_MAX}
min={BIG5_MIN}
max={BIG5_MAX}
setValues={(low, high) => {
onChange(
low > BIG5_MIN ? Math.round(low) : undefined,
high < BIG5_MAX ? Math.round(high) : undefined
)
}}
marks={[
{value: 0, label: '0'},
{value: 25, label: '25'},
{value: 50, label: '50'},
{value: 75, label: '75'},
{value: 100, label: '100'},
].map((m) => ({
value: ((m.value - BIG5_MIN) / (BIG5_MAX - BIG5_MIN)) * 100,
label: m.label,
}))}
/>
</div>
)
}
export function Big5Filters(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
}) {
const {filters, updateFilter} = props
const t = useT()
return (
<div className="w-full max-w-md py-2">
<Big5SliderRow
label={t('profile.big5_openness', 'Openness')}
minValue={filters.big5_openness_min}
maxValue={filters.big5_openness_max}
onChange={(min, max) =>
updateFilter({
big5_openness_min: min,
big5_openness_max: max,
})
}
/>
<Big5SliderRow
label={t(
'profile.big5_conscientiousness',
'Conscientiousness'
)}
minValue={filters.big5_conscientiousness_min}
maxValue={filters.big5_conscientiousness_max}
onChange={(min, max) =>
updateFilter({
big5_conscientiousness_min: min,
big5_conscientiousness_max: max,
})
}
/>
<Big5SliderRow
label={t('profile.big5_extraversion', 'Extraversion')}
minValue={filters.big5_extraversion_min}
maxValue={filters.big5_extraversion_max}
onChange={(min, max) =>
updateFilter({
big5_extraversion_min: min,
big5_extraversion_max: max,
})
}
/>
<Big5SliderRow
label={t('profile.big5_agreeableness', 'Agreeableness')}
minValue={filters.big5_agreeableness_min}
maxValue={filters.big5_agreeableness_max}
onChange={(min, max) =>
updateFilter({
big5_agreeableness_min: min,
big5_agreeableness_max: max,
})
}
/>
<Big5SliderRow
label={t('profile.big5_neuroticism', 'Neuroticism')}
minValue={filters.big5_neuroticism_min}
maxValue={filters.big5_neuroticism_max}
onChange={(min, max) =>
updateFilter({
big5_neuroticism_min: min,
big5_neuroticism_max: max,
})
}
/>
</div>
)
}

View File

@@ -42,6 +42,7 @@ import {InterestFilter, InterestFilterText} from "web/components/filters/interes
import {OptionTableKey} from "common/profiles/constants";
import {useT} from "web/lib/locale";
import {ResetFiltersButton} from "web/components/searches/button";
import {Big5Filters, Big5FilterText, hasAnyBig5Filter} from "web/components/filters/big5-filter";
export function DesktopFilters(props: {
filters: Partial<FilterFields>
@@ -646,6 +647,31 @@ export function DesktopFilters(props: {
menuWidth="w-[400px]"
/>
{/* BIG FIVE PERSONALITY */}
<CustomizeableDropdown
buttonContent={(open) => (
<DropdownButton
open={open}
content={
<Row className="items-center gap-1">
<BsPersonVcard className="h-4 w-4"/>
<Big5FilterText
filters={filters}
highlightedClass={open || hasAnyBig5Filter(filters) ? 'text-primary-500' : undefined}
/>
</Row>
}
/>
)}
dropdownMenuContent={
<Col className="mx-2 mb-4">
<Big5Filters filters={filters} updateFilter={updateFilter}/>
</Col>
}
popoverClassName="bg-canvas-50"
menuWidth="w-[420px]"
/>
{/* EDUCATION */}
<CustomizeableDropdown
buttonContent={(open: boolean) => (

View File

@@ -43,6 +43,7 @@ import {LuCigarette, LuGraduationCap} from "react-icons/lu";
import {RiScales3Line} from "react-icons/ri";
import {PiHandsPrayingBold} from "react-icons/pi";
import {ResetFiltersButton} from "web/components/searches/button";
import {Big5Filters, Big5FilterText, hasAnyBig5Filter} from "web/components/filters/big5-filter";
import {FilterGuide} from "web/components/guidance";
function MobileFilters(props: {
@@ -534,6 +535,25 @@ function MobileFilters(props: {
<MbtiFilter filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* BIG FIVE PERSONALITY */}
<MobileFilterSection
title={t('profile.big5', 'Personality (Big Five)')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAnyBig5Filter(filters)}
icon={<BsPersonVcard className="h-4 w-4"/>}
selection={
<Big5FilterText
filters={filters}
highlightedClass={
hasAnyBig5Filter(filters) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<Big5Filters filters={filters} updateFilter={updateFilter}/>
</MobileFilterSection>
{/* EDUCATION */}
<MobileFilterSection
title={t("profile.education.short_name", "Education")}

View File

@@ -47,6 +47,7 @@ import {AddOptionEntry} from "web/components/add-option-entry";
import {sleep} from "common/util/time"
import {SignupBio} from "web/components/bio/editable-bio";
import {Editor} from "@tiptap/core";
import {Slider} from "web/components/widgets/slider";
export const OptionalProfileUserForm = (props: {
@@ -644,6 +645,55 @@ export const OptionalProfileUserForm = (props: {
/>
</Col>
{/* Big Five personality traits (0100) */}
<Col className={clsx(colClassName, 'max-w-[550px]')}>
<label className={clsx(labelClassName)}>
{t('profile.big5', 'Big Five Personality Traits')}
</label>
<div className="space-y-4">
<Big5Slider
label={t('profile.big5_openness', 'Openness')}
value={profile.big5_openness ?? 50}
onChange={(v) => setProfile('big5_openness', v)}
/>
<Big5Slider
label={t(
'profile.big5_conscientiousness',
'Conscientiousness'
)}
value={profile.big5_conscientiousness ?? 50}
onChange={(v) => setProfile('big5_conscientiousness', v)}
/>
<Big5Slider
label={t('profile.big5_extraversion', 'Extraversion')}
value={profile.big5_extraversion ?? 50}
onChange={(v) => setProfile('big5_extraversion', v)}
/>
<Big5Slider
label={t(
'profile.big5_agreeableness',
'Agreeableness'
)}
value={profile.big5_agreeableness ?? 50}
onChange={(v) => setProfile('big5_agreeableness', v)}
/>
<Big5Slider
label={t(
'profile.big5_neuroticism',
'Neuroticism'
)}
value={profile.big5_neuroticism ?? 50}
onChange={(v) => setProfile('big5_neuroticism', v)}
/>
<p className="text-sm text-ink-500">
{t(
'profile.big5_hint',
'Drag each slider to set where you see yourself on these traits (0 = low, 100 = high).'
)}
</p>
</div>
</Col>
<Category title={t('profile.optional.diet', 'Diet')}/>
<Col className={clsx(colClassName)}>
@@ -900,3 +950,33 @@ function Category({title, className}: { title: string, className?: string }) {
return <h3 className={clsx("text-xl font-semibold mb-[-8px]", className)}>{title}</h3>;
}
const Big5Slider = (props: {
label: string
value: number
onChange: (v: number) => void
}) => {
const {label, value, onChange} = props
return (
<div>
<div className="mb-1 flex items-center justify-between text-sm text-ink-600">
<span>{label}</span>
<span className="font-semibold text-ink-700">{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>
)
}

View File

@@ -34,6 +34,7 @@ import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa";
import {useLocale, useT} from "web/lib/locale";
import {useChoices} from "web/hooks/use-choices";
import {getSeekingGenderText} from "web/lib/profile/seeking";
import {TbBulb, TbCheck, TbMoodSad, TbUsers} from "react-icons/tb";
export function AboutRow(props: {
icon: ReactNode
@@ -119,6 +120,7 @@ export default function ProfileAbout(props: {
icon={<BsPersonVcard className="h-5 w-5"/>}
text={profile.mbti ? INVERTED_MBTI_CHOICES[profile.mbti] : null}
/>
<Big5Traits profile={profile}/>
<AboutRow
icon={<HiOutlineGlobe className="h-5 w-5"/>}
text={profile.ethnicity
@@ -340,6 +342,85 @@ function LastOnline(props: { lastOnlineTime?: string }) {
)
}
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">
<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