diff --git a/backend/api/src/get-compatibililty-questions.ts b/backend/api/src/get-compatibililty-questions.ts index 9433a7c3..211de7ba 100644 --- a/backend/api/src/get-compatibililty-questions.ts +++ b/backend/api/src/get-compatibililty-questions.ts @@ -3,20 +3,42 @@ import {createSupabaseDirectClient} from 'shared/supabase/init' import {Row} from 'common/supabase/utils' export function shuffle(array: T[]): T[] { - const arr = [...array]; // copy to avoid mutating the original + const arr = [...array] // copy to avoid mutating the original for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [arr[i], arr[j]] = [arr[j], arr[i]]; + const j = Math.floor(Math.random() * (i + 1)) + ;[arr[i], arr[j]] = [arr[j], arr[i]] } - return arr; + return arr } export const getCompatibilityQuestions: APIHandler< 'get-compatibility-questions' > = async (props, _auth) => { - const {locale = 'en'} = props + const {locale = 'en', keyword} = props const pg = createSupabaseDirectClient() + // Build query parameters + const params: (string | number)[] = [locale] + const paramIndex = 2 + + // Build keyword filter condition - search in question text and multiple_choice_options keys + const keywordFilter = keyword + ? `AND ( + COALESCE(cpt.question, cp.question) ILIKE $${paramIndex} + OR EXISTS ( + SELECT 1 + FROM jsonb_object_keys( + COALESCE(cpt.multiple_choice_options, cp.multiple_choice_options) + ) AS option_key + WHERE option_key ILIKE $${paramIndex} + ) + )` + : '' + + if (keyword) { + params.push(`%${keyword}%`) + } + const questions = await pg.manyOrNone< Row<'compatibility_prompts'> & { answer_count: number; score: number } >( @@ -52,6 +74,7 @@ export const getCompatibilityQuestions: APIHandler< AND $1 <> 'en' WHERE cp.answer_type = 'compatibility_multiple_choice' + ${keywordFilter} GROUP BY cp.id, cpt.question, @@ -59,7 +82,7 @@ export const getCompatibilityQuestions: APIHandler< ORDER BY cp.importance_score `, - [locale] + params ) // console.debug({questions}) diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts index a213ccca..f8877273 100644 --- a/common/src/api/schema.ts +++ b/common/src/api/schema.ts @@ -1,7 +1,7 @@ import {arraybeSchema, baseProfilesSchema, combinedProfileSchema, contentSchema, zBoolean,} from 'common/api/zod-types' import {PrivateChatMessage} from 'common/chat-message' import {CompatibilityScore} from 'common/profiles/compatibility-score' -import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES} from 'common/profiles/constants' +import {MAX_COMPATIBILITY_QUESTION_LENGTH, OPTION_TABLES,} from 'common/profiles/constants' import {Profile, ProfileRow} from 'common/profiles/profile' import {Row} from 'common/supabase/utils' import {PrivateUser, User} from 'common/user' @@ -49,7 +49,12 @@ export const API = (_apiTypeCheck = { message: 'Server is working.' uid?: string version?: string - git?: { revision?: string; commitDate?: string; author?: string, message?: string } + git?: { + revision?: string + commitDate?: string + author?: string + message?: string + } }, summary: 'Check whether the API server is running', tag: 'General', @@ -342,7 +347,8 @@ export const API = (_apiTypeCheck = { authed: true, rateLimited: false, props: z.object({ - locale: z.string().optional() + locale: z.string().optional(), + keyword: z.string().optional(), }), returns: {} as { status: 'success' @@ -470,10 +476,12 @@ export const API = (_apiTypeCheck = { method: 'GET', authed: true, rateLimited: true, - props: z.object({ - limit: z.coerce.number().min(1).max(200).optional(), - offset: z.coerce.number().min(0).optional(), - }).strict(), + props: z + .object({ + limit: z.coerce.number().min(1).max(200).optional(), + offset: z.coerce.number().min(0).optional(), + }) + .strict(), returns: {} as { status: 'success' hidden: { @@ -545,8 +553,8 @@ export const API = (_apiTypeCheck = { .strict(), returns: {} as { status: 'success' | 'fail' - profiles: Profile[], - count: number, + profiles: Profile[] + count: number }, summary: 'List profiles with filters, pagination and ordering', tag: 'Profiles', @@ -788,7 +796,7 @@ export const API = (_apiTypeCheck = { summary: 'Create a new vote/poll', tag: 'Votes', }, - 'vote': { + vote: { method: 'POST', authed: true, rateLimited: true, @@ -825,7 +833,7 @@ export const API = (_apiTypeCheck = { summary: 'Find places near a GeoDB city ID within a radius', tag: 'Locations', }, - 'contact': { + contact: { method: 'POST', authed: false, rateLimited: true, @@ -852,7 +860,7 @@ export const API = (_apiTypeCheck = { rateLimited: true, returns: {} as any, props: z.object({ - subscription: z.record(z.any()) + subscription: z.record(z.any()), }), summary: 'Save a push/browser subscription for the user', tag: 'Notifications', @@ -873,12 +881,11 @@ export const API = (_apiTypeCheck = { authed: true, rateLimited: true, returns: {} as Row<'bookmarked_searches'>, - props: z - .object({ - search_filters: z.any().optional(), - location: z.any().optional(), - search_name: z.string().nullable().optional(), - }), + props: z.object({ + search_filters: z.any().optional(), + location: z.any().optional(), + search_name: z.string().nullable().optional(), + }), summary: 'Create a bookmarked search for quick reuse', tag: 'Searches', }, @@ -988,11 +995,12 @@ export type ValidatedAPIParams = z.output< APISchema['props'] > -export type APIResponse = APISchema extends { - returns: Record - } - ? APISchema['returns'] - : void +export type APIResponse = + APISchema extends { + returns: Record + } + ? APISchema['returns'] + : void export type APIResponseOptionalContinue = | { continue: () => Promise; result: APIResponse } diff --git a/web/hooks/use-questions.ts b/web/hooks/use-questions.ts index 0cc836ee..7c6df918 100644 --- a/web/hooks/use-questions.ts +++ b/web/hooks/use-questions.ts @@ -10,7 +10,7 @@ import { } from 'web/lib/supabase/questions' import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state' import {api} from 'web/lib/api' -import {useLocale} from "web/lib/locale"; +import {useLocale} from 'web/lib/locale' export const useQuestions = () => { const [questions, setQuestions] = useState[]>([]) @@ -44,7 +44,7 @@ export const useUserAnswers = (userId: string | undefined) => { getUserAnswers(userId).then(setAnswers) } - return { refreshAnswers, answers } + return {refreshAnswers, answers} } export const useUserCompatibilityAnswers = (userId: string | undefined) => { @@ -72,7 +72,7 @@ export const useUserCompatibilityAnswers = (userId: string | undefined) => { getUserCompatibilityAnswers(userId).then(setCompatibilityAnswers) } - return { refreshCompatibilityAnswers, compatibilityAnswers } + return {refreshCompatibilityAnswers, compatibilityAnswers} } export type QuestionWithCountType = Row<'compatibility_prompts'> & { @@ -93,26 +93,32 @@ export const useFRQuestionsWithAnswerCount = () => { return FRquestionsWithCount as QuestionWithCountType[] } -export const useCompatibilityQuestionsWithAnswerCount = () => { +export const useCompatibilityQuestionsWithAnswerCount = (keyword?: string) => { const {locale} = useLocale() const [compatibilityQuestions, setCompatibilityQuestions] = usePersistentInMemoryState( [], `compatibility-questions-with-count` ) + const [isLoading, setIsLoading] = useState(true) async function refreshCompatibilityQuestions() { - return api('get-compatibility-questions', {locale}).then((res) => { - setCompatibilityQuestions(res.questions) - }) + setIsLoading(true) + return api('get-compatibility-questions', {locale, keyword}).then( + (res) => { + setCompatibilityQuestions(res.questions) + setIsLoading(false) + } + ) } useEffect(() => { refreshCompatibilityQuestions() - }, [locale]) + }, [locale, keyword]) return { refreshCompatibilityQuestions, compatibilityQuestions, + isLoading, } } diff --git a/web/messages/de.json b/web/messages/de.json index 4b71aba0..fabf82fa 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -149,6 +149,9 @@ "compatibility.tabs.to_answer": "Zu beantworten", "compatibility.title": "Ihre Kompatibilitätsfragen", "compatibility.tooltip": "Kompatibilitätswert zwischen Ihnen beiden", + "compatibility.empty.no_results": "Keine Ergebnisse für \"{keyword}\"", + "compatibility.seo.title": "Kompatibilität", + "compatibility.seo.description": "Ihre Kompatibilitätsfragen anzeigen und verwalten", "contact.editor.placeholder": "Kontaktieren Sie uns hier...", "contact.form_link": "Feedback-Formular", "contact.intro_middle": " oder über unsere ", @@ -1136,5 +1139,6 @@ "filter.label.diet": "Ernährung", "filter.label.political_beliefs": "Politische Ansichten", "filter.label.mbti": "MBTI", - "filter.drinks.per_month": "pro Monat" + "filter.drinks.per_month": "pro Monat", + "compatibility.search_placeholder": "Fragen und Antworten durchsuchen..." } diff --git a/web/messages/fr.json b/web/messages/fr.json index ad4077b1..68c7f075 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -1136,5 +1136,9 @@ "filter.label.diet": "Régime", "filter.label.political_beliefs": "Opinions politiques", "filter.label.mbti": "MBTI", - "filter.drinks.per_month": "par mois" + "filter.drinks.per_month": "par mois", + "compatibility.search_placeholder": "Rechercher des questions et réponses...", + "compatibility.seo.title": "Compatibilité", + "compatibility.seo.description": "Afficher et gérer vos questions de compatibilité", + "compatibility.empty.no_results": "Aucun résultat pour \"{keyword}\"" } diff --git a/web/pages/compatibility.tsx b/web/pages/compatibility.tsx index e55266f8..ed8b36d3 100644 --- a/web/pages/compatibility.tsx +++ b/web/pages/compatibility.tsx @@ -1,18 +1,22 @@ import {useUser} from 'web/hooks/use-user' -import {useCompatibilityQuestionsWithAnswerCount, useUserCompatibilityAnswers} from 'web/hooks/use-questions' -import {useCallback, useEffect, useMemo, useState} from 'react' +import {useCompatibilityQuestionsWithAnswerCount, useUserCompatibilityAnswers,} from 'web/hooks/use-questions' +import {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {Row} from 'common/supabase/utils' import {Question} from 'web/lib/supabase/questions' import {Col} from 'web/components/layout/col' import {Title} from 'web/components/widgets/title' -import {PageBase} from "web/components/page-base"; -import {UncontrolledTabs} from "web/components/layout/tabs"; -import {CompatibilityAnswerBlock} from "web/components/answers/compatibility-questions-display"; -import {User} from "common/user"; -import {CompassLoadingIndicator} from "web/components/widgets/loading-indicator"; -import {useIsMobile} from "web/hooks/use-is-mobile"; -import {LoadMoreUntilNotVisible} from "web/components/widgets/visibility-observer"; +import {PageBase} from 'web/components/page-base' +import {UncontrolledTabs} from 'web/components/layout/tabs' +import {CompatibilityAnswerBlock} from 'web/components/answers/compatibility-questions-display' +import {User} from 'common/user' +import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator' +import {useIsMobile} from 'web/hooks/use-is-mobile' +import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer' import {useT} from 'web/lib/locale' +import {Input} from 'web/components/widgets/input' +import {debounce} from 'lodash' +import clsx from "clsx"; +import {SEO} from "web/components/SEO"; type QuestionWithAnswer = Question & { answer?: Row<'compatibility_answers'> @@ -24,11 +28,27 @@ export default function CompatibilityPage() { const user = useUser() const isMobile = useIsMobile() const sep = isMobile ? '\n' : '' - const {compatibilityAnswers, refreshCompatibilityAnswers} = useUserCompatibilityAnswers(user?.id) - const {compatibilityQuestions, refreshCompatibilityQuestions} = useCompatibilityQuestionsWithAnswerCount() - const [isLoading, setIsLoading] = useState(true) + const [keyword, setKeyword] = useState('') + const [debouncedKeyword, setDebouncedKeyword] = useState('') + const searchInputRef = useRef(null) + const {compatibilityAnswers, refreshCompatibilityAnswers} = + useUserCompatibilityAnswers(user?.id) + const {compatibilityQuestions, refreshCompatibilityQuestions, isLoading} = + useCompatibilityQuestionsWithAnswerCount(debouncedKeyword || undefined) const t = useT() + // Debounce keyword changes + const debouncedSetKeyword = useMemo( + () => debounce((value: string) => setDebouncedKeyword(value), 500), + [] + ) + + useEffect(() => { + debouncedSetKeyword(keyword) + // Cleanup debounce on unmount + return () => debouncedSetKeyword.cancel() + }, [keyword, debouncedSetKeyword]) + const questionsWithAnswers = useMemo(() => { if (!compatibilityQuestions) return [] @@ -36,12 +56,14 @@ export default function CompatibilityPage() { compatibilityAnswers?.map((a) => [a.question_id, a]) ?? [] ) - return compatibilityQuestions.map((q) => ({ - ...q, - answer: answerMap.get(q.id), - })).sort( - (a, b) => a.importance_score - b.importance_score - ) as QuestionWithAnswer[] + return compatibilityQuestions + .map((q) => ({ + ...q, + answer: answerMap.get(q.id), + })) + .sort( + (a, b) => a.importance_score - b.importance_score + ) as QuestionWithAnswer[] }, [compatibilityQuestions, compatibilityAnswers]) const {answered, notAnswered, skipped} = useMemo(() => { @@ -69,7 +91,7 @@ export default function CompatibilityPage() { Promise.all([ refreshCompatibilityAnswers(), refreshCompatibilityQuestions(), - ]).finally(() => setIsLoading(false)) + ]).finally(() => console.log('refreshed compatibility')) } }, [user?.id]) @@ -80,15 +102,34 @@ export default function CompatibilityPage() { return ( - {user ? + + {user ? ( - {t('compatibility.title','Your Compatibility Questions')} + + {t('compatibility.title', 'Your Compatibility Questions')} + + { + setKeyword(e.target.value) + }} + /> ), }, { - title: `${t('compatibility.tabs.to_answer','To Answer')} ${sep}(${notAnswered.length})`, + title: `${t('compatibility.tabs.to_answer', 'To Answer')} ${sep}(${notAnswered.length})`, content: ( ), }, { - title: `${t('compatibility.tabs.skipped','Skipped')} ${sep}(${skipped.length})`, + title: `${t('compatibility.tabs.skipped', 'Skipped')} ${sep}(${skipped.length})`, content: ( ), }, ]} /> - : + ) : (
-
{t('compatibility.sign_in_prompt','Please sign in to view your compatibility questions')}
+
+ {t( + 'compatibility.sign_in_prompt', + 'Please sign in to view your compatibility questions' + )} +
- } + )}
) } @@ -141,12 +190,14 @@ function QuestionList({ isLoading, user, refreshCompatibilityAll, + keyword, }: { questions: QuestionWithAnswer[] status: 'answered' | 'not-answered' | 'skipped' isLoading: boolean user: User refreshCompatibilityAll: () => void + keyword: string }) { const t = useT() const BATCH_SIZE = 100 @@ -165,18 +216,38 @@ function QuestionList({ setVisibleCount((prev) => Math.min(prev + BATCH_SIZE, questions.length)) console.log('end loadMore') return true - }, [visibleCount, questions.length]); + }, [visibleCount, questions.length]) - if (isLoading) { + if (isLoading && questions.length === 0) { return } - if (questions.length === 0) { + if (!isLoading && questions.length === 0) { return (
- {status === 'answered' && t('compatibility.empty.answered',"You haven't answered any questions yet.")} - {status === 'not-answered' && t('compatibility.empty.not_answered',"All questions have been answered!")} - {status === 'skipped' && t('compatibility.empty.skipped',"You haven't skipped any questions.")} + {keyword ? ( + t('compatibility.empty.no_results', 'No results for "{keyword}"', { + keyword, + }) + ) : ( + <> + {status === 'answered' && + t( + 'compatibility.empty.answered', + "You haven't answered any questions yet." + )} + {status === 'not-answered' && + t( + 'compatibility.empty.not_answered', + 'All questions have been answered!' + )} + {status === 'skipped' && + t( + 'compatibility.empty.skipped', + "You haven't skipped any questions." + )} + + )}
) } @@ -188,7 +259,10 @@ function QuestionList({ {visibleQuestions.map((q) => (