Add bookmarked search emails and factor out utils from web to common

This commit is contained in:
MartinBraquet
2025-09-16 12:36:18 +02:00
parent 31404cb89a
commit 639991dde4
27 changed files with 643 additions and 415 deletions

View File

@@ -5,7 +5,6 @@ import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supa
import {getCompatibleLovers} from 'api/compatible-lovers'
import {intersection} from 'lodash'
import {MAX_INT, MIN_INT} from "common/constants";
import {Lover} from "common/love/lover";
export type profileQueryType = {
limit?: number | undefined,
@@ -22,6 +21,7 @@ export type profileQueryType = {
is_smoker?: boolean | undefined,
geodbCityIds?: String[] | undefined,
compatibleWithUserId?: string | undefined,
skipId?: string | undefined,
orderBy?: string | undefined,
}
@@ -43,6 +43,7 @@ export const loadProfiles = async (props: profileQueryType) => {
geodbCityIds,
compatibleWithUserId,
orderBy: orderByParam,
skipId,
} = props
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
@@ -50,7 +51,10 @@ export const loadProfiles = async (props: profileQueryType) => {
// compatibility. TODO: do this in sql
if (orderByParam === 'compatibility_score') {
if (!compatibleWithUserId) return {status: 'fail', lovers: []}
if (!compatibleWithUserId) {
console.error('Incompatible with user ID')
throw Error('Incompatible with user ID')
}
const {compatibleLovers} = await getCompatibleLovers(compatibleWithUserId)
const lovers = compatibleLovers.filter(
@@ -72,6 +76,7 @@ export const loadProfiles = async (props: profileQueryType) => {
(has_kids == 0 && !l.has_kids) ||
(l.has_kids && l.has_kids > 0)) &&
(!is_smoker || l.is_smoker === is_smoker) &&
(l.id.toString() != skipId) &&
(!geodbCityIds ||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
)
@@ -135,6 +140,8 @@ export const loadProfiles = async (props: profileQueryType) => {
geodbCityIds?.length &&
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
skipId && where(`user_id != $(skipId)`, {skipId}),
orderBy(`${orderByParam} desc`),
after &&
where(
@@ -151,6 +158,10 @@ export const loadProfiles = async (props: profileQueryType) => {
}
export const getProfiles: APIHandler<'get-profiles'> = async (props, _auth) => {
const lovers = await loadProfiles(props)
return {status: 'success', lovers: lovers as Lover[]}
try {
const lovers = await loadProfiles(props)
return {status: 'success', lovers: lovers}
} catch {
return {status: 'fail', lovers: []}
}
}

View File

@@ -1,12 +1,23 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from "shared/supabase/init";
import {convertRow} from "shared/love/supabase";
import {from, join, renderSql, select, where} from "shared/supabase/sql-builder";
import {from, renderSql, select} from "shared/supabase/sql-builder";
import {loadProfiles, profileQueryType} from "api/get-profiles";
import {Row} from "common/supabase/utils";
import {sendSearchAlertsEmail} from "email/functions/helpers";
import {MatchesByUserType} from "common/love/bookmarked_searches";
import {keyBy} from "lodash";
export function convertSearchRow(row: any): any {
return row
}
export const notifyBookmarkedSearch = async (matches: MatchesByUserType) => {
for (const [_, value] of Object.entries(matches)) {
await sendSearchAlertsEmail(value.user, value.privateUser, value.matches)
}
}
export const sendSearchNotifications: APIHandler<'send-search-notifications'> = async (_, auth) => {
const pg = createSupabaseDirectClient()
@@ -14,20 +25,58 @@ export const sendSearchNotifications: APIHandler<'send-search-notifications'> =
select('bookmarked_searches.*'),
from('bookmarked_searches'),
)
const searches = pg.map(search_query, [], convertSearchRow)
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
console.log(`Running ${searches.length} bookmarked searches`)
const query = renderSql(
select('lovers.*, name, username, users.data as user'),
from('lovers'),
join('users on users.id = lovers.user_id'),
where('looking_for_matches = true'),
where(
`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`
const _users = await pg.map(
renderSql(
select('users.*'),
from('users'),
),
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
)
[],
convertSearchRow
) as Row<'users'>[]
const users = keyBy(_users, 'id')
console.log('users', users)
const profiles = await pg.map(query, [], convertRow)
const _privateUsers = await pg.map(
renderSql(
select('private_users.*'),
from('private_users'),
),
[],
convertSearchRow
) as Row<'private_users'>[]
const privateUsers = keyBy(_privateUsers, 'id')
console.log('privateUsers', privateUsers)
return {status: 'success', lovers: profiles}
const matches: MatchesByUserType = {}
for (const row of searches) {
if (typeof row.search_filters !== 'object') continue;
const props = {...row.search_filters, skipId: row.creator_id}
const profiles = await loadProfiles(props as profileQueryType)
console.log(profiles.map((item: any) => item.name))
if (!profiles.length) continue
if (!(row.creator_id in matches)) {
if (!privateUsers[row.creator_id]) continue
matches[row.creator_id] = {
user: users[row.creator_id],
privateUser: privateUsers[row.creator_id]['data'],
matches: [],
}
}
matches[row.creator_id].matches.push({
id: row.creator_id,
description: {filters: row.search_filters, location: row.location},
matches: [profiles.map((item: any) => ({
name: item.name,
username: item.username,
}))],
})
}
console.log(JSON.stringify(matches, null, 2))
await notifyBookmarkedSearch(matches)
return {status: 'success'}
}

View File

@@ -1,12 +1,14 @@
import { PrivateUser, User } from 'common/user'
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
import { sendEmail } from './send-email'
import { NewMatchEmail } from '../new-match'
import { NewMessageEmail } from '../new-message'
import { NewEndorsementEmail } from '../new-endorsement'
import { Test } from '../test'
import { getLover } from 'shared/love/supabase'
import {PrivateUser, User} from 'common/user'
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
import {sendEmail} from './send-email'
import {NewMatchEmail} from '../new-match'
import {NewMessageEmail} from '../new-message'
import {NewEndorsementEmail} from '../new-endorsement'
import {Test} from '../test'
import {getLover} from 'shared/love/supabase'
import {renderToStaticMarkup} from "react-dom/server";
import {MatchesType} from "common/love/bookmarked_searches";
import NewSearchAlertsEmail from "email/new-search_alerts";
const from = 'Compass <no-reply@compassmeet.com>'
@@ -14,7 +16,7 @@ export const sendNewMatchEmail = async (
privateUser: PrivateUser,
matchedWithUser: User
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'new_match'
)
@@ -44,7 +46,7 @@ export const sendNewMessageEmail = async (
toUser: User,
channelId: number
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'new_message'
)
@@ -57,22 +59,6 @@ export const sendNewMessageEmail = async (
return
}
console.log({
from,
subject: `${fromUser.name} sent you a message!`,
to: privateUser.email,
html: renderToStaticMarkup(
<NewMessageEmail
fromUser={fromUser}
fromUserLover={lover}
toUser={toUser}
channelId={channelId}
unsubscribeUrl={unsubscribeUrl}
email={privateUser.email}
/>
),
})
return await sendEmail({
from,
subject: `${fromUser.name} sent you a message!`,
@@ -90,13 +76,40 @@ export const sendNewMessageEmail = async (
})
}
export const sendSearchAlertsEmail = async (
toUser: User,
privateUser: PrivateUser,
matches: MatchesType[],
) => {
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'new_search_alerts'
)
const email = privateUser.email;
if (!email || !sendToEmail) return
return await sendEmail({
from,
subject: `Some people recently matched your bookmarked searches!`,
to: email,
html: renderToStaticMarkup(
<NewSearchAlertsEmail
toUser={toUser}
matches={matches}
unsubscribeUrl={unsubscribeUrl}
email={email}
/>
),
})
}
export const sendNewEndorsementEmail = async (
privateUser: PrivateUser,
fromUser: User,
onUser: User,
text: string
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'new_endorsement'
)
@@ -123,6 +136,6 @@ export const sendTestEmail = async (toEmail: string) => {
from,
subject: 'Test email from Compass',
to: toEmail,
html: renderToStaticMarkup(<Test name="Test User" />),
html: renderToStaticMarkup(<Test name="Test User"/>),
})
}

View File

@@ -0,0 +1,129 @@
import {
Body,
Button,
Container,
Head,
Html,
Img,
Link,
Preview,
Section,
Text,
} from '@react-email/components'
import {type User} from 'common/user'
import {type LoverRow} from 'common/love/lover'
import {
jamesLover,
jamesUser,
sinclairLover,
sinclairUser,
} from './functions/mock'
import {DOMAIN} from 'common/envs/constants'
import {getLoveOgImageUrl} from 'common/love/og-image'
import {button, container, content, Footer, imageContainer, main, paragraph, profileImage} from "email/utils";
import {sendSearchAlertsEmail} from "email/functions/helpers";
import {MatchesType} from "common/love/bookmarked_searches";
import {formatFilters, locationType} from "common/searches"
import {FilterFields} from "common/filters";
interface NewMessageEmailProps {
toUser: User
matches: MatchesType[]
unsubscribeUrl: string
email?: string
}
export const NewSearchAlertsEmail = ({
toUser,
unsubscribeUrl,
matches,
email,
}: NewMessageEmailProps) => {
const name = toUser.name.split(' ')[0]
return (
<Html>
<Head/>
<Preview>Some people recently matched your bookmarked searches!</Preview>
<Body style={main}>
<Container style={container}>
<Section style={content}>
<Text style={paragraph}>Hi {name},</Text>
<Text style={paragraph}>The following people matched your bookmarked searches in the past 24h:</Text>
<Text
className={
'border-ink-300bg-canvas-0 inline-flex flex-col gap-2 rounded-md border p-1 text-sm shadow-sm'
}
>
<ol className="list-decimal list-inside space-y-2">
{(matches || []).map((match) => (
<li key={match.id}
className="items-center justify-between gap-2 list-item marker:text-ink-500 marker:font-bold">
{formatFilters(match.description.filters as Partial<FilterFields>, match.description.location as locationType)}
<span>{match.matches.map(p => p.toString()).join(', ')}</span>
</li>
))}
</ol>
</Text>
</Section>
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
</Container>
</Body>
</Html>
)
}
const matchSamples = [
{
"id": "ID search 1",
"description": {
"filters": {
"orderBy": "created_time"
},
"location": null
},
"matches": [
[
{
"name": "James Bond",
"username": "jamesbond"
},
{
"name": "Lily",
"username": "lilyrose"
}
]
]
},
{
"id": "ID search 2",
"description": {
"filters": {
"genders": [
"female"
],
"orderBy": "created_time"
},
"location": null
},
"matches": [
[
{
"name": "Lily",
"username": "lilyrose"
}
]
]
}
]
NewSearchAlertsEmail.PreviewProps = {
toUser: sinclairUser,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
matches: matchSamples,
} as NewMessageEmailProps
export default NewSearchAlertsEmail

View File

@@ -57,7 +57,6 @@ export const API = (_apiTypeCheck = {
props: z.object({}),
returns: {} as {
status: 'success' | 'fail'
lovers: Lover[]
},
},
'mark-all-notifs-read': {

52
common/src/filters.ts Normal file
View File

@@ -0,0 +1,52 @@
import {Lover, LoverRow} from "common/love/lover";
import {cloneDeep} from "lodash";
import {filterDefined} from "common/util/array";
export type FilterFields = {
orderBy: 'last_online_time' | 'created_time' | 'compatibility_score'
geodbCityIds: string[] | null
genders: string[]
name: string | undefined
} & Pick<
LoverRow,
| 'wants_kids_strength'
| 'pref_relation_styles'
| 'is_smoker'
| 'has_kids'
| 'pref_gender'
| 'pref_age_min'
| 'pref_age_max'
>
export const orderLovers = (
lovers: Lover[],
starredUserIds: string[] | undefined
) => {
if (!lovers) return
let s = cloneDeep(lovers)
if (starredUserIds) {
s = filterDefined([
...starredUserIds.map((id) => s.find((l) => l.user_id === id)),
...s.filter((l) => !starredUserIds.includes(l.user_id)),
])
}
// s = alternateWomenAndMen(s)
return s
}
export const initialFilters: Partial<FilterFields> = {
geodbCityIds: undefined,
name: undefined,
genders: undefined,
pref_age_max: undefined,
pref_age_min: undefined,
has_kids: undefined,
wants_kids_strength: undefined,
is_smoker: undefined,
pref_relation_styles: undefined,
pref_gender: undefined,
orderBy: 'created_time',
}
export type OriginLocation = { id: string; name: string }

51
common/src/has-kids.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface HasKidLabel {
name: string
shortName: string
value: number
}
export interface HasKidsLabelsMap {
[key: string]: HasKidLabel
}
export const hasKidsLabels: HasKidsLabelsMap = {
no_preference: {
name: 'Any kids',
shortName: 'Any kids',
value: -1,
},
has_kids: {
name: 'Has kids',
shortName: 'Yes',
value: 1,
},
doesnt_have_kids: {
name: `Doesn't have kids`,
shortName: 'No',
value: 0,
},
}
export const hasKidsNames = Object.values(hasKidsLabels).reduce<Record<number, string>>(
(acc, {value, name}) => {
acc[value] = name
return acc
},
{}
)
export const generateChoicesMap = (
labels: HasKidsLabelsMap
): Record<string, number> => {
return Object.values(labels).reduce(
(acc: Record<string, number>, label: HasKidLabel) => {
acc[label.shortName] = label.value
return acc
},
{}
)
}
// export const NO_PREFERENCE_STRENGTH = -1
// export const WANTS_KIDS_STRENGTH = 2
// export const DOESNT_WANT_KIDS_STRENGTH = 0

View File

@@ -0,0 +1,26 @@
export interface MatchPrivateUser {
email: string
notificationPreferences: any
}
export interface MatchUser {
name: string
username: string
}
export interface MatchesType {
description: {
filters: any; // You might want to replace 'any' with a more specific type
location: any; // You might want to replace 'any' with a more specific type
};
matches: any[]; // You might want to replace 'any' with a more specific type
id: string
}
export interface MatchesByUserType {
[key: string]: {
user: any;
privateUser: any;
matches: MatchesType[];
}
}

87
common/src/searches.ts Normal file
View File

@@ -0,0 +1,87 @@
// Define nice labels for each key
import {FilterFields, initialFilters} from "common/filters";
import {wantsKidsNames} from "common/wants-kids";
import {hasKidsNames} from "common/has-kids";
const filterLabels: Record<string, string> = {
geodbCityIds: "",
location: "",
name: "Searching",
genders: "",
pref_age_max: "Max age",
pref_age_min: "Min age",
has_kids: "",
wants_kids_strength: "Kids",
is_smoker: "",
pref_relation_styles: "Seeking",
pref_gender: "",
orderBy: "",
}
export type locationType = {
location: {
name: string
}
radius: number
}
export function formatFilters(filters: Partial<FilterFields>, location: locationType | null): String[] | null {
const entries: String[] = []
let ageEntry = null
let ageMin: number | undefined | null = filters.pref_age_min
if (ageMin == 18) ageMin = undefined
let ageMax = filters.pref_age_max;
if (ageMax == 99 || ageMax == 100) ageMax = undefined
if (ageMin || ageMax) {
let text: string = 'Age: '
if (ageMin) text = `${text}${ageMin}`
if (ageMax) {
if (ageMin) {
text = `${text}-${ageMax}`
} else {
text = `${text}up to ${ageMax}`
}
} else {
text = `${text}+`
}
ageEntry = text
}
Object.entries(filters).forEach(([key, value]) => {
const typedKey = key as keyof FilterFields
if (value === undefined || value === null) return
if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy') return
if (Array.isArray(value) && value.length === 0) return
if (initialFilters[typedKey] === value) return
const label = filterLabels[typedKey] ?? key
let stringValue = value
if (key === 'has_kids') stringValue = hasKidsNames[value as number]
if (key === 'wants_kids_strength') stringValue = wantsKidsNames[value as number]
if (Array.isArray(value)) stringValue = value.join(', ')
if (!label) {
const str = String(stringValue)
stringValue = str.charAt(0).toUpperCase() + str.slice(1)
}
const display = stringValue
entries.push(`${label}${label ? ': ' : ''}${display}`)
})
if (ageEntry) entries.push(ageEntry)
if (location?.location?.name) {
const locString = `${location?.location?.name} (${location?.radius}mi)`
entries.push(locString)
}
if (entries.length === 0) return ['Anyone']
return entries
}

View File

@@ -8,6 +8,7 @@ export type notification_preferences = {
new_endorsement: notification_destination_types[]
new_love_like: notification_destination_types[]
new_love_ship: notification_destination_types[]
new_search_alerts: notification_destination_types[]
// User-related
new_message: notification_destination_types[]
@@ -37,6 +38,7 @@ export const getDefaultNotificationPreferences = (isDev?: boolean) => {
}
const defaults: notification_preferences = {
new_match: constructPref(true, true, true),
new_search_alerts: constructPref(true, true, true),
new_endorsement: constructPref(true, true, true),
new_love_like: constructPref(true, false, false),
new_love_ship: constructPref(true, false, false),
@@ -59,8 +61,10 @@ export const getNotificationDestinationsForUser = (
privateUser: PrivateUser,
type: notification_preference
) => {
const destinations = privateUser.notificationPreferences[type]
const opt_out = privateUser.notificationPreferences.opt_out_all
let destinations = privateUser.notificationPreferences[type]
if (!destinations) destinations = ['email', 'browser', 'mobile']
let opt_out = privateUser.notificationPreferences.opt_out_all
if (!opt_out) opt_out = []
return {
sendToEmail: destinations.includes('email') && !opt_out.includes('email'),

68
common/src/wants-kids.ts Normal file
View File

@@ -0,0 +1,68 @@
import {hasKidsLabels} from "common/has-kids";
export type KidLabel = {
name: string
shortName: string
strength: number
}
export type KidsLabelsMap = Record<string, KidLabel>
export const wantsKidsLabels: KidsLabelsMap = {
no_preference: {
name: 'Any preference',
shortName: 'Either',
strength: -1,
},
wants_kids: {
name: 'Wants kids',
shortName: 'Yes',
strength: 2,
},
doesnt_want_kids: {
name: `Doesn't want kids`,
shortName: 'No',
strength: 0,
},
}
export const wantsKidsNames = Object.values(wantsKidsLabels).reduce<Record<number, string>>(
(acc, {strength, name}) => {
acc[strength] = name
return acc
},
{}
)
export type wantsKidsDatabase = 0 | 1 | 2 | 3 | 4
export function wantsKidsToHasKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
if (wantsKidsStrength < wantsKidsLabels.wants_kids.strength) {
return hasKidsLabels.doesnt_have_kids.value
}
return hasKidsLabels.no_preference.value
}
export function wantsKidsDatabaseToWantsKidsFilter(
wantsKidsStrength: wantsKidsDatabase
) {
// console.log(wantsKidsStrength)
if (wantsKidsStrength == wantsKidsLabels.no_preference.strength) {
return wantsKidsLabels.no_preference.strength
}
if (wantsKidsStrength > wantsKidsLabels.wants_kids.strength) {
return wantsKidsLabels.wants_kids.strength
}
if (wantsKidsStrength < wantsKidsLabels.wants_kids.strength) {
return wantsKidsLabels.doesnt_want_kids.strength
}
return wantsKidsLabels.no_preference.strength
}
export const generateChoicesMap = (labels: KidsLabelsMap): Record<string, number> => {
return Object.values(labels).reduce(
(acc: Record<string, number>, label: KidLabel) => {
acc[label.shortName] = label.strength
return acc
},
{}
)
}

View File

@@ -1,6 +1,6 @@
import clsx from 'clsx'
import { FilterFields } from './search'
import { RangeSlider } from 'web/components/widgets/slider'
import {FilterFields} from "common/filters";
export const PREF_AGE_MIN = 18
export const PREF_AGE_MAX = 100

View File

@@ -19,11 +19,12 @@ import {
RelationshipFilter,
RelationshipFilterText,
} from './relationship-filter'
import { FilterFields } from './search'
import { KidsLabel, wantsKidsLabels } from './wants-kids-filter'
import { HasKidsLabel, hasKidsLabels } from './has-kids-filter'
import { KidsLabel, wantsKidsLabelsWithIcon } from './wants-kids-filter'
import { HasKidsLabel } from './has-kids-filter'
import { MyMatchesToggle } from './my-matches-toggle'
import { Lover } from 'common/love/lover'
import {FilterFields} from "common/filters";
import {hasKidsLabels} from "common/has-kids";
export function DesktopFilters(props: {
filters: Partial<FilterFields>

View File

@@ -3,7 +3,8 @@ import GenderIcon from '../gender-icon'
import { Gender } from 'common/gender'
import { Row } from 'web/components/layout/row'
import { MultiCheckbox } from 'web/components/multi-checkbox'
import { FilterFields } from './search'
import {FilterFields} from "common/filters";
export function GenderFilterText(props: {
gender: Gender[] | undefined

View File

@@ -1,70 +1,20 @@
import clsx from 'clsx'
import { Row } from 'web/components/layout/row'
import { ChoicesToggleGroup } from 'web/components/widgets/choices-toggle-group'
import { FilterFields } from './search'
import { FaChild } from 'react-icons/fa6'
import {Row} from 'web/components/layout/row'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {FaChild} from 'react-icons/fa6'
import {FilterFields} from "common/filters";
import {generateChoicesMap, hasKidsLabels} from "common/has-kids";
export const NO_PREFERENCE_STRENGTH = -1
export const WANTS_KIDS_STRENGTH = 2
export const DOESNT_WANT_KIDS_STRENGTH = 0
interface HasKidLabel {
name: string
shortName: string
value: number
}
interface HasKidsLabelsMap {
[key: string]: HasKidLabel
}
export const hasKidsLabels: HasKidsLabelsMap = {
no_preference: {
name: 'Any kids',
shortName: 'Any kids',
value: -1,
},
has_kids: {
name: 'Has kids',
shortName: 'Yes',
value: 1,
},
doesnt_have_kids: {
name: `Doesn't have kids`,
shortName: 'No',
value: 0,
},
}
export const hasKidsNames = Object.values(hasKidsLabels).reduce<Record<number, string>>(
(acc, { value, name }) => {
acc[value] = name
return acc
},
{}
)
const generateChoicesMap = (
labels: HasKidsLabelsMap
): Record<string, number> => {
return Object.values(labels).reduce(
(acc: Record<string, number>, label: HasKidLabel) => {
acc[label.shortName] = label.value
return acc
},
{}
)
}
export function HasKidsLabel(props: {
has_kids: number
highlightedClass?: string
mobile?: boolean
}) {
const { has_kids, highlightedClass, mobile } = props
const {has_kids, highlightedClass, mobile} = props
return (
<Row className="items-center gap-0.5">
<FaChild className="hidden h-4 w-4 sm:inline" />
<FaChild className="hidden h-4 w-4 sm:inline"/>
<span
className={clsx(highlightedClass, has_kids !== -1 && 'font-semibold')}
>
@@ -73,12 +23,12 @@ export function HasKidsLabel(props: {
? hasKidsLabels.has_kids.shortName
: hasKidsLabels.has_kids.name
: has_kids == hasKidsLabels.doesnt_have_kids.value
? mobile
? hasKidsLabels.doesnt_have_kids.shortName
: hasKidsLabels.doesnt_have_kids.name
: mobile
? hasKidsLabels.no_preference.shortName
: hasKidsLabels.no_preference.name}
? mobile
? hasKidsLabels.doesnt_have_kids.shortName
: hasKidsLabels.doesnt_have_kids.name
: mobile
? hasKidsLabels.no_preference.shortName
: hasKidsLabels.no_preference.name}
</span>
</Row>
)
@@ -88,12 +38,12 @@ export function HasKidsFilter(props: {
filters: Partial<FilterFields>
updateFilter: (newState: Partial<FilterFields>) => void
}) {
const { filters, updateFilter } = props
const {filters, updateFilter} = props
return (
<ChoicesToggleGroup
currentChoice={filters.has_kids ?? 0}
choicesMap={generateChoicesMap(hasKidsLabels)}
setChoice={(c) => updateFilter({ has_kids: Number(c) })}
setChoice={(c) => updateFilter({has_kids: Number(c)})}
toggleClassName="w-1/3 justify-center"
/>
)

View File

@@ -1,26 +1,16 @@
import clsx from 'clsx'
import { Col } from 'web/components/layout/col'
import { Slider } from 'web/components/widgets/slider'
import { usePersistentInMemoryState } from 'web/hooks/use-persistent-in-memory-state'
import { Row } from 'web/components/layout/row'
import {
originToCity,
City,
CityRow,
useCitySearch,
loverToCity,
} from '../search-location'
import { Lover } from 'common/love/lover'
import { useEffect, useState } from 'react'
import { Input } from 'web/components/widgets/input'
import { XIcon } from '@heroicons/react/solid'
import { uniqBy } from 'lodash'
import { buildArray } from 'common/util/array'
export const PREF_AGE_MIN = 18
export const PREF_AGE_MAX = 100
export type OriginLocation = { id: string; name: string }
import {Col} from 'web/components/layout/col'
import {Slider} from 'web/components/widgets/slider'
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
import {Row} from 'web/components/layout/row'
import {City, CityRow, loverToCity, originToCity, useCitySearch,} from '../search-location'
import {Lover} from 'common/love/lover'
import {useEffect, useState} from 'react'
import {Input} from 'web/components/widgets/input'
import {XIcon} from '@heroicons/react/solid'
import {uniqBy} from 'lodash'
import {buildArray} from 'common/util/array'
import {OriginLocation} from "common/filters";
export function LocationFilterText(props: {
location: OriginLocation | undefined | null
@@ -28,7 +18,7 @@ export function LocationFilterText(props: {
radius: number
highlightedClass?: string
}) {
const { location, youLover, radius, highlightedClass } = props
const { location, radius, highlightedClass } = props
if (!location) {
return (

View File

@@ -5,7 +5,7 @@ import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { AgeFilter, AgeFilterText, getNoMinMaxAge } from './age-filter'
import { GenderFilter, GenderFilterText } from './gender-filter'
import { HasKidsFilter, HasKidsLabel, hasKidsLabels } from './has-kids-filter'
import { HasKidsFilter, HasKidsLabel } from './has-kids-filter'
import {
LocationFilter,
LocationFilterProps,
@@ -16,18 +16,19 @@ import {
RelationshipFilter,
RelationshipFilterText,
} from './relationship-filter'
import { FilterFields } from './search'
import {
KidsLabel,
WantsKidsFilter,
WantsKidsIcon,
wantsKidsLabels,
wantsKidsLabelsWithIcon,
} from './wants-kids-filter'
import { FaChild } from 'react-icons/fa6'
import { MyMatchesToggle } from './my-matches-toggle'
import { Lover } from 'common/love/lover'
import { Gender } from 'common/gender'
import { RelationshipType } from 'web/lib/util/convert-relationship-type'
import {FilterFields} from "common/filters";
import {hasKidsLabels} from "common/has-kids";
export function MobileFilters(props: {
filters: Partial<FilterFields>

View File

@@ -3,7 +3,8 @@ import GenderIcon from '../gender-icon'
import { Gender } from 'common/gender'
import { Row } from 'web/components/layout/row'
import { MultiCheckbox } from 'web/components/multi-checkbox'
import { FilterFields } from './search'
import {FilterFields} from "common/filters";
export function PrefGenderFilterText(props: {
pref_gender: Gender[] | undefined

View File

@@ -1,10 +1,10 @@
import clsx from 'clsx'
import {convertRelationshipType, RelationshipType,} from 'web/lib/util/convert-relationship-type'
import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-text'
import {FilterFields} from './search'
import {MultiCheckbox} from 'web/components/multi-checkbox'
import {RELATIONSHIP_CHOICES} from "web/components/filters/choices";
import {FilterFields} from "common/filters";
export function RelationshipFilterText(props: {
relationship: RelationshipType[] | undefined

View File

@@ -1,4 +1,4 @@
import {Lover, LoverRow} from 'common/love/lover'
import {Lover} from 'common/love/lover'
import React, {useEffect, useState} from 'react'
import {IoFilterSharp} from 'react-icons/io5'
import {Button} from 'web/components/buttons/button'
@@ -15,24 +15,8 @@ import {BookmarkedSearchesType} from "web/hooks/use-bookmarked-searches";
import {submitBookmarkedSearch} from "web/lib/supabase/searches";
import {useUser} from "web/hooks/use-user";
import {isEqual} from "lodash";
import {initialFilters} from "web/components/filters/use-filters";
import toast from "react-hot-toast";
export type FilterFields = {
orderBy: 'last_online_time' | 'created_time' | 'compatibility_score'
geodbCityIds: string[] | null
genders: string[]
name: string | undefined
} & Pick<
LoverRow,
| 'wants_kids_strength'
| 'pref_relation_styles'
| 'is_smoker'
| 'has_kids'
| 'pref_gender'
| 'pref_age_min'
| 'pref_age_max'
>
import {FilterFields, initialFilters} from "common/filters";
function isOrderBy(input: string): input is FilterFields['orderBy'] {
return ['last_online_time', 'created_time', 'compatibility_score'].includes(

View File

@@ -1,88 +1,43 @@
import { Lover } from 'common/love/lover'
import { filterDefined } from 'common/util/array'
import { cloneDeep, debounce, isEqual } from 'lodash'
import { useCallback } from 'react'
import { useEffectCheckEquality } from 'web/hooks/use-effect-check-equality'
import { useIsLooking } from 'web/hooks/use-is-looking'
import { useNearbyCities } from 'web/hooks/use-nearby-locations'
import { usePersistentLocalState } from 'web/hooks/use-persistent-local-state'
import { OriginLocation } from './location-filter'
import { FilterFields } from './search'
import {
wantsKidsDatabaseToWantsKidsFilter,
wantsKidsDatabase,
wantsKidsToHasKidsFilter,
} from './wants-kids-filter'
export const orderLovers = (
lovers: Lover[],
starredUserIds: string[] | undefined
) => {
if (!lovers) return
let s = cloneDeep(lovers)
if (starredUserIds) {
s = filterDefined([
...starredUserIds.map((id) => s.find((l) => l.user_id === id)),
...s.filter((l) => !starredUserIds.includes(l.user_id)),
])
}
// s = alternateWomenAndMen(s)
return s
}
// const alternateWomenAndMen = (lovers: Lover[]) => {
// const [women, nonWomen] = partition(lovers, (l) => l.gender === 'female')
// return filterDefined(zip(women, nonWomen).flat())
// }
export const initialFilters: Partial<FilterFields> = {
geodbCityIds: undefined,
name: undefined,
genders: undefined,
pref_age_max: undefined,
pref_age_min: undefined,
has_kids: undefined,
wants_kids_strength: undefined,
is_smoker: undefined,
pref_relation_styles: undefined,
pref_gender: undefined,
orderBy: 'created_time',
}
import {Lover} from "common/love/lover";
import {useIsLooking} from "web/hooks/use-is-looking";
import {usePersistentLocalState} from "web/hooks/use-persistent-local-state";
import {useCallback} from "react";
import {debounce, isEqual} from "lodash";
import {useNearbyCities} from "web/hooks/use-nearby-locations";
import {useEffectCheckEquality} from "web/hooks/use-effect-check-equality";
import {wantsKidsDatabase, wantsKidsDatabaseToWantsKidsFilter, wantsKidsToHasKidsFilter} from "common/wants-kids";
import {FilterFields, initialFilters, OriginLocation} from "common/filters";
export const useFilters = (you: Lover | undefined) => {
const isLooking = useIsLooking()
const [filters, setFilters] = usePersistentLocalState<Partial<FilterFields>>(
isLooking ? initialFilters : { ...initialFilters, orderBy: 'created_time' },
isLooking ? initialFilters : {...initialFilters, orderBy: 'created_time'},
'profile-filters-2'
)
const updateFilter = (newState: Partial<FilterFields>) => {
const updatedState = { ...newState }
const updatedState = {...newState}
if ('pref_age_min' in updatedState && updatedState.pref_age_min !== undefined) {
if (updatedState.pref_age_min != null && updatedState.pref_age_min <= 18) {
updatedState.pref_age_min = undefined
}
}
if ('pref_age_max' in updatedState && updatedState.pref_age_max !== undefined) {
if (updatedState.pref_age_max != null && updatedState.pref_age_max >= 99) {
updatedState.pref_age_max = undefined
}
}
setFilters((prevState) => ({ ...prevState, ...updatedState }))
setFilters((prevState) => ({...prevState, ...updatedState}))
}
const clearFilters = () => {
setFilters(
isLooking
? initialFilters
: { ...initialFilters, orderBy: 'created_time' }
: {...initialFilters, orderBy: 'created_time'}
)
setLocation(undefined)
}
@@ -101,7 +56,7 @@ export const useFilters = (you: Lover | undefined) => {
const nearbyCities = useNearbyCities(location?.id, radius)
useEffectCheckEquality(() => {
updateFilter({ geodbCityIds: nearbyCities })
updateFilter({geodbCityIds: nearbyCities})
}, [nearbyCities])
const locationFilterProps = {
@@ -144,7 +99,7 @@ export const useFilters = (you: Lover | undefined) => {
setRadius(100)
debouncedSetRadius(100) // clear any pending debounced sets
if (you?.geodb_city_id && you.city) {
setLocation({ id: you?.geodb_city_id, name: you?.city })
setLocation({id: you?.geodb_city_id, name: you?.city})
}
} else {
clearFilters()
@@ -160,3 +115,8 @@ export const useFilters = (you: Lover | undefined) => {
locationFilterProps,
}
}
// const alternateWomenAndMen = (lovers: Lover[]) => {
// const [women, nonWomen] = partition(lovers, (l) => l.gender === 'female')
// return filterDefined(zip(women, nonWomen).flat())
// }

View File

@@ -2,94 +2,45 @@ import {ReactNode} from 'react'
import {MdNoStroller, MdOutlineStroller, MdStroller} from 'react-icons/md'
import {Row} from 'web/components/layout/row'
import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group'
import {FilterFields} from './search'
import {hasKidsLabels} from './has-kids-filter'
import clsx from 'clsx'
import {FilterFields} from "common/filters";
interface KidLabel {
name: string
shortName: string
import {generateChoicesMap, KidLabel, wantsKidsLabels} from "common/wants-kids"
interface KidLabelWithIcon extends KidLabel {
icon: ReactNode
strength: number
}
interface KidsLabelsMap {
[key: string]: KidLabel
interface KidsLabelsMapWithIcon {
[key: string]: KidLabelWithIcon
}
export type wantsKidsDatabase = 0 | 1 | 2 | 3 | 4
export function wantsKidsToHasKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
if (wantsKidsStrength < wantsKidsLabels.wants_kids.strength) {
return hasKidsLabels.doesnt_have_kids.value
}
return hasKidsLabels.no_preference.value
}
export function wantsKidsDatabaseToWantsKidsFilter(
wantsKidsStrength: wantsKidsDatabase
) {
console.log(wantsKidsStrength)
if (wantsKidsStrength == wantsKidsLabels.no_preference.strength) {
return wantsKidsLabels.no_preference.strength
}
if (wantsKidsStrength > wantsKidsLabels.wants_kids.strength) {
return wantsKidsLabels.wants_kids.strength
}
if (wantsKidsStrength < wantsKidsLabels.wants_kids.strength) {
return wantsKidsLabels.doesnt_want_kids.strength
}
return wantsKidsLabels.no_preference.strength
}
export const wantsKidsLabels: KidsLabelsMap = {
export const wantsKidsLabelsWithIcon: KidsLabelsMapWithIcon = {
...wantsKidsLabels,
no_preference: {
name: 'Any preference',
shortName: 'Either',
...wantsKidsLabels.no_preference,
icon: <MdOutlineStroller className="h-4 w-4"/>,
strength: -1,
},
wants_kids: {
name: 'Wants kids',
shortName: 'Yes',
...wantsKidsLabels.wants_kids,
icon: <MdStroller className="h-4 w-4"/>,
strength: 2,
},
doesnt_want_kids: {
name: `Doesn't want kids`,
shortName: 'No',
...wantsKidsLabels.doesnt_want_kids,
icon: <MdNoStroller className="h-4 w-4"/>,
strength: 0,
},
}
export const wantsKidsNames = Object.values(wantsKidsLabels).reduce<Record<number, string>>(
(acc, {strength, name}) => {
acc[strength] = name
return acc
},
{}
)
const generateChoicesMap = (labels: KidsLabelsMap): Record<string, number> => {
return Object.values(labels).reduce(
(acc: Record<string, number>, label: KidLabel) => {
acc[label.shortName] = label.strength
return acc
},
{}
)
}
export function WantsKidsIcon(props: { strength: number; className?: string }) {
const {strength, className} = props
return (
<span className={className}>
{strength == wantsKidsLabels.no_preference.strength
? wantsKidsLabels.no_preference.icon
: strength == wantsKidsLabels.wants_kids.strength
? wantsKidsLabels.wants_kids.icon
: wantsKidsLabels.doesnt_want_kids.icon}
{strength == wantsKidsLabelsWithIcon.no_preference.strength
? wantsKidsLabelsWithIcon.no_preference.icon
: strength == wantsKidsLabelsWithIcon.wants_kids.strength
? wantsKidsLabelsWithIcon.wants_kids.icon
: wantsKidsLabelsWithIcon.doesnt_want_kids.icon}
</span>
)
}
@@ -106,21 +57,21 @@ export function KidsLabel(props: {
<WantsKidsIcon strength={strength} className={clsx('hidden sm:inline')}/>
<span
className={clsx(
strength != wantsKidsLabels.no_preference.strength && 'font-semibold',
strength != wantsKidsLabelsWithIcon.no_preference.strength && 'font-semibold',
highlightedClass
)}
>
{strength == wantsKidsLabels.no_preference.strength
{strength == wantsKidsLabelsWithIcon.no_preference.strength
? mobile
? wantsKidsLabels.no_preference.shortName
: wantsKidsLabels.no_preference.name
: strength == wantsKidsLabels.wants_kids.strength
? wantsKidsLabelsWithIcon.no_preference.shortName
: wantsKidsLabelsWithIcon.no_preference.name
: strength == wantsKidsLabelsWithIcon.wants_kids.strength
? mobile
? wantsKidsLabels.wants_kids.shortName
: wantsKidsLabels.wants_kids.name
? wantsKidsLabelsWithIcon.wants_kids.shortName
: wantsKidsLabelsWithIcon.wants_kids.name
: mobile
? wantsKidsLabels.doesnt_want_kids.shortName
: wantsKidsLabels.doesnt_want_kids.name}
? wantsKidsLabelsWithIcon.doesnt_want_kids.shortName
: wantsKidsLabelsWithIcon.doesnt_want_kids.name}
</span>
</Row>
)
@@ -135,7 +86,7 @@ export function WantsKidsFilter(props: {
return (
<ChoicesToggleGroup
currentChoice={filters.wants_kids_strength ?? 0}
choicesMap={generateChoicesMap(wantsKidsLabels)}
choicesMap={generateChoicesMap(wantsKidsLabelsWithIcon)}
setChoice={(c) => updateFilter({wants_kids_strength: Number(c)})}
toggleClassName="w-1/3 justify-center"
/>

View File

@@ -7,7 +7,6 @@ import {getStars} from 'web/lib/supabase/stars'
import Router from 'next/router'
import {useCallback, useEffect, useRef, useState} from 'react'
import {Button} from 'web/components/buttons/button'
import {orderLovers, useFilters} from 'web/components/filters/use-filters'
import {ProfileGrid} from 'web/components/profile-grid'
import {LoadingIndicator} from 'web/components/widgets/loading-indicator'
import {Title} from 'web/components/widgets/title'
@@ -16,6 +15,8 @@ import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-sta
import {useUser} from 'web/hooks/use-user'
import {api} from 'web/lib/api'
import {useBookmarkedSearches} from "web/hooks/use-bookmarked-searches";
import {orderLovers} from "common/filters";
import {useFilters} from "web/components/filters/use-filters";
export function ProfilesHome() {
const user = useUser();

View File

@@ -1,9 +1,9 @@
import clsx from 'clsx'
import { useEffect, useRef, useState } from 'react'
import { OriginLocation } from './filters/location-filter'
import { api } from 'web/lib/api'
import { countryCodeToFlag } from 'web/lib/util/location'
import { LoverRow } from 'common/love/lover'
import {OriginLocation} from "common/filters";
export type City = {
geodb_city_id: string

View File

@@ -1,15 +1,12 @@
import {User} from "common/user";
import {ReactElement} from "react";
import {Button} from "web/components/buttons/button";
import {Modal, MODAL_CLASS} from "web/components/layout/modal";
import {Col} from "web/components/layout/col";
import {BookmarkedSearchesType} from "web/hooks/use-bookmarked-searches";
import {useUser} from "web/hooks/use-user";
import {initialFilters} from "web/components/filters/use-filters";
import {deleteBookmarkedSearch} from "web/lib/supabase/searches";
import {FilterFields} from "web/components/filters/search";
import {hasKidsNames} from "web/components/filters/has-kids-filter";
import {wantsKidsNames} from "web/components/filters/wants-kids-filter";
import {formatFilters, locationType} from "common/searches";
import {FilterFields} from "common/filters";
export function BookmarkSearchButton(props: {
bookmarkedSearches: BookmarkedSearchesType[]
@@ -42,107 +39,6 @@ export function BookmarkSearchButton(props: {
)
}
// Define nice labels for each key
const filterLabels: Record<string, string> = {
geodbCityIds: "",
location: "",
name: "Searching",
genders: "",
pref_age_max: "Max age",
pref_age_min: "Min age",
has_kids: "",
wants_kids_strength: "Kids",
is_smoker: "",
pref_relation_styles: "Seeking",
pref_gender: "",
orderBy: "",
}
export type locationType = {
location: {
name: string
}
radius: number
}
function formatFilters(filters: Partial<FilterFields>, location: locationType | null): ReactElement | null {
const entries: ReactElement[] = []
let ageEntry = null
let ageMin: number | undefined | null = filters.pref_age_min
if (ageMin == 18) ageMin = undefined
let ageMax = filters.pref_age_max;
if (ageMax == 99 || ageMax == 100) ageMax = undefined
if (ageMin || ageMax) {
let text: string = 'Age: '
if (ageMin) text = `${text}${ageMin}`
if (ageMax) {
if (ageMin) {
text = `${text}-${ageMax}`
} else {
text = `${text}up to ${ageMax}`
}
} else {
text = `${text}+`
}
ageEntry = <span key='age'>{text}</span>
}
Object.entries(filters).forEach(([key, value]) => {
const typedKey = key as keyof FilterFields
if (value === undefined || value === null) return
if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy') return
if (Array.isArray(value) && value.length === 0) return
if (initialFilters[typedKey] === value) return
const label = filterLabels[typedKey] ?? key
let stringValue = value
if (key === 'has_kids') stringValue = hasKidsNames[value as number]
if (key === 'wants_kids_strength') stringValue = wantsKidsNames[value as number]
if (Array.isArray(value)) stringValue = value.join(', ')
if (!label) {
const str = String(stringValue)
stringValue = str.charAt(0).toUpperCase() + str.slice(1)
}
const display: ReactElement = key === 'name'
? <i>{stringValue as string}</i>
: <>{stringValue}</>
entries.push(
<span key={key}>
{label}
{label ? ': ' : ''}
{display}
</span>
)
})
if (ageEntry) entries.push(ageEntry)
if (location?.location?.name) {
const locString = `${location?.location?.name} (${location?.radius}mi)`
entries.push(<span key="location">{locString}</span>)
}
if (entries.length === 0) return <span>Anything</span>
// Join with " • " separators
return (
<span>
{entries.map((entry, i) => (
<span key={i}>
{entry}
{i < entries.length - 1 ? ' • ' : ''}
</span>
))}
</span>
)
}
function ButtonModal(props: {
open: boolean
@@ -172,7 +68,7 @@ function ButtonModal(props: {
{(bookmarkedSearches || []).map((search) => (
<li key={search.id}
className="items-center justify-between gap-2 list-item marker:text-ink-500 marker:font-bold">
{formatFilters(search.search_filters as Partial<FilterFields>, search.location as locationType)}
{formatFilters(search.search_filters as Partial<FilterFields>, search.location as locationType)?.join(" • ")}
<button
onClick={async () => {
await deleteBookmarkedSearch(search.id)

View File

@@ -2,8 +2,8 @@ import {Row, run} from "common/supabase/utils";
import {db} from "web/lib/supabase/db";
import {filterKeys} from "web/components/questions-form";
import {track} from "web/lib/service/analytics";
import {FilterFields} from "web/components/filters/search";
import {removeNullOrUndefinedProps} from "common/util/object";
import {FilterFields} from "common/filters";
export const getUserBookmarkedSearches = async (userId: string) => {

View File

@@ -159,7 +159,7 @@ const LoadedNotificationSettings = (props: { privateUser: PrivateUser }) => {
},
{
type: 'new_message',
question: '... sends you a new messages?',
question: '... sends you a new message?',
},
{
type: 'new_love_like',
@@ -181,7 +181,10 @@ const LoadedNotificationSettings = (props: { privateUser: PrivateUser }) => {
type: 'on_new_follow',
question: '... follows you?',
},
{
type: 'new_search_alerts',
question: 'Alerts from bookmarked searches?',
},
{
type: 'opt_out_all',
question: