Add "Get Notified" button for saved searches and improve filters UI behavior:

- Introduced `GetNotifiedButton` for bookmarking searches and receiving notifications when new profiles match.
- Optimized responsive behavior of filters for desktop and mobile layouts.
- Enhanced "no profiles found" state with a clear call-to-action and improved visual design.
- Updated translations for notification strings in German and French.
This commit is contained in:
MartinBraquet
2026-07-26 14:25:46 +02:00
parent deeb05f2e0
commit fdc57f80af
6 changed files with 255 additions and 108 deletions

View File

@@ -174,7 +174,7 @@
"common.new": "Neu",
"common.next": "Weiter",
"common.no": "Nein",
"common.notified": "Benachrichtigt werden",
"common.notified": "Benachrichtigen, wenn diese Suche jemanden findet",
"common.notified_any": "Über neue Profile benachrichtigt werden",
"common.or": "Oder",
"common.people": "Personen",

View File

@@ -174,7 +174,7 @@
"common.new": "Nouveau",
"common.next": "Suivant",
"common.no": "Non",
"common.notified": "Recevoir notifs pour les filtres sélectionnés",
"common.notified": "Être notifié quand cette recherche trouve quelqu'un",
"common.notified_any": "Recevoir notifs pour tout nouveau profil",
"common.or": "Ou",
"common.people": "personnes",

View File

@@ -1,25 +1,23 @@
import {QuestionMarkCircleIcon} from '@heroicons/react/24/outline'
import {XMarkIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {DisplayUser} from 'common/api/user-types'
import {FilterFields} from 'common/filters'
import {Profile} from 'common/profiles/profile'
import {debounce as debounceFn} from 'lodash'
import {Bell} from 'lucide-react'
import {forwardRef, ReactElement, useEffect, useRef, useState} from 'react'
import toast from 'react-hot-toast'
import {IoFilterSharp} from 'react-icons/io5'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {RightModal} from 'web/components/layout/right-modal'
import {Row} from 'web/components/layout/row'
import {BookmarkSearchButton, BookmarkStarButton} from 'web/components/searches/button'
import {GetNotifiedButton} from 'web/components/searches/get-notified-button'
import {Input} from 'web/components/widgets/input'
import {Select} from 'web/components/widgets/select'
import {Tooltip} from 'web/components/widgets/tooltip'
import {BookmarkedSearchesType} from 'web/hooks/use-bookmarked-searches'
import {useIsClearedFilters} from 'web/hooks/use-is-cleared-filters'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {submitBookmarkedSearch} from 'web/lib/supabase/searches'
import {LocationFilterProps} from './location-filter'
@@ -27,8 +25,6 @@ function isOrderBy(input: string): input is FilterFields['orderBy'] {
return ['last_online_time', 'created_time', 'compatibility_score'].includes(input)
}
const MAX_BOOKMARKED_SEARCHES = 10
export const Search = forwardRef<
HTMLInputElement,
{
@@ -47,6 +43,9 @@ export const Search = forwardRef<
highlightFilters?: boolean
highlightSort?: boolean
setOpenFiltersModal?: (open: boolean) => void
// True when the parent renders filtersElement as a docked column instead (desktop),
// so this component's own slide-over shouldn't also open.
suppressFiltersModal?: boolean
filtersElement: ReactElement
}
>((props, ref) => {
@@ -63,6 +62,7 @@ export const Search = forwardRef<
openFilters,
openFiltersModal: parentOpenFiltersModal,
setOpenFiltersModal: parentSetOpenFiltersModal,
suppressFiltersModal,
highlightFilters,
highlightSort,
filtersElement,
@@ -106,12 +106,8 @@ export const Search = forwardRef<
// const [textToType, setTextToType] = useState(getRandomPair())
// const [_, setCharIndex] = useState(0)
// const [isHolding, setIsHolding] = useState(false)
const [bookmarked, setBookmarked] = useState(false)
const [loadingBookmark, setLoadingBookmark] = useState(false)
const [openBookmarks, setOpenBookmarks] = useState(false)
const [openStarBookmarks, setOpenStarBookmarks] = useState(false)
const user = useUser()
const isClearedFilters = useIsClearedFilters(filters)
// const choices = useChoicesContext()
const [keywordInput, setKeywordInput] = useState(filters.name ?? '')
@@ -157,25 +153,21 @@ export const Search = forwardRef<
// return () => clearInterval(interval)
// }, [textToType, isHolding])
useEffect(() => {
setTimeout(() => setBookmarked(false), 2000)
}, [bookmarked])
return (
<Col className={'text-ink-600 w-full gap-2 py-2 text-sm main-font'}>
<Row className={'mb-2 justify-between gap-2'}>
<Row className="mb-2 items-center justify-center gap-1.5 flex-wrap">
<Input
ref={ref}
value={keywordInput}
placeholder={placeholder}
className={'w-full sm:w-96'}
className="w-full max-w-md !h-14 !rounded-full !px-5 text-base shadow-md"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setKeywordInput(e.target.value)
}}
searchIcon
/>
<Row className="gap-2">
<Row className="gap-1.5 shrink-0 items-center">
<Select
ref={sortSelectRef}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
@@ -186,7 +178,10 @@ export const Search = forwardRef<
}
}}
value={filters.orderBy || 'created_time'}
className={`w-18 border-ink-300 rounded-md${highlightSort ? ' border-blue-500 ring-2 ring-blue-300' : ''}`}
className={clsx(
'!h-10 w-auto !rounded-full !border-canvas-200 !bg-transparent !shadow-none text-xs text-ink-500',
highlightSort && 'border-blue-500 ring-2 ring-blue-300',
)}
>
<option value="created_time">{t('common.new', 'New')}</option>
{youProfile && (
@@ -195,57 +190,50 @@ export const Search = forwardRef<
<option value="last_online_time">{t('common.active', 'Active')}</option>
</Select>
<Button
color={highlightFilters ? 'blue' : 'none'}
color={highlightFilters ? 'blue' : 'gray-white'}
size="sm"
className={`border-ink-300 border lg:hidden${highlightFilters ? ' border-blue-500' : ''}`}
className={clsx(
'!h-10 !rounded-full border border-canvas-200',
highlightFilters && 'border-blue-500',
)}
onClick={handleOpenFilters}
>
<IoFilterSharp className="h-5 w-5" />
<IoFilterSharp className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">{t('search.filters', 'Filters')}</span>
</Button>
<GetNotifiedButton
filters={filters}
locationFilterProps={locationFilterProps}
bookmarkedSearches={bookmarkedSearches}
refreshBookmarkedSearches={refreshBookmarkedSearches}
onSaved={() => setOpenBookmarks(true)}
iconOnly
size="sm"
color="gray-white"
className="!h-10 !w-10 !rounded-full border border-canvas-200 !p-0"
/>
</Row>
</Row>
<RightModal
className="bg-canvas-50 w-2/3 text-sm lg:hidden h-full max-h-screen overflow-y-auto"
open={openFiltersModal}
className="bg-canvas-50 w-full sm:w-96 text-sm h-full max-h-screen overflow-y-auto"
open={openFiltersModal && !suppressFiltersModal}
setOpen={setOpenFiltersModal}
>
<Row className="items-center justify-between px-3 pt-3">
<span className="font-medium text-ink-900">{t('search.filters', 'Filters')}</span>
<Button
size="2xs"
color="gray-white"
onClick={() => setOpenFiltersModal(false)}
aria-label={t('common.close', 'Close')}
>
<XMarkIcon className="h-5 w-5" />
</Button>
</Row>
{filtersElement}
</RightModal>
<Row className="items-center justify-between w-full flex-wrap gap-2">
<Row className={'mb-2 gap-2'}>
<Button
disabled={loadingBookmark}
loading={loadingBookmark}
onClick={() => {
if (bookmarkedSearches.length >= MAX_BOOKMARKED_SEARCHES) {
toast.error(
`You can bookmark maximum ${MAX_BOOKMARKED_SEARCHES} searches; please delete one first.`,
)
setOpenBookmarks(true)
return
}
setLoadingBookmark(true)
submitBookmarkedSearch(filters, locationFilterProps, user?.id).finally(() => {
setLoadingBookmark(false)
setBookmarked(true)
refreshBookmarkedSearches()
setOpenBookmarks(true)
})
}}
// size={'xs'}
color={'none'}
className={'text-white bg-cta hover:bg-cta-hover rounded-xl text-xs transition-colors'}
>
<Bell className="h-4 w-4 mr-1 hidden sm:flex" />{' '}
{bookmarked
? t('common.saved', 'Saved!')
: loadingBookmark
? ''
: isClearedFilters
? t('common.notified_any', 'Get notified for any new profile')
: t('common.notified', 'Get notified for selected filters')}
</Button>
<BookmarkSearchButton
refreshBookmarkedSearches={refreshBookmarkedSearches}
bookmarkedSearches={bookmarkedSearches}

View File

@@ -1,6 +1,7 @@
import {JSONContent} from '@tiptap/core'
import clsx from 'clsx'
import {INVERTED_DIET_CHOICES, INVERTED_LANGUAGE_CHOICES} from 'common/choices'
import {FilterFields} from 'common/filters'
import {Gender} from 'common/gender'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {Profile} from 'common/profiles/profile'
@@ -13,6 +14,7 @@ import {
HandHeart,
Languages,
Salad,
SearchX,
Sparkles,
Wine,
} from 'lucide-react'
@@ -20,17 +22,20 @@ import Image from 'next/image'
import Link from 'next/link'
import React, {useEffect, 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'
import {IconWithInfo} from 'web/components/icons'
import {Row} from 'web/components/layout/row'
import {SendMessageButton} from 'web/components/messaging/send-message-button'
import {ProfileLocation} from 'web/components/profile/profile-location'
import {getSeekingText} from 'web/components/profile-about'
import {GetNotifiedButton} from 'web/components/searches/get-notified-button'
import {CompatibleBadge} from 'web/components/widgets/compatible-badge'
import {Content} from 'web/components/widgets/editor'
import HideProfileButton from 'web/components/widgets/hide-profile-button'
import {StarButton} from 'web/components/widgets/star-button'
import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer'
import {BookmarkedSearchesType} from 'web/hooks/use-bookmarked-searches'
import {useChoicesContext} from 'web/hooks/use-choices'
import {isDark, useTheme} from 'web/hooks/use-theme'
import {useUser} from 'web/hooks/use-user'
@@ -91,6 +96,10 @@ export const ProfileGrid = (props: {
hiddenUserIds?: string[]
onUndoHidden?: (userId: string) => void
displayOptions?: Partial<DisplayOptions>
filters: Partial<FilterFields>
locationFilterProps: LocationFilterProps
bookmarkedSearches: BookmarkedSearchesType[]
refreshBookmarkedSearches: () => void
}) => {
const {
profiles,
@@ -104,6 +113,10 @@ export const ProfileGrid = (props: {
hiddenUserIds,
onUndoHidden,
displayOptions,
filters,
locationFilterProps,
bookmarkedSearches,
refreshBookmarkedSearches,
} = props
const {cardSize} = displayOptions ?? {}
@@ -155,15 +168,29 @@ export const ProfileGrid = (props: {
!isLoadingMore &&
!isReloading &&
other_profiles.length === 0 && (
<div className="py-8 text-center">
<p>{t('profile_grid.no_profiles', 'No profiles found.')}</p>
<p>
<Col className="items-center gap-3 py-16 px-4 text-center">
<div className="rounded-full bg-canvas-200 p-4">
<SearchX className="h-7 w-7 text-ink-400" />
</div>
<p className="text-lg font-medium text-ink-900">
{t('profile_grid.no_profiles', 'No profiles found')}
</p>
<p className="max-w-sm text-ink-500">
{t(
'profile_grid.notification_cta',
"Feel free to click on Get Notified and we'll notify you when new users match your search!",
"We'll notify you as soon as someone new matches your search.",
)}
</p>
</div>
<GetNotifiedButton
filters={filters}
locationFilterProps={locationFilterProps}
bookmarkedSearches={bookmarkedSearches}
refreshBookmarkedSearches={refreshBookmarkedSearches}
color="cta"
size="lg"
className="mt-1"
/>
</Col>
)
)}
</div>
@@ -401,47 +428,54 @@ function ProfilePreview(props: {
className={clsx('pt-1 text-xs', cardSize !== 'large' && 'hidden sm:flex')}
/>
)}
{onHide && (
<HideProfileButton
hiddenUserId={profile.user_id}
onHidden={onHide}
className="ml-1"
stopPropagation
eyeOff
onPointerDown={() => {
hideButtonClickedRef.current = true
}}
/>
)}
{hasStar !== undefined && (
<StarButton
targetProfile={profile}
isStarred={hasStar}
refresh={refreshStars}
size={'h-4 w-4'}
className="h-7 w-7 !rounded-lg !p-1 hover:border-primary-400 hover:bg-primary-50"
onPointerDown={() => {
hideButtonClickedRef.current = true
}}
/>
)}
{user && (
<div
className={clsx(cardSize !== 'large' && 'hidden sm:flex')}
data-testid="message-profile-button"
>
<SendMessageButton
toUser={user}
profile={profile}
size={'h-4 w-4'}
className={clsx('!p-1 w-7 h-7')}
accentIfMessaged
<Row
className={clsx(
'items-start gap-1 transition-opacity',
'lg:opacity-0 lg:group-hover:opacity-100 lg:group-focus-within:opacity-100',
)}
>
{onHide && (
<HideProfileButton
hiddenUserId={profile.user_id}
onHidden={onHide}
className="ml-1"
stopPropagation
eyeOff
onPointerDown={() => {
hideButtonClickedRef.current = true
}}
/>
</div>
)}
)}
{hasStar !== undefined && (
<StarButton
targetProfile={profile}
isStarred={hasStar}
refresh={refreshStars}
size={'h-4 w-4'}
className="h-7 w-7 !rounded-lg !p-1 hover:border-primary-400 hover:bg-primary-50"
onPointerDown={() => {
hideButtonClickedRef.current = true
}}
/>
)}
{user && (
<div
className={clsx(cardSize !== 'large' && 'hidden sm:flex')}
data-testid="message-profile-button"
>
<SendMessageButton
toUser={user}
profile={profile}
size={'h-4 w-4'}
className={clsx('!p-1 w-7 h-7')}
accentIfMessaged
onPointerDown={() => {
hideButtonClickedRef.current = true
}}
/>
</div>
)}
</Row>
</Row>
<div className={clsx('flex lg:flex-row h-full lg:justify-between', cardClass)}>

View File

@@ -1,3 +1,5 @@
import {XMarkIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {debug} from 'common/logger'
import {Profile} from 'common/profiles/profile'
import {removeNullOrUndefinedProps} from 'common/util/object'
@@ -77,6 +79,16 @@ export function ProfilesHome() {
const {locale} = useLocale()
const isClearedFilters = useIsClearedFilters(filters)
const isMobile = useIsMobile()
// Tracked separately from `isMobile` (640px) since the docked filters column only replaces
// the slide-over once there's room beside the grid, at the `lg` (1024px) breakpoint.
const [isDesktop, setIsDesktop] = useState(false)
useEffect(() => {
const mql = window.matchMedia('(min-width: 1024px)')
const update = () => setIsDesktop(mql.matches)
update()
mql.addEventListener('change', update)
return () => mql.removeEventListener('change', update)
}, [])
const [sendScrollWarning, setSendScrollWarning] = useState(true)
const [recentlyHiddenIds, setRecentlyHiddenIds] = useState<string[]>([])
const {refreshHiddenProfiles} = useHiddenProfiles()
@@ -224,9 +236,11 @@ export function ProfilesHome() {
/>
)
const showDockedFilters = isDesktop && openFiltersModal
return (
<div className="lg:grid lg:grid-cols-12 lg:gap-4">
<Col className={'lg:col-span-9'}>
<div className={clsx(showDockedFilters && 'lg:grid lg:grid-cols-12 lg:gap-4')}>
<Col className={clsx(showDockedFilters && 'lg:col-span-9')}>
{showSignupBanner && user && (
<div className="w-full bg-canvas-50 rounded-xl text-center py-3 px-3 relative">
<Col className="items-center justify-center gap-2">
@@ -321,9 +335,10 @@ export function ProfilesHome() {
<Title className="!mb-2 text-3xl">{t('profiles.title', 'People')}</Title>
<Search
ref={searchInputRef}
openFilters={() => setOpenFiltersModal(true)}
openFilters={() => setOpenFiltersModal((open) => !open)}
openFiltersModal={openFiltersModal}
setOpenFiltersModal={setOpenFiltersModal}
suppressFiltersModal={isDesktop}
highlightFilters={highlightFilters}
highlightSort={highlightSort}
youProfile={you}
@@ -361,14 +376,30 @@ export function ProfilesHome() {
hiddenUserIds={recentlyHiddenIds}
onUndoHidden={onUndoHidden}
displayOptions={displayOptions}
filters={filters}
locationFilterProps={locationFilterProps}
bookmarkedSearches={bookmarkedSearches}
refreshBookmarkedSearches={refreshBookmarkedSearches}
/>
</>
)}
</Col>
{/* Desktop: filters sidebar on the right */}
<div className="hidden lg:block lg:col-span-3 lg:sticky lg:top-4 lg:h-fit text-sm bg-canvas-50 rounded-xl max-h-[calc(100vh-2rem)] overflow-y-auto">
{filtersElement}
</div>
{showDockedFilters && (
<div className="hidden lg:flex lg:flex-col lg:col-span-3 lg:sticky lg:top-4 lg:h-fit text-sm bg-canvas-50 rounded-xl max-h-[calc(100vh-2rem)] overflow-y-auto">
<Row className="items-center justify-between px-3 pt-3">
<span className="font-medium text-ink-900">{t('search.filters', 'Filters')}</span>
<Button
size="2xs"
color="gray-white"
onClick={() => setOpenFiltersModal(false)}
aria-label={t('common.close', 'Close')}
>
<XMarkIcon className="h-5 w-5" />
</Button>
</Row>
{filtersElement}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,94 @@
import clsx from 'clsx'
import {FilterFields} from 'common/filters'
import {Bell} from 'lucide-react'
import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
import {Button, ColorType, SizeType} from 'web/components/buttons/button'
import {LocationFilterProps} from 'web/components/filters/location-filter'
import {Tooltip} from 'web/components/widgets/tooltip'
import {BookmarkedSearchesType} from 'web/hooks/use-bookmarked-searches'
import {useIsClearedFilters} from 'web/hooks/use-is-cleared-filters'
import {useUser} from 'web/hooks/use-user'
import {useT} from 'web/lib/locale'
import {submitBookmarkedSearch} from 'web/lib/supabase/searches'
const MAX_BOOKMARKED_SEARCHES = 10
// Saves the current search as a bookmarked search so the user gets notified when new
// profiles match it. Shared between the top search bar (icon) and the empty results state
// (full CTA) so the save/loading/"Saved!" behavior stays identical in both places.
export function GetNotifiedButton(props: {
filters: Partial<FilterFields>
locationFilterProps: LocationFilterProps
bookmarkedSearches: BookmarkedSearchesType[]
refreshBookmarkedSearches: () => void
onSaved?: () => void
iconOnly?: boolean
size?: SizeType
color?: ColorType
className?: string
}) {
const {
filters,
locationFilterProps,
bookmarkedSearches,
refreshBookmarkedSearches,
onSaved,
iconOnly,
size = 'md',
color = 'none',
className,
} = props
const user = useUser()
const t = useT()
const isClearedFilters = useIsClearedFilters(filters)
const [bookmarked, setBookmarked] = useState(false)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!bookmarked) return
const timeout = setTimeout(() => setBookmarked(false), 2000)
return () => clearTimeout(timeout)
}, [bookmarked])
const label = bookmarked
? t('common.saved', 'Saved!')
: isClearedFilters
? t('common.notified_any', 'Get notified for any new profile')
: t('common.notified', 'Notify me when this search matches someone')
const handleClick = () => {
if (bookmarkedSearches.length >= MAX_BOOKMARKED_SEARCHES) {
toast.error(
`You can bookmark maximum ${MAX_BOOKMARKED_SEARCHES} searches; please delete one first.`,
)
onSaved?.()
return
}
setLoading(true)
submitBookmarkedSearch(filters, locationFilterProps, user?.id).finally(() => {
setLoading(false)
setBookmarked(true)
refreshBookmarkedSearches()
onSaved?.()
})
}
const button = (
<Button
disabled={loading}
loading={loading}
onClick={handleClick}
size={size}
color={color}
className={className}
aria-label={label}
>
<Bell className={clsx('h-4 w-4', !iconOnly && 'mr-1.5', bookmarked && 'fill-current')} />
{!iconOnly && (loading ? '' : label)}
</Button>
)
return iconOnly ? <Tooltip text={label}>{button}</Tooltip> : button
}