diff --git a/common/messages/de.json b/common/messages/de.json index 6da19aec..e692867a 100644 --- a/common/messages/de.json +++ b/common/messages/de.json @@ -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", diff --git a/common/messages/fr.json b/common/messages/fr.json index 9e9f7f94..8e94f71b 100644 --- a/common/messages/fr.json +++ b/common/messages/fr.json @@ -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", diff --git a/web/components/filters/search.tsx b/web/components/filters/search.tsx index 73f432e7..f49ae788 100644 --- a/web/components/filters/search.tsx +++ b/web/components/filters/search.tsx @@ -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 ( - + ) => { setKeywordInput(e.target.value) }} searchIcon /> - + + setOpenBookmarks(true)} + iconOnly + size="sm" + color="gray-white" + className="!h-10 !w-10 !rounded-full border border-canvas-200 !p-0" + /> + + {t('search.filters', 'Filters')} + + {filtersElement} - - void displayOptions?: Partial + filters: Partial + 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 && ( -
-

{t('profile_grid.no_profiles', 'No profiles found.')}

-

+ +

+ +
+

+ {t('profile_grid.no_profiles', 'No profiles found')} +

+

{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.", )}

-
+ + ) )} @@ -401,47 +428,54 @@ function ProfilePreview(props: { className={clsx('pt-1 text-xs', cardSize !== 'large' && 'hidden sm:flex')} /> )} - {onHide && ( - { - hideButtonClickedRef.current = true - }} - /> - )} - {hasStar !== undefined && ( - { - hideButtonClickedRef.current = true - }} - /> - )} - {user && ( -
- + {onHide && ( + { hideButtonClickedRef.current = true }} /> -
- )} + )} + {hasStar !== undefined && ( + { + hideButtonClickedRef.current = true + }} + /> + )} + {user && ( +
+ { + hideButtonClickedRef.current = true + }} + /> +
+ )} +
diff --git a/web/components/profiles/profiles-home.tsx b/web/components/profiles/profiles-home.tsx index 8569df17..f1e3d5e4 100644 --- a/web/components/profiles/profiles-home.tsx +++ b/web/components/profiles/profiles-home.tsx @@ -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([]) const {refreshHiddenProfiles} = useHiddenProfiles() @@ -224,9 +236,11 @@ export function ProfilesHome() { /> ) + const showDockedFilters = isDesktop && openFiltersModal + return ( -
- +
+ {showSignupBanner && user && (
@@ -321,9 +335,10 @@ export function ProfilesHome() { {t('profiles.title', 'People')} 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} /> )} - {/* Desktop: filters sidebar on the right */} -
- {filtersElement} -
+ {showDockedFilters && ( +
+ + {t('search.filters', 'Filters')} + + + {filtersElement} +
+ )}
) } diff --git a/web/components/searches/get-notified-button.tsx b/web/components/searches/get-notified-button.tsx new file mode 100644 index 00000000..a4ac9e8e --- /dev/null +++ b/web/components/searches/get-notified-button.tsx @@ -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 + 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 = ( + + ) + + return iconOnly ? {button} : button +}