mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-01 09:30:58 -05:00
Add onboarding for profiles page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
16
web/components/guidance.tsx
Normal file
16
web/components/guidance.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
12
web/hooks/use-is-cleared-filters.ts
Normal file
12
web/hooks/use-is-cleared-filters.ts
Normal 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]
|
||||
)
|
||||
}
|
||||
@@ -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": ".",
|
||||
|
||||
@@ -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": ".",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user