mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-24 17:41:27 -04:00
Add pinned compatibility questions feature with backend support and UI integration
This commit is contained in:
@@ -11,6 +11,7 @@ import {getHiddenProfiles} from 'api/get-hidden-profiles'
|
||||
import {getLastMessages} from 'api/get-last-messages'
|
||||
import {getMessagesCountEndpoint} from 'api/get-messages-count'
|
||||
import {getOptions} from 'api/get-options'
|
||||
import {getPinnedCompatibilityQuestions} from 'api/get-pinned-compatibility-questions'
|
||||
import {getChannelMessagesEndpoint} from 'api/get-private-messages'
|
||||
import {getUser} from 'api/get-user'
|
||||
import {hideProfile} from 'api/hide-profile'
|
||||
@@ -20,6 +21,7 @@ import {saveSubscriptionMobile} from 'api/save-subscription-mobile'
|
||||
import {sendSearchNotifications} from 'api/send-search-notifications'
|
||||
import {localSendTestEmail} from 'api/test'
|
||||
import {unhideProfile} from 'api/unhide-profile'
|
||||
import {updateCompatibilityQuestionPin} from 'api/update-compatibility-question-pin'
|
||||
import {updateConnectionInterests} from 'api/update-connection-interests'
|
||||
import {updateOptions} from 'api/update-options'
|
||||
import {vote} from 'api/vote'
|
||||
@@ -406,7 +408,7 @@ Most endpoints require a valid Firebase JWT token. This gives you access to your
|
||||
|
||||
To obtain a token:
|
||||
|
||||
**In your app or browser console while logged in (JavaScript/TypeScript):**
|
||||
**In your browser console while logged in (CTRL+SHIFT+C, then select the Console tab):**
|
||||
\`\`\`js
|
||||
const db = await new Promise((res, rej) => {
|
||||
const req = indexedDB.open('firebaseLocalStorageDb')
|
||||
@@ -633,6 +635,8 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'update-user-locale': updateUserLocale,
|
||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||
'update-profile': updateProfileEndpoint,
|
||||
'update-compatibility-question-pin': updateCompatibilityQuestionPin,
|
||||
'get-pinned-compatibility-questions': getPinnedCompatibilityQuestions,
|
||||
'get-connection-interests': getConnectionInterestsEndpoint,
|
||||
'update-connection-interest': updateConnectionInterests,
|
||||
'user/by-id/:id': getUser,
|
||||
|
||||
22
backend/api/src/get-pinned-compatibility-questions.ts
Normal file
22
backend/api/src/get-pinned-compatibility-questions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {APIHandler} from 'api/helpers/endpoint'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export async function getPinnedQuestionIds(userId: string) {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const rows = await pg.manyOrNone<Row<'compatibility_prompts_pinned'>>(
|
||||
`select * from compatibility_prompts_pinned
|
||||
where user_id = $1
|
||||
order by created_time desc`,
|
||||
[userId],
|
||||
)
|
||||
// newest-first in table; return in that order
|
||||
return rows.map((r) => r.question_id)
|
||||
}
|
||||
|
||||
export const getPinnedCompatibilityQuestions: APIHandler<
|
||||
'get-pinned-compatibility-questions'
|
||||
> = async (_props, auth) => {
|
||||
const pinnedQuestionIds = await getPinnedQuestionIds(auth.uid)
|
||||
return {status: 'success', pinnedQuestionIds}
|
||||
}
|
||||
28
backend/api/src/update-compatibility-question-pin.ts
Normal file
28
backend/api/src/update-compatibility-question-pin.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {getPinnedQuestionIds} from 'api/get-pinned-compatibility-questions'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {type APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const updateCompatibilityQuestionPin: APIHandler<
|
||||
'update-compatibility-question-pin'
|
||||
> = async ({questionId, pinned}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
if (pinned) {
|
||||
await pg.none(
|
||||
`insert into compatibility_prompts_pinned (user_id, question_id)
|
||||
values ($1, $2)
|
||||
on conflict (user_id, question_id) do nothing`,
|
||||
[auth.uid, questionId],
|
||||
)
|
||||
} else {
|
||||
await pg.none(
|
||||
`delete from compatibility_prompts_pinned
|
||||
where user_id = $1 and question_id = $2`,
|
||||
[auth.uid, questionId],
|
||||
)
|
||||
}
|
||||
|
||||
const pinnedQuestionIds = await getPinnedQuestionIds(auth.uid)
|
||||
return {status: 'success', pinnedQuestionIds}
|
||||
}
|
||||
@@ -53,4 +53,5 @@ BEGIN;
|
||||
\i backend/supabase/migrations/20260224_add_connection_preferences.sql
|
||||
\i backend/supabase/migrations/20260225_add_keywords_to_profiles.sql
|
||||
\i backend/supabase/migrations/20260308_add_importance_counts.sql
|
||||
\i backend/supabase/migrations/20260319_add_compatibility_prompts_pinned.sql
|
||||
COMMIT;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS compatibility_prompts_pinned
|
||||
(
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||
created_time TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
question_id BIGINT NOT NULL REFERENCES compatibility_prompts (id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE (user_id, question_id)
|
||||
);
|
||||
|
||||
ALTER TABLE compatibility_prompts_pinned
|
||||
ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cpp_user_created_time
|
||||
ON compatibility_prompts_pinned (user_id, created_time DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cpp_user_question
|
||||
ON compatibility_prompts_pinned (user_id, question_id);
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
"answers.next": "Weiter",
|
||||
"answers.opinion.edit": "Bearbeiten",
|
||||
"answers.opinion.fill": "Meinungsskala ausfüllen",
|
||||
"answers.pinned.pin_tooltip": "Diese Frage anpinnen, um sie zuerst auf Profilen zu sehen",
|
||||
"answers.pinned.pin_aria": "Frage anpinnen",
|
||||
"answers.pinned.title": "Angepinnte Fragen, die Sie zuerst sehen möchten",
|
||||
"answers.pinned.unpin_tooltip": "Diese Frage losen",
|
||||
"answers.pinned.unpin_aria": "Frage losen",
|
||||
"answers.pinned.error": "Fehler beim Aktualisieren der Einstellung",
|
||||
"answers.preferred.user_answer": "Die Antwort von {name}",
|
||||
"answers.preferred.your_answer": "Ihre Antwort",
|
||||
"answers.sort.important_to_them": "Wichtig für {name}",
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
"answers.next": "Suivant",
|
||||
"answers.opinion.edit": "Modifier",
|
||||
"answers.opinion.fill": "Remplir l'échelle d'opinion",
|
||||
"answers.pinned.pin_tooltip": "Épingler cette question pour voir la réponse des autres en premier sur leur profil",
|
||||
"answers.pinned.pin_aria": "Épingler la question",
|
||||
"answers.pinned.title": "Questions que vous avez épinglées",
|
||||
"answers.pinned.unpin_tooltip": "Désépingler cette question",
|
||||
"answers.pinned.unpin_aria": "Désépingler la question",
|
||||
"answers.pinned.error": "Erreur lors de la mise à jour de la préférence",
|
||||
"answers.preferred.user_answer": "La réponse de {name}",
|
||||
"answers.preferred.your_answer": "Votre réponse",
|
||||
"answers.sort.important_to_them": "Important pour {name}",
|
||||
|
||||
@@ -554,6 +554,35 @@ export const API = (_apiTypeCheck = {
|
||||
summary: 'Submit or update a compatibility answer',
|
||||
tag: 'Compatibility',
|
||||
},
|
||||
'update-compatibility-question-pin': {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
rateLimited: true,
|
||||
props: z
|
||||
.object({
|
||||
questionId: z.number(),
|
||||
pinned: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
pinnedQuestionIds: number[]
|
||||
},
|
||||
summary: 'Pin or unpin a compatibility question for your profile views',
|
||||
tag: 'Compatibility',
|
||||
},
|
||||
'get-pinned-compatibility-questions': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
rateLimited: false,
|
||||
props: z.object({}).strict(),
|
||||
returns: {} as {
|
||||
status: 'success'
|
||||
pinnedQuestionIds: number[]
|
||||
},
|
||||
summary: 'Get pinned compatibility question ids for current user',
|
||||
tag: 'Compatibility',
|
||||
},
|
||||
'get-profile-answers': {
|
||||
method: 'GET',
|
||||
authed: true,
|
||||
|
||||
@@ -224,6 +224,42 @@ export type Database = {
|
||||
},
|
||||
]
|
||||
}
|
||||
compatibility_prompts_pinned: {
|
||||
Row: {
|
||||
created_time: string
|
||||
id: number
|
||||
question_id: number
|
||||
user_id: string
|
||||
}
|
||||
Insert: {
|
||||
created_time?: string
|
||||
id?: never
|
||||
question_id: number
|
||||
user_id: string
|
||||
}
|
||||
Update: {
|
||||
created_time?: string
|
||||
id?: never
|
||||
question_id?: number
|
||||
user_id?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: 'compatibility_prompts_pinned_question_id_fkey'
|
||||
columns: ['question_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'compatibility_prompts'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
{
|
||||
foreignKeyName: 'compatibility_prompts_pinned_user_id_fkey'
|
||||
columns: ['user_id']
|
||||
isOneToOne: false
|
||||
referencedRelation: 'users'
|
||||
referencedColumns: ['id']
|
||||
},
|
||||
]
|
||||
}
|
||||
compatibility_prompts_translations: {
|
||||
Row: {
|
||||
locale: string
|
||||
|
||||
@@ -64,7 +64,7 @@ function AddCompatibilityQuestionModal(props: {
|
||||
<CreateCompatibilityModalContent afterAddQuestion={afterAddQuestion} setOpen={setOpen} />
|
||||
) : (
|
||||
<AnswerCompatibilityQuestionContent
|
||||
compatibilityQuestion={dbQuestion as QuestionWithStats}
|
||||
question={dbQuestion as QuestionWithStats}
|
||||
user={user}
|
||||
onSubmit={() => {
|
||||
// setOpen(false)
|
||||
|
||||
@@ -221,7 +221,7 @@ function AnswerCompatibilityQuestionModal(props: {
|
||||
key={sortedQuestions[questionIndex].id}
|
||||
index={questionIndex}
|
||||
total={sortedQuestions.length}
|
||||
compatibilityQuestion={sortedQuestions[questionIndex]}
|
||||
question={sortedQuestions[questionIndex]}
|
||||
user={user}
|
||||
onSubmit={() => {
|
||||
setOpen(false)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {shortenNumber} from 'common/util/format'
|
||||
import {sortBy} from 'lodash'
|
||||
import {useState} from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {PinQuestionButton} from 'web/components/answers/pin-question-button'
|
||||
import {Button} from 'web/components/buttons/button'
|
||||
import {CompatibilitySort, CompatibilitySortWidget} from 'web/components/compatibility/sort-widget'
|
||||
import {Col} from 'web/components/layout/col'
|
||||
@@ -102,7 +103,7 @@ export function getEmptyAnswer(userId: string, questionId: number) {
|
||||
}
|
||||
|
||||
export function AnswerCompatibilityQuestionContent(props: {
|
||||
compatibilityQuestion: QuestionWithStats
|
||||
question: QuestionWithStats
|
||||
user: User
|
||||
index?: number
|
||||
total?: number
|
||||
@@ -114,34 +115,24 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
sort?: CompatibilitySort
|
||||
setSort?: (sort: CompatibilitySort) => void
|
||||
}) {
|
||||
const {
|
||||
compatibilityQuestion,
|
||||
user,
|
||||
onSubmit,
|
||||
isLastQuestion,
|
||||
onNext,
|
||||
noSkip,
|
||||
index,
|
||||
total,
|
||||
sort,
|
||||
setSort,
|
||||
} = props
|
||||
const {question, user, onSubmit, isLastQuestion, onNext, noSkip, index, total, sort, setSort} =
|
||||
props
|
||||
const t = useT()
|
||||
const [answer, setAnswer] = useState<CompatibilityAnswerSubmitType>(
|
||||
(props.answer as CompatibilityAnswerSubmitType) ??
|
||||
getEmptyAnswer(user.id, compatibilityQuestion.id),
|
||||
(props.answer as CompatibilityAnswerSubmitType) ?? getEmptyAnswer(user.id, question.id),
|
||||
)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [skipLoading, setSkipLoading] = useState(false)
|
||||
|
||||
if (
|
||||
compatibilityQuestion.answer_type !== 'compatibility_multiple_choice' ||
|
||||
!compatibilityQuestion.multiple_choice_options
|
||||
question.answer_type !== 'compatibility_multiple_choice' ||
|
||||
!question.multiple_choice_options
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const optionOrder = sortBy(Object.entries(compatibilityQuestion.multiple_choice_options), 1).map(
|
||||
const optionOrder = sortBy(Object.entries(question.multiple_choice_options), 1).map(
|
||||
([label]) => label,
|
||||
)
|
||||
|
||||
@@ -151,13 +142,11 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
|
||||
const importanceValid = answer.importance !== null && answer.importance !== -1
|
||||
|
||||
const shortenedPopularity = compatibilityQuestion.answer_count
|
||||
? shortenNumber(compatibilityQuestion.answer_count)
|
||||
: null
|
||||
const shortenedPopularity = question.answer_count ? shortenNumber(question.answer_count) : null
|
||||
return (
|
||||
<Col className="min-h-0 w-full gap-4">
|
||||
<Col className="gap-1 shrink-0">
|
||||
<Row>
|
||||
<Row className={'gap-2'}>
|
||||
{isFinite(index!) && isFinite(total!) && (
|
||||
<span className={'text-sm'}>
|
||||
<span className="text-ink-600 font-semibold">{index! + 1}</span> / {total}
|
||||
@@ -173,7 +162,8 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
<Row className={''}>
|
||||
<Row className={'gap-2'}>
|
||||
<PinQuestionButton questionId={question.id} />
|
||||
{shortenedPopularity && (
|
||||
<Tooltip
|
||||
text={t(
|
||||
@@ -188,14 +178,14 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
</Row>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isFinite(compatibilityQuestion.community_importance_percent) && (
|
||||
{isFinite(question.community_importance_percent) && (
|
||||
<span className={'text-sm ml-auto guidance'}>
|
||||
{t('compatibility.question.community_importance', 'Community Importance')}:{' '}
|
||||
{Math.round(compatibilityQuestion.community_importance_percent)}%
|
||||
{Math.round(question.community_importance_percent)}%
|
||||
</span>
|
||||
)}
|
||||
</Row>
|
||||
<div data-testid="compatibility-question">{compatibilityQuestion.question}</div>
|
||||
<div data-testid="compatibility-question">{question.question}</div>
|
||||
</Col>
|
||||
<Col className={clsx(SCROLLABLE_MODAL_CLASS, 'w-full gap-4 flex-1 min-h-0 pr-2')}>
|
||||
<Col className="gap-2">
|
||||
@@ -257,7 +247,7 @@ export function AnswerCompatibilityQuestionContent(props: {
|
||||
disabled={loading || skipLoading}
|
||||
onClick={() => {
|
||||
setSkipLoading(true)
|
||||
submitCompatibilityAnswer(getEmptyAnswer(user.id, compatibilityQuestion.id))
|
||||
submitCompatibilityAnswer(getEmptyAnswer(user.id, question.id))
|
||||
.then(() => {
|
||||
if (isLastQuestion) {
|
||||
onSubmit()
|
||||
|
||||
@@ -12,8 +12,10 @@ import {Row as rowFor} from 'common/supabase/utils'
|
||||
import {User} from 'common/user'
|
||||
import {shortenNumber} from 'common/util/format'
|
||||
import {keyBy, partition, sortBy} from 'lodash'
|
||||
import {PinIcon} from 'lucide-react'
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
import {AddCompatibilityQuestionButton} from 'web/components/answers/add-compatibility-question-button'
|
||||
import DropdownMenu from 'web/components/comments/dropdown-menu'
|
||||
import {
|
||||
compareBySort,
|
||||
@@ -32,6 +34,7 @@ import {Tooltip} from 'web/components/widgets/tooltip'
|
||||
import {shortenName} from 'web/components/widgets/user-link'
|
||||
import {useIsLooking} from 'web/hooks/use-is-looking'
|
||||
import {usePersistentInMemoryState} from 'web/hooks/use-persistent-in-memory-state'
|
||||
import {usePinnedQuestionIds} from 'web/hooks/use-pinned-question-ids'
|
||||
import {useProfile} from 'web/hooks/use-profile'
|
||||
import {useCompatibleProfiles} from 'web/hooks/use-profiles'
|
||||
import {
|
||||
@@ -43,7 +46,6 @@ import {useT} from 'web/lib/locale'
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
|
||||
import {Subtitle} from '../widgets/profile-subtitle'
|
||||
import {AddCompatibilityQuestionButton} from './add-compatibility-question-button'
|
||||
import {
|
||||
AnswerCompatibilityQuestionButton,
|
||||
AnswerSkippedCompatibilityQuestionsButton,
|
||||
@@ -59,8 +61,10 @@ import {
|
||||
submitCompatibilityAnswer,
|
||||
} from './answer-compatibility-question-content'
|
||||
import {PreferredList, PreferredListNoComparison} from './compatibility-question-preferred-list'
|
||||
import {PinQuestionButton} from './pin-question-button'
|
||||
|
||||
const NUM_QUESTIONS_TO_SHOW = 8
|
||||
const NUM_QUESTIONS_TO_SHOW = 4
|
||||
const NUM_PINNED_QUESTIONS_TO_SHOW = 4
|
||||
|
||||
export function separateQuestionsArray(
|
||||
questions: QuestionWithStats[],
|
||||
@@ -101,6 +105,8 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
const compatibleProfiles = useCompatibleProfiles(currentUser?.id)
|
||||
const compatibilityScore = compatibleProfiles?.profileCompatibilityScores?.[profile.user_id]
|
||||
|
||||
const {pinnedQuestionIds, refreshPinnedQuestionIds} = usePinnedQuestionIds()
|
||||
|
||||
const {refreshCompatibilityQuestions, compatibilityQuestions} =
|
||||
useCompatibilityQuestionsWithAnswerCount()
|
||||
|
||||
@@ -125,7 +131,8 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
const refreshCompatibilityAll = useCallback(() => {
|
||||
refreshCompatibilityAnswers()
|
||||
refreshCompatibilityQuestions()
|
||||
}, [refreshCompatibilityAnswers, refreshCompatibilityQuestions])
|
||||
refreshPinnedQuestionIds()
|
||||
}, [refreshCompatibilityAnswers, refreshCompatibilityQuestions, refreshPinnedQuestionIds])
|
||||
|
||||
const isLooking = useIsLooking()
|
||||
const [sort, setSort] = usePersistentInMemoryState<CompatibilitySort>(
|
||||
@@ -184,23 +191,68 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
currentSlice + NUM_QUESTIONS_TO_SHOW,
|
||||
)
|
||||
|
||||
const pinnedAnswers = useMemo(() => {
|
||||
if (!pinnedQuestionIds?.length) return []
|
||||
const pinned = answers.filter((a) => pinnedQuestionIds.includes(a.question_id))
|
||||
const idToIndex = new Map(pinnedQuestionIds.map((id, i) => [id, i] as const))
|
||||
return sortBy(pinned, (a) => idToIndex.get(a.question_id) ?? Infinity)
|
||||
}, [answers, pinnedQuestionIds])
|
||||
|
||||
const [pinnedPage, setPinnedPage] = useState(0)
|
||||
const pinnedCurrentSlice = pinnedPage * NUM_PINNED_QUESTIONS_TO_SHOW
|
||||
const shownPinnedAnswers = pinnedAnswers.slice(
|
||||
pinnedCurrentSlice,
|
||||
pinnedCurrentSlice + NUM_PINNED_QUESTIONS_TO_SHOW,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setPinnedPage(0)
|
||||
}, [user.id])
|
||||
|
||||
if (!isCurrentUser && !answeredQuestions.length) return null
|
||||
|
||||
return (
|
||||
<Col className="gap-4">
|
||||
<Row className="flex-wrap items-center justify-between gap-x-6 gap-y-4">
|
||||
<Row className={'gap-8'}>
|
||||
<Subtitle>
|
||||
{isCurrentUser
|
||||
? t('answers.display.your_prompts', 'Your Compatibility Prompts')
|
||||
: t('answers.display.user_prompts', "{name}'s Compatibility Prompts", {
|
||||
name: shortenName(user.name),
|
||||
})}
|
||||
</Subtitle>
|
||||
{compatibilityScore && (
|
||||
<CompatibleBadge compatibility={compatibilityScore} className={'mt-7 mr-4'} />
|
||||
<Row className={'gap-8'}>
|
||||
<Subtitle>
|
||||
{isCurrentUser
|
||||
? t('answers.display.your_prompts', 'Your Compatibility Prompts')
|
||||
: t('answers.display.user_prompts', "{name}'s Compatibility Prompts", {
|
||||
name: shortenName(user.name),
|
||||
})}
|
||||
</Subtitle>
|
||||
{compatibilityScore && (
|
||||
<CompatibleBadge compatibility={compatibilityScore} className={'mt-7 mr-4'} />
|
||||
)}
|
||||
</Row>
|
||||
{pinnedAnswers.length > 0 && (
|
||||
<Col className="gap-3">
|
||||
<PinIcon />
|
||||
{shownPinnedAnswers.map((answer) => (
|
||||
<CompatibilityAnswerBlock
|
||||
key={`pinned-${answer.question_id}`}
|
||||
answer={answer}
|
||||
yourQuestions={answeredQuestions}
|
||||
user={user}
|
||||
isCurrentUser={isCurrentUser}
|
||||
refreshCompatibilityAll={refreshCompatibilityAll}
|
||||
profile={profile}
|
||||
fromProfilePage={fromProfilePage}
|
||||
showCommunityInfo={showCommunityInfo}
|
||||
/>
|
||||
))}
|
||||
{NUM_PINNED_QUESTIONS_TO_SHOW < pinnedAnswers.length && (
|
||||
<Pagination
|
||||
page={pinnedPage}
|
||||
pageSize={NUM_PINNED_QUESTIONS_TO_SHOW}
|
||||
totalItems={pinnedAnswers.length}
|
||||
setPage={setPinnedPage}
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
<div className="border-canvas-200 border-b" />
|
||||
</Col>
|
||||
)}
|
||||
<Row className="flex-wrap items-center justify-between gap-x-6 gap-y-4">
|
||||
{answeredQuestions.length > 0 && (
|
||||
<div className="relative mt-3">
|
||||
{/*<input*/}
|
||||
@@ -254,26 +306,6 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{isCurrentUser && !fromProfilePage && (
|
||||
<span className="custom-link">
|
||||
{otherQuestions.length < 1 ? (
|
||||
<span className="text-ink-600 text-sm">
|
||||
{t(
|
||||
'answers.display.already_answered_all',
|
||||
"You've already answered all the compatibility questions—",
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ink-600 text-sm">
|
||||
{t(
|
||||
'answers.display.answer_more',
|
||||
'Answer more questions to increase your compatibility scores—or ',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<AddCompatibilityQuestionButton refreshCompatibilityAll={refreshCompatibilityAll} />
|
||||
</span>
|
||||
)}
|
||||
{shownAnswers.map((answer) => {
|
||||
return (
|
||||
<CompatibilityAnswerBlock
|
||||
@@ -294,6 +326,34 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{NUM_QUESTIONS_TO_SHOW < answers.length && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={NUM_QUESTIONS_TO_SHOW}
|
||||
totalItems={sortedAndFilteredAnswers.length}
|
||||
setPage={setPage}
|
||||
/>
|
||||
)}
|
||||
{isCurrentUser && !fromProfilePage && (
|
||||
<span className="custom-link">
|
||||
{otherQuestions.length < 1 ? (
|
||||
<span className="text-ink-600 text-sm">
|
||||
{t(
|
||||
'answers.display.already_answered_all',
|
||||
"You've already answered all the compatibility questions—",
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ink-600 text-sm">
|
||||
{t(
|
||||
'answers.display.answer_more',
|
||||
'Answer more questions to increase your compatibility scores—or ',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<AddCompatibilityQuestionButton refreshCompatibilityAll={refreshCompatibilityAll} />
|
||||
</span>
|
||||
)}
|
||||
{isCurrentUser && (
|
||||
<Row className={'w-full justify-center gap-8'}>
|
||||
{(fromSignup || (otherQuestions.length >= 1 && !fromProfilePage)) && (
|
||||
@@ -316,14 +376,6 @@ export function CompatibilityQuestionsDisplay(props: {
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{NUM_QUESTIONS_TO_SHOW < answers.length && (
|
||||
<Pagination
|
||||
page={page}
|
||||
pageSize={NUM_QUESTIONS_TO_SHOW}
|
||||
totalItems={sortedAndFilteredAnswers.length}
|
||||
setPage={setPage}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
@@ -421,6 +473,7 @@ export function CompatibilityAnswerBlock(props: {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!!currentUser && <PinQuestionButton questionId={question.id} />}
|
||||
{isCurrentUser && isAnswered && (
|
||||
<>
|
||||
<ImportanceButton
|
||||
@@ -577,7 +630,7 @@ export function CompatibilityAnswerBlock(props: {
|
||||
<Col className={MODAL_CLASS}>
|
||||
<AnswerCompatibilityQuestionContent
|
||||
key={`edit answer.id`}
|
||||
compatibilityQuestion={question}
|
||||
question={question}
|
||||
answer={newAnswer}
|
||||
user={user}
|
||||
onSubmit={() => {
|
||||
|
||||
68
web/components/answers/pin-question-button.tsx
Normal file
68
web/components/answers/pin-question-button.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {BookmarkIcon as PinOutline} from '@heroicons/react/24/outline'
|
||||
import {BookmarkIcon as PinSolid} from '@heroicons/react/24/solid'
|
||||
import clsx from 'clsx'
|
||||
import toast from 'react-hot-toast'
|
||||
import {Tooltip} from 'web/components/widgets/tooltip'
|
||||
import {usePinnedQuestionIds} from 'web/hooks/use-pinned-question-ids'
|
||||
import {api} from 'web/lib/api'
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
interface PinQuestionButtonProps {
|
||||
questionId: number
|
||||
onPinChange?: (pinnedQuestionIds: number[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PinQuestionButton({questionId, onPinChange, className}: PinQuestionButtonProps) {
|
||||
const t = useT()
|
||||
const {pinnedQuestionIds, setPinnedQuestionIds, refreshPinnedQuestionIds} = usePinnedQuestionIds()
|
||||
const isPinned = (pinnedQuestionIds ?? []).includes(questionId)
|
||||
|
||||
const handlePinToggle = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
api('update-compatibility-question-pin', {
|
||||
questionId,
|
||||
pinned: !isPinned,
|
||||
})
|
||||
.then((res) => {
|
||||
refreshPinnedQuestionIds()
|
||||
setPinnedQuestionIds(res.pinnedQuestionIds ?? [])
|
||||
onPinChange?.(res.pinnedQuestionIds ?? [])
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
toast.error(err.message ?? t('answers.pinned.error', 'Error updating preference'))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
text={
|
||||
isPinned
|
||||
? t('answers.pinned.unpin_tooltip', 'Unpin this question')
|
||||
: t('answers.pinned.pin_tooltip', 'Pin this question to see it first on profiles')
|
||||
}
|
||||
>
|
||||
<button
|
||||
className={clsx(
|
||||
'rounded transition-colors',
|
||||
isPinned ? 'text-primary-700' : 'text-ink-400 hover:text-ink-700',
|
||||
className,
|
||||
)}
|
||||
onClick={handlePinToggle}
|
||||
aria-label={
|
||||
isPinned
|
||||
? t('answers.pinned.unpin_aria', 'Unpin question')
|
||||
: t('answers.pinned.pin_aria', 'Pin question')
|
||||
}
|
||||
>
|
||||
{isPinned ? (
|
||||
<PinSolid className="h-5 w-5 text-primary-600" />
|
||||
) : (
|
||||
<PinOutline className="h-5 w-5 text-ink-500" />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
36
web/hooks/use-pinned-question-ids.tsx
Normal file
36
web/hooks/use-pinned-question-ids.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import {createContext, ReactNode, useCallback, useContext, useEffect, useState} from 'react'
|
||||
import {api} from 'web/lib/api'
|
||||
|
||||
const PinnedQuestionIdsContext = createContext<{
|
||||
pinnedQuestionIds: number[] | undefined
|
||||
setPinnedQuestionIds: (ids: number[]) => void
|
||||
refreshPinnedQuestionIds: () => void
|
||||
} | null>(null)
|
||||
|
||||
export function PinnedQuestionIdsProvider({children}: {children: ReactNode}) {
|
||||
const [pinnedQuestionIds, setPinnedQuestionIds] = useState<number[] | undefined>(undefined)
|
||||
|
||||
const refreshPinnedQuestionIds = useCallback(() => {
|
||||
api('get-pinned-compatibility-questions').then((res) => {
|
||||
setPinnedQuestionIds(res.pinnedQuestionIds ?? [])
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshPinnedQuestionIds()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PinnedQuestionIdsContext.Provider
|
||||
value={{pinnedQuestionIds, setPinnedQuestionIds, refreshPinnedQuestionIds}}
|
||||
>
|
||||
{children}
|
||||
</PinnedQuestionIdsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function usePinnedQuestionIds() {
|
||||
const ctx = useContext(PinnedQuestionIdsContext)
|
||||
if (!ctx) throw new Error('usePinnedQuestionIds must be used within PinnedQuestionIdsProvider')
|
||||
return ctx
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {isEqual} from 'lodash'
|
||||
import {SetStateAction, useMemo, useRef, useState} from 'react'
|
||||
import {SetStateAction, useCallback, useRef, useState} from 'react'
|
||||
|
||||
export const useStateCheckEquality = <T>(initialState: T) => {
|
||||
const [state, setState] = useState(initialState)
|
||||
@@ -7,8 +7,8 @@ export const useStateCheckEquality = <T>(initialState: T) => {
|
||||
const stateRef = useRef(state)
|
||||
stateRef.current = state
|
||||
|
||||
const checkSetState = useMemo(
|
||||
() => (next: SetStateAction<T>) => {
|
||||
const checkSetState = useCallback(
|
||||
(next: SetStateAction<T>) => {
|
||||
const state = stateRef.current
|
||||
const newState = next instanceof Function ? next(state) : next
|
||||
if (!isEqual(state, newState)) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {ChoicesProvider} from 'web/hooks/use-choices'
|
||||
import {useFontPreferenceManager} from 'web/hooks/use-font-preference'
|
||||
import {useHasLoaded} from 'web/hooks/use-has-loaded'
|
||||
import {HiddenProfilesProvider} from 'web/hooks/use-hidden-profiles'
|
||||
import {PinnedQuestionIdsProvider} from 'web/hooks/use-pinned-question-ids'
|
||||
import {updateStatusBar} from 'web/hooks/use-theme'
|
||||
import {updateBackendLocale} from 'web/lib/api'
|
||||
import {DAYJS_LOCALE_IMPORTS, registerDatePickerLocale} from 'web/lib/dayjs'
|
||||
@@ -198,11 +199,13 @@ function MyApp(props: AppProps<PageProps>) {
|
||||
<ErrorBoundary>
|
||||
<AuthProvider serverUser={pageProps.auth}>
|
||||
<ChoicesProvider>
|
||||
<HiddenProfilesProvider>
|
||||
<WebPush />
|
||||
<AndroidPush />
|
||||
<Component {...pageProps} />
|
||||
</HiddenProfilesProvider>
|
||||
<PinnedQuestionIdsProvider>
|
||||
<HiddenProfilesProvider>
|
||||
<WebPush />
|
||||
<AndroidPush />
|
||||
<Component {...pageProps} />
|
||||
</HiddenProfilesProvider>
|
||||
</PinnedQuestionIdsProvider>
|
||||
</ChoicesProvider>
|
||||
</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
Reference in New Issue
Block a user