Add /compatibility page to browse all the questions

This commit is contained in:
MartinBraquet
2025-11-13 20:10:53 +01:00
parent 7c4d66bbf5
commit 314037dd06
9 changed files with 375 additions and 105 deletions

View File

@@ -13,6 +13,7 @@ import {getCompatibleProfilesHandler} from './compatible-profiles'
import {createComment} from './create-comment'
import {createCompatibilityQuestion} from './create-compatibility-question'
import {setCompatibilityAnswer} from './set-compatibility-answer'
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
@@ -341,6 +342,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'hide-comment': hideComment,
'create-compatibility-question': createCompatibilityQuestion,
'set-compatibility-answer': setCompatibilityAnswer,
'delete-compatibility-answer': deleteCompatibilityAnswer,
'create-vote': createVote,
'vote': vote,
'contact': contact,

View File

@@ -0,0 +1,30 @@
import {APIHandler} from 'api/helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError} from 'common/api/utils'
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
{id}, auth) => {
const pg = createSupabaseDirectClient()
// Verify user is the answer author
const item = await pg.oneOrNone(
`SELECT *
FROM compatibility_answers
WHERE id = $1
AND creator_id = $2`,
[id, auth.uid]
)
if (!item) {
throw new APIError(404, 'Item not found')
}
// Delete the answer
await pg.none(
`DELETE
FROM compatibility_answers
WHERE id = $1
AND creator_id = $2`,
[id, auth.uid]
)
}

View File

@@ -295,6 +295,47 @@ export const API = (_apiTypeCheck = {
summary: 'Remove the pinned photo from a profile',
tag: 'Profiles',
},
'create-compatibility-question': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
options: z.record(z.string(), z.number()),
}),
summary: 'Create a new compatibility question with options',
tag: 'Compatibility',
},
'set-compatibility-answer': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as Row<'compatibility_answers'>,
props: z
.object({
questionId: z.number(),
multipleChoice: z.number(),
prefChoices: z.array(z.number()),
importance: z.number(),
explanation: z.string().nullable().optional(),
})
.strict(),
summary: 'Submit or update a compatibility answer',
tag: 'Compatibility',
},
'get-profile-answers': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}).strict(),
returns: {} as {
status: 'success'
answers: Row<'compatibility_answers'>[]
},
summary: 'Get compatibility answers for a profile',
tag: 'Compatibility',
},
'get-compatibility-questions': {
method: 'GET',
authed: true,
@@ -310,6 +351,16 @@ export const API = (_apiTypeCheck = {
summary: 'Retrieve compatibility questions and stats',
tag: 'Compatibility',
},
'delete-compatibility-answer': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
id: z.number(),
}),
summary: 'Delete a compatibility question',
tag: 'Compatibility',
},
'like-profile': {
method: 'POST',
authed: true,
@@ -626,47 +677,6 @@ export const API = (_apiTypeCheck = {
// summary: 'Get reactions for a message',
// tag: 'Messages',
// },
'create-compatibility-question': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
options: z.record(z.string(), z.number()),
}),
summary: 'Create a new compatibility question with options',
tag: 'Compatibility',
},
'set-compatibility-answer': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as Row<'compatibility_answers'>,
props: z
.object({
questionId: z.number(),
multipleChoice: z.number(),
prefChoices: z.array(z.number()),
importance: z.number(),
explanation: z.string().nullable().optional(),
})
.strict(),
summary: 'Submit or update a compatibility answer',
tag: 'Compatibility',
},
'get-profile-answers': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}).strict(),
returns: {} as {
status: 'success'
answers: Row<'compatibility_answers'>[]
},
summary: 'Get compatibility answers for a profile',
tag: 'Compatibility',
},
'create-vote': {
method: 'POST',
authed: true,

View File

@@ -1,11 +1,12 @@
import { User } from 'common/user'
import { QuestionWithCountType } from 'web/hooks/use-questions'
import { useState } from 'react'
import { Button } from 'web/components/buttons/button'
import { Col } from 'web/components/layout/col'
import { MODAL_CLASS, Modal } from 'web/components/layout/modal'
import { AnswerCompatibilityQuestionContent } from './answer-compatibility-question-content'
import {User} from 'common/user'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {useState} from 'react'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {AnswerCompatibilityQuestionContent} from './answer-compatibility-question-content'
import router from "next/router";
import Link from "next/link";
export function AnswerCompatibilityQuestionButton(props: {
user: User | null | undefined
@@ -29,7 +30,7 @@ export function AnswerCompatibilityQuestionButton(props: {
return (
<>
{size === 'md' ? (
<Button onClick={() => setOpen(true)} color="gray-outline">
<Button onClick={() => setOpen(true)} color="none" className={'px-3 py-2 rounded-md border border-primary-600 text-ink-700 hover:bg-primary-50 hover:text-ink-900'}>
Answer Questions{' '}
<span className="text-primary-600 ml-2">
+{questionsToAnswer.length}
@@ -57,12 +58,21 @@ export function AnswerCompatibilityQuestionButton(props: {
)
}
export function CompatibilityPageButton() {
return (
<Link
href="/compatibility"
className="px-3 py-2 rounded-md border border-primary-600 text-ink-700 hover:bg-primary-50 flex items-center justify-center text-center"
>View List of Questions</Link>
)
}
export function AnswerSkippedCompatibilityQuestionsButton(props: {
user: User | null | undefined
skippedQuestions: QuestionWithCountType[]
refreshCompatibilityAll: () => void
}) {
const { user, skippedQuestions, refreshCompatibilityAll } = props
const {user, skippedQuestions, refreshCompatibilityAll} = props
const [open, setOpen] = useState(false)
if (!user) return null
return (
@@ -92,7 +102,7 @@ function AnswerCompatibilityQuestionModal(props: {
refreshCompatibilityAll: () => void
onClose?: () => void
}) {
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose } = props
const {open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose} = props
const [questionIndex, setQuestionIndex] = useState(0)
return (
<Modal

View File

@@ -1,7 +1,7 @@
import {RadioGroup} from '@headlessui/react'
import {UserIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import {Row as rowFor, run} from 'common/supabase/utils'
import {Row as rowFor} from 'common/supabase/utils'
import {User} from 'common/user'
import {shortenNumber} from 'common/util/format'
import {sortBy} from 'lodash'
@@ -15,10 +15,9 @@ import {RadioToggleGroup} from 'web/components/widgets/radio-toggle-group'
import {Tooltip} from 'web/components/widgets/tooltip'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {track} from 'web/lib/service/analytics'
import {db} from 'web/lib/supabase/db'
import {api} from 'web/lib/api'
import {filterKeys} from '../questions-form'
import toast from "react-hot-toast";
import toast from "react-hot-toast"
export type CompatibilityAnswerSubmitType = Omit<
rowFor<'compatibility_answers'>,
@@ -70,9 +69,9 @@ export const submitCompatibilityAnswer = async (
// Track only if upsert succeeds
track('answer compatibility question', {
...newAnswer,
});
})
} catch (error) {
console.error('Failed to set compatibility answer:', error);
console.error('Failed to set compatibility answer:', error)
toast.error('Error submitting. Try again?')
}
}
@@ -83,20 +82,15 @@ export const deleteCompatibilityAnswer = async (
) => {
if (!userId || !id) return
try {
await run(
db
.from('compatibility_answers')
.delete()
.match({id: id, creator_id: userId})
)
await track('delete compatibility question', {id});
await api('delete-compatibility-answer', {id})
await track('delete compatibility question', {id})
} catch (error) {
console.error('Failed to delete prompt answer:', error);
console.error('Failed to delete prompt answer:', error)
toast.error('Error deleting. Try again?')
}
}
function getEmptyAnswer(userId: string, questionId: number) {
export function getEmptyAnswer(userId: string, questionId: number) {
return {
creator_id: userId,
explanation: null,
@@ -112,7 +106,7 @@ export function AnswerCompatibilityQuestionContent(props: {
user: User
index?: number
total?: number
answer?: rowFor<'compatibility_answers'> | null
answer?: CompatibilityAnswerSubmitType | null
onSubmit: () => void
onNext?: () => void
isLastQuestion: boolean
@@ -160,11 +154,11 @@ export function AnswerCompatibilityQuestionContent(props: {
return (
<Col className="h-full w-full gap-4">
<Col className="gap-1">
{compatibilityQuestion.importance_score > 0 && <Row className="text-blue-400 -mt-4 w-full justify-start text-sm">
<span>
Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.
</span>
</Row>}
{/*{compatibilityQuestion.importance_score > 0 && <Row className="text-blue-400 -mt-4 w-full justify-start text-sm">*/}
{/* <span>*/}
{/* Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.*/}
{/* </span>*/}
{/*</Row>}*/}
{index !== null &&
index !== undefined &&
total !== null &&

View File

@@ -22,11 +22,11 @@ import {Subtitle} from '../widgets/profile-subtitle'
import {AddCompatibilityQuestionButton} from './add-compatibility-question-button'
import {
AnswerCompatibilityQuestionButton,
AnswerSkippedCompatibilityQuestionsButton,
AnswerSkippedCompatibilityQuestionsButton, CompatibilityPageButton,
} from './answer-compatibility-question-button'
import {
AnswerCompatibilityQuestionContent,
deleteCompatibilityAnswer,
AnswerCompatibilityQuestionContent, CompatibilityAnswerSubmitType,
deleteCompatibilityAnswer, getEmptyAnswer,
IMPORTANCE_CHOICES,
IMPORTANCE_DISPLAY_COLORS,
} from './answer-compatibility-question-content'
@@ -41,7 +41,7 @@ import {buildArray} from 'common/util/array'
const NUM_QUESTIONS_TO_SHOW = 8
function separateQuestionsArray(
export function separateQuestionsArray(
questions: QuestionWithCountType[],
skippedAnswerQuestionIds: Set<number>,
answeredQuestionIds: Set<number>
@@ -199,7 +199,7 @@ export function CompatibilityQuestionsDisplay(props: {
<span className="text-ink-600 text-sm">
Answer more questions to increase your compatibility scoresor{' '}
</span>
)}
)}
<AddCompatibilityQuestionButton
refreshCompatibilityAll={refreshCompatibilityAll}
/>
@@ -225,12 +225,15 @@ export function CompatibilityQuestionsDisplay(props: {
</>
)}
{otherQuestions.length >= 1 && isCurrentUser && !fromProfilePage && (
<AnswerCompatibilityQuestionButton
user={user}
otherQuestions={otherQuestions}
refreshCompatibilityAll={refreshCompatibilityAll}
fromSignup={fromSignup}
/>
<Row className={'w-full justify-center gap-8'}>
<AnswerCompatibilityQuestionButton
user={user}
otherQuestions={otherQuestions}
refreshCompatibilityAll={refreshCompatibilityAll}
fromSignup={fromSignup}
/>
<CompatibilityPageButton/>
</Row>
)}
{skippedQuestions.length > 0 && isCurrentUser && (
<Row className="w-full justify-end">
@@ -300,12 +303,13 @@ function CompatibilitySortWidget(props: {
)
}
function CompatibilityAnswerBlock(props: {
answer: rowFor<'compatibility_answers'>
export function CompatibilityAnswerBlock(props: {
answer?: rowFor<'compatibility_answers'>
yourQuestions: QuestionWithCountType[]
question?: QuestionWithCountType
user: User
isCurrentUser: boolean
profile: Profile
profile?: Profile
refreshCompatibilityAll: () => void
fromProfilePage?: Profile
}) {
@@ -318,11 +322,17 @@ function CompatibilityAnswerBlock(props: {
refreshCompatibilityAll,
fromProfilePage,
} = props
const question = yourQuestions.find((q) => q.id === answer.question_id)
const question = props.question || yourQuestions.find((q) => q.id === answer?.question_id)
const [editOpen, setEditOpen] = useState<boolean>(false)
const currentUser = useUser()
const currentProfile = useProfile()
const [newAnswer, setNewAnswer] = useState<CompatibilityAnswerSubmitType | undefined>(props.answer)
useEffect(() => {
setNewAnswer(props.answer)
}, [props.answer]);
const comparedProfile = isCurrentUser
? null
: !!fromProfilePage
@@ -332,26 +342,27 @@ function CompatibilityAnswerBlock(props: {
if (
!question ||
!question.multiple_choice_options ||
answer.multiple_choice == null
answer && answer?.multiple_choice == null
)
return null
const answerText = getStringKeyFromNumValue(
const answerText = answer ? getStringKeyFromNumValue(
answer.multiple_choice,
question.multiple_choice_options as Record<string, number>
)
const preferredAnswersText = answer.pref_choices.map((choice) =>
) : null
const preferredAnswersText = answer ? answer.pref_choices.map((choice) =>
getStringKeyFromNumValue(
choice,
question.multiple_choice_options as Record<string, number>
)
)
) : []
const distinctPreferredAnswersText = preferredAnswersText.filter(
(text) => text !== answerText
)
const preferredDoesNotIncludeAnswerText =
!preferredAnswersText.includes(answerText)
answerText && !preferredAnswersText.includes(answerText)
const isAnswered = answer && answer.multiple_choice > -1
return (
<Col
className={
@@ -361,7 +372,7 @@ function CompatibilityAnswerBlock(props: {
<Row className="text-ink-800 justify-between gap-1 font-semibold">
{question.question}
<Row className="gap-4 font-normal">
{comparedProfile && (
{comparedProfile && isAnswered && (
<div className="hidden sm:block">
<CompatibilityDisplay
question={question}
@@ -373,7 +384,7 @@ function CompatibilityAnswerBlock(props: {
/>
</div>
)}
{isCurrentUser && (
{isCurrentUser && isAnswered && (
<>
<ImportanceButton
className="hidden sm:block"
@@ -402,11 +413,11 @@ function CompatibilityAnswerBlock(props: {
)}
</Row>
</Row>
<Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
{answerText && <Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
{answerText}
</Row>
</Row>}
<Row className="px-2 -mt-4">
{answer.explanation && (
{answer?.explanation && (
<Linkify className="" text={answer.explanation}/>
)}
</Row>
@@ -429,9 +440,29 @@ function CompatibilityAnswerBlock(props: {
</Row>
</Col>
)}
{!isAnswered && (
<Row className="flex-wrap gap-2 mt-0">
{sortBy(
Object.entries(question.multiple_choice_options),
1
).map(([label]) => label).map((label, i) => (
<button
onClick={() => {
const _answer = getEmptyAnswer(user.id, question.id)
_answer.multiple_choice = i
setNewAnswer(_answer)
setEditOpen(true)
}}
className="bg-canvas-100 hover:bg-canvas-200 w-fit gap-1 rounded px-2 py-1 text-sm"
>
{label}
</button>
))}
</Row>
)}
<Col>
{comparedProfile && (
{comparedProfile && isAnswered && (
<Row className="w-full justify-end sm:hidden">
<CompatibilityDisplay
question={question}
@@ -443,7 +474,7 @@ function CompatibilityAnswerBlock(props: {
/>
</Row>
)}
{isCurrentUser && (
{isCurrentUser && isAnswered && (
<Row className="w-full justify-end sm:hidden">
<ImportanceButton
importance={answer.importance}
@@ -451,13 +482,14 @@ function CompatibilityAnswerBlock(props: {
/>
</Row>
)}
{/*{question.importance_score == 0 && <div className="text-ink-500 text-sm">Core Question</div>}*/}
</Col>
<Modal open={editOpen} setOpen={setEditOpen}>
<Col className={MODAL_CLASS}>
<AnswerCompatibilityQuestionContent
key={`edit answer.id`}
compatibilityQuestion={question}
answer={answer}
answer={newAnswer}
user={user}
onSubmit={() => {
setEditOpen(false)
@@ -474,7 +506,7 @@ function CompatibilityAnswerBlock(props: {
function CompatibilityDisplay(props: {
question: QuestionWithCountType
profile1: Profile
profile1?: Profile
profile2: Profile
answer1: rowFor<'compatibility_answers'>
currentUserIsComparedProfile: boolean
@@ -514,7 +546,7 @@ function CompatibilityDisplay(props: {
const [open, setOpen] = useState(false)
if (profile1.id === profile2.id) return null
if (!profile1 || profile1.id === profile2.id) return null
const showCreateAnswer =
(!answer2 || answer2.importance == -1) &&

View File

@@ -81,7 +81,7 @@ export const Button = forwardRef(function Button(
props: {
className?: string
size?: SizeType
color?: ColorType
color?: ColorType | null
type?: 'button' | 'reset' | 'submit'
loading?: boolean
} & JSX.IntrinsicElements['button'],
@@ -101,7 +101,7 @@ export const Button = forwardRef(function Button(
return (
<button
type={type}
className={clsx(buttonClass(size, color), className)}
className={clsx(color && buttonClass(size, color), className)}
disabled={disabled || loading}
ref={ref}
{...rest}

View File

@@ -166,7 +166,11 @@ export function ControlledTabs(props: TabProps & { activeIndex: number }) {
<Row className="justify-center">{tab.stackedTabIcon}</Row>
)}
<Row className={'items-center'}>
{tab.title}
<Col>
{tab.title.split('\n').map((line, _i) => (
<Row className={'items-center justify-center'}>{line}</Row>
))}
</Col>
{tab.inlineTabIcon}
</Row>
</Tooltip>
@@ -212,6 +216,7 @@ export function UncontrolledTabs(props: TabProps & { defaultIndex?: number }) {
setActiveIndex(i)
onClick?.(titleOrQueryTitle, i)
}}
labelsParentClassName={'gap-0 xs:gap-4'}
/>
)
}

187
web/pages/compatibility.tsx Normal file
View File

@@ -0,0 +1,187 @@
import {useUser} from 'web/hooks/use-user'
import {useCompatibilityQuestionsWithAnswerCount, useUserCompatibilityAnswers} from 'web/hooks/use-questions'
import {useEffect, useMemo, 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";
type QuestionWithAnswer = Question & {
answer?: Row<'compatibility_answers'>
answer_count: number
score: number
}
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 questionsWithAnswers = useMemo(() => {
if (!compatibilityQuestions) return []
const answerMap = new Map(
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[]
}, [compatibilityQuestions, compatibilityAnswers])
const {answered, notAnswered, skipped} = useMemo(() => {
const answered: QuestionWithAnswer[] = []
const notAnswered: QuestionWithAnswer[] = []
const skipped: QuestionWithAnswer[] = []
questionsWithAnswers.forEach((q) => {
if (q.answer) {
if (q.answer.multiple_choice === -1) {
skipped.push(q)
} else {
answered.push(q)
}
} else {
notAnswered.push(q)
}
})
return {answered, notAnswered, skipped}
}, [questionsWithAnswers])
useEffect(() => {
if (user?.id) {
Promise.all([
refreshCompatibilityAnswers(),
refreshCompatibilityQuestions(),
]).finally(() => setIsLoading(false))
}
}, [user?.id])
const refreshCompatibilityAll = () => {
refreshCompatibilityAnswers()
refreshCompatibilityQuestions()
}
if (!user) {
return (
<PageBase trackPageView={'compatibility'}>
<div className="flex h-full flex-col items-center justify-center">
<div className="text-xl">Please sign in to view your compatibility questions</div>
</div>
</PageBase>
)
}
return (
<PageBase trackPageView={'compatibility'}>
<Col className="w-full p-4">
<Title className="mb-4">Your Compatibility Questions</Title>
<UncontrolledTabs
trackingName={'compatibility page'}
tabs={[
{
title: `Answered ${sep}(${answered.length})`,
content: (
<QuestionList
questions={answered}
status="answered"
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
/>
),
},
{
title: `To Answer ${sep}(${notAnswered.length})`,
content: (
<QuestionList
questions={notAnswered}
status="not-answered"
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
/>
),
},
{
title: `Skipped ${sep}(${skipped.length})`,
content: (
<QuestionList
questions={skipped}
status="skipped"
isLoading={isLoading}
user={user}
refreshCompatibilityAll={refreshCompatibilityAll}
/>
),
},
]}
/>
</Col>
</PageBase>
)
}
function QuestionList({
questions,
status,
isLoading,
user,
refreshCompatibilityAll,
}: {
questions: QuestionWithAnswer[]
status: 'answered' | 'not-answered' | 'skipped'
isLoading: boolean
user: User
refreshCompatibilityAll: () => void
}) {
if (isLoading) {
return <CompassLoadingIndicator/>
}
if (questions.length === 0) {
return (
<div className="text-ink-500 p-4">
{status === 'answered' && 'You haven\'t answered any questions yet.'}
{status === 'not-answered' && 'All questions have been answered!'}
{status === 'skipped' && 'You haven\'t skipped any questions.'}
</div>
)
}
return (
<div className="space-y-4 p-2">
{questions.map((q) => (
<div
key={q.id}
className="bg-canvas-0 border-canvas-100 rounded-lg border px-2 pt-2 shadow-sm transition-colors"
>
<CompatibilityAnswerBlock
key={q.answer?.question_id}
question={q}
answer={q.answer}
yourQuestions={questions}
user={user}
isCurrentUser={true}
refreshCompatibilityAll={refreshCompatibilityAll}
/>
</div>
))}
</div>
)
}