From 476fe214db33f75714dbb618eb89391f76d323f2 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Tue, 28 Jul 2026 03:20:51 +0200 Subject: [PATCH] Add profile form navigation: sticky section index for desktop and collapsible bar for mobile; introduce `useLongPressReveal` hook for touch actions; improve form and layout styles for better usability and consistency. --- common/messages/de.json | 3 + common/messages/fr.json | 3 + .../compatibility-questions-display.tsx | 28 +- web/components/optional-profile-form.tsx | 685 ++++++++++-------- web/components/photos-modal.tsx | 26 +- web/components/profile-form-nav.tsx | 225 ++++++ web/components/profile-grid.tsx | 71 +- web/components/profile/profile-info.tsx | 5 +- web/components/voice-autofill-section.tsx | 6 +- web/components/widgets/add-photos.tsx | 13 +- .../widgets/choices-toggle-group.tsx | 48 +- web/components/widgets/input.tsx | 15 +- web/components/widgets/select.tsx | 4 +- .../widgets/share-profile-button.tsx | 17 +- web/hooks/use-long-press-reveal.ts | 104 +++ web/pages/profile.tsx | 41 +- web/pages/signup.tsx | 59 +- web/pages/stats.tsx | 12 +- 18 files changed, 912 insertions(+), 453 deletions(-) create mode 100644 web/components/profile-form-nav.tsx create mode 100644 web/hooks/use-long-press-reveal.ts diff --git a/common/messages/de.json b/common/messages/de.json index 819f84dc..8526a0e9 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -165,6 +165,7 @@ "common.and": "und", "common.back": "Zurück", "common.change": "Ändern", + "common.click_to_clear": "Zum Löschen klicken", "common.close": "Schließen", "common.compatible": "Kompatibel", "common.continue": "Weiter", @@ -600,7 +601,9 @@ "profile.age_min_compact": "{min}+", "profile.age_range_compact": "{min}-{max}", "profile.connect.no_shared_types": "Du und {name} seid nicht für dieselbe Art von Verbindung offen.", + "profile.form.sections": "Abschnitte", "profile.interests": "Interessen", + "profile.optional.add_photos": "Fotos hinzufügen", "send_message.placeholder": "Was hat dich an {name}s Profil wirklich angesprochen? Was möchtest du gemeinsam erkunden?", "send_message.title": "Beginne ein bedeutungsvolles Gespräch", "send_message.guidance": "Compass geht um Tiefe. Nimm dir einen Moment, um etwas Echtes zu schreiben.", diff --git a/common/messages/fr.json b/common/messages/fr.json index a3a731a3..276386dd 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -165,6 +165,7 @@ "common.and": "et", "common.back": "Retour", "common.change": "Modifier", + "common.click_to_clear": "Cliquer pour effacer", "common.close": "Fermer", "common.compatible": "Compatible", "common.continue": "Continuer", @@ -600,7 +601,9 @@ "profile.age_min_compact": "{min}+", "profile.age_range_compact": "{min}-{max}", "profile.connect.no_shared_types": "Vous et {name} n’êtes pas ouverts au même type de relation.", + "profile.form.sections": "Sections", "profile.interests": "Centres d’intérêt", + "profile.optional.add_photos": "Ajouter des photos", "send_message.placeholder": "Qu'est-ce qui vous a vraiment touché dans le profil de {name} ? Qu'aimeriez-vous explorer ensemble ?", "send_message.title": "Commencez une conversation qui a du sens", "send_message.guidance": "Prenez un moment pour écrire quelque chose d'authentique.", diff --git a/web/components/answers/compatibility-questions-display.tsx b/web/components/answers/compatibility-questions-display.tsx index 0f2fec5c..75752ce3 100644 --- a/web/components/answers/compatibility-questions-display.tsx +++ b/web/components/answers/compatibility-questions-display.tsx @@ -31,6 +31,7 @@ import {Linkify} from 'web/components/widgets/linkify' import {Pagination} from 'web/components/widgets/pagination' import {Tooltip} from 'web/components/widgets/tooltip' import {shortenName} from 'web/components/widgets/user-link' +import {useLongPressReveal} from 'web/hooks/use-long-press-reveal' import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state' import {usePinnedQuestionIds} from 'web/hooks/use-pinned-question-ids' import {useProfile} from 'web/hooks/use-profile' @@ -387,6 +388,13 @@ export function CompatibilityAnswerBlock(props: { setNewAnswer(props.answer) }, [props.answer]) + // Touch has no hover to uncover the pin with, so a press and hold on the prompt does it instead. + const { + containerRef: rowRef, + revealed: showPin, + handlers: longPressHandlers, + } = useLongPressReveal() + const comparedProfile = isCurrentUser ? null : fromProfilePage @@ -420,11 +428,16 @@ export function CompatibilityAnswerBlock(props: { return ( @@ -459,12 +472,21 @@ export function CompatibilityAnswerBlock(props: { )} {/* Pinning is a rare action that was occupying permanent space in every row. It appears on - hover where there is a pointer, and stays put where there is not — a touch device has no - hover state to reveal it with. Focus reveals it too, so it stays keyboard-reachable. */} + hover where there is a pointer and on a press and hold where there is not. Focus reveals + it too, so it stays keyboard-reachable. While hidden it is also unclickable on touch — + an invisible button there is just a trap next to the question. */} {!!currentUser && ( )} {isCurrentUser && isAnswered && ( diff --git a/web/components/optional-profile-form.tsx b/web/components/optional-profile-form.tsx index 1c0f4c92..f154c224 100644 --- a/web/components/optional-profile-form.tsx +++ b/web/components/optional-profile-form.tsx @@ -444,16 +444,16 @@ export const OptionalProfileUserForm = (props: { 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). */} - + {/* 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. */} -
+
-

+

{t( 'profile.optional.subtitle', 'Although all the fields below are optional, they will help people better understand you and connect with you.', @@ -463,7 +463,10 @@ export const OptionalProfileUserForm = (props: { {/* 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. */} + {( [ ['voice', t('profile.autofill.by_voice', 'By voice')], @@ -477,10 +480,10 @@ export const OptionalProfileUserForm = (props: { aria-pressed={autofillMode === mode} disabled={isExtracting} className={clsx( - 'flex-1 rounded-lg px-3 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed', + '-mb-px border-b-2 px-1 pb-3 text-sm transition-colors disabled:cursor-not-allowed', autofillMode === mode - ? 'bg-canvas-0 text-ink-900 shadow-sm' - : 'text-ink-700 hover:text-ink-900', + ? 'border-primary-500 text-ink-1000' + : 'border-transparent text-ink-500 hover:text-ink-800', )} > {label} @@ -608,13 +611,15 @@ export const OptionalProfileUserForm = (props: { )} {showAllGenders && ( <> -

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

+

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

) => setProfile('gender_details', e.target.value) } - className={'w-full sm:w-[700px]'} + className={'w-full'} value={(profile as any)['gender_details'] ?? undefined} placeholder={t( 'profile.gender.details_placeholder', @@ -658,7 +663,9 @@ export const OptionalProfileUserForm = (props: { - {t('profile.optional.feet', 'Feet')} + + {t('profile.optional.feet', 'Feet')} + - {t('profile.optional.inches', 'Inches')} + + {t('profile.optional.inches', 'Inches')} + - {t('profile.optional.centimeters', 'Centimeters')} + + {t('profile.optional.centimeters', 'Centimeters')} + ) => setProfile('headline', e.target.value) } - className={'w-full md:w-[700px] bg-canvas-50 border rounded-md p-2'} + className={ + '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} /> @@ -841,7 +854,7 @@ export const OptionalProfileUserForm = (props: { .filter(Boolean) setProfile('keywords', keywords) }} - className={'w-full sm:w-[600px]'} + className={'w-full'} value={keywordsString} placeholder={t( 'profile.optional.keywords_placeholder', @@ -850,6 +863,26 @@ export const OptionalProfileUserForm = (props: { /> + {/* 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. */} + + + { + debug('bio changed', e, profile.bio) + setProfile('bio', e.getJSON()) + setProfile('bio_length', e.getText().length) + }} + /> + @@ -865,7 +898,7 @@ export const OptionalProfileUserForm = (props: { - {t('common.min', 'Min')} + {t('common.min', 'Min')} + + + + + ) => + setProfile('occupation_title', e.target.value) + } + className={'w-52'} + value={profile['occupation_title'] ?? undefined} + /> + + + + + ) => + setProfile('company', e.target.value) + } + className={'w-52'} + value={profile['company'] ?? undefined} + /> + + + + + + + + + + [ + t(`profile.education.${v}`, k), + v, + ]) as any, + )} + setChoice={(c) => setProfile('education_level', c)} + /> + + + + + + ) => + setProfile('university', e.target.value) + } + className={'w-52'} + value={profile['university'] ?? undefined} + /> + + @@ -968,13 +1079,15 @@ export const OptionalProfileUserForm = (props: { )} {showAllOrientations && ( <> -

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

+

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

) => setProfile('orientation_details', e.target.value) } - className={'w-full sm:w-[700px]'} + className={'w-full'} value={(profile as any)['orientation_details'] ?? undefined} placeholder={t( 'profile.orientation.details_placeholder', @@ -1040,104 +1153,6 @@ export const OptionalProfileUserForm = (props: { )} - - - - - - - - - - - - [ - t(`profile.education.${v}`, k), - v, - ]) as any, - )} - setChoice={(c) => setProfile('education_level', c)} - /> - - - - - - ) => - setProfile('university', e.target.value) - } - className={'w-52'} - value={profile['university'] ?? undefined} - /> - - - - - - - ) => - setProfile('occupation_title', e.target.value) - } - className={'w-52'} - value={profile['occupation_title'] ?? undefined} - /> - - - - - ) => - setProfile('company', e.target.value) - } - className={'w-52'} - value={profile['company'] ?? undefined} - /> - - - - @@ -1150,14 +1165,14 @@ export const OptionalProfileUserForm = (props: { translationPrefix={'profile.political'} onChange={(selected) => setProfile('political_beliefs', selected)} /> -

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

+

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

) => setProfile('political_details', e.target.value) } - className={'w-full sm:w-[700px]'} + className={'w-full'} value={profile['political_details'] ?? undefined} /> @@ -1174,191 +1189,18 @@ export const OptionalProfileUserForm = (props: { translationPrefix={'profile.religion'} onChange={(selected) => setProfile('religion', selected)} /> -

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

+

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

) => setProfile('religious_beliefs', e.target.value) } - className={'w-full sm:w-[700px]'} + className={'w-full'} value={profile['religious_beliefs'] ?? undefined} /> - - - {/* 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. */} - - - - 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 && ( - - )} - {showAllNeurotypes && ( - <> -

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

- ) => - 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…', - )} - /> - - )} - - - - - setProfile('mbti', c)} - className="grid grid-cols-4 xs:grid-cols-8" - /> - - - {/* Big Five personality traits (0–100) */} - - - - {/* 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 && ( - - )} - -

- {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. */} - - {t('profile.big5_wikipedia_link', 'Wikipedia article')} - - {t( - 'profile.big5_guidance_suffix', - '). You can take a free well-cited public-domain approximate test ', - )} - - {t('profile.big5_test_link', 'here')} - - {t('profile.big5_guidance_end', '.')} -

-
- setProfile('big5_openness', v)} - /> - setProfile('big5_conscientiousness', v)} - /> - setProfile('big5_extraversion', v)} - /> - setProfile('big5_agreeableness', v)} - /> - setProfile('big5_neuroticism', v)} - /> -
-

- {t( - 'profile.big5_hint', - 'Drag each slider to set where you see yourself on these traits (0 = low, 100 = high).', - )} -

- - - - - {/* 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. */} - - -