From 542152eadb788d4b19c2d3067d06c0720350acb3 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Mon, 26 Jan 2026 22:53:31 +0100 Subject: [PATCH] Store option IDs instead of EN labels in profiles and make keyword search match selected options --- android/app/build.gradle | 4 +- android/app/capacitor.build.gradle | 4 +- backend/api/package.json | 2 +- backend/api/src/get-profiles.ts | 45 ++++++++--- backend/api/src/update-options.ts | 31 ++++++-- backend/email/emails/functions/mock.ts | 4 + backend/supabase/causes_translations.sql | 25 ++++++ backend/supabase/interests_translations.sql | 27 +++++++ backend/supabase/migration.sql | 1 + backend/supabase/profile_causes.sql | 24 ++++++ backend/supabase/profile_interests.sql | 25 ++++++ backend/supabase/profile_work.sql | 24 ++++++ backend/supabase/profiles.sql | 33 +++++--- backend/supabase/rebuild_profile_search.sql | 44 +++++++++++ backend/supabase/work_translations.sql | 25 ++++++ common/src/api/schema.ts | 4 +- common/src/profiles/profile.ts | 14 ++-- common/src/supabase/schema.ts | 88 +++++++++++++++++++++ web/components/add-option-entry.tsx | 13 ++- web/components/filters/desktop-filters.tsx | 2 +- web/components/filters/interest-filter.tsx | 23 +++--- web/components/filters/mobile-filters.tsx | 2 +- web/components/optional-profile-form.tsx | 21 ++--- web/components/profile-about.tsx | 13 ++- web/components/profiles/profiles-home.tsx | 5 +- web/hooks/use-choices.ts | 36 +++++++-- web/messages/de.json | 69 ---------------- web/messages/fr.json | 73 +---------------- 28 files changed, 462 insertions(+), 219 deletions(-) create mode 100644 backend/supabase/causes_translations.sql create mode 100644 backend/supabase/interests_translations.sql create mode 100644 backend/supabase/rebuild_profile_search.sql create mode 100644 backend/supabase/work_translations.sql diff --git a/android/app/build.gradle b/android/app/build.gradle index 36725908..42cac46c 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -8,8 +8,8 @@ android { applicationId "com.compassconnections.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 24 - versionName "1.2.0" + versionCode 25 + versionName "1.3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index f4e3fa4c..f4fc69cf 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -2,8 +2,8 @@ android { compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } } diff --git a/backend/api/package.json b/backend/api/package.json index 47226faa..8f64ea8c 100644 --- a/backend/api/package.json +++ b/backend/api/package.json @@ -1,7 +1,7 @@ { "name": "@compass/api", "description": "Backend API endpoints", - "version": "1.1.0", + "version": "1.2.0", "private": true, "scripts": { "watch:serve": "tsx watch src/serve.ts", diff --git a/backend/api/src/get-profiles.ts b/backend/api/src/get-profiles.ts index 0c5de074..9150ac2f 100644 --- a/backend/api/src/get-profiles.ts +++ b/backend/api/src/get-profiles.ts @@ -38,6 +38,7 @@ export type profileQueryType = { skipId?: string | undefined, orderBy?: string | undefined, lastModificationWithin?: string | undefined, + locale?: string | undefined, } & { [K in OptionTableKey]?: string[] | undefined } @@ -82,6 +83,7 @@ export const loadProfiles = async (props: profileQueryType) => { orderBy: orderByParam = 'created_time', lastModificationWithin, skipId, + locale = 'en', } = props const filterLocation = lat && lon && radius @@ -102,6 +104,10 @@ export const loadProfiles = async (props: profileQueryType) => { const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id' + const joinInterests = !!interests?.length + const joinCauses = !!causes?.length + const joinWork = !!work?.length + // Pre-aggregated interests per profile function getManyToManyJoin(label: OptionTableKey) { return `( @@ -122,9 +128,9 @@ export const loadProfiles = async (props: profileQueryType) => { const joins = [ orderByParam === 'last_online_time' && leftJoin(userActivityJoin), orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin), - interests && leftJoin(interestsJoin), - causes && leftJoin(causesJoin), - work && leftJoin(workJoin), + joinInterests && leftJoin(interestsJoin), + joinCauses && leftJoin(causesJoin), + joinWork && leftJoin(workJoin), ] const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}` @@ -146,10 +152,23 @@ export const loadProfiles = async (props: profileQueryType) => { SELECT 1 FROM profile_${label} JOIN ${label} ON ${label}.id = profile_${label}.option_id WHERE profile_${label}.profile_id = profiles.id - AND ${label}.name = ANY (ARRAY[$(values)]) + AND ${label}.id = ANY (ARRAY[$(values)]) )` } + function getOptionClauseKeyword(label: OptionTableKey) { + return `EXISTS ( + SELECT 1 FROM profile_${label} + JOIN ${label} ON ${label}.id = profile_${label}.option_id + LEFT JOIN ${label}_translations + ON ${label}_translations.option_id = profile_${label}.option_id + AND ${label}_translations.locale = $(locale) + WHERE profile_${label}.profile_id = profiles.id + AND lower(COALESCE(${label}_translations.name, ${label}.name)) ILIKE '%' || lower($(word)) || '%' + )` + } + + const filters = [ where('looking_for_matches = true'), where(`profiles.disabled != true`), @@ -160,8 +179,14 @@ export const loadProfiles = async (props: profileQueryType) => { where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`), ...keywords.map(word => where( - `lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`, - {word} + `lower(users.name) ilike '%' || lower($(word)) || '%' + or lower(search_text) ilike '%' || lower($(word)) || '%' + or search_tsv @@ phraseto_tsquery('english', $(word)) + OR ${getOptionClauseKeyword('interests')} + OR ${getOptionClauseKeyword('causes')} + OR ${getOptionClauseKeyword('work')} + `, + {word, locale} )), genders?.length && where(`gender = ANY($(genders))`, {genders}), @@ -227,7 +252,7 @@ export const loadProfiles = async (props: profileQueryType) => { {religion} ), - interests?.length && where(getManyToManyClause('interests'), {values: interests}), + interests?.length && where(getManyToManyClause('interests'), {values: interests.map(Number)}), causes?.length && where(getManyToManyClause('causes'), {values: causes}), @@ -278,9 +303,9 @@ export const loadProfiles = async (props: profileQueryType) => { } else if (orderByParam === 'last_online_time') { selectCols += ', user_activity.last_online_time' } - if (interests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests` - if (causes) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes` - if (work) selectCols += `, COALESCE(profile_work.work, '{}') AS work` + if (joinInterests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests` + if (joinCauses) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes` + if (joinWork) selectCols += `, COALESCE(profile_work.work, '{}') AS work` const query = renderSql( select(selectCols), diff --git a/backend/api/src/update-options.ts b/backend/api/src/update-options.ts index 1d8dc416..5f6ebbf5 100644 --- a/backend/api/src/update-options.ts +++ b/backend/api/src/update-options.ts @@ -5,15 +5,22 @@ import {tryCatch} from 'common/util/try-catch' import {OPTION_TABLES} from "common/profiles/constants"; export const updateOptions: APIHandler<'update-options'> = async ( - {table, names}, + {table, values}, auth ) => { if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table') - if (!names || !Array.isArray(names) || names.length === 0) { - throw new APIError(400, 'No names provided') + if (!values || !Array.isArray(values)) { + throw new APIError(400, 'No ids provided') } - log('Updating profile options', {table, names}) + const idsWithNumbers = values.map(id => { + const numberId = Number(id) + return isNaN(numberId) ? {isNumber: false, v: id} : {isNumber: true, v: numberId} + }) + const names: string[] = idsWithNumbers.filter(item => !item.isNumber).map(item => item.v) as string[] + const ids: number[] = idsWithNumbers.filter(item => item.isNumber).map(item => item.v) as number[] + + log('Updating profile options', {table, ids, names}) const pg = createSupabaseDirectClient() @@ -25,8 +32,20 @@ export const updateOptions: APIHandler<'update-options'> = async ( const profileId = profileIdResult.id const result = await tryCatch(pg.tx(async (t) => { - const ids: number[] = [] - for (const name of names) { + const currentOptionsResult = await t.manyOrNone<{ id: string }>( + `SELECT option_id as id + FROM profile_${table} + WHERE profile_id = $1`, + [profileId] + ) + const currentOptions = currentOptionsResult.map(row => row.id) + if (currentOptions.sort().join(',') === ids.sort().join(',') && !names?.length) { + log(`Skipping /update-${table} because they are already the same`) + return undefined + } + + // Add new options + for (const name of (names || [])) { const row = await t.one<{ id: number }>( `INSERT INTO ${table} (name, creator_id) VALUES ($1, $2) diff --git a/backend/email/emails/functions/mock.ts b/backend/email/emails/functions/mock.ts index 661613d8..b863c3de 100644 --- a/backend/email/emails/functions/mock.ts +++ b/backend/email/emails/functions/mock.ts @@ -110,6 +110,8 @@ export const sinclairProfile: ProfileRow = { }, bio_text: 'the futa in futarchy', bio_tsv: 'the futa in futarchy', + search_text: 'the futa in futarchy', + search_tsv: 'the futa in futarchy', age: 25, } @@ -221,5 +223,7 @@ export const jamesProfile: ProfileRow = { }, bio_text: 'the futa in futarchy', bio_tsv: 'the futa in futarchy', + search_text: 'the futa in futarchy', + search_tsv: 'the futa in futarchy', age: 32, } diff --git a/backend/supabase/causes_translations.sql b/backend/supabase/causes_translations.sql new file mode 100644 index 00000000..d3e03fa8 --- /dev/null +++ b/backend/supabase/causes_translations.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS causes_translations +( + option_id BIGINT NOT NULL REFERENCES causes (id) ON DELETE CASCADE, + locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc. + name TEXT NOT NULL, + PRIMARY KEY (option_id, locale) +); + +-- Row Level Security +ALTER TABLE causes_translations + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON causes_translations; +CREATE POLICY "public read" ON causes_translations + FOR SELECT USING (true); + +CREATE INDEX idx_causes_translations_option_locale + ON causes_translations (option_id, locale); + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_causes_translations_name_trgm + ON causes_translations + USING GIN (name gin_trgm_ops); + diff --git a/backend/supabase/interests_translations.sql b/backend/supabase/interests_translations.sql new file mode 100644 index 00000000..429f4617 --- /dev/null +++ b/backend/supabase/interests_translations.sql @@ -0,0 +1,27 @@ +CREATE TABLE IF NOT EXISTS interests_translations +( + option_id BIGINT NOT NULL REFERENCES interests (id) ON DELETE CASCADE, + locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc. + name TEXT NOT NULL, + PRIMARY KEY (option_id, locale) +); + +-- Row Level Security +ALTER TABLE interests_translations + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON interests_translations; +CREATE POLICY "public read" ON interests_translations + FOR SELECT USING (true); + +DROP INDEX IF EXISTS idx_interests_translations_option_locale; +CREATE INDEX idx_interests_translations_option_locale + ON interests_translations (option_id, locale); + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +DROP INDEX IF EXISTS idx_interests_translations_name_trgm; +CREATE INDEX idx_interests_translations_name_trgm + ON interests_translations + USING GIN (name gin_trgm_ops); + diff --git a/backend/supabase/migration.sql b/backend/supabase/migration.sql index 4c1eddf7..46e8917d 100644 --- a/backend/supabase/migration.sql +++ b/backend/supabase/migration.sql @@ -1,4 +1,5 @@ BEGIN; +\i backend/supabase/rebuild_profile_search.sql \i backend/supabase/functions.sql \i backend/supabase/firebase.sql \i backend/supabase/profiles.sql diff --git a/backend/supabase/profile_causes.sql b/backend/supabase/profile_causes.sql index ff2c48be..db8cf078 100644 --- a/backend/supabase/profile_causes.sql +++ b/backend/supabase/profile_causes.sql @@ -21,3 +21,27 @@ CREATE INDEX idx_profile_causes_profile CREATE INDEX idx_profile_causes_interest ON profile_causes (option_id); + +-- Trigger to update /get-profiles search +CREATE OR REPLACE FUNCTION trg_profile_causes_rebuild_search() + RETURNS trigger AS +$$ +BEGIN + PERFORM rebuild_profile_search( + COALESCE(NEW.profile_id, OLD.profile_id) + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_profile_causes_search_ins + AFTER INSERT + ON profile_causes + FOR EACH ROW +EXECUTE FUNCTION trg_profile_causes_rebuild_search(); + +CREATE TRIGGER trg_profile_causes_search_del + AFTER DELETE + ON profile_causes + FOR EACH ROW +EXECUTE FUNCTION trg_profile_causes_rebuild_search(); diff --git a/backend/supabase/profile_interests.sql b/backend/supabase/profile_interests.sql index f371b818..39a38a79 100644 --- a/backend/supabase/profile_interests.sql +++ b/backend/supabase/profile_interests.sql @@ -21,3 +21,28 @@ CREATE INDEX idx_profile_interests_profile CREATE INDEX idx_profile_interests_interest ON profile_interests (option_id); + +-- Trigger to update /get-profiles search +CREATE OR REPLACE FUNCTION trg_profile_interests_rebuild_search() + RETURNS trigger AS +$$ +BEGIN + PERFORM rebuild_profile_search( + COALESCE(NEW.profile_id, OLD.profile_id) + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_profile_interests_search_ins + AFTER INSERT + ON profile_interests + FOR EACH ROW +EXECUTE FUNCTION trg_profile_interests_rebuild_search(); + +CREATE TRIGGER trg_profile_interests_search_del + AFTER DELETE + ON profile_interests + FOR EACH ROW +EXECUTE FUNCTION trg_profile_interests_rebuild_search(); + diff --git a/backend/supabase/profile_work.sql b/backend/supabase/profile_work.sql index e3a27c4e..ed61f323 100644 --- a/backend/supabase/profile_work.sql +++ b/backend/supabase/profile_work.sql @@ -21,3 +21,27 @@ CREATE INDEX idx_profile_work_profile CREATE INDEX idx_profile_work_interest ON profile_work (option_id); + +-- Trigger to update /get-profiles search +CREATE OR REPLACE FUNCTION trg_profile_work_rebuild_search() + RETURNS trigger AS +$$ +BEGIN + PERFORM rebuild_profile_search( + COALESCE(NEW.profile_id, OLD.profile_id) + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_profile_work_search_ins + AFTER INSERT + ON profile_work + FOR EACH ROW +EXECUTE FUNCTION trg_profile_work_rebuild_search(); + +CREATE TRIGGER trg_profile_work_search_del + AFTER DELETE + ON profile_work + FOR EACH ROW +EXECUTE FUNCTION trg_profile_work_rebuild_search(); diff --git a/backend/supabase/profiles.sql b/backend/supabase/profiles.sql index cac05d8c..18dc6b8e 100644 --- a/backend/supabase/profiles.sql +++ b/backend/supabase/profiles.sql @@ -123,28 +123,35 @@ CREATE INDEX profiles_bio_trgm_idx --- bio_text ALTER TABLE profiles ADD COLUMN bio_text TEXT; -UPDATE profiles -SET bio_text = ( - SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ') - FROM jsonb_path_query(bio, '$.**.text') AS t(value) -); ALTER TABLE profiles ADD COLUMN bio_tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED; CREATE INDEX profiles_bio_tsv_idx ON profiles USING GIN (bio_tsv); -CREATE OR REPLACE FUNCTION update_bio_text() +ALTER TABLE profiles + ADD COLUMN search_text TEXT, + ADD COLUMN search_tsv tsvector; + +-- Rebuild search (search_txt and search_tsv) +CREATE OR REPLACE FUNCTION trg_profiles_rebuild_search() RETURNS trigger AS $$ BEGIN - NEW.bio_text := ( - SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ') - FROM jsonb_path_query(NEW.bio, '$.**.text') AS t(value) - ); + PERFORM rebuild_profile_search(NEW.id); RETURN NEW; END; $$ LANGUAGE plpgsql; -CREATE TRIGGER trg_update_bio_text - BEFORE INSERT OR UPDATE OF bio ON profiles - FOR EACH ROW EXECUTE FUNCTION update_bio_text(); +DROP FUNCTION IF EXISTS update_bio_text; +DROP TRIGGER IF EXISTS trg_update_bio_text ON profiles; + +CREATE TRIGGER trg_profiles_rebuild_search + AFTER INSERT OR UPDATE OF bio + ON profiles + FOR EACH ROW +EXECUTE FUNCTION trg_profiles_rebuild_search(); + +CREATE INDEX profiles_search_tsv_idx + ON profiles USING GIN (search_tsv); + + diff --git a/backend/supabase/rebuild_profile_search.sql b/backend/supabase/rebuild_profile_search.sql new file mode 100644 index 00000000..f3041186 --- /dev/null +++ b/backend/supabase/rebuild_profile_search.sql @@ -0,0 +1,44 @@ +CREATE OR REPLACE FUNCTION rebuild_profile_search(profile_id_param BIGINT) + RETURNS void AS +$$ +DECLARE + bio_part TEXT; + interests_part TEXT; + causes_part TEXT; + work_part TEXT; +BEGIN + -- Bio text + SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ') + INTO bio_part + FROM profiles p, + jsonb_path_query(p.bio, '$.**.text') AS t(value) + WHERE p.id = profile_id_param; + + -- Interests text + SELECT string_agg(i.name, ' ') + INTO interests_part + FROM profile_interests pi + JOIN interests i ON i.id = pi.option_id + WHERE pi.profile_id = profile_id_param; + + -- Causes text + SELECT string_agg(i.name, ' ') + INTO causes_part + FROM profile_causes pc + JOIN causes i ON i.id = pc.option_id + WHERE pc.profile_id = profile_id_param; + + -- Work text + SELECT string_agg(i.name, ' ') + INTO work_part + FROM profile_work pw + JOIN work i ON i.id = pw.option_id + WHERE pw.profile_id = profile_id_param; + + UPDATE profiles + SET bio_text = bio_part, + search_text = concat_ws(' ', bio_part, interests_part, causes_part, work_part), + search_tsv = to_tsvector('english', concat_ws(' ', bio_part, interests_part, causes_part, work_part)) + WHERE id = profile_id_param; +END; +$$ LANGUAGE plpgsql; diff --git a/backend/supabase/work_translations.sql b/backend/supabase/work_translations.sql new file mode 100644 index 00000000..3fc31ebe --- /dev/null +++ b/backend/supabase/work_translations.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS work_translations +( + option_id BIGINT NOT NULL REFERENCES work (id) ON DELETE CASCADE, + locale TEXT NOT NULL, -- 'en', 'fr', 'de', etc. + name TEXT NOT NULL, + PRIMARY KEY (option_id, locale) +); + +-- Row Level Security +ALTER TABLE work_translations + ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "public read" ON work_translations; +CREATE POLICY "public read" ON work_translations + FOR SELECT USING (true); + +CREATE INDEX idx_work_translations_option_locale + ON work_translations (option_id, locale); + +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_work_translations_name_trgm + ON work_translations + USING GIN (name gin_trgm_ops); + diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index 8f78fa34..99337691 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -482,6 +482,7 @@ export const API = (_apiTypeCheck = { radius: z.coerce.number().optional(), compatibleWithUserId: z.string().optional(), skipId: z.string().optional(), + locale: z.string().optional(), orderBy: z .enum(['last_online_time', 'created_time', 'compatibility_score']) .optional() @@ -504,6 +505,7 @@ export const API = (_apiTypeCheck = { props: z .object({ table: z.enum(OPTION_TABLES), + locale: z.string().optional(), }) .strict(), summary: 'Get profile options like interests', @@ -517,7 +519,7 @@ export const API = (_apiTypeCheck = { props: z .object({ table: z.enum(OPTION_TABLES), - names: arraybeSchema.optional(), + values: arraybeSchema.optional(), }) .strict(), summary: 'Update profile options like interests', diff --git a/common/src/profiles/profile.ts b/common/src/profiles/profile.ts index b1213706..fcc8a03d 100644 --- a/common/src/profiles/profile.ts +++ b/common/src/profiles/profile.ts @@ -21,30 +21,30 @@ export const getProfileRow = async (userId: string, db: SupabaseClient): Promise const interestsRes = await run( db .from('profile_interests') - .select('interests(name)') + .select('interests(name, id)') .eq('profile_id', profile.id) ) - const interests = interestsRes.data?.map((row: any) => row.interests.name) || [] + const interests = interestsRes.data?.map((row: any) => String(row.interests.id)) || [] // Fetch causes const causesRes = await run( db .from('profile_causes') - .select('causes(name)') + .select('causes(name, id)') .eq('profile_id', profile.id) ) - const causes = causesRes.data?.map((row: any) => row.causes.name) || [] + const causes = causesRes.data?.map((row: any) => String(row.causes.id)) || [] // Fetch causes const workRes = await run( db .from('profile_work') - .select('work(name)') + .select('work(name, id)') .eq('profile_id', profile.id) ) - const work = workRes.data?.map((row: any) => row.work.name) || [] + const work = workRes.data?.map((row: any) => String(row.work.id)) || [] - // console.debug('work', work) + // console.debug({work, interests, causes}) return { ...profile, diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts index 46ed6a5f..cf667c03 100644 --- a/common/src/supabase/schema.ts +++ b/common/src/supabase/schema.ts @@ -78,6 +78,32 @@ export type Database = { }, ] } + causes_translations: { + Row: { + locale: string + name: string + option_id: number + } + Insert: { + locale: string + name: string + option_id: number + } + Update: { + locale?: string + name?: string + option_id?: number + } + Relationships: [ + { + foreignKeyName: 'causes_translations_option_id_fkey' + columns: ['option_id'] + isOneToOne: false + referencedRelation: 'causes' + referencedColumns: ['id'] + }, + ] + } compatibility_answers: { Row: { created_time: string @@ -327,6 +353,32 @@ export type Database = { }, ] } + interests_translations: { + Row: { + locale: string + name: string + option_id: number + } + Insert: { + locale: string + name: string + option_id: number + } + Update: { + locale?: string + name?: string + option_id?: number + } + Relationships: [ + { + foreignKeyName: 'interests_translations_option_id_fkey' + columns: ['option_id'] + isOneToOne: false + referencedRelation: 'interests' + referencedColumns: ['id'] + }, + ] + } private_user_message_channel_members: { Row: { channel_id: number @@ -814,6 +866,8 @@ export type Database = { religion: string[] | null religious_belief_strength: number | null religious_beliefs: string | null + search_text: string | null + search_tsv: unknown twitter: string | null university: string | null user_id: string @@ -869,6 +923,8 @@ export type Database = { religion?: string[] | null religious_belief_strength?: number | null religious_beliefs?: string | null + search_text?: string | null + search_tsv?: unknown twitter?: string | null university?: string | null user_id: string @@ -924,6 +980,8 @@ export type Database = { religion?: string[] | null religious_belief_strength?: number | null religious_beliefs?: string | null + search_text?: string | null + search_tsv?: unknown twitter?: string | null university?: string | null user_id?: string @@ -1289,6 +1347,32 @@ export type Database = { }, ] } + work_translations: { + Row: { + locale: string + name: string + option_id: number + } + Insert: { + locale: string + name: string + option_id: number + } + Update: { + locale?: string + name?: string + option_id?: number + } + Relationships: [ + { + foreignKeyName: 'work_translations_option_id_fkey' + columns: ['option_id'] + isOneToOne: false + referencedRelation: 'work' + referencedColumns: ['id'] + }, + ] + } } Views: { [_ in never]: never @@ -1341,6 +1425,10 @@ export type Database = { } millis_to_ts: { Args: { millis: number }; Returns: string } random_alphanumeric: { Args: { length: number }; Returns: string } + rebuild_profile_search: { + Args: { profile_id_param: number } + Returns: undefined + } show_limit: { Args: never; Returns: number } show_trgm: { Args: { '': string }; Returns: string[] } to_jsonb: { Args: { '': Json }; Returns: Json } diff --git a/web/components/add-option-entry.tsx b/web/components/add-option-entry.tsx index 9a8b7784..7c22dcd6 100644 --- a/web/components/add-option-entry.tsx +++ b/web/components/add-option-entry.tsx @@ -4,6 +4,8 @@ import {Col} from "web/components/layout/col"; import clsx from "clsx"; import {colClassName, labelClassName} from "web/pages/signup"; import {MultiCheckbox} from "web/components/multi-checkbox"; +import {invert} from "lodash"; +import {useLocale} from "web/lib/locale"; export function AddOptionEntry(props: { title?: string @@ -14,12 +16,17 @@ export function AddOptionEntry(props: { label: OptionTableKey, }) { const {profile, setProfile, label, choices, setChoices, title} = props + const {locale} = useLocale() + const sortedChoices = Object.fromEntries( + Object.entries(invert(choices)).sort((a, b) => + a[0].localeCompare(b[0], locale) + ) + ) return {title && } String(s))} onChange={(selected) => setProfile(label, selected)} addOption={(v: string) => { console.log(`Adding ${label}:`, v) diff --git a/web/components/filters/desktop-filters.tsx b/web/components/filters/desktop-filters.tsx index 6b0e818a..cf95435e 100644 --- a/web/components/filters/desktop-filters.tsx +++ b/web/components/filters/desktop-filters.tsx @@ -52,7 +52,7 @@ export function DesktopFilters(props: { isYourFilters: boolean locationFilterProps: LocationFilterProps includeRelationshipFilters: boolean | undefined - choices: Record> + choices: Record> }) { const { filters, diff --git a/web/components/filters/interest-filter.tsx b/web/components/filters/interest-filter.tsx index e4632832..90cc5ccf 100644 --- a/web/components/filters/interest-filter.tsx +++ b/web/components/filters/interest-filter.tsx @@ -3,8 +3,9 @@ import stringOrStringArrayToText from 'web/lib/util/string-or-string-array-to-te import {MultiCheckbox} from 'web/components/multi-checkbox' import {FilterFields} from "common/filters"; import {OptionTableKey} from 'common/profiles/constants' -import {useT} from 'web/lib/locale' -import {toKey} from "common/parsing"; +import {useLocale, useT} from 'web/lib/locale' +import {useChoices} from "web/hooks/use-choices"; +import {invert} from "lodash"; export function InterestFilterText(props: { options: string[] | undefined @@ -14,6 +15,7 @@ export function InterestFilterText(props: { const {options, highlightedClass, label} = props const t = useT() const length = (options ?? []).length + const {choices} = useChoices('interests') if (!options || length < 1) { return ( @@ -37,7 +39,7 @@ export function InterestFilterText(props: {
{stringOrStringArrayToText({ - text: options.map((o) => t(`profile.${label}.${toKey(o)}`, o)), + text: options.map(id => choices[id]), capitalizeFirstLetterOption: true, t: t, })}{' '} @@ -49,18 +51,21 @@ export function InterestFilterText(props: { export function InterestFilter(props: { filters: Partial updateFilter: (newState: Partial) => void - choices: Record + choices: Record label: OptionTableKey }) { const {filters, updateFilter, choices, label} = props + const {locale} = useLocale() + const sortedChoices = Object.fromEntries( + Object.entries(invert(choices)).sort((a, b) => + a[0].localeCompare(b[0], locale) + ) + ) return ( { - updateFilter({[label]: c}) - }} + choices={sortedChoices as any} + onChange={(c) => updateFilter({[label]: c})} /> ) } diff --git a/web/components/filters/mobile-filters.tsx b/web/components/filters/mobile-filters.tsx index c583e9a2..7a0c09ce 100644 --- a/web/components/filters/mobile-filters.tsx +++ b/web/components/filters/mobile-filters.tsx @@ -53,7 +53,7 @@ function MobileFilters(props: { isYourFilters: boolean locationFilterProps: LocationFilterProps includeRelationshipFilters: boolean | undefined - choices: Record> + choices: Record> }) { const t = useT() const { diff --git a/web/components/optional-profile-form.tsx b/web/components/optional-profile-form.tsx index 2f8137ee..221fab5d 100644 --- a/web/components/optional-profile-form.tsx +++ b/web/components/optional-profile-form.tsx @@ -3,7 +3,7 @@ import {Title} from 'web/components/widgets/title' import {Col} from 'web/components/layout/col' import clsx from 'clsx' import {MultiCheckbox} from 'web/components/multi-checkbox' -import {useT} from 'web/lib/locale' +import {useLocale, useT} from 'web/lib/locale' import {Row} from 'web/components/layout/row' import {Input} from 'web/components/widgets/input' import {ChoicesToggleGroup} from 'web/components/widgets/choices-toggle-group' @@ -81,11 +81,12 @@ export const OptionalProfileUserForm = (props: { const [interestChoices, setInterestChoices] = useState({}) const [causeChoices, setCauseChoices] = useState({}) const [workChoices, setWorkChoices] = useState({}) + const {locale} = useLocale() useEffect(() => { - fetchChoices('interests').then(setInterestChoices) - fetchChoices('causes').then(setCauseChoices) - fetchChoices('work').then(setWorkChoices) + fetchChoices('interests', locale).then(setInterestChoices) + fetchChoices('causes', locale).then(setCauseChoices) + fetchChoices('work', locale).then(setWorkChoices) }, [db]) const handleSubmit = async () => { @@ -104,14 +105,14 @@ export const OptionalProfileUserForm = (props: { const promises: Promise[] = [ tryCatch(updateProfile(removeUndefinedProps(otherProfileProps) as any)) ] - if (interests?.length) { - promises.push(api('update-options', {table: 'interests', names: interests})) + if (interests) { + promises.push(api('update-options', {table: 'interests', values: interests})) } - if (causes?.length) { - promises.push(api('update-options', {table: 'causes', names: causes})) + if (causes) { + promises.push(api('update-options', {table: 'causes', values: causes})) } - if (work?.length) { - promises.push(api('update-options', {table: 'work', names: work})) + if (work) { + promises.push(api('update-options', {table: 'work', values: work})) } try { await Promise.all(promises) diff --git a/web/components/profile-about.tsx b/web/components/profile-about.tsx index f98cc9b1..5357aed0 100644 --- a/web/components/profile-about.tsx +++ b/web/components/profile-about.tsx @@ -33,7 +33,7 @@ import {MAX_INT, MIN_INT} from "common/constants"; import {GiFruitBowl} from "react-icons/gi"; import {FaBriefcase, FaHandsHelping, FaHeart, FaStar} from "react-icons/fa"; import {useLocale, useT} from "web/lib/locale"; -import {toKey} from "common/parsing"; +import {useChoices} from "web/hooks/use-choices"; export function AboutRow(props: { icon: ReactNode @@ -79,6 +79,11 @@ export default function ProfileAbout(props: { }) { const {profile, userActivity, isCurrentUser} = props const t = useT() + const {choices: interestsById} = useChoices('interests') + const {choices: causesById} = useChoices('causes') + const {choices: workById} = useChoices('work') + const {locale} = useLocale() + return ( } - text={profile.work?.map(work => t(`profile.work.${toKey(work)}`, work))} + text={profile.work?.map(id => workById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]} /> } @@ -104,11 +109,11 @@ export default function ProfileAbout(props: { /> } - text={profile.interests?.map(interest => t(`profile.interests.${toKey(interest)}`, interest))} + text={profile.interests?.map(id => interestsById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]} /> } - text={profile.causes?.map(cause => t(`profile.causes.${toKey(cause)}`, cause))} + text={profile.causes?.map(id => causesById[id]).filter(Boolean).sort((a, b) => a.localeCompare(b, locale)) as string[]} /> } diff --git a/web/components/profiles/profiles-home.tsx b/web/components/profiles/profiles-home.tsx index 3d35f3c4..ab3d0d6c 100644 --- a/web/components/profiles/profiles-home.tsx +++ b/web/components/profiles/profiles-home.tsx @@ -14,7 +14,7 @@ import {useUser} from 'web/hooks/use-user' import {api} from 'web/lib/api' import {useBookmarkedSearches} from "web/hooks/use-bookmarked-searches" import {useFilters} from "web/components/filters/use-filters" -import {useT} from "web/lib/locale"; +import {useLocale, useT} from "web/lib/locale"; export function ProfilesHome() { const user = useUser() @@ -35,6 +35,7 @@ export function ProfilesHome() { const [isLoadingMore, setIsLoadingMore] = useState(false) const [isReloading, setIsReloading] = useState(false) const t = useT() + const {locale} = useLocale() // const [debouncedAgeRange, setRawAgeRange] = useState({ // min: filters.pref_age_min ?? PREF_AGE_MIN, @@ -56,6 +57,7 @@ export function ProfilesHome() { const args = removeNullOrUndefinedProps({ limit: 20, compatibleWithUserId: user?.id, + locale, ...filters }) console.debug('Refreshing profiles, filters:', args) @@ -87,6 +89,7 @@ export function ProfilesHome() { limit: 20, compatibleWithUserId: user?.id, after: lastProfile?.id.toString(), + locale, ...filters }) as any) if (result.profiles.length === 0) return false diff --git a/web/hooks/use-choices.ts b/web/hooks/use-choices.ts index aed3392c..ebc42ea9 100644 --- a/web/hooks/use-choices.ts +++ b/web/hooks/use-choices.ts @@ -2,20 +2,40 @@ import {useEffect} from 'react' import {run} from 'common/supabase/utils' import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state' import {db} from 'web/lib/supabase/db' +import {useLocale} from "web/lib/locale"; -export async function fetchChoices(label: string) { - const {data} = await run(db.from(label).select('name').order('name')) - console.log('Fetched choices:', data) - const results = Object.fromEntries(data.map((row: { name: string }) => [row.name, row.name])) - return results; +export async function fetchChoices(label: string, locale: string) { + let choicesById: Record = {}; + const {data} = await run( + db + .from(label) + .select(` + id, + name, + ${label}_translations!left ( + locale, + name + ) + `) + .eq(`${label}_translations.locale`, locale) + .order('name', {ascending: true}) + ) + + data.forEach((row: any) => { + const translated = row[`${label}_translations`]?.[0]?.name ?? row.name + choicesById[row.id] = translated + }) + + return choicesById } export const useChoices = (label: string) => { - const [choices, setChoices] = usePersistentInMemoryState({}, `${label}-choices`) + const [choices, setChoices] = usePersistentInMemoryState>({}, `${label}-choices`) + const {locale} = useLocale() const refreshChoices = async () => { try { - const results = await fetchChoices(label) + const results = await fetchChoices(label, locale) setChoices(results) } catch (err) { console.error('Error fetching choices:', err) @@ -26,7 +46,7 @@ export const useChoices = (label: string) => { useEffect(() => { console.log('Fetching choices in use effect...') refreshChoices() - }, []) + }, [locale]) return {choices, refreshChoices} } diff --git a/web/messages/de.json b/web/messages/de.json index f1c268db..0713383a 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -388,20 +388,6 @@ "profile.bio.tips_list": "- Ihre Grundwerte, Interessen und Aktivitäten\n- Persönlichkeitsmerkmale, was Sie einzigartig macht und was Ihnen wichtig ist\n- Verbindungsziele (Zusammenarbeit, Freundschaft, romantisch)\n- Erwartungen und Grenzen\n- Verfügbarkeit, wie man Sie kontaktieren oder ein Gespräch beginnen kann (E-Mail, soziale Netzwerke, etc.)\n- Optional: romantische Vorlieben, Lebensgewohnheiten und Gesprächsthemen", "profile.bio.too_short": "Bio zu kurz. Das Profil kann aus den Suchergebnissen gefiltert werden.", "profile.bio.too_short_tooltip": "Da Ihre Bio zu kurz ist, filtert der Compass-Algorithmus Ihr Profil aus den Suchergebnissen (außer „Kurze Bios einbeziehen“ ist ausgewählt). Dies stellt sicher, dass Suchergebnisse aussagekräftige Profile anzeigen.", - "profile.causes.ai_safety": "KI-Sicherheit", - "profile.causes.animal_rights": "Tierrechte", - "profile.causes.animal_welfare": "Tierschutz", - "profile.causes.biodiversity": "Biodiversität", - "profile.causes.cause_agnostic": "Ursachenagnostisch", - "profile.causes.climate_change": "Klimawandel", - "profile.causes.degrowth": "Degrowth", - "profile.causes.disaster_relief": "Katastrophenhilfe", - "profile.causes.education": "Bildung", - "profile.causes.extreme_poverty": "Extreme Armut", - "profile.causes.gender_equality": "Gleichstellung der Geschlechter", - "profile.causes.human_rights": "Menschenrechte", - "profile.causes.mental_health": "Psychische Gesundheit", - "profile.causes.sustainability": "Nachhaltigkeit", "profile.comments.current_user_hint": "Andere Benutzer können Ihnen hier Empfehlungen hinterlassen.", "profile.comments.add_comment": "Kommentar hinzufügen", "profile.comments.placeholder": "Schreiben Sie Ihre Empfehlung...", @@ -461,33 +447,6 @@ "profile.header.toast.failed_enable": "Aktivierung des Profils fehlgeschlagen", "profile.info.signup_to_see": "Registrieren Sie sich, um das Profil zu sehen", "profile.interested_in": "Interessiert an", - "profile.interests.chess": "Schach", - "profile.interests.coding": "Programmieren", - "profile.interests.collecting": "Sammeln", - "profile.interests.cooking": "Kochen", - "profile.interests.crafting": "Handwerk", - "profile.interests.crochet": "Häkeln", - "profile.interests.dancing": "Tanzen", - "profile.interests.drawing": "Zeichnen", - "profile.interests.game_design": "Spieleentwicklung", - "profile.interests.gardening": "Gärtnern", - "profile.interests.guitar": "Gitarre", - "profile.interests.gym": "Fitnessstudio", - "profile.interests.hiking": "Wandern", - "profile.interests.meditation": "Meditation", - "profile.interests.music_listening": "Musik hören", - "profile.interests.music_playing": "Musik spielen", - "profile.interests.painting": "Malen", - "profile.interests.philosophy": "Philosophie", - "profile.interests.photography": "Fotografie", - "profile.interests.piano": "Klavier", - "profile.interests.pottery": "Töpfern", - "profile.interests.reading": "Lesen", - "profile.interests.running": "Laufen", - "profile.interests.traveling": "Reisen", - "profile.interests.video_games": "Videospiele", - "profile.interests.writing": "Schreiben", - "profile.interests.yoga": "Yoga", "profile.language.akan": "Akan", "profile.language.amharic": "Amharisch", "profile.language.arabic": "Arabisch", @@ -705,34 +664,6 @@ "profile.wants_kids_2": "Neutral oder offen für Kinder", "profile.wants_kids_3": "Neigt dazu, Kinder zu wollen", "profile.wants_kids_4": "Möchte Kinder", - "profile.work.academia": "Akademischer Bereich", - "profile.work.agriculture": "Landwirtschaft", - "profile.work.construction": "Baugewerbe", - "profile.work.consulting": "Beratung", - "profile.work.content_design": "Content-Design", - "profile.work.data_science": "Data Science", - "profile.work.education": "Bildung", - "profile.work.engineering": "Ingenieurwesen", - "profile.work.fashion": "Mode", - "profile.work.film": "Film", - "profile.work.finance": "Finanzen", - "profile.work.graphic_design": "Grafikdesign", - "profile.work.human_resources": "Personalwesen", - "profile.work.it": "IT", - "profile.work.journalism": "Journalismus", - "profile.work.law": "Recht", - "profile.work.management": "Management", - "profile.work.marketing": "Marketing", - "profile.work.medicine": "Medizin", - "profile.work.music": "Musik", - "profile.work.nursing": "Krankenpflege", - "profile.work.research_and_design": "Forschung und Design", - "profile.work.sales": "Vertrieb", - "profile.work.social_enterprise": "Sozialunternehmen", - "profile.work.social_work": "Soziale Arbeit", - "profile.work.spirits_maker": "Spirituosenherstellung", - "profile.work.sports": "Sport", - "profile.work.tourism": "Tourismus", "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", diff --git a/web/messages/fr.json b/web/messages/fr.json index 49140ddb..3f46b44b 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -388,20 +388,6 @@ "profile.bio.tips_list": "- Vos valeurs fondamentales, intérêts et activités\n- Traits de personnalité, ce qui vous rend unique et ce qui vous tient à cœur\n- Objectifs de connexion (collaboration, amitié, romantique)\n- Attentes et limites\n- Disponibilité, comment vous contacter ou démarrer une conversation (e-mail, réseaux sociaux, etc.)\n- Optionnel : préférences romantiques, habitudes de vie et sujets de conversation", "profile.bio.too_short": "Bio trop courte. Le profil peut être filtré des résultats de recherche.", "profile.bio.too_short_tooltip": "Comme votre bio est trop courte, l'algorithme de Compass filtre votre profil des résultats de recherche (sauf si « Inclure les bios courtes » est sélectionné). Cela garantit que les recherches affichent des profils significatifs.", - "profile.causes.ai_safety": "Sécurité de l'IA", - "profile.causes.animal_rights": "Droits des animaux", - "profile.causes.animal_welfare": "Bien-être animal", - "profile.causes.biodiversity": "Biodiversité", - "profile.causes.cause_agnostic": "Cause agnostique", - "profile.causes.climate_change": "Changement climatique", - "profile.causes.degrowth": "Décroissance", - "profile.causes.disaster_relief": "Secours en cas de catastrophe", - "profile.causes.education": "Éducation", - "profile.causes.extreme_poverty": "Pauvreté extrême", - "profile.causes.gender_equality": "Égalité des genres", - "profile.causes.human_rights": "Droits humains", - "profile.causes.mental_health": "Santé mentale", - "profile.causes.sustainability": "Durabilité", "profile.comments.current_user_hint": "Les autres utilisateurs peuvent vous laisser des recommandations ici.", "profile.comments.add_comment": "Ajouter un commentaire", "profile.comments.placeholder": "Écrivez votre recommandation...", @@ -461,33 +447,6 @@ "profile.header.toast.failed_enable": "Échec de l'activation du profil", "profile.info.signup_to_see": "Inscrivez-vous pour voir le profil", "profile.interested_in": "Intéressé·e par", - "profile.interests.chess": "Échecs", - "profile.interests.coding": "Programmation", - "profile.interests.collecting": "Collection", - "profile.interests.cooking": "Cuisine", - "profile.interests.crafting": "Artisanat", - "profile.interests.crochet": "Crochet", - "profile.interests.dancing": "Danse", - "profile.interests.drawing": "Dessin", - "profile.interests.game_design": "Conception de jeux", - "profile.interests.gardening": "Jardinage", - "profile.interests.guitar": "Guitare", - "profile.interests.gym": "Salle de sport", - "profile.interests.hiking": "Randonnée", - "profile.interests.meditation": "Méditation", - "profile.interests.music_listening": "Écoute de musique", - "profile.interests.music_playing": "Pratique musicale", - "profile.interests.painting": "Peinture", - "profile.interests.philosophy": "Philosophie", - "profile.interests.photography": "Photographie", - "profile.interests.piano": "Piano", - "profile.interests.pottery": "Poterie", - "profile.interests.reading": "Lecture", - "profile.interests.running": "Course à pied", - "profile.interests.traveling": "Voyage", - "profile.interests.video_games": "Jeux vidéo", - "profile.interests.writing": "Écriture", - "profile.interests.yoga": "Yoga", "profile.language.akan": "Akan", "profile.language.amharic": "Amharique", "profile.language.arabic": "Arabe", @@ -705,36 +664,8 @@ "profile.wants_kids_2": "Neutre ou ouvert·e à avoir des enfants", "profile.wants_kids_3": "Tend à vouloir des enfants", "profile.wants_kids_4": "Veut des enfants", - "profile.work.academia": "Monde universitaire", - "profile.work.agriculture": "Agriculture", - "profile.work.construction": "Construction", - "profile.work.consulting": "Conseil", - "profile.work.content_design": "Design de contenu", - "profile.work.data_science": "Science des données", - "profile.work.education": "Éducation", - "profile.work.engineering": "Ingénierie", - "profile.work.fashion": "Mode", - "profile.work.film": "Cinéma", - "profile.work.finance": "Finance", - "profile.work.graphic_design": "Design graphique", - "profile.work.human_resources": "Ressources humaines", - "profile.work.it": "Informatique", - "profile.work.journalism": "Journalisme", - "profile.work.law": "Droit", - "profile.work.management": "Gestion", - "profile.work.marketing": "Marketing", - "profile.work.medicine": "Médecine", - "profile.work.music": "Musique", - "profile.work.nursing": "Soins infirmiers", - "profile.work.research_and_design": "Recherche et conception", - "profile.work.sales": "Vente", - "profile.work.social_enterprise": "Entreprise sociale", - "profile.work.social_work": "Travail social", - "profile.work.spirits_maker": "Fabricant de spiritueux", - "profile.work.sports": "Sport", - "profile.work.tourism": "Tourisme", "profile_grid.no_profiles": "Aucun profil trouvé.", - "profile_grid.notification_cta": "N'hésitez pas à cliquer sur \"Être notifié\" et nous vous préviendrons quand de nouveaux utilisateurs correspondront à votre recherche !", + "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", "register.agreement.and": " et ", "register.agreement.prefix": "En vous inscrivant, j'accepte les ", @@ -782,7 +713,7 @@ "saved_people.list_header": "Voici les personnes que vous avez enregistrées :", "saved_people.title": "Personnes enregistrées", "saved_searches.button": "Recherches enregistrées", - "saved_searches.empty_state": "Vous n'avez enregistré aucune recherche. Pour en enregistrer une, cliquez sur Être notifié et nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront.", + "saved_searches.empty_state": "Vous n'avez enregistré aucune recherche. Pour en enregistrer une, cliquez sur Recevoir notifs et nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront.", "saved_searches.notification_note": "Nous vous préviendrons quotidiennement lorsque de nouvelles personnes correspondront à vos recherches ci-dessous.", "saved_searches.title": "Recherches enregistrées", "security.contact.email_button": "E‑mail",