Add onboarding for profiles page

This commit is contained in:
MartinBraquet
2026-01-31 14:43:22 +01:00
parent 3e8470a216
commit 0b2b60bd49
9 changed files with 222 additions and 29 deletions

View File

@@ -43,6 +43,7 @@ import {LuCigarette, LuGraduationCap} from "react-icons/lu";
import {RiScales3Line} from "react-icons/ri";
import {PiHandsPrayingBold} from "react-icons/pi";
import {ResetFiltersButton} from "web/components/searches/button";
import {FilterGuide} from "web/components/guidance";
function MobileFilters(props: {
filters: Partial<FilterFields>
@@ -81,13 +82,9 @@ function MobileFilters(props: {
return (
<Col className="mb-[calc(20px+env(safe-area-inset-bottom))] mt-[calc(20px+env(safe-area-inset-top))]">
{/*<div*/}
{/* // className="fixed inset-x-0 bg-canvas-50"*/}
{/* style={{*/}
{/* // bottom: 0,*/}
{/* height: 'env(safe-area-inset-top)',*/}
{/* }}*/}
{/*/>*/}
<FilterGuide className={'justify-between px-4 py-2'}/>
<Row className="justify-between px-4">
<Col className="py-2">
<MyMatchesToggle

View File

@@ -1,5 +1,5 @@
import {Profile} from 'common/profiles/profile'
import {useEffect, useState} from 'react'
import {forwardRef, useEffect, useRef, useState} from 'react'
import {IoFilterSharp} from 'react-icons/io5'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
@@ -15,13 +15,14 @@ import {BookmarkedSearchesType} from "web/hooks/use-bookmarked-searches";
import {submitBookmarkedSearch} from "web/lib/supabase/searches";
import {useUser} from "web/hooks/use-user";
import toast from "react-hot-toast";
import {FilterFields, initialFilters} from "common/filters";
import {FilterFields} from "common/filters";
import {DisplayUser} from "common/api/user-types";
import {useChoices} from "web/hooks/use-choices";
import {useT} from "web/lib/locale";
import {isEqual} from "lodash";
import {Tooltip} from "web/components/widgets/tooltip";
import {QuestionMarkCircleIcon} from "@heroicons/react/outline";
import {useIsClearedFilters} from "web/hooks/use-is-cleared-filters";
import {FilterGuide} from "web/components/guidance";
function isOrderBy(input: string): input is FilterFields['orderBy'] {
return ['last_online_time', 'created_time', 'compatibility_score'].includes(
@@ -100,7 +101,8 @@ function getRandomPair(words = WORDS, count = 3): string {
const MAX_BOOKMARKED_SEARCHES = 10;
export const Search = (props: {
export const Search = forwardRef<HTMLInputElement, {
youProfile: Profile | undefined | null
starredUsers: DisplayUser[]
refreshStars: () => void
@@ -114,7 +116,12 @@ export const Search = (props: {
bookmarkedSearches: BookmarkedSearchesType[]
refreshBookmarkedSearches: () => void
profileCount: number | undefined
}) => {
openFilters?: () => void
openFiltersModal?: boolean
highlightFilters?: boolean
highlightSort?: boolean
setOpenFiltersModal?: (open: boolean) => void
}>((props, ref) => {
const {
youProfile,
updateFilter,
@@ -128,9 +135,43 @@ export const Search = (props: {
starredUsers,
refreshStars,
profileCount,
openFilters,
openFiltersModal: parentOpenFiltersModal,
setOpenFiltersModal: parentSetOpenFiltersModal,
highlightFilters,
highlightSort,
} = props
const [openFiltersModal, setOpenFiltersModal] = useState(false)
const [internalOpenFiltersModal, setInternalOpenFiltersModal] = useState(false)
const openFiltersModal = parentOpenFiltersModal ?? internalOpenFiltersModal
const setOpenFiltersModal = parentSetOpenFiltersModal ?? setInternalOpenFiltersModal
const sortSelectRef = useRef<HTMLSelectElement>(null)
const handleOpenFilters = () => {
if (openFilters) {
openFilters()
} else {
setOpenFiltersModal(true)
}
}
useEffect(() => {
if (highlightSort && sortSelectRef.current) {
setTimeout(() => {
if (sortSelectRef.current) {
sortSelectRef.current.focus()
// Try multiple approaches to open the dropdown
sortSelectRef.current.click()
const event = new MouseEvent('mousedown', {bubbles: true})
sortSelectRef.current.dispatchEvent(event)
const event2 = new MouseEvent('click', {bubbles: true})
sortSelectRef.current.dispatchEvent(event2)
}
}, 1000)
}
}, [highlightSort])
const [placeholder, setPlaceholder] = useState('');
const [textToType, setTextToType] = useState(getRandomPair());
@@ -143,10 +184,7 @@ export const Search = (props: {
const [openStarBookmarks, setOpenStarBookmarks] = useState(false);
const user = useUser()
const youSeekingRelationship = youProfile?.pref_relation_styles?.includes('relationship')
const isClearedFilters = isEqual(
{...filters, orderBy: undefined},
{...initialFilters, orderBy: undefined}
)
const isClearedFilters = useIsClearedFilters(filters)
const {choices: interestChoices} = useChoices('interests')
const {choices: causeChoices} = useChoices('causes')
const {choices: workChoices} = useChoices('work')
@@ -189,6 +227,7 @@ export const Search = (props: {
<Col className={'text-ink-600 w-full gap-2 py-2 text-sm main-font'}>
<Row className={'mb-2 justify-between gap-2'}>
<Input
ref={ref}
value={filters.name ?? ''}
placeholder={placeholder}
className={'w-full max-w-xs'}
@@ -199,6 +238,7 @@ export const Search = (props: {
<Row className="gap-2">
<Select
ref={sortSelectRef}
onChange={(e) => {
if (isOrderBy(e.target.value)) {
updateFilter({
@@ -207,7 +247,7 @@ export const Search = (props: {
}
}}
value={filters.orderBy || 'created_time'}
className={'w-18 border-ink-300 rounded-md'}
className={`w-18 border-ink-300 rounded-md${highlightSort ? ' border-blue-500 ring-2 ring-blue-300' : ''}`}
>
<option value="created_time">{t('common.new', 'New')}</option>
{youProfile && (
@@ -216,15 +256,16 @@ export const Search = (props: {
<option value="last_online_time">{t('common.active', 'Active')}</option>
</Select>
<Button
color="none"
color={highlightFilters ? "blue" : "none"}
size="sm"
className="border-ink-300 border sm:hidden "
onClick={() => setOpenFiltersModal(true)}
className={`border-ink-300 border sm:hidden${highlightFilters ? " border-blue-500" : ""}`}
onClick={handleOpenFilters}
>
<IoFilterSharp className="h-5 w-5"/>
</Button>
</Row>
</Row>
<FilterGuide className={'hidden sm:inline'}/>
<Row
className={
'border-ink-300 dark:border-ink-300 hidden flex-wrap items-center gap-4 pb-4 pt-1 sm:inline-flex'
@@ -315,11 +356,11 @@ export const Search = (props: {
<p>{profileCount} {(profileCount ?? 0) > 1 ? t('common.people', 'people') : t('common.person', 'person')}</p>
{!filters.shortBio && <Tooltip
text={t('search.include_short_bios_tooltip', 'To list all the profiles, tick "Include incomplete profiles"')}>
<QuestionMarkCircleIcon className="w-5 h-5"/>
<QuestionMarkCircleIcon className="w-5 h-5"/>
</Tooltip>}
</Row>
)}
</Row>
</Col>
)
}
})

View File

@@ -0,0 +1,16 @@
import {useRouter} from "next/router";
import clsx from "clsx";
import {useT} from "web/lib/locale";
export function FilterGuide(props: {
className?: string
}) {
const {className} = props
const router = useRouter()
const {query} = router
const fromSignup = query.fromSignup === 'true'
const t = useT()
return fromSignup && <p className={clsx("guidance", className)}>
{t('profiles.filter_guide', 'Filter below by intent, age, location, and more')}
</p>;
}

View File

@@ -15,6 +15,13 @@ import {api} from 'web/lib/api'
import {useBookmarkedSearches} from "web/hooks/use-bookmarked-searches"
import {useFilters} from "web/components/filters/use-filters"
import {useLocale, useT} from "web/lib/locale";
import {useIsClearedFilters} from "web/hooks/use-is-cleared-filters";
import {Button} from "web/components/buttons/button";
import {Col} from "web/components/layout/col";
import {Row} from "web/components/layout/row";
import {useRouter} from "next/router";
import {useIsMobile} from "web/hooks/use-is-mobile";
import toast from "react-hot-toast";
export function ProfilesHome() {
const user = useUser()
@@ -34,8 +41,18 @@ export function ProfilesHome() {
const {bookmarkedSearches, refreshBookmarkedSearches} = useBookmarkedSearches(user?.id)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [isReloading, setIsReloading] = useState(false)
const [showBanner, setShowBanner] = useState(true)
const [openFiltersModal, setOpenFiltersModal] = useState(false)
const [highlightFilters, setHighlightFilters] = useState(false)
const [highlightSort, setHighlightSort] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const t = useT()
const {locale} = useLocale()
const isClearedFilters = useIsClearedFilters(filters)
const router = useRouter()
const {query} = router
const fromSignup = query.fromSignup === 'true'
const isMobile = useIsMobile()
// const [debouncedAgeRange, setRawAgeRange] = useState({
// min: filters.pref_age_min ?? PREF_AGE_MIN,
@@ -80,13 +97,29 @@ export function ProfilesHome() {
// const displayProfiles = profiles && orderProfiles(profiles, starredUserIds)
const displayProfiles = profiles
const limit = 20
const loadMore = useCallback(async () => {
if (!profiles || isLoadingMore) return false
if (fromSignup && isClearedFilters && profiles.length <= limit) {
toast(t('profiles.search_tip', 'Tip: Searching first helps you find better matches. Scrolling endlessly reduces relevance.'), {
icon: '⚠️',
style: {
// background: 'rgba(128,128,128,0.15)', // light gray bg, adjust for dark mode
// color: 'rgba(0,0,0,0.75)', // dark text for light mode
padding: '8px 8px',
borderRadius: '8px',
// fontFamily: 'Inter, sans-serif',
fontSize: '14px',
},
duration: 10000,
});
}
try {
setIsLoadingMore(true)
const lastProfile = profiles[profiles.length - 1]
const result = await api('get-profiles', removeNullOrUndefinedProps({
limit: 20,
limit,
compatibleWithUserId: user?.id,
after: lastProfile?.id.toString(),
locale,
@@ -105,9 +138,74 @@ export function ProfilesHome() {
return (
<>
{showBanner && fromSignup && (
<div className="lg:col-span-12 w-full bg-canvas-100 rounded text-center py-3 px-3 relative">
<Col className="items-center justify-center gap-2">
<span
className={'mb-2'}>{t('profiles.search_intention', 'Compass works best when you search with intention. Try using keywords or filters instead of scrolling.')}</span>
<Row className="gap-2 mb-2">
<Button
size="sm"
color="gray-white"
className={'border'}
onClick={() => {
searchInputRef.current?.focus()
}}
>
{t('profiles.try_keyword_search', 'Try a keyword search')}
</Button>
{isMobile && <Button
size="sm"
color={"gray-white"}
className={'border'}
onClick={() => {
if (!isMobile) return
setHighlightFilters(true)
setTimeout(() => {
setHighlightFilters(false)
setOpenFiltersModal(true)
}, 700)
}}
>
{t('profiles.show_filters', 'Show me the filters')}
</Button>}
<Button
size="sm"
color={"gray-white"}
className={'border'}
onClick={() => {
setHighlightSort(true)
setTimeout(() => {
setHighlightSort(false)
}, 1000)
}}
>
{t('profiles.sort_differently', 'Sort differently')}
</Button>
</Row>
<Row className="gap-2 mb-6 sm:mb-2">
<p>{t('profiles.interactive_profiles', 'Profiles are interactive — click any card to learn more and reach out.')}</p>
</Row>
</Col>
<Button
size="2xs"
color="gray-white"
onClick={() => setShowBanner(false)}
className="absolute bottom-1 right-1"
>
{t('profiles.dismiss', 'Dismiss')}
</Button>
</div>
)}
{/*{user && !profile && <Button className="mb-4 lg:hidden" onClick={() => Router.push('signup')}>Create a profile</Button>}*/}
<Title className="!mb-2 text-3xl">{t("profiles.title", "People")}</Title>
<Search
ref={searchInputRef}
openFilters={() => setOpenFiltersModal(true)}
openFiltersModal={openFiltersModal}
setOpenFiltersModal={setOpenFiltersModal}
highlightFilters={highlightFilters}
highlightSort={highlightSort}
youProfile={you}
starredUsers={starredUsers ?? []}
refreshStars={refreshStars}
@@ -123,7 +221,12 @@ export function ProfilesHome() {
/>
{displayProfiles === undefined || compatibleProfiles === undefined ? (
<CompassLoadingIndicator/>
) : (
) : (<>
{isClearedFilters &&
<p className={'guidance'}>
{t('profiles.seeing_all_profiles', 'You are seeing all profiles. Use search or filters to narrow it down.')}
</p>
}
<ProfileGrid
profiles={displayProfiles}
loadMore={loadMore}
@@ -133,7 +236,7 @@ export function ProfilesHome() {
starredUserIds={starredUserIds}
refreshStars={refreshStars}
/>
)}
</>)}
</>
)
}

View File

@@ -1,10 +1,12 @@
import clsx from 'clsx'
import {forwardRef} from 'react'
export const Select = (props: JSX.IntrinsicElements['select']) => {
export const Select = forwardRef<HTMLSelectElement, JSX.IntrinsicElements['select']>((props, ref) => {
const { className, children, ...rest } = props
return (
<select
ref={ref}
className={clsx(
'bg-canvas-0 text-ink-1000 border-ink-300 focus:border-primary-500 focus:ring-primary-500 h-12 cursor-pointer self-start overflow-hidden rounded-md border pl-4 pr-10 text-sm shadow-sm focus:outline-none',
className
@@ -14,4 +16,4 @@ export const Select = (props: JSX.IntrinsicElements['select']) => {
{children}
</select>
)
}
})

View File

@@ -0,0 +1,12 @@
import {FilterFields, initialFilters} from "common/filters";
import {isEqual} from "lodash";
import {useMemo} from "react";
export function useIsClearedFilters(filters: Partial<FilterFields>): boolean {
return useMemo(() =>
isEqual(
{...filters, orderBy: undefined},
{...initialFilters, orderBy: undefined}
), [filters]
)
}

View File

@@ -699,6 +699,15 @@
"profile_grid.no_profiles": "Keine Profile gefunden.",
"profile_grid.notification_cta": "Klicken Sie gern auf „Benachrichtigen“, und wir informieren Sie, sobald neue Nutzer Ihrer Suche entsprechen.",
"profiles.title": "Personen",
"profiles.search_intention": "Compass funktioniert am besten, wenn Sie mit Absicht suchen. Versuchen Sie, Schlüsselwörter oder Filter anstelle des Scrollens zu verwenden.",
"profiles.try_keyword_search": "Schlüsselwortsuche versuchen",
"profiles.show_filters": "Filter anzeigen",
"profiles.sort_differently": "Anders sortieren",
"profiles.interactive_profiles": "Profile sind interaktiv — klicken Sie auf jede Karte, um mehr zu erfahren und Kontakt aufzunehmen.",
"profiles.dismiss": "Verwerfen",
"profiles.seeing_all_profiles": "Sie sehen alle Profile. Verwenden Sie Suche oder Filter, um einzugrenzen.",
"profiles.filter_guide": "Filtern Sie unten nach Absicht, Alter, Standort und mehr",
"profiles.search_tip": "Tipp: Suchen Sie zuerst, um bessere Übereinstimmungen zu finden. Endloses Scrollen reduziert die Relevanz.",
"register.agreement.and": " und ",
"register.agreement.prefix": "Mit der Registrierung akzeptiere ich die ",
"register.agreement.suffix": ".",

View File

@@ -699,6 +699,15 @@
"profile_grid.no_profiles": "Aucun profil trouvé.",
"profile_grid.notification_cta": "N'hésitez pas à cliquer sur \"Recevoir notifs\" et nous vous préviendrons quand de nouveaux utilisateurs correspondront à votre recherche !",
"profiles.title": "Personnes",
"profiles.search_intention": "Compass fonctionne mieux lorsque vous cherchez avec intention. Essayez d'utiliser des mots-clés ou des filtres au lieu de faire défiler.",
"profiles.try_keyword_search": "Essayer une recherche par mot-clé",
"profiles.show_filters": "Voir les filtres",
"profiles.sort_differently": "Trier différemment",
"profiles.interactive_profiles": "Les profils sont interactifs — cliquez sur n'importe quelle carte pour en savoir plus et entrer en contact.",
"profiles.dismiss": "Fermer",
"profiles.seeing_all_profiles": "Vous voyez tous les profils. Utilisez la recherche ou les filtres pour affiner.",
"profiles.filter_guide": "Filtrez ci-dessous par intention, âge, lieu et plus encore",
"profiles.search_tip": "Astuce : Cherchez d'abord par filtres et mot-clés pour trouver de meilleures correspondances. Le défilement sans fin réduit la pertinence.",
"register.agreement.and": " et ",
"register.agreement.prefix": "En vous inscrivant, j'accepte les ",
"register.agreement.suffix": ".",

View File

@@ -491,7 +491,7 @@ ol > li {
ol > li::marker {
font-weight: 600;
font-size: 1em;
color: #374151; /* pick a visible color */
color: #374151; /* pick a visible color */
}
/* Onboarding animations */
@@ -529,3 +529,7 @@ ol > li::marker {
animation: fade-out-slow 3s ease-out forwards;
}
.guidance {
opacity: 0.55;
font-size: 14px;
}