Show selected filters at the top and allow for removed them one by one

This commit is contained in:
MartinBraquet
2026-02-28 00:34:11 +01:00
parent 74fc6a744e
commit f6a65e875b
12 changed files with 151 additions and 105 deletions

View File

@@ -1,8 +1,8 @@
import {Body, Container, Head, Html, Link, Preview, Section, Text} from '@react-email/components'
import {DOMAIN} from 'common/envs/constants'
import {FilterFields} from 'common/filters'
import {formatFilters, locationType} from 'common/filters-format'
import {MatchesType} from 'common/profiles/bookmarked_searches'
import {formatFilters, locationType} from 'common/searches'
import {type User} from 'common/user'
import {container, content, Footer, main, paragraph} from 'email/utils'
import React from 'react'

View File

@@ -312,7 +312,7 @@
"filter.age.years": "ans",
"filter.any": "Tous",
"filter.any_causes": "Toute cause",
"filter.any_diet": "Tout régime",
"filter.any_diet": "Tout régime alimentaire",
"filter.any_drinks": "Toute boisson",
"filter.any_education": "Tous niveaux d'études",
"filter.any_interests": "Tout intérêt",
@@ -958,7 +958,7 @@
"filter.selected_plural": "filtres sélectionnés",
"filter.clear_all": "Tout effacer",
"filter.group.relationship": "Amour & Famille",
"filter.group.background": "Contexte",
"filter.group.background": "Milieu",
"filter.group.lifestyle": "Mode de vie",
"filter.group.values": "Valeurs & Croyances",
"filter.group.personality": "Personnalité",

View File

@@ -49,7 +49,7 @@ export type locationType = {
radius: number
}
const skippedKeys = [
export const SKIPPED_FORMAT_FILTERS_KEYS = [
'pref_age_min',
'pref_age_max',
'geodbCityIds',
@@ -59,6 +59,9 @@ const skippedKeys = [
'lat',
'lon',
'radius',
'raised_in_lat',
'raised_in_lon',
'raised_in_radius',
// Big Five min/max keys are handled separately
'big5_openness_min',
'big5_openness_max',
@@ -101,7 +104,7 @@ export function formatFilters(
if (ageMin) {
text = `${text}-${ageMax}`
} else {
text = `${text}${translate('filter.age.up_to', 'up to')} ${ageMax}`
text = `${text}${translate('filter.age.up_to', 'up to')} ${ageMax} ${translate('filter.age.years', 'years old')}`
}
} else {
text = `${text}+`
@@ -113,7 +116,7 @@ export function formatFilters(
const typedKey = key as keyof FilterFields
if (value === undefined || value === null) return
if (skippedKeys.includes(typedKey)) return
if (SKIPPED_FORMAT_FILTERS_KEYS.includes(typedKey)) return
if (Array.isArray(value) && value.length === 0) return
if (initialFilters[typedKey] === value) return
@@ -241,13 +244,13 @@ export function formatFilters(
if (drinksMin !== undefined && drinksMax !== undefined) {
// Both min and max: "12-78"
drinksText = `${drinksMin}-${drinksMax}`
drinksText = drinksMin === drinksMax ? String(drinksMax) : `${drinksMin}-${drinksMax}`
} else if (drinksMin !== undefined) {
// Only min: "12+"
drinksText = `${drinksMin}+`
} else {
// Only max: "up to 82"
drinksText = `${translate('filter.age.up_to', 'up to')} ${drinksMax}`
drinksText = drinksMax === 0 ? '0' : `${translate('filter.age.up_to', 'up to')} ${drinksMax}`
}
entries.push(`${drinksLabel}: ${drinksText} ${perMonth}`)

View File

@@ -3,12 +3,6 @@ import {Profile, ProfileRow} from 'common/profiles/profile'
import {filterDefined} from 'common/util/array'
import {cloneDeep} from 'lodash'
// export type TargetArea = {
// lat: number
// lon: number
// radius: number
// }
export type FilterFields = {
orderBy: 'last_online_time' | 'created_time' | 'compatibility_score'
last_active: string | undefined

View File

@@ -33,6 +33,24 @@ export function groupConsecutive<T, U>(xs: T[], key: (x: T) => U) {
}
export function nullifyEmpty<T>(array: T[]) {
// Nullify a list if empty ([])
if (!Array.isArray(array)) return null
return array.length > 0 ? array : null
}
export function nullifyDictValues(array: Record<any, any>) {
// Nullify all the values of the dict
return Object.entries(array).reduce((acc, [key, _]) => {
return {...acc, [key]: null}
}, {})
}
export function sampleDictByPrefix(array: Record<any, any>, prefix: string) {
// Extract the keys that start with the prefix
return Object.entries(array).reduce((acc, [key, value]) => {
if (key.startsWith(prefix)) {
return {...acc, [key]: value}
}
return acc
}, {})
}

View File

@@ -35,16 +35,16 @@ export const wantsKidsNames = Object.values(wantsKidsLabels).reduce<Record<numbe
export type wantsKidsDatabase = 0 | 1 | 2 | 3 | 4
export function wantsKidsToHasKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
if (wantsKidsStrength < wantsKidsLabels.wants_kids.strength) {
if (wantsKidsStrength >= 0 && wantsKidsStrength < wantsKidsLabels.wants_kids.strength) {
return hasKidsLabels.doesnt_have_kids.value
}
return hasKidsLabels.no_preference.value
return null // hasKidsLabels.no_preference.value
}
export function wantsKidsDatabaseToWantsKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
// console.debug(wantsKidsStrength)
if (wantsKidsStrength == wantsKidsLabels.no_preference.strength) {
return wantsKidsLabels.no_preference.strength
return null // wantsKidsLabels.no_preference.strength
}
if (wantsKidsStrength > wantsKidsLabels.wants_kids.strength) {
return wantsKidsLabels.wants_kids.strength

View File

@@ -2,9 +2,11 @@ import {ChevronDownIcon, ChevronUpIcon} from '@heroicons/react/outline'
import {XIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import {FilterFields} from 'common/filters'
import {formatFilters, SKIPPED_FORMAT_FILTERS_KEYS} from 'common/filters-format'
import {Gender} from 'common/gender'
import {OptionTableKey} from 'common/profiles/constants'
import {Profile} from 'common/profiles/profile'
import {nullifyDictValues, sampleDictByPrefix} from 'common/util/array'
import {removeNullOrUndefinedProps} from 'common/util/object'
import {ReactNode, useState} from 'react'
import {
@@ -32,7 +34,8 @@ import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {NewBadge} from 'web/components/new-badge'
import {ResetFiltersButton} from 'web/components/searches/button'
import {useChoices} from 'web/hooks/use-choices'
import {useAllChoices, useChoices} from 'web/hooks/use-choices'
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import {useT} from 'web/lib/locale'
import {DietType, RelationshipType, RomanticType} from 'web/lib/util/convert-types'
@@ -51,9 +54,8 @@ function countActiveFilters(
locationFilterProps: LocationFilterProps,
raisedInLocationFilterProps: LocationFilterProps,
) {
let parsedFilters = Object.keys(removeNullOrUndefinedProps({...filters, orderBy: undefined}))
parsedFilters = parsedFilters.filter((key) => !key.startsWith('big5_'))
let count = parsedFilters.length
const keys = Object.keys(filters).filter((key) => !key.startsWith('big5_'))
let count = keys.length
if (locationFilterProps.location) count = count - 2
if (raisedInLocationFilterProps.location) count = count - 2
if (filters.pref_age_min && filters.pref_age_max) count--
@@ -69,16 +71,40 @@ function SelectedFiltersSummary(props: {
updateFilter: (newState: Partial<FilterFields>) => void
clearFilters: () => void
}) {
const {filters, locationFilterProps, raisedInLocationFilterProps, updateFilter, clearFilters} =
props
const {locationFilterProps, raisedInLocationFilterProps, updateFilter, clearFilters} = props
const t = useT()
const choicesIdsToLabels = useAllChoices()
const {measurementSystem} = useMeasurementSystem()
const filters = removeNullOrUndefinedProps({...props.filters, orderBy: undefined})
const filterCount = countActiveFilters(filters, locationFilterProps, raisedInLocationFilterProps)
if (filterCount === 0) return null
const selectedFilters: {label: string; onClear: () => void}[] = []
function formatLabel(filters: any) {
return String(
formatFilters(
filters,
locationFilterProps.location as any,
choicesIdsToLabels,
measurementSystem,
t,
)?.join(' • ') || Object.values(filters)[0],
)
}
Object.entries(filters).forEach(([key, value]) => {
const typedKey = key as keyof FilterFields
if (value === undefined || value === null) return
if (SKIPPED_FORMAT_FILTERS_KEYS.includes(typedKey)) return
selectedFilters.push({
label: formatLabel({[key]: value}),
onClear: () => updateFilter({[key]: undefined}),
})
})
if (locationFilterProps.location) {
selectedFilters.push({
label: locationFilterProps.location.name || t('filter.location', 'Location'),
@@ -90,8 +116,11 @@ function SelectedFiltersSummary(props: {
}
if (raisedInLocationFilterProps.location) {
let label = t('filter.raised_in', 'Grew up')
if (raisedInLocationFilterProps.location.name)
label = `${label}: ${raisedInLocationFilterProps.location.name}`
selectedFilters.push({
label: t('filter.raised_in', 'Grew up'),
label: label,
onClear: () => {
raisedInLocationFilterProps.setLocation(null)
updateFilter({
@@ -103,30 +132,24 @@ function SelectedFiltersSummary(props: {
})
}
if (filters.pref_age_min || filters.pref_age_max) {
const ageLabel =
filters.pref_age_min && filters.pref_age_max
? `${filters.pref_age_min}-${filters.pref_age_max}`
: filters.pref_age_min
? `${filters.pref_age_min}+`
: `< ${filters.pref_age_max}`
selectedFilters.push({
label: `${t('filter.age.label', 'Age')}: ${ageLabel}`,
onClear: () => updateFilter({pref_age_min: undefined, pref_age_max: undefined}),
})
function formatAggregatedFields(prefix: string) {
const aggFilters = sampleDictByPrefix(filters, prefix)
if (Object.keys(aggFilters).length > 0) {
selectedFilters.push({
label: formatLabel(aggFilters),
onClear: () => updateFilter(nullifyDictValues(aggFilters)),
})
}
}
if (filters.genders?.length) {
selectedFilters.push({
label: filters.genders.join(', '),
onClear: () => updateFilter({genders: undefined}),
})
}
formatAggregatedFields('pref_age')
formatAggregatedFields('big5')
formatAggregatedFields('drink')
if (filters.pref_relation_styles?.length) {
if (filters.shortBio) {
selectedFilters.push({
label: filters.pref_relation_styles.join(', '),
onClear: () => updateFilter({pref_relation_styles: undefined}),
label: t('filter.short_bio_toggle', 'Include incomplete profiles'),
onClear: () => updateFilter({shortBio: undefined}),
})
}
@@ -147,7 +170,7 @@ function SelectedFiltersSummary(props: {
{selectedFilters.map((filter, idx) => (
<Row
key={idx}
className="items-center gap-1 text-primary-700 px-2 py-1 rounded-full text-sm"
className="items-center gap-1 text-primary-700 px-2 py-1 rounded-full text-sm bg-canvas-50"
>
<span>{filter.label}</span>
<button onClick={filter.onClear} className="hover:text-primary-900">
@@ -466,6 +489,52 @@ function Filters(props: {
setOpenGroup={setOpenGroup}
// icon={<GiFruitBowl className="h-4 w-4" />}
>
<FilterSection
title={t('profile.optional.interests', 'Interests')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.interests || undefined)}
selection={
<InterestFilterText
options={filters.interests as string[] | undefined}
label={'interests'}
highlightedClass={
hasAny(filters.interests || undefined) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.interests}
label="interests"
/>
</FilterSection>
<FilterSection
title={t('profile.optional.causes', 'Causes')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.causes || undefined)}
selection={
<InterestFilterText
options={filters.causes as string[] | undefined}
label={'causes'}
highlightedClass={
hasAny(filters.causes || undefined) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.causes}
label="causes"
/>
</FilterSection>
<FilterSection
title={t('profile.optional.diet', 'Diet')}
openFilter={openFilter}
@@ -542,52 +611,6 @@ function Filters(props: {
>
<LanguageFilter filters={filters} updateFilter={updateFilter} />
</FilterSection>
<FilterSection
title={t('profile.optional.interests', 'Interests')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.interests || undefined)}
selection={
<InterestFilterText
options={filters.interests as string[] | undefined}
label={'interests'}
highlightedClass={
hasAny(filters.interests || undefined) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.interests}
label="interests"
/>
</FilterSection>
<FilterSection
title={t('profile.optional.causes', 'Causes')}
openFilter={openFilter}
setOpenFilter={setOpenFilter}
isActive={hasAny(filters.causes || undefined)}
selection={
<InterestFilterText
options={filters.causes as string[] | undefined}
label={'causes'}
highlightedClass={
hasAny(filters.causes || undefined) ? 'text-primary-600' : 'text-ink-900'
}
/>
}
>
<InterestFilter
filters={filters}
updateFilter={updateFilter}
choices={choices.causes}
label="causes"
/>
</FilterSection>
</FilterGroup>
{/* Values & Beliefs Group */}

View File

@@ -23,7 +23,7 @@ export function ShortBioToggle(props: {
<input
id={label}
type="checkbox"
className="border-ink-300 bg-canvas-0 dark:border-ink-500 text-primary-600 focus:ring-primary-500 h-4 w-4 rounded hover:bg-canvas-200"
className="border-ink-300 bg-canvas-0 dark:border-ink-500 text-primary-600 focus:ring-primary-500 h-4 w-4 rounded hover:bg-canvas-200 cursor-pointer"
checked={on}
onChange={(e) => updateFilter({shortBio: e.target.checked ? true : undefined})}
/>

View File

@@ -1,7 +1,8 @@
import clsx from 'clsx'
import {FilterFields} from 'common/filters'
import {invert} from 'lodash'
import {DropdownOptions} from 'web/components/comments/dropdown-menu'
import {Row} from 'web/components/layout/row'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {useT} from 'web/lib/locale'
export function SmokerFilterText(props: {
@@ -14,6 +15,7 @@ export function SmokerFilterText(props: {
return (
<Row className="items-center gap-0.5">
<span className={clsx(highlightedClass, is_smoker != null && 'font-semibold')}>
{is_smoker == null && t('profile.smokes', 'Smokes') + ': '}
{is_smoker == null
? t('common.either', 'Either')
: is_smoker
@@ -35,24 +37,23 @@ export function SmokerFilter(props: {
const {filters, updateFilter} = props
const choicesMap = {
Either: 'either',
Yes: 'yes',
No: 'no',
Either: 'either',
} as const
const currentChoice = filters.is_smoker == null ? 'either' : filters.is_smoker ? 'yes' : 'no'
return (
<ChoicesToggleGroup
currentChoice={currentChoice}
choicesMap={choicesMap}
translationPrefix={'profile.smoker'}
setChoice={(c) => {
<DropdownOptions
items={invert(choicesMap)}
activeKey={String(currentChoice)}
translationPrefix="profile.smoker"
onClick={(c) => {
if (c === 'either') updateFilter({is_smoker: undefined})
else if (c === 'yes') updateFilter({is_smoker: true})
else updateFilter({is_smoker: false})
}}
toggleClassName="w-1/3 justify-center"
/>
)
}

View File

@@ -2,6 +2,7 @@ import {LOCALE_TO_LANGUAGE} from 'common/choices'
import {MAX_INT, MIN_INT} from 'common/constants'
import {FilterFields, initialFilters, OriginLocation} from 'common/filters'
import {logger} from 'common/logging'
import {kmToMiles} from 'common/measurement-utils'
import {Profile} from 'common/profiles/profile'
import {
wantsKidsDatabase,
@@ -11,6 +12,7 @@ import {
import {debounce, isEqual} from 'lodash'
import {useCallback, useEffect} from 'react'
import {useIsLooking} from 'web/hooks/use-is-looking'
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
import {usePersistentLocalState} from 'web/hooks/use-persistent-local-state'
import {getLocale} from 'web/lib/locale-cookie'
@@ -46,7 +48,11 @@ export const useFilters = (you: Profile | undefined, fromSignup?: boolean) => {
setRaisedInLocation(undefined)
}
const [radius, setRadius] = usePersistentLocalState<number>(100, 'search-radius')
const {measurementSystem} = useMeasurementSystem()
const defaultRadius = measurementSystem === 'imperial' ? 100 : kmToMiles(100)
const [radius, setRadius] = usePersistentLocalState<number>(defaultRadius, 'search-radius')
const debouncedSetRadius = useCallback(debounce(setRadius, 200), [setRadius])
@@ -56,7 +62,7 @@ export const useFilters = (you: Profile | undefined, fromSignup?: boolean) => {
)
const [raisedInRadius, setRaisedInRadius] = usePersistentLocalState<number>(
100,
defaultRadius,
'raised-in-radius',
)
@@ -174,8 +180,8 @@ export const useFilters = (you: Profile | undefined, fromSignup?: boolean) => {
const setYourFilters = (checked: boolean) => {
if (checked) {
updateFilter(yourFilters)
setRadius(100)
debouncedSetRadius(100) // clear any pending debounced sets
setRadius(defaultRadius)
debouncedSetRadius(defaultRadius) // clear any pending debounced sets
if (you?.geodb_city_id && you.city && you.city_latitude && you.city_longitude) {
setLocation({
id: you?.geodb_city_id,

View File

@@ -2,7 +2,7 @@ import {XIcon} from '@heroicons/react/outline'
import clsx from 'clsx'
import {DisplayUser} from 'common/api/user-types'
import {FilterFields} from 'common/filters'
import {formatFilters, locationType} from 'common/searches'
import {formatFilters, locationType} from 'common/filters-format'
import {User} from 'common/user'
import Link from 'next/link'
import {useState} from 'react'

View File

@@ -550,6 +550,7 @@ input {
.hover-bold {
transition: text-shadow 0.2s ease;
cursor: pointer;
}
.hover-bold:hover {