mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 09:48:47 -04:00
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.
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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<HTMLDivElement>()
|
||||
|
||||
const comparedProfile = isCurrentUser
|
||||
? null
|
||||
: fromProfilePage
|
||||
@@ -420,11 +428,16 @@ export function CompatibilityAnswerBlock(props: {
|
||||
|
||||
return (
|
||||
<Col
|
||||
ref={rowRef}
|
||||
data-testid="profile-compatibility-section"
|
||||
{...longPressHandlers}
|
||||
className={clsx(
|
||||
// No card. A prompt is question → answer → alternatives, which is structurally the same object
|
||||
// as a Details row, so it gets the same treatment: a rule, not a box.
|
||||
'group border-canvas-200 flex-grow gap-2 whitespace-pre-line border-b py-6 leading-relaxed',
|
||||
// Stops the hold from turning into a text selection / iOS callout — but only where there is
|
||||
// no hover, so a pointer user can still select and copy the question and answer.
|
||||
'[@media(hover:none)]:select-none [@media(hover:none)]:[-webkit-touch-callout:none]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -459,12 +472,21 @@ export function CompatibilityAnswerBlock(props: {
|
||||
</div>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<PinQuestionButton
|
||||
questionId={question.id}
|
||||
className="opacity-0 transition-opacity focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
className={clsx(
|
||||
'transition-opacity',
|
||||
showPin
|
||||
? 'opacity-100'
|
||||
: [
|
||||
'opacity-0 [@media(hover:none)]:pointer-events-none',
|
||||
'focus-visible:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100',
|
||||
],
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isCurrentUser && isAnswered && (
|
||||
|
||||
@@ -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). */}
|
||||
<Col className={'gap-8 max-w-3xl pb-32'}>
|
||||
<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="flex items-start gap-3 rounded-xl bg-primary-100/60 ring-1 ring-primary-200 p-4">
|
||||
<div className="border-primary-400 flex items-start gap-3 border-l-2 pl-4">
|
||||
<InformationCircleIcon
|
||||
className="w-5 h-5 text-primary-700 shrink-0 mt-0.5"
|
||||
className="w-5 h-5 text-primary-600 shrink-0 mt-0.5"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<p className="text-sm text-ink-700 leading-relaxed">
|
||||
<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.',
|
||||
@@ -463,7 +463,10 @@ export const OptionalProfileUserForm = (props: {
|
||||
<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. */}
|
||||
<Row className="w-full gap-1 rounded-xl bg-canvas-100 p-1 ring-1 ring-canvas-200">
|
||||
{/* 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')],
|
||||
@@ -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 && (
|
||||
<>
|
||||
<p className="mt-1">{t('profile.optional.details', 'Details')}</p>
|
||||
<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 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: {
|
||||
<label className={clsx(labelClassName)}>{t('profile.optional.height', 'Height')}</label>
|
||||
<Row className={'gap-2'}>
|
||||
<Col>
|
||||
<span>{t('profile.optional.feet', 'Feet')}</span>
|
||||
<span className={clsx(labelClassName, 'mb-1')}>
|
||||
{t('profile.optional.feet', 'Feet')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
data-testid="height-feet"
|
||||
@@ -673,7 +680,9 @@ export const OptionalProfileUserForm = (props: {
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<span>{t('profile.optional.inches', 'Inches')}</span>
|
||||
<span className={clsx(labelClassName, 'mb-1')}>
|
||||
{t('profile.optional.inches', 'Inches')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
data-testid="height-inches"
|
||||
@@ -693,7 +702,9 @@ export const OptionalProfileUserForm = (props: {
|
||||
{t('common.or', 'OR').toUpperCase()}
|
||||
</div>
|
||||
<Col>
|
||||
<span>{t('profile.optional.centimeters', 'Centimeters')}</span>
|
||||
<span className={clsx(labelClassName, 'mb-1')}>
|
||||
{t('profile.optional.centimeters', 'Centimeters')}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
data-testid="height-centimeters"
|
||||
@@ -814,7 +825,9 @@ export const OptionalProfileUserForm = (props: {
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
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: {
|
||||
/>
|
||||
</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)}>
|
||||
@@ -865,7 +898,7 @@ export const OptionalProfileUserForm = (props: {
|
||||
</label>
|
||||
<Row className={'gap-2'}>
|
||||
<Col>
|
||||
<span>{t('common.min', 'Min')}</span>
|
||||
<span className={clsx(labelClassName, 'mb-1')}>{t('common.min', 'Min')}</span>
|
||||
<Select
|
||||
data-testid="pref-age-min"
|
||||
value={profile['pref_age_min'] ?? ''}
|
||||
@@ -885,7 +918,7 @@ export const OptionalProfileUserForm = (props: {
|
||||
</Select>
|
||||
</Col>
|
||||
<Col>
|
||||
<span>{t('common.max', 'Max')}</span>
|
||||
<span className={clsx(labelClassName, 'mb-1')}>{t('common.max', 'Max')}</span>
|
||||
<Select
|
||||
data-testid="pref-age-max"
|
||||
value={profile['pref_age_max'] ?? ''}
|
||||
@@ -921,6 +954,84 @@ export const OptionalProfileUserForm = (props: {
|
||||
/>
|
||||
</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)}>
|
||||
@@ -968,13 +1079,15 @@ export const OptionalProfileUserForm = (props: {
|
||||
)}
|
||||
{showAllOrientations && (
|
||||
<>
|
||||
<p className="mt-1">{t('profile.optional.details', 'Details')}</p>
|
||||
<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 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: {
|
||||
</>
|
||||
)}
|
||||
|
||||
<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.morality', 'Morality')} />
|
||||
<AddOptionEntry
|
||||
title={t('profile.optional.causes', 'Causes')}
|
||||
choices={causeChoices}
|
||||
setChoices={setCauseChoices}
|
||||
profile={profile}
|
||||
setProfile={setProfile}
|
||||
label={'causes'}
|
||||
/>
|
||||
|
||||
<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.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.political_beliefs', 'Political beliefs')} />
|
||||
|
||||
<Col className={clsx(colClassName)}>
|
||||
@@ -1150,14 +1165,14 @@ export const OptionalProfileUserForm = (props: {
|
||||
translationPrefix={'profile.political'}
|
||||
onChange={(selected) => setProfile('political_beliefs', selected)}
|
||||
/>
|
||||
<p>{t('profile.optional.details', 'Details')}</p>
|
||||
<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 sm:w-[700px]'}
|
||||
className={'w-full'}
|
||||
value={profile['political_details'] ?? undefined}
|
||||
/>
|
||||
</Col>
|
||||
@@ -1174,191 +1189,18 @@ export const OptionalProfileUserForm = (props: {
|
||||
translationPrefix={'profile.religion'}
|
||||
onChange={(selected) => setProfile('religion', selected)}
|
||||
/>
|
||||
<p>{t('profile.optional.details', 'Details')}</p>
|
||||
<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 sm:w-[700px]'}
|
||||
className={'w-full'}
|
||||
value={profile['religious_beliefs'] ?? undefined}
|
||||
/>
|
||||
</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')}
|
||||
</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.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)}>
|
||||
@@ -1373,6 +1215,26 @@ export const OptionalProfileUserForm = (props: {
|
||||
/>
|
||||
</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)}>
|
||||
@@ -1500,6 +1362,154 @@ 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={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)}>
|
||||
@@ -1508,7 +1518,7 @@ export const OptionalProfileUserForm = (props: {
|
||||
{/*</label>*/}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="col-span-full max-h-60 overflow-y-auto w-full">
|
||||
<div className="scrollbar-visible col-span-full max-h-60 overflow-y-auto w-full pr-2">
|
||||
<MultiCheckbox
|
||||
choices={LANGUAGE_CHOICES}
|
||||
selected={profile.languages || []}
|
||||
@@ -1539,26 +1549,39 @@ export const OptionalProfileUserForm = (props: {
|
||||
{/* />*/}
|
||||
{/*</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 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} />
|
||||
|
||||
<Category title={t('profile.basics.bio', 'Bio')} className={'mt-0'} />
|
||||
<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)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Row className={'justify-end'}>
|
||||
<Button
|
||||
className={clsx(
|
||||
@@ -1598,6 +1621,7 @@ const CitySearchBox = (props: {onCitySelected: (city: City | undefined) => void}
|
||||
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
|
||||
@@ -1642,16 +1666,39 @@ const CitySearchBox = (props: {onCitySelected: (city: City | undefined) => void}
|
||||
* 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 (
|
||||
// `mt-6` on top of the parent's `gap-8`, against `pt-5` below: the rule has to sit closer to the
|
||||
// heading it introduces than to the section it closes, or it reads as a trailing underline for the
|
||||
// previous block. Even spacing on both sides is the default mistake here.
|
||||
<div className={clsx('w-full border-t border-canvas-200 mt-6 pt-5', className)}>
|
||||
{/* `!mt-0`: the global h1–h6 rule puts a 24px margin on this, which silently doubled the space
|
||||
below the rule and made the spacing symmetric again. It is also what the old `mb-[-8px]` on
|
||||
this component was compensating for. */}
|
||||
<h3 className="font-heading text-2xl font-bold tracking-tight text-ink-900 !mt-0">{title}</h3>
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import {User} from 'common/user'
|
||||
import {useState} from 'react'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Modal} from 'web/components/layout/modal'
|
||||
import {ShareProfileButtons} from 'web/components/widgets/share-profile-button'
|
||||
import {ShareCTAButton} from 'web/components/widgets/share-cta-button'
|
||||
import {useProfileShareUrl} from 'web/components/widgets/share-profile-button'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
import {ProfileCardViewer} from './profile-card-viewer'
|
||||
@@ -70,8 +71,9 @@ export const ViewProfileCardButton = (props: {
|
||||
const {user, profile, width, height} = props
|
||||
const [open, setOpen] = useState<boolean>(false)
|
||||
const t = useT()
|
||||
// Hooks run before the guard below, so the empty-username fallback is only ever a placeholder.
|
||||
const shareUrl = useProfileShareUrl(user?.username ?? '')
|
||||
if (!user || !profile) return
|
||||
const username = user.username
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -96,11 +98,21 @@ export const ViewProfileCardButton = (props: {
|
||||
<Modal open={open} setOpen={setOpen} size={'lg'} className={''}>
|
||||
<Col className="gap-4 bg-canvas-100/75 rounded-2xl justify-center">
|
||||
<ProfileCardViewer user={user} profile={profile} width={width} height={height} />
|
||||
<ShareProfileButtons
|
||||
username={username}
|
||||
className={'justify-center gap-4 text-3xl pb-4'}
|
||||
buttonClassName={'hover:bg-canvas-200'}
|
||||
/>
|
||||
{/* One primary share action rather than the former X / LinkedIn / share trio: the OS share
|
||||
sheet already covers every destination those two buttons hard-coded, so the row was three
|
||||
controls competing to be the same thing. Same CTA styling as the /about closing block. */}
|
||||
<div className="flex justify-center pb-4">
|
||||
<ShareCTAButton
|
||||
url={shareUrl}
|
||||
shareTitle={t('share_profile.share.title', 'A profile worth seeing on Compass')}
|
||||
shareText={t(
|
||||
'share_profile.share.text',
|
||||
'Thought you might want to see this profile on Compass — a free directory for finding your people, searchable by values, interests, and demographics. No ads, no swiping, no dubious algorithm.',
|
||||
)}
|
||||
label={t('button.share.label', 'Share')}
|
||||
copiedLabel={t('copy_link_button.link_copied', 'Link copied!')}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
225
web/components/profile-form-nav.tsx
Normal file
225
web/components/profile-form-nav.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import clsx from 'clsx'
|
||||
import {ChevronDownIcon} from 'lucide-react'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
|
||||
import {Row} from 'web/components/layout/row'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
type FormSection = {id: string; title: string}
|
||||
|
||||
/**
|
||||
* The profile form's sections and which one is currently being read.
|
||||
*
|
||||
* The list is read from the DOM rather than declared here. The form's markup is the source of truth
|
||||
* for what exists and in what order, and a hand-kept copy of that list would silently drift the first
|
||||
* time a section moved — which it did.
|
||||
*
|
||||
* Shared by both presentations below so the rail and the mobile chip cannot disagree about where you
|
||||
* are.
|
||||
*/
|
||||
function useFormSections() {
|
||||
const [sections, setSections] = useState<FormSection[]>([])
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const form = document.querySelector('[data-profile-form]')
|
||||
if (!form) return
|
||||
|
||||
// Bail when the list is unchanged. `setSections` with a fresh array on every call re-renders,
|
||||
// which mutates the DOM, which fires the observer again — a loop that also thrashed the scroll
|
||||
// listener below, since it re-subscribes whenever `sections` changes identity.
|
||||
const read = () =>
|
||||
setSections((prev) => {
|
||||
const next = Array.from(form.querySelectorAll<HTMLElement>('[data-form-section]')).map(
|
||||
(node) => ({id: node.id, title: node.dataset.formSection ?? ''}),
|
||||
)
|
||||
const same =
|
||||
prev.length === next.length &&
|
||||
prev.every((p, i) => p.id === next[i].id && p.title === next[i].title)
|
||||
return same ? prev : next
|
||||
})
|
||||
|
||||
read()
|
||||
|
||||
// Sections come and go with the form's own conditionals — Family only exists once a relationship
|
||||
// is being sought — so the list is re-read when the form's subtree changes.
|
||||
const mutations = new MutationObserver(read)
|
||||
mutations.observe(form, {childList: true, subtree: true})
|
||||
return () => mutations.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (sections.length === 0) return
|
||||
|
||||
// Read straight from scroll position rather than with an IntersectionObserver. The observer
|
||||
// version needed a band across the upper viewport to fire in, and a section heading is a few
|
||||
// pixels tall — during any normal scroll the headings jumped clean over the band and nothing ever
|
||||
// became active. Position always answers: the active section is simply the last one whose heading
|
||||
// has passed the line.
|
||||
const update = () => {
|
||||
const line = window.innerHeight * 0.25
|
||||
let current = sections[0].id
|
||||
for (const {id} of sections) {
|
||||
const el = document.getElementById(id)
|
||||
if (!el || el.getBoundingClientRect().top > line) break
|
||||
current = id
|
||||
}
|
||||
setActiveId(current)
|
||||
}
|
||||
|
||||
update()
|
||||
window.addEventListener('scroll', update, {passive: true})
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [sections])
|
||||
|
||||
const activeIndex = sections.findIndex((s) => s.id === activeId)
|
||||
|
||||
return {
|
||||
sections,
|
||||
activeId,
|
||||
activeIndex: activeIndex === -1 ? 0 : activeIndex,
|
||||
goTo: (id: string) => document.getElementById(id)?.scrollIntoView({behavior: 'smooth'}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky section index for wide screens, in the gutter beside the form.
|
||||
*
|
||||
* The form is eighteen sections and several thousand pixels tall. Without an index the only way to
|
||||
* reach Substances is to scroll past everything before it, and there is no way to tell how much is
|
||||
* left — which is the difference between a form that feels long and one that feels endless.
|
||||
*/
|
||||
export function ProfileFormNav(props: {className?: string}) {
|
||||
const {className} = props
|
||||
const {sections, activeId, goTo} = useFormSections()
|
||||
|
||||
if (sections.length === 0) return null
|
||||
|
||||
return (
|
||||
<nav className={clsx('flex flex-col gap-0.5', className)} aria-label="Form sections">
|
||||
{sections.map(({id, title}) => {
|
||||
const isActive = id === activeId
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => goTo(id)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
className={clsx(
|
||||
'border-l-2 py-1.5 pl-3 text-left text-sm transition-colors',
|
||||
isActive
|
||||
? 'border-primary-500 text-ink-1000'
|
||||
: 'border-canvas-300 text-ink-500 hover:border-primary-400 hover:text-ink-800',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same index for narrow screens, as one sticky row: position in the form, the current section,
|
||||
* and the full list one tap away.
|
||||
*
|
||||
* Not a reflow of the rail — stacked above the form, eighteen links would be a screenful of
|
||||
* navigation to scroll past before reaching the first field, on the very form whose problem is
|
||||
* length. The need is greater here than on desktop, since every field stacks and the page runs
|
||||
* roughly three times longer; the answer just has to cost one row instead of a screen.
|
||||
*
|
||||
* It hides while you scroll down and returns when you scroll up. A permanent bar would spend 44px of
|
||||
* an already short viewport on something wanted a few times per session, and the bottom edge is
|
||||
* already taken by the nav bar and the floating Save button.
|
||||
*/
|
||||
export function ProfileFormSectionBar(props: {className?: string}) {
|
||||
const {className} = props
|
||||
const {sections, activeId, activeIndex, goTo} = useFormSections()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [hidden, setHidden] = useState(false)
|
||||
const t = useT()
|
||||
|
||||
useEffect(() => {
|
||||
let lastY = window.scrollY
|
||||
const onScroll = () => {
|
||||
const y = window.scrollY
|
||||
// The 8px deadband stops momentum jitter from flickering the bar on and off.
|
||||
if (Math.abs(y - lastY) > 8) {
|
||||
setHidden(y > lastY && y > 160)
|
||||
lastY = y
|
||||
}
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, {passive: true})
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
if (sections.length === 0) return null
|
||||
|
||||
const active = sections[activeIndex]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* `--tnh` is the native top inset (globals.css). Pinned at `top-0` this sat under the Android
|
||||
status bar in the Capacitor shell; the variable is 0 in a browser, so one rule covers both. */}
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-canvas-100/95 border-canvas-300 sticky top-[var(--tnh)] z-20 -mx-6 border-b px-6 backdrop-blur transition-transform duration-200',
|
||||
hidden && '-translate-y-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-haspopup="dialog"
|
||||
className="flex w-full items-center gap-3 py-3 text-left"
|
||||
>
|
||||
<span className="text-ink-400 font-dm-sans tabular-nums" style={{fontSize: '11px'}}>
|
||||
{activeIndex + 1}/{sections.length}
|
||||
</span>
|
||||
<span className="text-ink-900 min-w-0 flex-1 truncate text-sm">{active?.title}</span>
|
||||
<ChevronDownIcon className="text-ink-500 h-4 w-4 flex-none" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<Col className={MODAL_CLASS}>
|
||||
<div
|
||||
className="text-ink-400 font-dm-sans mb-2 uppercase"
|
||||
style={{fontSize: '10px', letterSpacing: '0.18em'}}
|
||||
>
|
||||
{t('profile.form.sections', 'Sections')}
|
||||
</div>
|
||||
<Col className="w-full">
|
||||
{sections.map(({id, title}, i) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
goTo(id)
|
||||
}}
|
||||
aria-current={id === activeId ? 'true' : undefined}
|
||||
className={clsx(
|
||||
'border-canvas-200 flex w-full items-center gap-3 border-b py-3 text-left text-sm last:border-b-0',
|
||||
id === activeId ? 'text-primary-700' : 'text-ink-700',
|
||||
)}
|
||||
>
|
||||
<Row className="text-ink-400 w-6 flex-none tabular-nums" style={{fontSize: '11px'}}>
|
||||
{i + 1}
|
||||
</Row>
|
||||
{title}
|
||||
</button>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Wine,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import React, {useEffect, useMemo, useRef, useState} from 'react'
|
||||
import React, {useMemo, useRef, useState} from 'react'
|
||||
import {PiMagnifyingGlassBold} from 'react-icons/pi'
|
||||
import {LocationFilterProps} from 'web/components/filters/location-filter'
|
||||
import GenderIcon from 'web/components/gender-icon'
|
||||
@@ -38,6 +38,7 @@ import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observe
|
||||
import {BookmarkedSearchesType} from 'web/hooks/use-bookmarked-searches'
|
||||
import {useChoicesContext} from 'web/hooks/use-choices'
|
||||
import {ColumnCountOptions, useColumnCount} from 'web/hooks/use-column-count'
|
||||
import {useLongPressReveal} from 'web/hooks/use-long-press-reveal'
|
||||
import {isDark, useTheme} from 'web/hooks/use-theme'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useT} from 'web/lib/locale'
|
||||
@@ -83,9 +84,6 @@ type CardSizeConfig = {
|
||||
|
||||
const MIN_BIO_LINES = 2
|
||||
|
||||
/** How long a touch has to stay down before it reveals the card actions instead of navigating. */
|
||||
const LONG_PRESS_MS = 400
|
||||
|
||||
const CARD_SIZE_CONFIG: Record<CardSize, CardSizeConfig> = {
|
||||
small: {
|
||||
columns: {minCardWidth: 300, maxColumns: 4},
|
||||
@@ -488,30 +486,15 @@ function ProfilePreview(props: {
|
||||
const pointerStartRef = useRef<{x: number; y: number} | null>(null)
|
||||
|
||||
// Touch has no hover, so the actions can't hide behind one — a press and hold reveals them.
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
/** Set once the hold fires, so the release that follows opens the actions instead of the profile. */
|
||||
const longPressedRef = useRef(false)
|
||||
|
||||
const clearLongPress = () => {
|
||||
if (longPressTimeoutRef.current) {
|
||||
clearTimeout(longPressTimeoutRef.current)
|
||||
longPressTimeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Any press outside puts them away again — including a press on another card, which reveals its own.
|
||||
useEffect(() => {
|
||||
if (!showActions) return
|
||||
const onDocumentPointerDown = (e: PointerEvent) => {
|
||||
if (!cardRef.current?.contains(e.target as Node)) setShowActions(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||||
return () => document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
}, [showActions])
|
||||
|
||||
useEffect(() => clearLongPress, [])
|
||||
const {
|
||||
containerRef: cardRef,
|
||||
revealed: showActions,
|
||||
longPressedRef,
|
||||
start: startLongPress,
|
||||
move: moveLongPress,
|
||||
cancel: clearLongPress,
|
||||
handlers: longPressHandlers,
|
||||
} = useLongPressReveal<HTMLDivElement>()
|
||||
|
||||
const {theme} = useTheme()
|
||||
const isDarkTheme = isDark(theme)
|
||||
@@ -551,23 +534,12 @@ function ProfilePreview(props: {
|
||||
}
|
||||
pointerStartRef.current = {x: e.clientX, y: e.clientY}
|
||||
|
||||
longPressedRef.current = false
|
||||
// Mouse users get the actions on hover, so holding the button down shouldn't hijack their click.
|
||||
if (e.pointerType !== 'mouse') {
|
||||
clearLongPress()
|
||||
longPressTimeoutRef.current = setTimeout(() => {
|
||||
longPressTimeoutRef.current = null
|
||||
longPressedRef.current = true
|
||||
setShowActions(true)
|
||||
}, LONG_PRESS_MS)
|
||||
}
|
||||
startLongPress(e)
|
||||
}
|
||||
|
||||
// A press that turns into a scroll is not a hold.
|
||||
const handlePointerMove = (e: React.PointerEvent) => {
|
||||
const start = pointerStartRef.current
|
||||
if (!start || !longPressTimeoutRef.current) return
|
||||
if (Math.abs(e.clientX - start.x) > 10 || Math.abs(e.clientY - start.y) > 10) clearLongPress()
|
||||
moveLongPress(e)
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: React.PointerEvent) => {
|
||||
@@ -605,20 +577,9 @@ function ProfilePreview(props: {
|
||||
pointerStartRef.current = null
|
||||
}
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Browsers still fire a click after the hold, which would navigate away from the actions we
|
||||
// just revealed.
|
||||
if (longPressedRef.current) {
|
||||
e.preventDefault()
|
||||
longPressedRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// The hold would otherwise also pop the browser's own link menu on top of the actions. Only
|
||||
// suppressed for a touch hold — right-click ("open in new tab") stays intact.
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
if (longPressedRef.current) e.preventDefault()
|
||||
}
|
||||
// Browsers still fire a click after the hold, which would navigate away from the actions we just
|
||||
// revealed; the hold would likewise pop the browser's own link menu on top of them.
|
||||
const {onClick: handleClick, onContextMenu: handleContextMenu} = longPressHandlers
|
||||
|
||||
// If this profile was just hidden, render a compact placeholder with Undo action.
|
||||
if (isHidden) {
|
||||
|
||||
@@ -94,8 +94,11 @@ export function ProfileInfo(props: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The bar sticks to the viewport top, which on native sits under the Android/iOS status bar.
|
||||
Pull it up through the body's safe-area padding and add that inset back as padding, so the
|
||||
bar's background fills the status-bar strip while its buttons stay clear of the native nav. */}
|
||||
<div
|
||||
className="bg-canvas-50/95 border-canvas-300 sticky top-0 z-20 mb-6 flex items-center border-b px-4 sm:px-9 py-4 backdrop-blur"
|
||||
className="bg-canvas-50/95 border-canvas-300 sticky top-0 z-20 mb-6 flex items-center border-b px-4 sm:px-9 py-4 -mt-[env(safe-area-inset-top)] pt-[calc(1rem+env(safe-area-inset-top))] backdrop-blur"
|
||||
style={{
|
||||
borderBottomWidth: '1px',
|
||||
gap: '10px',
|
||||
|
||||
@@ -161,8 +161,10 @@ export function VoiceAutofillSection(props: {
|
||||
fold on short viewports. Talking points stay visible while recording — they are the
|
||||
script. On mobile they stack, mic underneath. */}
|
||||
<Col className="gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex-1 rounded-xl bg-canvas-50 ring-1 ring-canvas-200 p-4">
|
||||
<p className="text-sm font-semibold text-ink-900">
|
||||
{/* A keyline, not a ringed card. These are talking points beside the mic, not a panel of
|
||||
their own — and the page has no other boxes left to be consistent with. */}
|
||||
<div className="border-canvas-300 flex-1 border-l pl-4">
|
||||
<p className="text-ink-800 text-sm">
|
||||
{t('profile.voice.prompts.title', 'Talk freely, or use these as a starting point:')}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1 text-sm text-ink-700">
|
||||
|
||||
@@ -70,17 +70,24 @@ export const AddPhotosWidget = (props: {
|
||||
/>
|
||||
<Row className="flex-wrap gap-2">
|
||||
<div className="relative" data-testid="profile-upload-photo">
|
||||
{/* Dashed and captioned, matching the empty states elsewhere. A flat grey rectangle with a
|
||||
64px plus in it read as a failed image rather than an invitation — and this is the most
|
||||
valuable field on the form. `text-gray-500` was also an off-palette literal that does not
|
||||
flip with the theme. */}
|
||||
<label
|
||||
className={clsx(
|
||||
'bg-canvas-50 hover:bg-ink-300 text-ink-0 dark:text-ink-500 hover:dark:text-ink-600 flex h-[200px] w-[200px] cursor-pointer flex-col items-center rounded-md transition-colors',
|
||||
'border-canvas-300 text-ink-500 hover:border-primary-400 hover:text-primary-600 flex h-[200px] w-[200px] cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed transition-colors',
|
||||
uploadingImages && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
htmlFor="photo-upload"
|
||||
>
|
||||
{uploadingImages ? (
|
||||
<div className="mx-auto my-auto h-16 w-16 animate-spin rounded-full border-b-2 border-gray-500"></div>
|
||||
<div className="border-primary-500 h-10 w-10 animate-spin rounded-full border-b-2" />
|
||||
) : (
|
||||
<PlusIcon className="mx-auto my-auto h-16 w-16 text-gray-500" />
|
||||
<>
|
||||
<PlusIcon className="h-8 w-8" />
|
||||
<span className="text-sm">{t('profile.optional.add_photos', 'Add photos')}</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,18 @@ import clsx from 'clsx'
|
||||
import {toKey} from 'common/parsing'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
// Selected states mirror MultiCheckbox's chip exactly, including the amber lift on the filled one, so
|
||||
// a single-choice control and a multi-choice control read as the same kind of thing.
|
||||
const SELECTED_CTA =
|
||||
'aria-checked:border-cta aria-checked:bg-cta aria-checked:text-white aria-checked:shadow-[0_2px_8px_rgba(193,127,62,0.28)]'
|
||||
const UNSELECTED =
|
||||
'border-canvas-300 bg-canvas-0 text-ink-600 hover:border-primary-400 hover:text-primary-700'
|
||||
|
||||
const colorClasses = {
|
||||
'indigo-dark':
|
||||
'text-ink-700 hover:bg-primary-100 hover:text-ink-700 aria-checked:bg-cta aria-checked:text-white',
|
||||
indigo: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-primary-100 aria-checked:text-primary-900',
|
||||
green: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-teal-500 aria-checked:text-ink-0',
|
||||
red: 'text-ink-700 hover:bg-ink-50 aria-checked:bg-scarlet-500 aria-checked:text-ink-0',
|
||||
'indigo-dark': `${UNSELECTED} ${SELECTED_CTA}`,
|
||||
indigo: `${UNSELECTED} aria-checked:border-primary-400 aria-checked:bg-primary-100 aria-checked:text-primary-900`,
|
||||
green: `${UNSELECTED} aria-checked:border-teal-500 aria-checked:bg-teal-500 aria-checked:text-ink-0`,
|
||||
red: `${UNSELECTED} aria-checked:border-scarlet-500 aria-checked:bg-scarlet-500 aria-checked:text-ink-0`,
|
||||
}
|
||||
|
||||
export type ColorType = keyof typeof colorClasses
|
||||
@@ -18,7 +24,10 @@ export function ChoicesToggleGroup<T extends Record<string, string | number | bo
|
||||
choicesMap: T
|
||||
disabled?: boolean
|
||||
disabledOptions?: Array<T[keyof T]>
|
||||
setChoice: (val: T[keyof T]) => void
|
||||
/** Receives `null` when the selected option is clicked again to clear it. */
|
||||
setChoice: (val: T[keyof T] | null) => void
|
||||
/** Opt out where the field genuinely has no empty state. */
|
||||
allowDeselect?: boolean
|
||||
color?: ColorType
|
||||
className?: string
|
||||
toggleClassName?: string
|
||||
@@ -36,17 +45,21 @@ export function ChoicesToggleGroup<T extends Record<string, string | number | bo
|
||||
children,
|
||||
toggleClassName,
|
||||
translationPrefix,
|
||||
allowDeselect = true,
|
||||
} = props
|
||||
const t = useT()
|
||||
// Object.entries(choicesMap).map(([choiceKey, choice]) => (
|
||||
// console.log(`${translationPrefix}.${toKey(String(choice))}`)
|
||||
// ))
|
||||
return (
|
||||
// No container box. A bordered well holding segments was a second selection language sitting
|
||||
// beside the pills that MultiCheckbox renders — and on the profile form the two appear within a
|
||||
// few hundred pixels of each other. Free-floating pills, styled to match, are the one language.
|
||||
<RadioGroup
|
||||
className={clsx(
|
||||
className,
|
||||
'flex flex-wrap border-ink-300 text-ink-400 bg-canvas-50 flex-row gap-2 rounded-md border p-1 text-sm shadow-sm',
|
||||
disabled && 'bg-canvas-50 !cursor-not-allowed',
|
||||
'flex flex-row flex-wrap gap-2',
|
||||
disabled && '!cursor-not-allowed',
|
||||
)}
|
||||
value={currentChoice ?? undefined}
|
||||
onChange={setChoice}
|
||||
@@ -57,12 +70,25 @@ export function ChoicesToggleGroup<T extends Record<string, string | number | bo
|
||||
key={choiceKey}
|
||||
value={choice}
|
||||
disabled={disabledOptions?.includes(choice as any)}
|
||||
// A radio group has no way back to empty: once an answer is picked the field is committed
|
||||
// for good, which is wrong on a form where every field is optional. Clicking the selected
|
||||
// option clears it. RadioGroup fires no `onChange` when the value is unchanged, so this
|
||||
// handler is the only thing that runs on that click.
|
||||
onClick={() => {
|
||||
if (allowDeselect && choice === currentChoice) setChoice(null)
|
||||
}}
|
||||
title={
|
||||
allowDeselect && choice === currentChoice
|
||||
? t('common.click_to_clear', 'Click to clear')
|
||||
: undefined
|
||||
}
|
||||
className={({disabled}) =>
|
||||
clsx(
|
||||
'inline-flex cursor-pointer select-none items-center rounded-full border px-3.5 py-1.5 text-sm font-medium outline-none transition-all duration-150',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary-400 focus-visible:ring-offset-1 focus-visible:ring-offset-canvas-50',
|
||||
disabled
|
||||
? 'text-ink-300 aria-checked:bg-ink-300 aria-checked:text-ink-0 cursor-not-allowed'
|
||||
: 'cursor-pointer ' + colorClasses[color],
|
||||
'ring-primary-500 flex items-center rounded-md p-2 outline-none transition-all focus-visible:ring-2 sm:px-3',
|
||||
? 'border-canvas-200 bg-canvas-100 text-ink-300 cursor-not-allowed'
|
||||
: colorClasses[color],
|
||||
toggleClassName,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,13 @@ export const Input = forwardRef(
|
||||
const {error, searchIcon, className, value, onChange, ...rest} = props
|
||||
const [hasValue, setHasValue] = useState(!!value)
|
||||
|
||||
// `focus-within` rather than `focus`: with `searchIcon` the border lives on this wrapper while the
|
||||
// caret is in a child input, so a plain `focus:` variant never fires and the field lights up
|
||||
// nowhere. The ring is the only thing telling you which of forty fields you are typing into.
|
||||
const rowClassName = clsx(
|
||||
'bg-canvas-50 h-12 rounded-xl border border-canvas-200 px-4 shadow-sm transition-colors items-center gap-2',
|
||||
'bg-canvas-50 h-12 rounded-xl border border-canvas-300 px-4 shadow-sm items-center gap-2',
|
||||
'transition-all duration-150 hover:border-primary-300',
|
||||
'focus-within:border-primary-400 focus-within:ring-2 focus-within:ring-primary-500/25',
|
||||
className,
|
||||
)
|
||||
|
||||
@@ -41,10 +46,10 @@ export const Input = forwardRef(
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
className={clsx(
|
||||
'w-full bg-canvas-50 invalid:border-error invalid:text-error invalid:placeholder-rose-700 focus:outline-none focus:ring-1 disabled:cursor-not-allowed md:text-sm [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0',
|
||||
'w-full bg-canvas-50 invalid:border-error invalid:text-error invalid:placeholder-rose-700 focus:outline-none focus:ring-0 disabled:cursor-not-allowed md:text-sm [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-outer-spin-button]:m-0',
|
||||
error
|
||||
? 'border-error text-error focus:border-error focus:ring-error placeholder-rose-700' // matches invalid: styles
|
||||
: 'focus:border-canvas-200 focus:ring-transparent',
|
||||
: '',
|
||||
!searchIcon && rowClassName,
|
||||
)}
|
||||
{...rest}
|
||||
@@ -54,13 +59,13 @@ export const Input = forwardRef(
|
||||
if (searchIcon)
|
||||
return (
|
||||
<Row className={rowClassName}>
|
||||
<Search className="w-4 h-4 text-canvas-400" />
|
||||
<Search className="w-4 h-4 text-ink-400" />
|
||||
{elem}
|
||||
{hasValue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-canvas-400 hover:text-primary-600 transition-colors"
|
||||
className="text-ink-400 hover:text-primary-600 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,9 @@ export const Select = forwardRef<HTMLSelectElement, ComponentPropsWithoutRef<'se
|
||||
<select
|
||||
ref={ref}
|
||||
className={clsx(
|
||||
'bg-canvas-50 text-ink-1000 border-ink-300 focus:border-primary-500 focus:ring-primary-500 h-12 cursor-pointer self-start overflow-hidden rounded-xl border pl-4 pr-10 text-sm shadow-sm focus:outline-none',
|
||||
// Matches Input: same border token, same hover, same focus ring. It sat on `border-ink-300` with a
|
||||
// bare 1px focus border, so a select and a text field next to each other looked unrelated.
|
||||
'bg-canvas-50 text-ink-1000 border-canvas-300 h-12 cursor-pointer self-start overflow-hidden rounded-xl border pl-4 pr-10 text-sm shadow-sm transition-all duration-150 hover:border-primary-300 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-500/25',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -11,18 +11,25 @@ import {Row} from 'web/components/layout/row'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
/**
|
||||
* The link a share of `username`'s profile should carry: tagged with the sharer's `?referrer=` so the
|
||||
* share gets credited to them, bare when the sharer is signed out or is the profile's own owner.
|
||||
*/
|
||||
export const useProfileShareUrl = (username: string) => {
|
||||
const currentUser = useUser()
|
||||
return currentUser && currentUser.username !== username
|
||||
? `https://${ENV_CONFIG.domain}/${username}?referrer=${currentUser.username}`
|
||||
: `https://${ENV_CONFIG.domain}/${username}`
|
||||
}
|
||||
|
||||
export const ShareProfileButton = (props: {
|
||||
username: string
|
||||
className?: string
|
||||
color?: ColorType
|
||||
}) => {
|
||||
const {username, className, color} = props
|
||||
const currentUser = useUser()
|
||||
const t = useT()
|
||||
const shareUrl =
|
||||
currentUser && currentUser.username !== username
|
||||
? `https://${ENV_CONFIG.domain}/${username}?referrer=${currentUser.username}`
|
||||
: `https://${ENV_CONFIG.domain}/${username}`
|
||||
const shareUrl = useProfileShareUrl(username)
|
||||
|
||||
return (
|
||||
<CopyLinkOrShareButton
|
||||
|
||||
104
web/hooks/use-long-press-reveal.ts
Normal file
104
web/hooks/use-long-press-reveal.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
|
||||
/** How long a touch has to stay down before it reveals the hidden actions. */
|
||||
export const LONG_PRESS_MS = 400
|
||||
|
||||
/** A press that drifts further than this is a scroll, not a hold. */
|
||||
const MOVE_TOLERANCE_PX = 10
|
||||
|
||||
/**
|
||||
* Reveals actions that are otherwise hidden behind hover, for devices that have no hover: a press
|
||||
* and hold uncovers them, and the next press anywhere else puts them away again.
|
||||
*
|
||||
* The caller owns the markup — attach `containerRef` to the element that should count as "inside"
|
||||
* for the dismiss-on-outside-press, spread `handlers` onto it, and drive the actions' visibility
|
||||
* from `revealed`. Consumers with their own pointer logic (press feedback, navigation) can call
|
||||
* `start` / `move` / `cancel` from inside their own handlers instead of spreading `handlers`.
|
||||
*/
|
||||
export function useLongPressReveal<T extends HTMLElement = HTMLElement>() {
|
||||
const containerRef = useRef<T>(null)
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const startRef = useRef<{x: number; y: number} | null>(null)
|
||||
/** Set once the hold fires, so the release that follows opens the actions instead of the link. */
|
||||
const longPressedRef = useRef(false)
|
||||
|
||||
const cancel = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// Any press outside puts them away again — including a press on a sibling, which reveals its own.
|
||||
useEffect(() => {
|
||||
if (!revealed) return
|
||||
const onDocumentPointerDown = (e: PointerEvent) => {
|
||||
if (!containerRef.current?.contains(e.target as Node)) setRevealed(false)
|
||||
}
|
||||
document.addEventListener('pointerdown', onDocumentPointerDown)
|
||||
return () => document.removeEventListener('pointerdown', onDocumentPointerDown)
|
||||
}, [revealed])
|
||||
|
||||
useEffect(() => cancel, [])
|
||||
|
||||
const start = (e: React.PointerEvent) => {
|
||||
longPressedRef.current = false
|
||||
// Mouse users get the actions on hover, so holding the button down shouldn't hijack their click.
|
||||
if (e.pointerType === 'mouse') return
|
||||
// A press that lands on one of the revealed actions is that action's, not a fresh hold.
|
||||
const target = e.target as HTMLElement | null
|
||||
if (!target || target.closest('button, [role="button"]')) return
|
||||
|
||||
startRef.current = {x: e.clientX, y: e.clientY}
|
||||
cancel()
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null
|
||||
longPressedRef.current = true
|
||||
setRevealed(true)
|
||||
}, LONG_PRESS_MS)
|
||||
}
|
||||
|
||||
const move = (e: React.PointerEvent) => {
|
||||
const start = startRef.current
|
||||
if (!start || !timeoutRef.current) return
|
||||
if (
|
||||
Math.abs(e.clientX - start.x) > MOVE_TOLERANCE_PX ||
|
||||
Math.abs(e.clientY - start.y) > MOVE_TOLERANCE_PX
|
||||
)
|
||||
cancel()
|
||||
}
|
||||
|
||||
// Browsers still fire a click after the hold, which would act on whatever sits under the finger.
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
if (longPressedRef.current) {
|
||||
e.preventDefault()
|
||||
longPressedRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
// The hold would otherwise also pop the browser's own menu on top of the actions. Only suppressed
|
||||
// for a touch hold — right-click ("open in new tab") stays intact.
|
||||
const onContextMenu = (e: React.MouseEvent) => {
|
||||
if (longPressedRef.current) e.preventDefault()
|
||||
}
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
revealed,
|
||||
setRevealed,
|
||||
/** True between the hold firing and the click it produces — check before acting on that click. */
|
||||
longPressedRef,
|
||||
start,
|
||||
move,
|
||||
cancel,
|
||||
handlers: {
|
||||
onPointerDown: start,
|
||||
onPointerMove: move,
|
||||
onPointerUp: cancel,
|
||||
onPointerCancel: cancel,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {BackButton} from 'web/components/back-button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {OptionalProfileUserForm} from 'web/components/optional-profile-form'
|
||||
import {PageBase} from 'web/components/page-base'
|
||||
import {ProfileFormNav, ProfileFormSectionBar} from 'web/components/profile-form-nav'
|
||||
import {RequiredProfileUserForm} from 'web/components/required-profile-form'
|
||||
import {SEO} from 'web/components/SEO'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
@@ -110,23 +111,31 @@ function ProfilePageInner(props: {user: User; profile: Profile}) {
|
||||
description={t('profile.seo.description', 'Your Profile')}
|
||||
url={`/profile`}
|
||||
/>
|
||||
<Col className="items-center">
|
||||
<Col className="w-full">
|
||||
<BackButton className="ml-2 mb-2 self-start" />
|
||||
<Col className={'w-full px-6 py-4'}>
|
||||
<RequiredProfileUserForm
|
||||
data={baseUser}
|
||||
setData={setBaseUserState}
|
||||
profileCreatedAlready
|
||||
/>
|
||||
<div className={'h-4'} />
|
||||
<OptionalProfileUserForm
|
||||
profile={profile}
|
||||
setProfile={setProfileState}
|
||||
user={baseUser}
|
||||
buttonLabel={t('profile.save', 'Save')}
|
||||
onSubmit={submitForm}
|
||||
/>
|
||||
</Col>
|
||||
{/* The index sits in the left gutter from `xl` up, where there is room for it beside a form
|
||||
capped at 3xl. Below that it is hidden rather than stacked: a list of eighteen links above
|
||||
the first field would be a second thing to read before starting. */}
|
||||
<div className="mx-auto flex w-full max-w-6xl gap-10 px-6 py-4">
|
||||
<ProfileFormNav className="sticky top-8 hidden h-fit w-52 shrink-0 xl:flex" />
|
||||
<Col className={'w-full min-w-0 max-w-3xl'}>
|
||||
{/* Same index, one row instead of a column, wherever the rail does not fit. */}
|
||||
<ProfileFormSectionBar className="xl:hidden" />
|
||||
<RequiredProfileUserForm
|
||||
data={baseUser}
|
||||
setData={setBaseUserState}
|
||||
profileCreatedAlready
|
||||
/>
|
||||
<div className={'h-4'} />
|
||||
<OptionalProfileUserForm
|
||||
profile={profile}
|
||||
setProfile={setProfileState}
|
||||
user={baseUser}
|
||||
buttonLabel={t('profile.save', 'Save')}
|
||||
onSubmit={submitForm}
|
||||
/>
|
||||
</Col>
|
||||
</div>
|
||||
</Col>
|
||||
</PageBase>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import {ensureDeviceToken} from 'web/components/auth-context'
|
||||
import {BackButton} from 'web/components/back-button'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
import {OptionalProfileUserForm} from 'web/components/optional-profile-form'
|
||||
import {ProfileFormNav, ProfileFormSectionBar} from 'web/components/profile-form-nav'
|
||||
import {initialRequiredState, RequiredProfileUserForm} from 'web/components/required-profile-form'
|
||||
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
|
||||
import {StepProgress} from 'web/components/widgets/step-progress'
|
||||
@@ -140,21 +141,26 @@ export default function SignupPage() {
|
||||
</div>
|
||||
</Col>
|
||||
) : (
|
||||
<Col className={'w-full max-w-4xl px-6 py-4'}>
|
||||
<BackButton className="-ml-2 mb-2 self-start" />
|
||||
{/* The flow had no progress indicator at all: two substantial form steps that looked alike,
|
||||
// Width is set per step rather than on the shared column. Step 1 is the eighteen-section form
|
||||
// plus its index, and inside a 4xl column the index left it about 550px wide — narrower than
|
||||
// the same form gets on /profile, with the page's whole right half empty.
|
||||
<Col className={'w-full px-6 py-4'}>
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
<BackButton className="-ml-2 mb-2 self-start" />
|
||||
{/* The flow had no progress indicator at all: two substantial form steps that looked alike,
|
||||
so the only way to learn there were two was to finish the first. The bar is deliberately
|
||||
the same component as /onboarding — those three screens lead directly here, and a reader
|
||||
who has just watched a 3-segment bar fill should recognise this as the next one. */}
|
||||
<StepProgress
|
||||
current={step + 1}
|
||||
total={TOTAL_STEPS}
|
||||
label={t('common.step_progress', 'Step {current} of {total}', {
|
||||
current: step + 1,
|
||||
total: TOTAL_STEPS,
|
||||
})}
|
||||
className="mb-7"
|
||||
/>
|
||||
<StepProgress
|
||||
current={step + 1}
|
||||
total={TOTAL_STEPS}
|
||||
label={t('common.step_progress', 'Step {current} of {total}', {
|
||||
current: step + 1,
|
||||
total: TOTAL_STEPS,
|
||||
})}
|
||||
className="mb-7"
|
||||
/>
|
||||
</div>
|
||||
{step === 0 ? (
|
||||
// Card + centered, matching the /onboarding screens that lead directly here — two short
|
||||
// fields floating left in a wide column read as unfinished, not deliberate.
|
||||
@@ -166,15 +172,22 @@ export default function SignupPage() {
|
||||
/>
|
||||
</div>
|
||||
) : step === 1 ? (
|
||||
<Col className={'w-full px-2 sm:px-6 py-4 mb-2 '}>
|
||||
<OptionalProfileUserForm
|
||||
profile={profileForm}
|
||||
setProfile={setProfileState}
|
||||
user={baseUser}
|
||||
bottomNavBarVisible={false}
|
||||
onSubmit={handleFinalSubmit}
|
||||
/>
|
||||
</Col>
|
||||
// Same index as /profile: this is the same eighteen-section form, and it is longer here
|
||||
// because nothing is filled in yet. Hidden below `xl`, where there is no gutter to put it
|
||||
// in without pushing a list of links above the first field.
|
||||
<div className={'mx-auto mb-2 flex w-full max-w-6xl gap-10 py-4'}>
|
||||
<ProfileFormNav className="sticky top-8 hidden h-fit w-52 shrink-0 xl:flex" />
|
||||
<Col className={'w-full min-w-0 max-w-3xl'}>
|
||||
<ProfileFormSectionBar className="xl:hidden" />
|
||||
<OptionalProfileUserForm
|
||||
profile={profileForm}
|
||||
setProfile={setProfileState}
|
||||
user={baseUser}
|
||||
bottomNavBarVisible={false}
|
||||
onSubmit={handleFinalSubmit}
|
||||
/>
|
||||
</Col>
|
||||
</div>
|
||||
) : (
|
||||
<CompassLoadingIndicator />
|
||||
)}
|
||||
@@ -189,7 +202,9 @@ export default function SignupPage() {
|
||||
// they sat as far apart from each other as consecutive fields did. The vertical rhythm between
|
||||
// fields comes from the parent `Col`'s `gap-8`, not from here.
|
||||
export const colClassName = 'items-start gap-1.5 w-full'
|
||||
export const labelClassName = 'font-semibold text-md text-ink-900'
|
||||
// Quiet, not bold. A form label names the field; the input is the thing with content in it, and
|
||||
// `font-semibold text-ink-900` gave the two equal weight down a fifty-field page.
|
||||
export const labelClassName = 'text-ink-500 text-sm font-normal'
|
||||
|
||||
function getInitialBaseUser() {
|
||||
const emailName = auth.currentUser?.email?.replace(/@.*$/, '')
|
||||
|
||||
@@ -100,13 +100,19 @@ function HeroBand({
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-16 -top-20 h-64 w-64 rounded-full bg-primary-500/20 blur-3xl"
|
||||
/>
|
||||
<div className="relative flex flex-wrap gap-x-10 gap-y-7 sm:gap-x-14">
|
||||
{/* Mobile is a 2-column grid rather than a wrapping flex row: with flex-wrap the tiles are sized by
|
||||
their digits, so the labels never line up and the last stat is left dangling at a different
|
||||
x-offset than the one above it. The grid keeps every label on a shared baseline and gives the
|
||||
headline number its own full-width row, which is where the emphasis belonged anyway. From `sm`
|
||||
up there is room for the single-row band, so it reverts to the flex layout. */}
|
||||
<div className="relative grid grid-cols-2 gap-x-8 gap-y-7 sm:flex sm:flex-wrap sm:gap-x-14">
|
||||
{items.map((item, i) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={clsx(
|
||||
i === 0 && 'sm:border-r sm:border-white/10 sm:pr-14',
|
||||
i > 0 && 'min-w-[92px]',
|
||||
i === 0 &&
|
||||
'col-span-2 border-b border-white/10 pb-7 sm:border-b-0 sm:border-r sm:border-white/10 sm:pb-0 sm:pr-14',
|
||||
i > 0 && 'sm:min-w-[92px]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user