Add neurotype and accessibility fields to profiles

- Added `accessibility_notes` (free text) to profiles to capture members' practical access needs while avoiding filterable attributes.
- Introduced `neurotype` (selectable values with details option) and GIN indexing to enable efficient keyword searches.
- Updated UI components, filters, and forms to support these new profile attributes.
- Modified backend and tests to handle the changes and ensure consistency across filtering, seeding, and display logic.
- Bumped API version to `1.49.0`.
This commit is contained in:
MartinBraquet
2026-07-24 16:27:41 +02:00
parent cfa39ec88c
commit 3a38e8affb
23 changed files with 434 additions and 4 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@compass/api",
"version": "1.48.0",
"version": "1.49.0",
"private": true,
"description": "Backend API endpoints",
"main": "src/serve.ts",

View File

@@ -6,6 +6,7 @@ import {
GENDERS,
LANGUAGE_CHOICES,
MBTI_CHOICES,
NEUROTYPE_CHOICES,
ORIENTATION_CHOICES,
POLITICAL_CHOICES,
RACE_CHOICES,
@@ -81,6 +82,7 @@ export type profileQueryType = {
languages?: string[] | undefined
religion?: string[] | undefined
orientation?: string[] | undefined
neurotype?: string[] | undefined
wants_kids_strength?: number | undefined
has_kids?: number | undefined
is_smoker?: boolean | undefined
@@ -131,6 +133,9 @@ const textFields = [
'religious_beliefs',
'orientation_details',
'gender_details',
'neurotype_details',
// Keyword-searchable but deliberately has no filter of its own — see the migration for why.
'accessibility_notes',
]
// Define choice fields to search
@@ -147,6 +152,7 @@ const arrayChoiceFields = [
{field: 'relationship_status', choices: RELATIONSHIP_STATUS_CHOICES},
{field: 'religion', choices: RELIGION_CHOICES},
{field: 'orientation', choices: ORIENTATION_CHOICES},
{field: 'neurotype', choices: NEUROTYPE_CHOICES},
{field: 'pref_relation_styles', choices: RELATIONSHIP_CHOICES},
{field: 'pref_romantic_styles', choices: ROMANTIC_CHOICES},
{field: 'languages', choices: LANGUAGE_CHOICES},
@@ -211,6 +217,7 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC
languages,
religion,
orientation,
neurotype,
wants_kids_strength,
has_kids,
interests,
@@ -508,6 +515,17 @@ export const loadProfiles = async (props: profileQueryType, db?: SupabaseDirectC
},
),
neurotype?.length &&
where(
// most are neurotypical, so we don't include the people who didn't answer if we're looking for
// anything else — a rare value is only meaningful when it was actually stated
(!neurotype.includes('neurotypical') ? '' : "neurotype IS NULL OR neurotype = '{}' OR ") +
`neurotype && $(neurotype)`,
{
neurotype,
},
),
interests?.length && where(getManyToManyClause('interests'), {values: interests.map(Number)}),
causes?.length && where(getManyToManyClause('causes'), {values: causes.map(Number)}),

View File

@@ -58,4 +58,6 @@ BEGIN;
\i backend/supabase/email_unsubscribe_tokens.sql
\i backend/supabase/migrations/20260524_add_orientation_to_profiles.sql
\i backend/supabase/migrations/20260709_add_last_checked_at_to_bookmarked_searches.sql
\i backend/supabase/migrations/20260724_add_neurotype_to_profiles.sql
\i backend/supabase/migrations/20260724_add_accessibility_notes_to_profiles.sql
COMMIT;

View File

@@ -0,0 +1,10 @@
-- Add accessibility notes to profiles table
-- Created: 2026-07-24
--
-- Deliberately free text rather than a checkbox taxonomy of conditions: a taxonomy exists mainly to be
-- filtered on, and filtering people out by disability is exactly what this field must not enable. Free
-- text keeps framing and depth of disclosure with the member. It is keyword-searchable (see `textFields`
-- in backend/api/src/get-profiles.ts) but has no filter of its own.
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS accessibility_notes TEXT;

View File

@@ -0,0 +1,9 @@
-- Add neurotype (neurodivergence identity) fields to profiles table
-- Created: 2026-07-24
ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS neurotype TEXT[],
ADD COLUMN IF NOT EXISTS neurotype_details TEXT;
-- Create GIN index for array field
CREATE INDEX IF NOT EXISTS profiles_neurotype_gin ON profiles USING GIN (neurotype);

View File

@@ -387,6 +387,7 @@
"filter.any_interests": "Jedes Interesse",
"filter.any_language": "Alle Sprachen",
"filter.any_mbti": "Jeder MBTI",
"filter.any_neurotype": "Beliebiger Neurotyp",
"filter.any_new_users": "Jegliche neue Nutzer",
"filter.any_orientation": "Jede Orientierung",
"filter.any_politics": "Jede politische Ausrichtung",
@@ -451,6 +452,7 @@
"filter.mine_toggle": "Meine Filter",
"filter.multiple": "Mehrere",
"filter.near": "in der Nähe von",
"filter.neurotype.show_more": "Mehr Neurotypen anzeigen",
"filter.orientation.show_more": "Mehr Orientierungen anzeigen",
"filter.raised_in": "Aufgewachsen",
"filter.relationship.any_connection": "Jede Beziehung",
@@ -752,6 +754,7 @@
"privacy.use.text": "Ihre Daten werden nur verwendet, um die Plattform zu betreiben, zu personalisieren und zu verbessern. Wir verkaufen Ihre persönlichen Informationen nicht an Dritte.",
"privacy.use.title": "2. Wie wir Ihre Daten verwenden",
"profile.about.raised_in": "Aufgewachsen in {location}",
"profile.accessibility_notes": "Barrierefreiheit",
"profile.age_any": "jeden Alters",
"profile.age_between": "zwischen {min} und {max} Jahren",
"profile.age_exact": "genau {min} Jahre",
@@ -1057,7 +1060,19 @@
"profile.mbti.ESFP": "Unterhalter",
"profile.mutual": "Gegenseitig",
"profile.mutual_interests": "Sie haben beide gemeinsame Interessen: {matches}",
"profile.neurotype": "Neurotyp",
"profile.neurotype.adhd": "ADHS",
"profile.neurotype.autistic": "Autistisch",
"profile.neurotype.details_placeholder": "Was hilfreich zu wissen ist über dein Denken oder Kommunizieren…",
"profile.neurotype.dyscalculic": "Dyskalkulie",
"profile.neurotype.dyslexic": "Legasthenie",
"profile.neurotype.hsp": "Hochsensibel (HSP)",
"profile.neurotype.neurotypical": "Neurotypisch",
"profile.neurotype.show_more": "Weitere Optionen anzeigen",
"profile.not_both_open_to_type": "Sie sind nicht beide für eine(n) {type} offen",
"profile.optional.accessibility_notes": "Barrierefreiheit",
"profile.optional.accessibility_notes_hint": "Optional und öffentlich wie der Rest deines Profils. Alles Praktische, das ein gutes Treffen erleichtert — Zugangsbedürfnisse, Energielevel, sensorische Vorlieben, ruhigere Orte. Sag so viel oder so wenig, wie du möchtest.",
"profile.optional.accessibility_notes_placeholder": "z. B. Ich nutze einen Rollstuhl, stufenlose Orte passen am besten. Fragen sind willkommen.",
"profile.optional.age": "Alter",
"profile.optional.age.error_max": "Bitte gib ein gültiges Alter ein",
"profile.optional.age.error_min": "Du musst mindestens 18 Jahre alt sein",
@@ -1101,6 +1116,7 @@
"profile.optional.languages": "Gesprochene Sprachen",
"profile.optional.location": "Standort",
"profile.optional.mbti": "MBTI-Persönlichkeitstyp",
"profile.optional.neurotype": "Neurotyp",
"profile.optional.num_kids": "Aktuelle Anzahl von Kindern",
"profile.optional.og_card": "Profilkarte",
"profile.optional.orientation": "Sexuelle Orientierung",

View File

@@ -387,6 +387,7 @@
"filter.any_interests": "Tout intérêt",
"filter.any_language": "Toutes les langues",
"filter.any_mbti": "Tout MBTI",
"filter.any_neurotype": "Tout neurotype",
"filter.any_new_users": "Tout nouvel utilisateur",
"filter.any_orientation": "Toute orientation",
"filter.any_politics": "Toute orientation politique",
@@ -451,6 +452,7 @@
"filter.mine_toggle": "Mes filtres",
"filter.multiple": "Multiple",
"filter.near": "près de",
"filter.neurotype.show_more": "Afficher plus de neurotypes",
"filter.orientation.show_more": "Afficher plus d'orientations",
"filter.raised_in": "A grandi",
"filter.relationship.any_connection": "Toute relation",
@@ -751,6 +753,7 @@
"privacy.use.text": "Vos données sont utilisées uniquement pour faire fonctionner, personnaliser et améliorer la plateforme. Nous ne vendons pas vos informations personnelles à des tiers.",
"privacy.use.title": "2. Comment nous utilisons vos données",
"profile.about.raised_in": "A grandi à {location}",
"profile.accessibility_notes": "Accessibilité",
"profile.age_any": "de tout âge",
"profile.age_between": "entre {min} et {max} ans",
"profile.age_exact": "exactement {min} ans",
@@ -1056,7 +1059,19 @@
"profile.mbti.ESFP": "Animateur",
"profile.mutual": "Mutuel",
"profile.mutual_interests": "Vous avez tous les deux des intérêts communs : {matches}. Contactez-les ci-dessus!",
"profile.neurotype": "Neurotype",
"profile.neurotype.adhd": "TDAH",
"profile.neurotype.autistic": "Autiste",
"profile.neurotype.details_placeholder": "Ce qu'il est utile de savoir sur votre façon de penser ou de communiquer…",
"profile.neurotype.dyscalculic": "Dyscalculique",
"profile.neurotype.dyslexic": "Dyslexique",
"profile.neurotype.hsp": "Hypersensible (HSP)",
"profile.neurotype.neurotypical": "Neurotypique",
"profile.neurotype.show_more": "Afficher plus d'options",
"profile.not_both_open_to_type": "Vous n'êtes pas tous les deux ouverts à un(e) {type}",
"profile.optional.accessibility_notes": "Accessibilité",
"profile.optional.accessibility_notes_hint": "Facultatif, et public comme le reste de votre profil. Tout ce qui est utile en pratique pour bien vous rencontrer — besoins d'accès, niveau d'énergie, préférences sensorielles, lieux plus calmes. Dites-en autant ou aussi peu que vous voulez.",
"profile.optional.accessibility_notes_placeholder": "ex. Je me déplace en fauteuil roulant, les lieux sans marches sont donc plus simples. N'hésitez pas à poser des questions.",
"profile.optional.age": "Âge",
"profile.optional.age.error_max": "Veuillez entrer un âge valide",
"profile.optional.age.error_min": "Vous devez avoir au moins 18 ans",
@@ -1100,6 +1115,7 @@
"profile.optional.languages": "Langues parlées",
"profile.optional.location": "Localisation",
"profile.optional.mbti": "Type de personnalité MBTI",
"profile.optional.neurotype": "Neurotype",
"profile.optional.num_kids": "Nombre actuel d'enfants",
"profile.optional.og_card": "Carte de profil",
"profile.optional.orientation": "Orientation sexuelle",

View File

@@ -762,6 +762,7 @@ export const API = (_apiTypeCheck = {
big5_neuroticism_max: z.coerce.number().optional(),
religion: arraybeSchema.optional(),
orientation: arraybeSchema.optional(),
neurotype: arraybeSchema.optional(),
pref_relation_styles: arraybeSchema.optional(),
pref_romantic_styles: arraybeSchema.optional(),
diet: arraybeSchema.optional(),

View File

@@ -123,6 +123,9 @@ const optionalProfilesSchema = z.object({
gender_details: z.string().optional().nullable(),
orientation: z.array(z.string()).optional().nullable(),
orientation_details: z.string().optional().nullable(),
neurotype: z.array(z.string()).optional().nullable(),
neurotype_details: z.string().optional().nullable(),
accessibility_notes: z.string().optional().nullable(),
religion: z.array(z.string()).optional().nullable(),
religious_belief_strength: z.number().optional().nullable(),
religious_beliefs: z.string().optional().nullable(),

View File

@@ -314,6 +314,27 @@ export const EXTRA_ORIENTATIONS = Object.values(ORIENTATION_CHOICES).filter(
(v) => !DEFAULT_ORIENTATIONS.includes(v as any),
)
// Neurotype is framed as an identity, not a diagnosis: no "undiagnosed" or "prefer not to say" options,
// and no psychiatric conditions. Anything a member doesn't want structured goes in `neurotype_details`.
export const NEUROTYPE_CHOICES = {
Autistic: 'autistic',
ADHD: 'adhd',
Neurotypical: 'neurotypical',
// No combined "AuDHD" option on purpose — that is ticking Autistic and ADHD together.
Dyslexic: 'dyslexic',
Dyscalculic: 'dyscalculic',
'Highly sensitive (HSP)': 'hsp',
} as const
export const DEFAULT_NEUROTYPES = [
NEUROTYPE_CHOICES.Autistic,
NEUROTYPE_CHOICES.ADHD,
NEUROTYPE_CHOICES.Neurotypical,
]
export const EXTRA_NEUROTYPES = Object.values(NEUROTYPE_CHOICES).filter(
(v) => !DEFAULT_NEUROTYPES.includes(v as any),
)
export const LAST_ONLINE_CHOICES = {
now: 'Currently online',
today: 'Today',
@@ -375,6 +396,7 @@ export const INVERTED_CANNABIS_CHOICES = invert(CANNABIS_CHOICES)
export const INVERTED_SUBSTANCE_INTENTION_CHOICES = invert(SUBSTANCE_INTENTION_CHOICES)
export const INVERTED_SUBSTANCE_PREFERENCE_CHOICES = invert(SUBSTANCE_PREFERENCE_CHOICES)
export const INVERTED_ORIENTATION_CHOICES = invert(ORIENTATION_CHOICES)
export const INVERTED_NEUROTYPE_CHOICES = invert(NEUROTYPE_CHOICES)
//Exported types for test files to use when referencing the keys of the choices objects
export type PersonalityKey = keyof typeof MBTI_CHOICES

View File

@@ -5,6 +5,7 @@ import {
INVERTED_GENDERS,
INVERTED_LANGUAGE_CHOICES,
INVERTED_MBTI_CHOICES,
INVERTED_NEUROTYPE_CHOICES,
INVERTED_ORIENTATION_CHOICES,
INVERTED_POLITICAL_CHOICES,
INVERTED_PSYCHEDELICS_CHOICES,
@@ -41,6 +42,7 @@ const filterLabels: Record<string, string> = {
work: '',
religion: '',
orientation: '',
neurotype: '',
orderBy: '',
hasPhoto: '',
diet: 'Diet',
@@ -186,6 +188,10 @@ export function formatFilters(
value = value.map((s) =>
translate(`profile.orientation.${s}`, INVERTED_ORIENTATION_CHOICES[s] ?? s),
)
} else if (key === 'neurotype') {
value = value.map((s) =>
translate(`profile.neurotype.${s}`, INVERTED_NEUROTYPE_CHOICES[s] ?? s),
)
} else if (key === 'languages') {
value = value.map((s) => translate(`profile.language.${s}`, INVERTED_LANGUAGE_CHOICES[s]))
} else if (key === 'pref_gender') {

View File

@@ -56,6 +56,7 @@ export type FilterFields = {
| 'pref_age_max'
| 'religion'
| 'orientation'
| 'neurotype'
>
export const orderProfiles = (profiles: Profile[], starredUserIds: string[] | undefined) => {
@@ -107,6 +108,7 @@ export const initialFilters: Partial<FilterFields> = {
languages: undefined,
religion: undefined,
orientation: undefined,
neurotype: undefined,
mbti: undefined,
pref_gender: undefined,
shortBio: undefined,

View File

@@ -1114,6 +1114,7 @@ export type Database = {
}
profiles: {
Row: {
accessibility_notes: string | null
age: number | null
allow_direct_messaging: boolean | null
allow_interest_indicating: boolean | null
@@ -1158,6 +1159,8 @@ export type Database = {
looking_for_matches: boolean
mbti: string | null
messaging_status: string
neurotype: string[] | null
neurotype_details: string | null
occupation: string | null
occupation_title: string | null
orientation: string[] | null
@@ -1195,6 +1198,7 @@ export type Database = {
wants_kids_strength: number | null
}
Insert: {
accessibility_notes?: string | null
age?: number | null
allow_direct_messaging?: boolean | null
allow_interest_indicating?: boolean | null
@@ -1239,6 +1243,8 @@ export type Database = {
looking_for_matches?: boolean
mbti?: string | null
messaging_status?: string
neurotype?: string[] | null
neurotype_details?: string | null
occupation?: string | null
occupation_title?: string | null
orientation?: string[] | null
@@ -1276,6 +1282,7 @@ export type Database = {
wants_kids_strength?: number | null
}
Update: {
accessibility_notes?: string | null
age?: number | null
allow_direct_messaging?: boolean | null
allow_interest_indicating?: boolean | null
@@ -1320,6 +1327,8 @@ export type Database = {
looking_for_matches?: boolean
mbti?: string | null
messaging_status?: string
neurotype?: string[] | null
neurotype_details?: string | null
occupation?: string | null
occupation_title?: string | null
orientation?: string[] | null

View File

@@ -18,6 +18,7 @@
- Languages — by languages spoken.
- Politics — political beliefs or ideological alignment.
- Religion — religious belief or affiliation.
- Neurotype — neurodivergence identity (multi-select with extended list hidden behind "Show more").
- MBTI — MyersBriggs personality type.
- Big Five — ranges of the Big Five personality traits.
- Last Active — how recently they were active on the platform.

View File

@@ -6,6 +6,7 @@ import {
GENDERS,
LANGUAGE_CHOICES,
MBTI_CHOICES,
NEUROTYPE_CHOICES,
ORIENTATION_CHOICES,
POLITICAL_CHOICES,
PSYCHEDELICS_CHOICES,
@@ -37,6 +38,14 @@ class UserAccountInformationForSeeding {
faker.number.int({min: 0, max: Object.values(ORIENTATION_CHOICES).length - 1})
],
)
neurotype = Array.from(
{length: faker.number.int({min: 1, max: 2})},
() =>
Object.values(NEUROTYPE_CHOICES)[
faker.number.int({min: 0, max: Object.values(NEUROTYPE_CHOICES).length - 1})
],
)
accessibility_notes = faker.lorem.sentence()
pref_age = {
min: faker.number.int({min: 18, max: 27}),
max: faker.number.int({min: 36, max: 68}),

View File

@@ -154,6 +154,7 @@ async function seedShowcaseProfile(profile: ShowcaseProfile, userId: string) {
age: profile.age,
gender: profile.gender,
orientation: profile.orientation,
neurotype: profile.neurotype ?? null,
headline: profile.headline,
bio,
bio_length: bioText.length,

View File

@@ -69,6 +69,7 @@ export async function seedDbUser(
age: userInfo.age,
gender: userInfo.randomElement(userInfo.gender),
orientation: userInfo.orientation,
neurotype: userInfo.neurotype,
headline: userInfo.headline,
keywords: profileKeywords,
ethnicity: [userInfo.randomElement(userInfo.ethnicity)],
@@ -96,6 +97,8 @@ export async function seedDbUser(
education_level: userInfo.randomElement(userInfo.education_level),
languages: languagesKnown,
orientation_details: 'Some orientation details',
neurotype_details: 'Some neurotype details',
accessibility_notes: userInfo.accessibility_notes,
pinned_url: '/images/default-avatar.png',
}

View File

@@ -25,6 +25,8 @@ export interface ShowcaseProfile {
age: number
gender: string
orientation: string[]
/** Optional — only some showcase profiles state a neurotype, as in real life. */
neurotype?: string[]
headline: string
/** Paragraphs of the bio, in order. */
bio: string[]
@@ -256,6 +258,7 @@ export const SHOWCASE_PROFILES: ShowcaseProfile[] = [
age: 26,
gender: 'female',
orientation: ['queer'],
neurotype: ['autistic'],
headline: 'Climate modeller. Meditation twice a day. Will talk about soil for hours.',
bio: [
'I model regional climate for a living, which means I spend most days being carefully precise about uncertainty and most evenings being asked whether we are doomed. (Short answer: the question is badly posed.)',
@@ -310,6 +313,7 @@ export const SHOWCASE_PROFILES: ShowcaseProfile[] = [
age: 61,
gender: 'male',
orientation: ['straight'],
neurotype: ['neurotypical'],
headline: 'Retired GP, second act as a choir conductor, still learning Bach',
bio: [
'Forty years of general practice taught me that most of what people need is someone who will actually listen for more than four minutes. I retired two years ago and I am still adjusting to having time.',
@@ -418,6 +422,7 @@ export const SHOWCASE_PROFILES: ShowcaseProfile[] = [
age: 29,
gender: 'non-binary',
orientation: ['pansexual'],
neurotype: ['adhd', 'hsp'],
headline: 'Game designer, amateur mycologist, host of a very small dinner party',
bio: [
'I design systems for a mid-size indie studio — mostly economies and progression, the invisible plumbing that makes a game feel fair. Before that I studied economics, which turns out to be the same job with worse graphics.',
@@ -641,6 +646,7 @@ export const SHOWCASE_PROFILES: ShowcaseProfile[] = [
age: 36,
gender: 'male',
orientation: ['straight', 'demisexual'],
neurotype: ['autistic', 'adhd'],
headline: 'Open-source maintainer looking for collaborators, not users',
bio: [
'I maintain a mid-sized open-source database tool that about four thousand companies depend on and roughly nine people contribute to. The maths of that is the most interesting problem in my life right now.',

View File

@@ -24,6 +24,7 @@ import {IncompleteProfilesToggle} from 'web/components/filters/incomplete-profil
import {InterestFilter, InterestFilterText} from 'web/components/filters/interest-filter'
import {LanguageFilter, LanguageFilterText} from 'web/components/filters/language-filter'
import {MbtiFilter, MbtiFilterText} from 'web/components/filters/mbti-filter'
import {NeurotypeFilter, NeurotypeFilterText} from 'web/components/filters/neurotype-filter'
import {OrientationFilter, OrientationFilterText} from 'web/components/filters/orientation-filter'
import {PoliticalFilter, PoliticalFilterText} from 'web/components/filters/political-filter'
import {
@@ -727,6 +728,25 @@ function Filters(props: {
setOpenGroup={setOpenGroup}
// icon={<BsPersonVcard className="h-4 w-4" />}
>
{/* Grouped with MBTI/Big Five rather than under Relationship — it describes how someone's mind
works, and matters just as much for friendship and collaboration as for dating. */}
<FilterSection
title={t('profile.optional.neurotype', 'Neurotype')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny((filters as any).neurotype || undefined)}
selection={
<NeurotypeFilterText options={(filters as any).neurotype as string[] | undefined} />
}
>
<NeurotypeFilter
filters={filters}
updateFilter={updateFilter}
profile={youProfile}
className={''}
/>
</FilterSection>
<FilterSection
title={t('profile.optional.mbti', 'MBTI')}
openFilter={openFilter}

View File

@@ -0,0 +1,102 @@
import clsx from 'clsx'
import {
DEFAULT_NEUROTYPES,
EXTRA_NEUROTYPES,
INVERTED_NEUROTYPE_CHOICES,
NEUROTYPE_CHOICES,
} from 'common/choices'
import {FilterFields} from 'common/filters'
import {toKey} from 'common/parsing'
import {Profile} from 'common/profiles/profile'
import {useState} from 'react'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {useT} from 'web/lib/locale'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
export function NeurotypeFilterText(props: {
options: string[] | undefined
highlightedClass?: string
}) {
const {options, highlightedClass} = props
const length = (options ?? []).length
const t = useT()
if (!options || length < 1) {
return (
<span className={clsx('text-semibold', highlightedClass)}>
{t('filter.any_neurotype', 'Any neurotype')}
</span>
)
}
if (length > 2) {
return (
<span>
<span className={clsx('font-semibold', highlightedClass)}>
{t('filter.multiple', 'Multiple')}
</span>
</span>
)
}
const convertedTypes = options.map((o) =>
t(`profile.neurotype.${toKey(o)}`, INVERTED_NEUROTYPE_CHOICES[o] ?? o),
)
return (
<div>
<span className={clsx('font-semibold', highlightedClass)}>
{stringOrStringArrayToText({
text: convertedTypes,
capitalizeFirstLetterOption: true,
t: t,
})}{' '}
</span>
</div>
)
}
export function NeurotypeFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
className?: string
profile?: Profile | null
}) {
const {filters, updateFilter, className, profile} = props
const t = useT()
const selected = ((filters as any).neurotype as string[] | undefined) ?? []
const hasExtendedSelected =
selected.some((v) => EXTRA_NEUROTYPES.includes(v as any)) ||
(profile?.neurotype && profile?.neurotype.some((v) => EXTRA_NEUROTYPES.includes(v as any)))
const [showAll, setShowAll] = useState(hasExtendedSelected)
const visibleChoices = Object.fromEntries(
Object.entries(NEUROTYPE_CHOICES).filter(
([, v]) => showAll || DEFAULT_NEUROTYPES.includes(v as any) || selected.includes(v),
),
)
return (
<>
<MultiCheckbox
selected={selected}
choices={visibleChoices}
translationPrefix={'profile.neurotype'}
onChange={(c) => {
updateFilter({neurotype: c} as any)
}}
optionsClassName={className}
/>
{!showAll && (
<button
type="button"
className="text-primary-600 mt-1 text-sm"
onClick={() => setShowAll(true)}
>
{t('filter.neurotype.show_more', 'Show more neurotypes')}
</button>
)}
</>
)
}

View File

@@ -4,13 +4,16 @@ 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,
@@ -123,6 +126,11 @@ export const OptionalProfileUserForm = (props: {
() => !!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()
@@ -1049,6 +1057,56 @@ export const OptionalProfileUserForm = (props: {
</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="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 sm:w-[700px]'}
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')}
@@ -1144,6 +1202,33 @@ export const OptionalProfileUserForm = (props: {
</p>
</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={'w-full md:w-[700px] bg-canvas-50 border rounded-md p-2'}
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.diet', 'Diet')} />
<Col className={clsx(colClassName)}>

View File

@@ -6,6 +6,7 @@ import {
INVERTED_EDUCATION_CHOICES,
INVERTED_LANGUAGE_CHOICES,
INVERTED_MBTI_CHOICES,
INVERTED_NEUROTYPE_CHOICES,
INVERTED_ORIENTATION_CHOICES,
INVERTED_POLITICAL_CHOICES,
INVERTED_PSYCHEDELICS_CHOICES,
@@ -23,7 +24,7 @@ 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, Languages, Leaf, Salad} from 'lucide-react'
import {Accessibility, Brain, Home, Languages, Leaf, Salad} from 'lucide-react'
import React, {ReactNode} from 'react'
import {BiSolidDrink} from 'react-icons/bi'
import {FaHeart} from 'react-icons/fa'
@@ -416,6 +417,29 @@ function Orientation(props: {profile: Profile}) {
)
}
function Neurotype(props: {profile: Profile}) {
const t = useT()
const {profile} = props
const p = profile as any
const neurotype = p.neurotype as string[] | null | undefined
const neurotypeDetails = p.neurotype_details as string | null | undefined
if (!neurotype?.length && !neurotypeDetails) return null
const neurotypeText = neurotype?.length
? formatChoiceList(neurotype, 'neurotype', INVERTED_NEUROTYPE_CHOICES, t)
: null
return (
<AboutRow
icon={<Brain className="h-5 w-5" />}
title={t('profile.neurotype', 'Neurotype')}
text={neurotypeText}
details={neurotypeDetails ? `"${neurotypeDetails}"` : undefined}
/>
)
}
function Ethnicity(props: {profile: Profile}) {
const t = useT()
const {profile} = props
@@ -761,13 +785,69 @@ export function ProfileInterestsAndCauses(props: {profile: Profile}) {
)
}
/**
* Whether the Personality card has anything to show. Exported so the card and its call site
* (`profile/profile-info.tsx`) can't drift apart and render an empty card.
*/
export function hasPersonality(profile: Profile) {
const p = profile as any
return !!(
profile.mbti ||
profile.big5_agreeableness ||
p.neurotype?.length ||
p.neurotype_details
)
}
/** Whether the Accessibility card has anything to show. Shared with its call site, as with `hasPersonality`. */
export function hasAccessibility(profile: Profile) {
return !!(profile as any).accessibility_notes
}
/**
* Its own card below Personality rather than a row in Details: it is practical meet-up information, not a
* demographic attribute, and it reads as an aside when buried in a list of them.
*
* Rendered as the member wrote it — no choice mapping, no chips, line breaks preserved. The whole point of
* this field being free text is that the framing belongs to them, so the UI must not restructure it.
*/
export function ProfileAccessibility(props: {profile: Profile}) {
const {profile} = props
const notes = (profile as any).accessibility_notes as string | null | undefined
if (!notes) return null
return (
<Row className="items-start gap-2.5">
<div
className="bg-canvas-100 border-canvas-200 text-ink-500 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border"
style={{width: '32px', height: '32px', marginTop: '1px'}}
>
<Accessibility className="h-5 w-5" />
</div>
<div
style={{
fontSize: '14px',
color: 'rgb(var(--color-ink-900))',
whiteSpace: 'pre-wrap',
}}
>
{notes}
</div>
</Row>
)
}
export function ProfilePersonality(props: {profile: Profile}) {
const {profile} = props
if (!profile.mbti && !profile.big5_agreeableness) return null
if (!hasPersonality(profile)) return null
return (
<Col className={clsx('relative gap-3 overflow-hidden rounded')}>
{/* Grouped with MBTI/Big Five rather than with orientation in the Details card: it describes how
someone's mind works, and matters just as much for friendship and collaboration as for dating. */}
<Neurotype profile={profile} />
<MBTI profile={profile} />
<Big5Traits profile={profile} />
</Col>

View File

@@ -11,6 +11,9 @@ import {SignUpButton} from 'web/components/nav/sidebar'
import {ConnectActions} from 'web/components/profile/connect-actions'
import ProfileHeader, {ProfileHeaderActions} from 'web/components/profile/profile-header'
import ProfileAbout, {
hasAccessibility,
hasPersonality,
ProfileAccessibility,
ProfileInterestsAndCauses,
ProfileLinks,
ProfilePersonality,
@@ -352,12 +355,18 @@ function ProfileContent(props: {
</ProfileCard>
)}
{(profile.mbti || profile.big5_agreeableness) && (
{hasPersonality(profile) && (
<ProfileCard title={t('profile.personality', 'Personality')} className="p-5">
<ProfilePersonality profile={profile} />
</ProfileCard>
)}
{hasAccessibility(profile) && (
<ProfileCard title={t('profile.accessibility_notes', 'Accessibility')} className="p-5">
<ProfileAccessibility profile={profile} />
</ProfileCard>
)}
{profile.links && Object.keys(profile.links).length > 0 && (
<ProfileCard title={t('profile.links', 'Links')} className="p-5">
<ProfileLinks profile={profile} />