diff --git a/apps/portal/package.json b/apps/portal/package.json index f1bdb9e7..208b1940 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -15,6 +15,7 @@ "@headlessui/react": "^1.7.3", "@heroicons/react": "^2.0.11", "@next-auth/prisma-adapter": "^1.0.4", + "@popperjs/core": "^2.11.6", "@prisma/client": "^4.4.0", "@supabase/supabase-js": "^1.35.7", "@tih/ui": "*", @@ -33,6 +34,8 @@ "react-dropzone": "^14.2.3", "react-hook-form": "^7.36.1", "react-pdf": "^5.7.2", + "react-popper": "^2.3.0", + "react-popper-tooltip": "^4.4.2", "react-query": "^3.39.2", "superjson": "^1.10.0", "zod": "^3.18.0" diff --git a/apps/portal/src/components/offers/constants.ts b/apps/portal/src/components/offers/constants.ts index 63a57d0e..10e3b0b1 100644 --- a/apps/portal/src/components/offers/constants.ts +++ b/apps/portal/src/components/offers/constants.ts @@ -110,9 +110,30 @@ export const educationFieldOptions = [ ]; export enum FieldError { - NonNegativeNumber = 'Please fill in a non-negative number in this field.', - Number = 'Please fill in a number in this field.', - Required = 'Please fill in this field.', + NON_NEGATIVE_NUMBER = 'Please fill in a non-negative number in this field.', + NUMBER = 'Please fill in a number in this field.', + REQUIRED = 'Please fill in this field.', } export const OVERALL_TAB = 'Overall'; + +export enum ProfileDetailTab { + ANALYSIS = 'Offer Engine Analysis', + BACKGROUND = 'Background', + OFFERS = 'Offers', +} + +export const profileDetailTabs = [ + { + label: ProfileDetailTab.OFFERS, + value: ProfileDetailTab.OFFERS, + }, + { + label: ProfileDetailTab.BACKGROUND, + value: ProfileDetailTab.BACKGROUND, + }, + { + label: ProfileDetailTab.ANALYSIS, + value: ProfileDetailTab.ANALYSIS, + }, +]; diff --git a/apps/portal/src/components/offers/offersSubmission/analysis/OfferAnalysis.tsx b/apps/portal/src/components/offers/offerAnalysis/OfferAnalysis.tsx similarity index 67% rename from apps/portal/src/components/offers/offersSubmission/analysis/OfferAnalysis.tsx rename to apps/portal/src/components/offers/offerAnalysis/OfferAnalysis.tsx index 4b45b3f2..67c9c9e1 100644 --- a/apps/portal/src/components/offers/offersSubmission/analysis/OfferAnalysis.tsx +++ b/apps/portal/src/components/offers/offerAnalysis/OfferAnalysis.tsx @@ -2,11 +2,9 @@ import { useEffect } from 'react'; import { useState } from 'react'; import { HorizontalDivider, Spinner, Tabs } from '@tih/ui'; -import { trpc } from '~/utils/trpc'; - -import OfferPercentileAnalysis from './OfferPercentileAnalysis'; +import OfferPercentileAnalysisText from './OfferPercentileAnalysisText'; import OfferProfileCard from './OfferProfileCard'; -import { OVERALL_TAB } from '../../constants'; +import { OVERALL_TAB } from '../constants'; import type { Analysis, @@ -29,20 +27,29 @@ function OfferAnalysisContent({ tab, }: OfferAnalysisContentProps) { if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) { + if (tab === OVERALL_TAB) { + return ( +

+ You are the first to submit an offer for your job title and YOE! Check + back later when there are more submissions. +

+ ); + } return (

- You are the first to submit an offer for these companies! Check back - later when there are more submissions. + You are the first to submit an offer for this company, job title and + YOE! Check back later when there are more submissions.

); } return ( <> - +

Here are some of the top offers relevant to you:

{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => ( ; -export default function OfferAnalysis({ profileId }: OfferAnalysisProps) { +export default function OfferAnalysis({ + allAnalysis, + isError, + isLoading, +}: OfferAnalysisProps) { const [tab, setTab] = useState(OVERALL_TAB); - const [allAnalysis, setAllAnalysis] = useState(null); const [analysis, setAnalysis] = useState(null); useEffect(() => { @@ -76,22 +88,6 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) { } }, [tab, allAnalysis]); - if (!profileId) { - return null; - } - - const getAnalysisResult = trpc.useQuery( - ['offers.analysis.get', { profileId }], - { - onError(error) { - console.error(error.message); - }, - onSuccess(data) { - setAllAnalysis(data); - }, - }, - ); - const tabOptions = [ { label: OVERALL_TAB, @@ -106,18 +102,13 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) { return ( analysis && (
-
- Result -
- {getAnalysisResult.isError && ( + {isError && (

An error occurred while generating profile analysis.

)} - {getAnalysisResult.isLoading && ( - - )} - {!getAnalysisResult.isError && !getAnalysisResult.isLoading && ( + {isLoading && } + {!isError && !isLoading && (
; + +export default function OfferPercentileAnalysisText({ + tab, + companyName, + offerAnalysis: { noOfOffers, percentile }, +}: OfferPercentileAnalysisTextProps) { + return tab === OVERALL_TAB ? ( +

+ Your highest offer is from {companyName}, which is{' '} + {percentile.toFixed(1)} percentile out of {noOfOffers}{' '} + offers received for the same job title and YOE(±1) in the last year. +

+ ) : ( +

+ Your offer from {companyName} is {percentile.toFixed(1)}{' '} + percentile out of {noOfOffers} offers received in {companyName} for + the same job title and YOE(±1) in the last year. +

+ ); +} diff --git a/apps/portal/src/components/offers/offerAnalysis/OfferProfileCard.tsx b/apps/portal/src/components/offers/offerAnalysis/OfferProfileCard.tsx new file mode 100644 index 00000000..af786c4b --- /dev/null +++ b/apps/portal/src/components/offers/offerAnalysis/OfferProfileCard.tsx @@ -0,0 +1,74 @@ +import { + BuildingOffice2Icon, + CalendarDaysIcon, +} from '@heroicons/react/24/outline'; +import { JobType } from '@prisma/client'; + +import { HorizontalDivider } from '~/../../../packages/ui/dist'; +import { convertMoneyToString } from '~/utils/offers/currency'; +import { formatDate } from '~/utils/offers/time'; + +import ProfilePhotoHolder from '../profile/ProfilePhotoHolder'; + +import type { AnalysisOffer } from '~/types/offers'; + +type OfferProfileCardProps = Readonly<{ + offerProfile: AnalysisOffer; +}>; + +export default function OfferProfileCard({ + offerProfile: { + company, + income, + profileName, + totalYoe, + level, + monthYearReceived, + jobType, + location, + title, + previousCompanies, + }, +}: OfferProfileCardProps) { + return ( +
+
+
+ +
+
+

{profileName}

+
+ + Current: + {previousCompanies[0]} +
+
+ + YOE: + {totalYoe} +
+
+
+ + +
+
+

{title}

+

+ Company: {company.name}, {location} +

+

Level: {level}

+
+
+

{formatDate(monthYearReceived)}

+

+ {jobType === JobType.FULLTIME + ? `${convertMoneyToString(income)} / year` + : `${convertMoneyToString(income)} / month`} +

+
+
+
+ ); +} diff --git a/apps/portal/src/components/offers/offersSubmission/OfferProfileSave.tsx b/apps/portal/src/components/offers/offersSubmission/OffersProfileSave.tsx similarity index 97% rename from apps/portal/src/components/offers/offersSubmission/OfferProfileSave.tsx rename to apps/portal/src/components/offers/offersSubmission/OffersProfileSave.tsx index 071da82a..f113ffdb 100644 --- a/apps/portal/src/components/offers/offersSubmission/OfferProfileSave.tsx +++ b/apps/portal/src/components/offers/offersSubmission/OffersProfileSave.tsx @@ -16,7 +16,7 @@ type OfferProfileSaveProps = Readonly<{ token?: string; }>; -export default function OfferProfileSave({ +export default function OffersProfileSave({ profileId, token, }: OfferProfileSaveProps) { @@ -84,7 +84,7 @@ export default function OfferProfileSave({ onClick={saveProfile} />
-
+
@@ -112,7 +112,7 @@ function FullTimeOfferDetailsForm({ placeholder={emptyOption} required={true} {...register(`offers.${index}.location`, { - required: FieldError.Required, + required: FieldError.REQUIRED, })} />
@@ -135,7 +135,7 @@ function FullTimeOfferDetailsForm({ {...register( `offers.${index}.offersFullTime.totalCompensation.currency`, { - required: FieldError.Required, + required: FieldError.REQUIRED, }, )} /> @@ -153,8 +153,8 @@ function FullTimeOfferDetailsForm({ {...register( `offers.${index}.offersFullTime.totalCompensation.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, }, )} @@ -171,7 +171,7 @@ function FullTimeOfferDetailsForm({ {...register( `offers.${index}.offersFullTime.baseSalary.currency`, { - required: FieldError.Required, + required: FieldError.REQUIRED, }, )} /> @@ -185,8 +185,8 @@ function FullTimeOfferDetailsForm({ startAddOnType="label" type="number" {...register(`offers.${index}.offersFullTime.baseSalary.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, })} /> @@ -198,7 +198,7 @@ function FullTimeOfferDetailsForm({ label="Currency" options={CURRENCY_OPTIONS} {...register(`offers.${index}.offersFullTime.bonus.currency`, { - required: FieldError.Required, + required: FieldError.REQUIRED, })} /> } @@ -211,8 +211,8 @@ function FullTimeOfferDetailsForm({ startAddOnType="label" type="number" {...register(`offers.${index}.offersFullTime.bonus.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, })} /> @@ -226,7 +226,7 @@ function FullTimeOfferDetailsForm({ label="Currency" options={CURRENCY_OPTIONS} {...register(`offers.${index}.offersFullTime.stocks.currency`, { - required: FieldError.Required, + required: FieldError.REQUIRED, })} /> } @@ -239,8 +239,8 @@ function FullTimeOfferDetailsForm({ startAddOnType="label" type="number" {...register(`offers.${index}.offersFullTime.stocks.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, })} /> @@ -300,7 +300,7 @@ function InternshipOfferDetailsForm({ required={true} {...register(`offers.${index}.offersIntern.title`, { minLength: 1, - required: FieldError.Required, + required: FieldError.REQUIRED, })} />
@@ -330,7 +330,7 @@ function InternshipOfferDetailsForm({ placeholder={emptyOption} required={true} {...register(`offers.${index}.location`, { - required: FieldError.Required, + required: FieldError.REQUIRED, })} /> @@ -343,7 +343,7 @@ function InternshipOfferDetailsForm({ placeholder={emptyOption} required={true} {...register(`offers.${index}.offersIntern.internshipCycle`, { - required: FieldError.Required, + required: FieldError.REQUIRED, })} /> @@ -365,7 +365,7 @@ function InternshipOfferDetailsForm({ monthRequired={true} yearLabel="" {...register(`offers.${index}.monthYearReceived`, { - required: FieldError.Required, + required: FieldError.REQUIRED, })} /> @@ -380,7 +380,7 @@ function InternshipOfferDetailsForm({ {...register( `offers.${index}.offersIntern.monthlySalary.currency`, { - required: FieldError.Required, + required: FieldError.REQUIRED, }, )} /> @@ -396,8 +396,8 @@ function InternshipOfferDetailsForm({ startAddOnType="label" type="number" {...register(`offers.${index}.offersIntern.monthlySalary.value`, { - min: { message: FieldError.NonNegativeNumber, value: 0 }, - required: FieldError.Required, + min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, + required: FieldError.REQUIRED, valueAsNumber: true, })} /> @@ -448,7 +448,7 @@ function OfferDetailsFormArray({ {fields.map((item, index) => { return (
- {jobType === JobType.FullTime ? ( + {jobType === JobType.FULLTIME ? ( ) : ( @@ -464,7 +464,7 @@ function OfferDetailsFormArray({ variant="tertiary" onClick={() => append( - jobType === JobType.FullTime + jobType === JobType.FULLTIME ? defaultFullTimeOfferValues : defaultInternshipOfferValues, ) @@ -474,8 +474,14 @@ function OfferDetailsFormArray({ ); } -export default function OfferDetailsForm() { - const [jobType, setJobType] = useState(JobType.FullTime); +type OfferDetailsFormProps = Readonly<{ + defaultJobType?: JobType; +}>; + +export default function OfferDetailsForm({ + defaultJobType = JobType.FULLTIME, +}: OfferDetailsFormProps) { + const [jobType, setJobType] = useState(defaultJobType); const [isDialogOpen, setDialogOpen] = useState(false); const { control } = useFormContext(); const fieldArrayValues = useFieldArray({ control, name: 'offers' }); @@ -483,17 +489,17 @@ export default function OfferDetailsForm() { const toggleJobType = () => { remove(); - if (jobType === JobType.FullTime) { - setJobType(JobType.Intern); + if (jobType === JobType.FULLTIME) { + setJobType(JobType.INTERN); append(defaultInternshipOfferValues); } else { - setJobType(JobType.FullTime); + setJobType(JobType.FULLTIME); append(defaultFullTimeOfferValues); } }; const switchJobTypeLabel = () => - jobType === JobType.FullTime ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME; + jobType === JobType.FULLTIME ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME; return (
@@ -506,9 +512,9 @@ export default function OfferDetailsForm() { display="block" label={JobTypeLabel.FULLTIME} size="md" - variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'} + variant={jobType === JobType.FULLTIME ? 'secondary' : 'tertiary'} onClick={() => { - if (jobType === JobType.FullTime) { + if (jobType === JobType.FULLTIME) { return; } setDialogOpen(true); @@ -520,9 +526,9 @@ export default function OfferDetailsForm() { display="block" label={JobTypeLabel.INTERN} size="md" - variant={jobType === JobType.Intern ? 'secondary' : 'tertiary'} + variant={jobType === JobType.INTERN ? 'secondary' : 'tertiary'} onClick={() => { - if (jobType === JobType.Intern) { + if (jobType === JobType.INTERN) { return; } setDialogOpen(true); diff --git a/apps/portal/src/components/offers/profile/EducationCard.tsx b/apps/portal/src/components/offers/profile/EducationCard.tsx index 7dd5b155..8c3fea98 100644 --- a/apps/portal/src/components/offers/profile/EducationCard.tsx +++ b/apps/portal/src/components/offers/profile/EducationCard.tsx @@ -3,18 +3,10 @@ import { LightBulbIcon, } from '@heroicons/react/24/outline'; -import type { EducationBackgroundType } from '~/components/offers/types'; - -type EducationEntity = { - endDate?: string; - field?: string; - school?: string; - startDate?: string; - type?: EducationBackgroundType; -}; +import type { EducationDisplayData } from '~/components/offers/types'; type Props = Readonly<{ - education: EducationEntity; + education: EducationDisplayData; }>; export default function EducationCard({ @@ -39,9 +31,7 @@ export default function EducationCard({
{(startDate || endDate) && (
-

{`${startDate ? startDate : 'N/A'} - ${ - endDate ? endDate : 'N/A' - }`}

+

{`${startDate || 'N/A'} - ${endDate || 'N/A'}`}

)}
diff --git a/apps/portal/src/components/offers/profile/OfferCard.tsx b/apps/portal/src/components/offers/profile/OfferCard.tsx index 8b3c9566..e9d2d7f2 100644 --- a/apps/portal/src/components/offers/profile/OfferCard.tsx +++ b/apps/portal/src/components/offers/profile/OfferCard.tsx @@ -6,10 +6,10 @@ import { } from '@heroicons/react/24/outline'; import { HorizontalDivider } from '@tih/ui'; -import type { OfferEntity } from '~/components/offers/types'; +import type { OfferDisplayData } from '~/components/offers/types'; type Props = Readonly<{ - offer: OfferEntity; + offer: OfferDisplayData; }>; export default function OfferCard({ @@ -58,52 +58,64 @@ export default function OfferCard({ } function BottomSection() { + if ( + !totalCompensation && + !monthlySalary && + !negotiationStrategy && + !otherComment + ) { + return null; + } + return ( -
-
-
- -

- {totalCompensation - ? `TC: ${totalCompensation}` - : `Monthly Salary: ${monthlySalary}`} -

+ <> + +
+
+ {totalCompensation || + (monthlySalary && ( +
+ +

+ {totalCompensation && `TC: ${totalCompensation}`} + {monthlySalary && `Monthly Salary: ${monthlySalary}`} +

+
+ ))} + {totalCompensation && ( +
+

+ Base / year: {base} ⋅ Stocks / year: {stocks} ⋅ Bonus / year:{' '} + {bonus} +

+
+ )}
- - {totalCompensation && ( -
-

- Base / year: {base} ⋅ Stocks / year: {stocks} ⋅ Bonus / year:{' '} - {bonus} -

+ {negotiationStrategy && ( +
+
+ + + "{negotiationStrategy}" + +
)} -
- {negotiationStrategy && ( -
-
- - - "{negotiationStrategy}" - + {otherComment && ( +
+
+ + "{otherComment}" +
-
- )} - {otherComment && ( -
-
- - "{otherComment}" -
-
- )} -
+ )} +
+ ); } return (
-
); diff --git a/apps/portal/src/components/offers/profile/ProfileDetails.tsx b/apps/portal/src/components/offers/profile/ProfileDetails.tsx index 1707fe42..38eecd5c 100644 --- a/apps/portal/src/components/offers/profile/ProfileDetails.tsx +++ b/apps/portal/src/components/offers/profile/ProfileDetails.tsx @@ -1,24 +1,155 @@ -import { AcademicCapIcon, BriefcaseIcon } from '@heroicons/react/24/outline'; -import { Spinner } from '@tih/ui'; +import { useState } from 'react'; +import { + AcademicCapIcon, + ArrowPathIcon, + BriefcaseIcon, +} from '@heroicons/react/24/outline'; +import { Button, Spinner } from '@tih/ui'; import EducationCard from '~/components/offers/profile/EducationCard'; import OfferCard from '~/components/offers/profile/OfferCard'; -import type { BackgroundCard, OfferEntity } from '~/components/offers/types'; -import { EducationBackgroundType } from '~/components/offers/types'; +import type { + BackgroundDisplayData, + OfferDisplayData, +} from '~/components/offers/types'; -type ProfileHeaderProps = Readonly<{ - background?: BackgroundCard; +import { trpc } from '~/utils/trpc'; + +import { ProfileDetailTab } from '../constants'; +import OfferAnalysis from '../offerAnalysis/OfferAnalysis'; + +import { ProfileAnalysis } from '~/types/offers'; + +type ProfileOffersProps = Readonly<{ + offers: Array; +}>; + +function ProfileOffers({ offers }: ProfileOffersProps) { + if (offers.length !== 0) { + return ( + <> + {offers.map((offer) => ( + + ))} + + ); + } + return ( +
+ + No offer is attached. +
+ ); +} + +type ProfileBackgroundProps = Readonly<{ + background?: BackgroundDisplayData; +}>; + +function ProfileBackground({ background }: ProfileBackgroundProps) { + if (!background?.experiences?.length && !background?.educations?.length) { + return ( +
+

No background information available.

+
+ ); + } + + return ( + <> + {background?.experiences?.length > 0 && ( + <> +
+ + Work Experience +
+ + + )} + {background?.educations?.length > 0 && ( + <> +
+ + Education +
+ + + )} + + ); +} + +type ProfileAnalysisProps = Readonly<{ + analysis?: ProfileAnalysis; + isEditable: boolean; + profileId: string; +}>; + +function ProfileAnalysis({ + analysis: profileAnalysis, + profileId, + isEditable, +}: ProfileAnalysisProps) { + const [analysis, setAnalysis] = useState(profileAnalysis); + const generateAnalysisMutation = trpc.useMutation( + ['offers.analysis.generate'], + { + onError(error) { + console.error(error.message); + }, + onSuccess(data) { + if (data) { + setAnalysis(data); + } + }, + }, + ); + + if (generateAnalysisMutation.isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ + {isEditable && ( +
+
+ )} +
+ ); +} + +type ProfileDetailsProps = Readonly<{ + analysis?: ProfileAnalysis; + background?: BackgroundDisplayData; + isEditable: boolean; isLoading: boolean; - offers: Array; - selectedTab: string; + offers: Array; + profileId: string; + selectedTab: ProfileDetailTab; }>; export default function ProfileDetails({ + analysis, background, isLoading, offers, selectedTab, -}: ProfileHeaderProps) { + profileId, + isEditable, +}: ProfileDetailsProps) { if (isLoading) { return (
@@ -26,54 +157,20 @@ export default function ProfileDetails({
); } - if (selectedTab === 'offers') { - if (offers.length !== 0) { - return ( - <> - {offers.map((offer) => ( - - ))} - - ); - } - return ( -
- - No offer is attached. -
- ); + if (selectedTab === ProfileDetailTab.OFFERS) { + return ; + } + if (selectedTab === ProfileDetailTab.BACKGROUND) { + return ; } - if (selectedTab === 'background') { + if (selectedTab === ProfileDetailTab.ANALYSIS) { return ( - <> - {background?.experiences && background?.experiences.length > 0 && ( - <> -
- - Work Experience -
- - - )} - {background?.educations && background?.educations.length > 0 && ( - <> -
- - Education -
- - - )} - + ); } - return
Detail page for {selectedTab}
; + return null; } diff --git a/apps/portal/src/components/offers/profile/ProfileHeader.tsx b/apps/portal/src/components/offers/profile/ProfileHeader.tsx index ceab5e0e..4a0d944b 100644 --- a/apps/portal/src/components/offers/profile/ProfileHeader.tsx +++ b/apps/portal/src/components/offers/profile/ProfileHeader.tsx @@ -1,7 +1,6 @@ import { useRouter } from 'next/router'; import { useState } from 'react'; import { - BookmarkSquareIcon, BuildingOffice2Icon, CalendarDaysIcon, PencilSquareIcon, @@ -10,17 +9,20 @@ import { import { Button, Dialog, Spinner, Tabs } from '@tih/ui'; import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder'; -import type { BackgroundCard } from '~/components/offers/types'; +import type { BackgroundDisplayData } from '~/components/offers/types'; import { getProfileEditPath } from '~/utils/offers/link'; +import type { ProfileDetailTab } from '../constants'; +import { profileDetailTabs } from '../constants'; + type ProfileHeaderProps = Readonly<{ - background?: BackgroundCard; + background?: BackgroundDisplayData; handleDelete: () => void; isEditable: boolean; isLoading: boolean; - selectedTab: string; - setSelectedTab: (tab: string) => void; + selectedTab: ProfileDetailTab; + setSelectedTab: (tab: ProfileDetailTab) => void; }>; export default function ProfileHeader({ @@ -42,14 +44,14 @@ export default function ProfileHeader({ function renderActionList() { return (
-
); diff --git a/apps/portal/src/components/questions/QuestionsNavigation.ts b/apps/portal/src/components/questions/QuestionsNavigation.ts index 457845bb..f46d2388 100644 --- a/apps/portal/src/components/questions/QuestionsNavigation.ts +++ b/apps/portal/src/components/questions/QuestionsNavigation.ts @@ -1,9 +1,10 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; const navigation: ProductNavigationItems = [ - { href: '/questions', name: 'My Lists' }, - { href: '/questions', name: 'My Questions' }, - { href: '/questions', name: 'History' }, + { href: '/questions/browse', name: 'Browse' }, + { href: '/questions/lists', name: 'My Lists' }, + { href: '/questions/my-questions', name: 'My Questions' }, + { href: '/questions/history', name: 'History' }, ]; const config = { diff --git a/apps/portal/src/components/questions/card/AnswerCard.tsx b/apps/portal/src/components/questions/card/AnswerCard.tsx index 20818ee7..5407e95e 100644 --- a/apps/portal/src/components/questions/card/AnswerCard.tsx +++ b/apps/portal/src/components/questions/card/AnswerCard.tsx @@ -13,6 +13,7 @@ export type AnswerCardProps = { commentCount?: number; content: string; createdAt: Date; + showHover?: boolean; upvoteCount: number; votingButtonsSize: VotingButtonsProps['size']; }; @@ -26,10 +27,14 @@ export default function AnswerCard({ commentCount, votingButtonsSize, upvoteCount, + showHover, }: AnswerCardProps) { const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId); + const hoverClass = showHover ? 'hover:bg-slate-50' : ''; + return ( -
+
; - -export default function FullQuestionCard(props: QuestionOverviewCardProps) { - return ( - - ); -} diff --git a/apps/portal/src/components/questions/card/QuestionAnswerCard.tsx b/apps/portal/src/components/questions/card/QuestionAnswerCard.tsx index b508e5b4..ab573d7e 100644 --- a/apps/portal/src/components/questions/card/QuestionAnswerCard.tsx +++ b/apps/portal/src/components/questions/card/QuestionAnswerCard.tsx @@ -4,11 +4,11 @@ import type { AnswerCardProps } from './AnswerCard'; import AnswerCard from './AnswerCard'; export type QuestionAnswerCardProps = Required< - Omit + Omit >; function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) { - return ; + return ; } const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref); diff --git a/apps/portal/src/components/questions/card/QuestionCard.tsx b/apps/portal/src/components/questions/card/QuestionCard.tsx deleted file mode 100644 index ba530f75..00000000 --- a/apps/portal/src/components/questions/card/QuestionCard.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { ChatBubbleBottomCenterTextIcon } from '@heroicons/react/24/outline'; -import type { QuestionsQuestionType } from '@prisma/client'; -import { Badge, Button } from '@tih/ui'; - -import { useQuestionVote } from '~/utils/questions/useVote'; - -import QuestionTypeBadge from '../QuestionTypeBadge'; -import VotingButtons from '../VotingButtons'; - -type UpvoteProps = - | { - showVoteButtons: true; - upvoteCount: number; - } - | { - showVoteButtons?: false; - upvoteCount?: never; - }; - -type StatisticsProps = - | { - answerCount: number; - showUserStatistics: true; - } - | { - answerCount?: never; - showUserStatistics?: false; - }; - -type ActionButtonProps = - | { - actionButtonLabel: string; - onActionButtonClick: () => void; - showActionButton: true; - } - | { - actionButtonLabel?: never; - onActionButtonClick?: never; - showActionButton?: false; - }; - -export type QuestionCardProps = ActionButtonProps & - StatisticsProps & - UpvoteProps & { - company: string; - content: string; - location: string; - questionId: string; - receivedCount: number; - role: string; - timestamp: string; - type: QuestionsQuestionType; - }; - -export default function QuestionCard({ - questionId, - company, - answerCount, - content, - // ReceivedCount, - type, - showVoteButtons, - showUserStatistics, - showActionButton, - actionButtonLabel, - onActionButtonClick, - upvoteCount, - timestamp, - role, - location, -}: QuestionCardProps) { - const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId); - - return ( -
- {showVoteButtons && ( - - )} -
-
-
- - -

- {timestamp} · {location} · {role} -

-
- {showActionButton && ( -
-
-

{content}

-
- {showUserStatistics && ( -
-
- )} -
-
- ); -} diff --git a/apps/portal/src/components/questions/card/QuestionOverviewCard.tsx b/apps/portal/src/components/questions/card/QuestionOverviewCard.tsx deleted file mode 100644 index fa786917..00000000 --- a/apps/portal/src/components/questions/card/QuestionOverviewCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import withHref from '~/utils/questions/withHref'; - -import type { QuestionCardProps } from './QuestionCard'; -import QuestionCard from './QuestionCard'; - -export type QuestionOverviewCardProps = Omit< - QuestionCardProps & { - showActionButton: false; - showUserStatistics: true; - showVoteButtons: true; - }, - | 'actionButtonLabel' - | 'onActionButtonClick' - | 'showActionButton' - | 'showUserStatistics' - | 'showVoteButtons' ->; - -function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) { - return ( - - ); -} - -const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref); -export default QuestionOverviewCard; diff --git a/apps/portal/src/components/questions/card/SimilarQuestionCard.tsx b/apps/portal/src/components/questions/card/SimilarQuestionCard.tsx deleted file mode 100644 index 063bdf91..00000000 --- a/apps/portal/src/components/questions/card/SimilarQuestionCard.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import type { QuestionCardProps } from './QuestionCard'; -import QuestionCard from './QuestionCard'; - -export type SimilarQuestionCardProps = Omit< - QuestionCardProps & { - showActionButton: true; - showUserStatistics: false; - showVoteButtons: false; - }, - | 'actionButtonLabel' - | 'answerCount' - | 'onActionButtonClick' - | 'showActionButton' - | 'showUserStatistics' - | 'showVoteButtons' - | 'upvoteCount' -> & { - onSimilarQuestionClick: () => void; -}; - -export default function SimilarQuestionCard(props: SimilarQuestionCardProps) { - const { onSimilarQuestionClick, ...rest } = props; - return ( - - ); -} diff --git a/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx b/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx new file mode 100644 index 00000000..3c54d58b --- /dev/null +++ b/apps/portal/src/components/questions/card/question/BaseQuestionCard.tsx @@ -0,0 +1,232 @@ +import clsx from 'clsx'; +import { useState } from 'react'; +import { + ChatBubbleBottomCenterTextIcon, + CheckIcon, + EyeIcon, + TrashIcon, +} from '@heroicons/react/24/outline'; +import type { QuestionsQuestionType } from '@prisma/client'; +import { Button } from '@tih/ui'; + +import { useQuestionVote } from '~/utils/questions/useVote'; + +import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm'; +import CreateQuestionEncounterForm from '../../forms/CreateQuestionEncounterForm'; +import QuestionAggregateBadge from '../../QuestionAggregateBadge'; +import QuestionTypeBadge from '../../QuestionTypeBadge'; +import VotingButtons from '../../VotingButtons'; + +type UpvoteProps = + | { + showVoteButtons: true; + upvoteCount: number; + } + | { + showVoteButtons?: false; + upvoteCount?: never; + }; + +type DeleteProps = + | { + onDelete: () => void; + showDeleteButton: true; + } + | { + onDelete?: never; + showDeleteButton?: false; + }; + +type AnswerStatisticsProps = + | { + answerCount: number; + showAnswerStatistics: true; + } + | { + answerCount?: never; + showAnswerStatistics?: false; + }; + +type ActionButtonProps = + | { + actionButtonLabel: string; + onActionButtonClick: () => void; + showActionButton: true; + } + | { + actionButtonLabel?: never; + onActionButtonClick?: never; + showActionButton?: false; + }; + +type ReceivedStatisticsProps = + | { + receivedCount: number; + showReceivedStatistics: true; + } + | { + receivedCount?: never; + showReceivedStatistics?: false; + }; + +type CreateEncounterProps = + | { + onReceivedSubmit: (data: CreateQuestionEncounterData) => void; + showCreateEncounterButton: true; + } + | { + onReceivedSubmit?: never; + showCreateEncounterButton?: false; + }; + +export type BaseQuestionCardProps = ActionButtonProps & + AnswerStatisticsProps & + CreateEncounterProps & + DeleteProps & + ReceivedStatisticsProps & + UpvoteProps & { + companies: Record; + content: string; + locations: Record; + questionId: string; + roles: Record; + showHover?: boolean; + timestamp: string; + truncateContent?: boolean; + type: QuestionsQuestionType; + }; + +export default function BaseQuestionCard({ + questionId, + companies, + answerCount, + content, + receivedCount, + type, + showVoteButtons, + showAnswerStatistics, + showReceivedStatistics, + showCreateEncounterButton, + showActionButton, + actionButtonLabel, + onActionButtonClick, + upvoteCount, + timestamp, + roles, + locations, + showHover, + onReceivedSubmit, + showDeleteButton, + onDelete, + truncateContent = true, +}: BaseQuestionCardProps) { + const [showReceivedForm, setShowReceivedForm] = useState(false); + const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId); + const hoverClass = showHover ? 'hover:bg-slate-50' : ''; + const cardContent = ( + <> + {showVoteButtons && ( + + )} +
+
+
+ + + + +

{timestamp}

+
+ {showActionButton && ( +
+

+ {content} +

+ {!showReceivedForm && + (showAnswerStatistics || + showReceivedStatistics || + showCreateEncounterButton) && ( +
+ {showAnswerStatistics && ( +
+ )} + {showReceivedForm && ( + { + setShowReceivedForm(false); + }} + onSubmit={(data) => { + onReceivedSubmit?.(data); + setShowReceivedForm(false); + }} + /> + )} +
+ + ); + + return ( +
+ {cardContent} + {showDeleteButton && ( +
+
+ )} +
+ ); +} diff --git a/apps/portal/src/components/questions/card/question/FullQuestionCard.tsx b/apps/portal/src/components/questions/card/question/FullQuestionCard.tsx new file mode 100644 index 00000000..c99b4409 --- /dev/null +++ b/apps/portal/src/components/questions/card/question/FullQuestionCard.tsx @@ -0,0 +1,35 @@ +import type { BaseQuestionCardProps } from './BaseQuestionCard'; +import BaseQuestionCard from './BaseQuestionCard'; + +export type QuestionOverviewCardProps = Omit< + BaseQuestionCardProps & { + showActionButton: false; + showAnswerStatistics: false; + showCreateEncounterButton: true; + showDeleteButton: false; + showReceivedStatistics: false; + showVoteButtons: true; + }, + | 'actionButtonLabel' + | 'onActionButtonClick' + | 'showActionButton' + | 'showAnswerStatistics' + | 'showCreateEncounterButton' + | 'showDeleteButton' + | 'showReceivedStatistics' + | 'showVoteButtons' +>; + +export default function FullQuestionCard(props: QuestionOverviewCardProps) { + return ( + + ); +} diff --git a/apps/portal/src/components/questions/card/question/QuestionListCard.tsx b/apps/portal/src/components/questions/card/question/QuestionListCard.tsx new file mode 100644 index 00000000..b7b74cfa --- /dev/null +++ b/apps/portal/src/components/questions/card/question/QuestionListCard.tsx @@ -0,0 +1,36 @@ +import withHref from '~/utils/questions/withHref'; + +import type { BaseQuestionCardProps } from './BaseQuestionCard'; +import BaseQuestionCard from './BaseQuestionCard'; + +export type QuestionListCardProps = Omit< + BaseQuestionCardProps & { + showActionButton: false; + showAnswerStatistics: false; + showDeleteButton: true; + showVoteButtons: false; + }, + | 'actionButtonLabel' + | 'onActionButtonClick' + | 'showActionButton' + | 'showAnswerStatistics' + | 'showDeleteButton' + | 'showVoteButtons' +>; + +function QuestionListCardWithoutHref(props: QuestionListCardProps) { + return ( + + ); +} + +const QuestionListCard = withHref(QuestionListCardWithoutHref); +export default QuestionListCard; diff --git a/apps/portal/src/components/questions/card/question/QuestionOverviewCard.tsx b/apps/portal/src/components/questions/card/question/QuestionOverviewCard.tsx new file mode 100644 index 00000000..77ae5efa --- /dev/null +++ b/apps/portal/src/components/questions/card/question/QuestionOverviewCard.tsx @@ -0,0 +1,42 @@ +import withHref from '~/utils/questions/withHref'; + +import type { BaseQuestionCardProps } from './BaseQuestionCard'; +import BaseQuestionCard from './BaseQuestionCard'; + +export type QuestionOverviewCardProps = Omit< + BaseQuestionCardProps & { + showActionButton: false; + showAnswerStatistics: true; + showCreateEncounterButton: false; + showDeleteButton: false; + showReceivedStatistics: true; + showVoteButtons: true; + }, + | 'actionButtonLabel' + | 'onActionButtonClick' + | 'onDelete' + | 'showActionButton' + | 'showAnswerStatistics' + | 'showCreateEncounterButton' + | 'showDeleteButton' + | 'showReceivedStatistics' + | 'showVoteButtons' +>; + +function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) { + return ( + + ); +} + +const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref); +export default QuestionOverviewCard; diff --git a/apps/portal/src/components/questions/card/question/SimilarQuestionCard.tsx b/apps/portal/src/components/questions/card/question/SimilarQuestionCard.tsx new file mode 100644 index 00000000..846a57e4 --- /dev/null +++ b/apps/portal/src/components/questions/card/question/SimilarQuestionCard.tsx @@ -0,0 +1,44 @@ +import type { BaseQuestionCardProps } from './BaseQuestionCard'; +import BaseQuestionCard from './BaseQuestionCard'; + +export type SimilarQuestionCardProps = Omit< + BaseQuestionCardProps & { + showActionButton: true; + showAnswerStatistics: true; + showCreateEncounterButton: false; + showDeleteButton: false; + showHover: true; + showReceivedStatistics: false; + showVoteButtons: false; + }, + | 'actionButtonLabel' + | 'onActionButtonClick' + | 'showActionButton' + | 'showAnswerStatistics' + | 'showCreateEncounterButton' + | 'showDeleteButton' + | 'showHover' + | 'showReceivedStatistics' + | 'showVoteButtons' +> & { + onSimilarQuestionClick: () => void; +}; + +export default function SimilarQuestionCard(props: SimilarQuestionCardProps) { + const { onSimilarQuestionClick, ...rest } = props; + return ( + + ); +} diff --git a/apps/portal/src/components/questions/filter/FilterSection.tsx b/apps/portal/src/components/questions/filter/FilterSection.tsx index 312122c3..b72bc7ff 100644 --- a/apps/portal/src/components/questions/filter/FilterSection.tsx +++ b/apps/portal/src/components/questions/filter/FilterSection.tsx @@ -1,14 +1,20 @@ -import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; -import { CheckboxInput, Collapsible, RadioList, TextInput } from '@tih/ui'; +import { useMemo } from 'react'; +import type { UseFormRegisterReturn } from 'react-hook-form'; +import { useForm } from 'react-hook-form'; +import { CheckboxInput, Collapsible, RadioList } from '@tih/ui'; -export type FilterOption = { - checked: boolean; +export type FilterChoice = { + id: string; label: string; value: V; }; +export type FilterOption = FilterChoice & { + checked: boolean; +}; + export type FilterChoices = ReadonlyArray< - Omit, 'checked'> + FilterChoice >; type FilterSectionType> = @@ -30,42 +36,87 @@ export type FilterSectionProps> = options: FilterOptions; } & ( | { - searchPlaceholder: string; + renderInput: (props: { + field: UseFormRegisterReturn<'search'>; + onOptionChange: FilterSectionType['onOptionChange']; + options: FilterOptions; + }) => React.ReactNode; showAll?: never; } | { - searchPlaceholder?: never; + renderInput?: never; showAll: true; } ); +export type FilterSectionFormData = { + search: string; +}; + export default function FilterSection< FilterOptions extends Array, >({ label, options, - searchPlaceholder, showAll, onOptionChange, isSingleSelect, + renderInput, }: FilterSectionProps) { + const { register, reset } = useForm(); + + const registerSearch = register('search'); + + const field: UseFormRegisterReturn<'search'> = { + ...registerSearch, + onChange: async (event) => { + await registerSearch.onChange(event); + reset(); + }, + }; + + const autocompleteOptions = useMemo(() => { + return options.filter((option) => !option.checked) as FilterOptions; + }, [options]); + + const selectedCount = useMemo(() => { + return options.filter((option) => option.checked).length; + }, [options]); + + const collapsibleLabel = useMemo(() => { + if (isSingleSelect) { + return label; + } + if (selectedCount === 0) { + return `${label} (all)`; + } + + return `${label} (${selectedCount})`; + }, [label, selectedCount, isSingleSelect]); + return ( -
- +
+
{!showAll && ( - +
+ {renderInput({ + field, + onOptionChange: async ( + optionValue: FilterOptions[number]['value'], + ) => { + reset(); + return onOptionChange(optionValue, true); + }, + options: autocompleteOptions, + })} +
)} {isSingleSelect ? (
option.checked)?.value} onChange={(value) => { onOptionChange(value); @@ -81,16 +132,18 @@ export default function FilterSection<
) : (
- {options.map((option) => ( - { - onOptionChange(option.value, checked); - }} - /> - ))} + {options + .filter((option) => showAll || option.checked) + .map((option) => ( + { + onOptionChange(option.value, checked); + }} + /> + ))}
)}
diff --git a/apps/portal/src/components/questions/ContributeQuestionForm.tsx b/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx similarity index 59% rename from apps/portal/src/components/questions/ContributeQuestionForm.tsx rename to apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx index a0e4d0b6..a4daa3c6 100644 --- a/apps/portal/src/components/questions/ContributeQuestionForm.tsx +++ b/apps/portal/src/components/questions/forms/ContributeQuestionForm.tsx @@ -1,26 +1,26 @@ import { startOfMonth } from 'date-fns'; import { useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { CalendarDaysIcon, UserIcon } from '@heroicons/react/24/outline'; import type { QuestionsQuestionType } from '@prisma/client'; import { Button, CheckboxInput, - Collapsible, + HorizontalDivider, Select, TextArea, - TextInput, } from '@tih/ui'; -import { QUESTION_TYPES } from '~/utils/questions/constants'; +import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants'; import { useFormRegister, useSelectRegister, } from '~/utils/questions/useFormRegister'; -import CompaniesTypeahead from '../shared/CompaniesTypeahead'; -import type { Month } from '../shared/MonthYearPicker'; -import MonthYearPicker from '../shared/MonthYearPicker'; +import CompanyTypeahead from '../typeahead/CompanyTypeahead'; +import LocationTypeahead from '../typeahead/LocationTypeahead'; +import RoleTypeahead from '../typeahead/RoleTypeahead'; +import type { Month } from '../../shared/MonthYearPicker'; +import MonthYearPicker from '../../shared/MonthYearPicker'; export type ContributeQuestionData = { company: string; @@ -59,8 +59,17 @@ export default function ContributeQuestionForm({ }; return (
+
+