Merge branch 'main' into hongpo/add-question-text-match

pull/412/head
hpkoh 3 years ago
commit 3d70ca3b87

@ -15,6 +15,7 @@
"@headlessui/react": "^1.7.3", "@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.11", "@heroicons/react": "^2.0.11",
"@next-auth/prisma-adapter": "^1.0.4", "@next-auth/prisma-adapter": "^1.0.4",
"@popperjs/core": "^2.11.6",
"@prisma/client": "^4.4.0", "@prisma/client": "^4.4.0",
"@supabase/supabase-js": "^1.35.7", "@supabase/supabase-js": "^1.35.7",
"@tih/ui": "*", "@tih/ui": "*",
@ -33,6 +34,8 @@
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-hook-form": "^7.36.1", "react-hook-form": "^7.36.1",
"react-pdf": "^5.7.2", "react-pdf": "^5.7.2",
"react-popper": "^2.3.0",
"react-popper-tooltip": "^4.4.2",
"react-query": "^3.39.2", "react-query": "^3.39.2",
"superjson": "^1.10.0", "superjson": "^1.10.0",
"zod": "^3.18.0" "zod": "^3.18.0"

@ -110,9 +110,30 @@ export const educationFieldOptions = [
]; ];
export enum FieldError { export enum FieldError {
NonNegativeNumber = 'Please fill in a non-negative number 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.', NUMBER = 'Please fill in a number in this field.',
Required = 'Please fill in this field.', REQUIRED = 'Please fill in this field.',
} }
export const OVERALL_TAB = 'Overall'; 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,
},
];

@ -2,11 +2,9 @@ import { useEffect } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui'; import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferPercentileAnalysis from './OfferPercentileAnalysis';
import OfferProfileCard from './OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../../constants'; import { OVERALL_TAB } from '../constants';
import type { import type {
Analysis, Analysis,
@ -29,20 +27,29 @@ function OfferAnalysisContent({
tab, tab,
}: OfferAnalysisContentProps) { }: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) { if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) {
return (
<p className="m-10">
You are the first to submit an offer for your job title and YOE! Check
back later when there are more submissions.
</p>
);
}
return ( return (
<p className="m-10"> <p className="m-10">
You are the first to submit an offer for these companies! Check back You are the first to submit an offer for this company, job title and
later when there are more submissions. YOE! Check back later when there are more submissions.
</p> </p>
); );
} }
return ( return (
<> <>
<OfferPercentileAnalysis <OfferPercentileAnalysisText
companyName={offer.company.name} companyName={offer.company.name}
offerAnalysis={offerAnalysis} offerAnalysis={offerAnalysis}
tab={tab} tab={tab}
/> />
<p className="mt-5">Here are some of the top offers relevant to you:</p>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => ( {offerAnalysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard <OfferProfileCard
key={topPercentileOffer.id} key={topPercentileOffer.id}
@ -54,12 +61,17 @@ function OfferAnalysisContent({
} }
type OfferAnalysisProps = Readonly<{ type OfferAnalysisProps = Readonly<{
profileId?: string; allAnalysis?: ProfileAnalysis | null;
isError: boolean;
isLoading: boolean;
}>; }>;
export default function OfferAnalysis({ profileId }: OfferAnalysisProps) { export default function OfferAnalysis({
allAnalysis,
isError,
isLoading,
}: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB); const [tab, setTab] = useState(OVERALL_TAB);
const [allAnalysis, setAllAnalysis] = useState<ProfileAnalysis | null>(null);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null); const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null);
useEffect(() => { useEffect(() => {
@ -76,22 +88,6 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
} }
}, [tab, allAnalysis]); }, [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 = [ const tabOptions = [
{ {
label: OVERALL_TAB, label: OVERALL_TAB,
@ -106,18 +102,13 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
return ( return (
analysis && ( analysis && (
<div> <div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900"> {isError && (
Result
</h5>
{getAnalysisResult.isError && (
<p className="m-10 text-center"> <p className="m-10 text-center">
An error occurred while generating profile analysis. An error occurred while generating profile analysis.
</p> </p>
)} )}
{getAnalysisResult.isLoading && ( {isLoading && <Spinner className="m-10" display="block" size="lg" />}
<Spinner className="m-10" display="block" size="lg" /> {!isError && !isLoading && (
)}
{!getAnalysisResult.isError && !getAnalysisResult.isLoading && (
<div> <div>
<Tabs <Tabs
label="Result Navigation" label="Result Navigation"

@ -0,0 +1,29 @@
import { OVERALL_TAB } from '../constants';
import type { Analysis } from '~/types/offers';
type OfferPercentileAnalysisTextProps = Readonly<{
companyName: string;
offerAnalysis: Analysis;
tab: string;
}>;
export default function OfferPercentileAnalysisText({
tab,
companyName,
offerAnalysis: { noOfOffers, percentile },
}: OfferPercentileAnalysisTextProps) {
return tab === OVERALL_TAB ? (
<p>
Your highest offer is from <b>{companyName}</b>, which is{' '}
<b>{percentile.toFixed(1)}</b> percentile out of <b>{noOfOffers}</b>{' '}
offers received for the same job title and YOE(±1) in the last year.
</p>
) : (
<p>
Your offer from <b>{companyName}</b> is <b>{percentile.toFixed(1)}</b>{' '}
percentile out of <b>{noOfOffers}</b> offers received in {companyName} for
the same job title and YOE(±1) in the last year.
</p>
);
}

@ -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 (
<div className="my-5 block rounded-lg bg-white p-4 px-8 shadow-md">
<div className="flex items-center gap-x-5">
<div>
<ProfilePhotoHolder size="sm" />
</div>
<div className="col-span-10">
<p className="font-bold">{profileName}</p>
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>{previousCompanies[0]}</span>
</div>
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span>{totalYoe}</span>
</div>
</div>
</div>
<HorizontalDivider />
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<p className="font-bold">{title}</p>
<p>
Company: {company.name}, {location}
</p>
<p>Level: {level}</p>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl">
{jobType === JobType.FULLTIME
? `${convertMoneyToString(income)} / year`
: `${convertMoneyToString(income)} / month`}
</p>
</div>
</div>
</div>
);
}

@ -16,7 +16,7 @@ type OfferProfileSaveProps = Readonly<{
token?: string; token?: string;
}>; }>;
export default function OfferProfileSave({ export default function OffersProfileSave({
profileId, profileId,
token, token,
}: OfferProfileSaveProps) { }: OfferProfileSaveProps) {
@ -84,7 +84,7 @@ export default function OfferProfileSave({
onClick={saveProfile} onClick={saveProfile}
/> />
</div> </div>
<div className="mb-10"> <div>
<Button <Button
icon={EyeIcon} icon={EyeIcon}
label="View your profile" label="View your profile"

@ -2,30 +2,34 @@ import { useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb'; import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OfferAnalysis from '~/components/offers/offersSubmission/analysis/OfferAnalysis'; import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
import OfferProfileSave from '~/components/offers/offersSubmission/OfferProfileSave';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm'; import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm'; import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type { import type {
OfferFormData, OfferFormData,
OffersProfileFormData, OffersProfileFormData,
} from '~/components/offers/types'; } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker'; import type { Month } from '~/components/shared/MonthYearPicker';
import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form'; import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time'; import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { CreateOfferProfileResponse } from '~/types/offers'; import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
import type {
CreateOfferProfileResponse,
ProfileAnalysis,
} from '~/types/offers';
const defaultOfferValues = { const defaultOfferValues = {
comments: '', comments: '',
companyId: '', companyId: '',
jobType: JobType.FullTime, jobType: JobType.FULLTIME,
location: '', location: '',
monthYearReceived: { monthYearReceived: {
month: getCurrentMonth() as Month, month: getCurrentMonth() as Month,
@ -36,18 +40,18 @@ const defaultOfferValues = {
export const defaultFullTimeOfferValues = { export const defaultFullTimeOfferValues = {
...defaultOfferValues, ...defaultOfferValues,
jobType: JobType.FullTime, jobType: JobType.FULLTIME,
}; };
export const defaultInternshipOfferValues = { export const defaultInternshipOfferValues = {
...defaultOfferValues, ...defaultOfferValues,
jobType: JobType.Intern, jobType: JobType.INTERN,
}; };
const defaultOfferProfileValues = { const defaultOfferProfileValues = {
background: { background: {
educations: [], educations: [],
experiences: [{ jobType: JobType.FullTime }], experiences: [{ jobType: JobType.FULLTIME }],
specificYoes: [], specificYoes: [],
totalYoe: 0, totalYoe: 0,
}, },
@ -78,6 +82,7 @@ export default function OffersSubmissionForm({
id: profileId || '', id: profileId || '',
token: token || '', token: token || '',
}); });
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () => const scrollToTop = () =>
@ -88,9 +93,26 @@ export default function OffersSubmissionForm({
}); });
const { handleSubmit, trigger } = formMethods; const { handleSubmit, trigger } = formMethods;
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
onSuccess(data) {
setAnalysis(data);
},
},
);
const formSteps: Array<FormStep> = [ const formSteps: Array<FormStep> = [
{ {
component: <OfferDetailsForm key={0} />, component: (
<OfferDetailsForm
key={0}
defaultJobType={initialOfferProfileValues.offers[0].jobType}
/>
),
hasNext: true, hasNext: true,
hasPrevious: false, hasPrevious: false,
label: 'Offer details', label: 'Offer details',
@ -102,14 +124,21 @@ export default function OffersSubmissionForm({
label: 'Background', label: 'Background',
}, },
{ {
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />, component: (
<OfferAnalysis
key={2}
allAnalysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
/>
),
hasNext: true, hasNext: true,
hasPrevious: false, hasPrevious: false,
label: 'Analysis', label: 'Analysis',
}, },
{ {
component: ( component: (
<OfferProfileSave <OffersProfileSave
key={3} key={3}
profileId={createProfileResponse.id || ''} profileId={createProfileResponse.id || ''}
token={createProfileResponse.token} token={createProfileResponse.token}
@ -139,15 +168,6 @@ export default function OffersSubmissionForm({
scrollToTop(); scrollToTop();
}; };
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const mutationpath = const mutationpath =
profileId && token ? 'offers.profile.update' : 'offers.profile.create'; profileId && token ? 'offers.profile.update' : 'offers.profile.create';

@ -1,27 +0,0 @@
import type { Analysis } from '~/types/offers';
type OfferPercentileAnalysisProps = Readonly<{
companyName: string;
offerAnalysis: Analysis;
tab: string;
}>;
export default function OfferPercentileAnalysis({
tab,
companyName,
offerAnalysis: { noOfOffers, percentile },
}: OfferPercentileAnalysisProps) {
return tab === 'Overall' ? (
<p>
Your highest offer is from {companyName}, which is {percentile} percentile
out of {noOfOffers} offers received for the same job type, same level, and
same YOE(+/-1) in the last year.
</p>
) : (
<p>
Your offer from {companyName} is {percentile} percentile out of{' '}
{noOfOffers} offers received in {companyName} for the same job type, same
level, and same YOE(+/-1) in the last year.
</p>
);
}

@ -1,61 +0,0 @@
import { UserCircleIcon } from '@heroicons/react/24/outline';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { formatDate } from '~/utils/offers/time';
import { JobType } from '../../types';
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 (
<div className="my-5 block rounded-lg border p-4">
<div className="grid grid-flow-col grid-cols-12 gap-x-10">
<div className="col-span-1">
<UserCircleIcon width={50} />
</div>
<div className="col-span-10">
<p className="text-sm font-semibold">{profileName}</p>
<p className="text-xs ">Previous company: {previousCompanies[0]}</p>
<p className="text-xs ">YOE: {totalYoe} year(s)</p>
</div>
</div>
<HorizontalDivider />
<div className="grid grid-flow-col grid-cols-2 gap-x-10">
<div className="col-span-1 row-span-3">
<p className="text-sm font-semibold">{title}</p>
<p className="text-xs ">
Company: {company.name}, {location}
</p>
<p className="text-xs ">Level: {level}</p>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end text-sm">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl">
{jobType === JobType.FullTime
? `$${income} / year`
: `$${income} / month`}
</p>
</div>
</div>
</div>
);
}

@ -1,4 +1,5 @@
import { useFormContext, useWatch } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form';
import { JobType } from '@prisma/client';
import { Collapsible, RadioList } from '@tih/ui'; import { Collapsible, RadioList } from '@tih/ui';
import { import {
@ -10,7 +11,6 @@ import {
titleOptions, titleOptions,
} from '~/components/offers/constants'; } from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types'; import type { BackgroundPostData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
@ -39,8 +39,8 @@ function YoeSection() {
required={true} required={true}
type="number" type="number"
{...register(`background.totalYoe`, { {...register(`background.totalYoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -52,7 +52,7 @@ function YoeSection() {
label="Specific YOE 1" label="Specific YOE 1"
type="number" type="number"
{...register(`background.specificYoes.0.yoe`, { {...register(`background.specificYoes.0.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -68,7 +68,7 @@ function YoeSection() {
label="Specific YOE 2" label="Specific YOE 2"
type="number" type="number"
{...register(`background.specificYoes.1.yoe`, { {...register(`background.specificYoes.1.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -128,7 +128,7 @@ function FullTimeJobFields() {
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`background.experiences.0.totalCompensation.value`, { {...register(`background.experiences.0.totalCompensation.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -158,7 +158,7 @@ function FullTimeJobFields() {
label="Duration (months)" label="Duration (months)"
type="number" type="number"
{...register(`background.experiences.0.durationInMonths`, { {...register(`background.experiences.0.durationInMonths`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -211,7 +211,7 @@ function InternshipJobFields() {
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`background.experiences.0.monthlySalary.value`, { {...register(`background.experiences.0.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -239,7 +239,7 @@ function InternshipJobFields() {
function CurrentJobSection() { function CurrentJobSection() {
const { register } = useFormContext(); const { register } = useFormContext();
const watchJobType = useWatch({ const watchJobType = useWatch({
defaultValue: JobType.FullTime, defaultValue: JobType.FULLTIME,
name: 'background.experiences.0.jobType', name: 'background.experiences.0.jobType',
}); });
@ -251,7 +251,7 @@ function CurrentJobSection() {
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5"> <div className="mb-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5"> <div className="mb-5">
<FormRadioList <FormRadioList
defaultValue={JobType.FullTime} defaultValue={JobType.FULLTIME}
isLabelHidden={true} isLabelHidden={true}
label="Job Type" label="Job Type"
orientation="horizontal" orientation="horizontal"
@ -259,16 +259,16 @@ function CurrentJobSection() {
<RadioList.Item <RadioList.Item
key="Full-time" key="Full-time"
label="Full-time" label="Full-time"
value={JobType.FullTime} value={JobType.FULLTIME}
/> />
<RadioList.Item <RadioList.Item
key="Internship" key="Internship"
label="Internship" label="Internship"
value={JobType.Intern} value={JobType.INTERN}
/> />
</FormRadioList> </FormRadioList>
</div> </div>
{watchJobType === JobType.FullTime ? ( {watchJobType === JobType.FULLTIME ? (
<FullTimeJobFields /> <FullTimeJobFields />
) : ( ) : (
<InternshipJobFields /> <InternshipJobFields />

@ -9,6 +9,7 @@ import { useFormContext } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form'; import { useFieldArray } from 'react-hook-form';
import { PlusIcon } from '@heroicons/react/20/solid'; import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline'; import { TrashIcon } from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client';
import { Button, Dialog } from '@tih/ui'; import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -31,7 +32,6 @@ import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
import type { OfferFormData } from '../../types'; import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../../types'; import { JobTypeLabel } from '../../types';
import { JobType } from '../../types';
import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{ type FullTimeOfferDetailsFormProps = Readonly<{
@ -72,7 +72,7 @@ function FullTimeOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.offersFullTime.title`, { {...register(`offers.${index}.offersFullTime.title`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
<FormTextInput <FormTextInput
@ -81,7 +81,7 @@ function FullTimeOfferDetailsForm({
placeholder="e.g. Front End" placeholder="e.g. Front End"
required={true} required={true}
{...register(`offers.${index}.offersFullTime.specialization`, { {...register(`offers.${index}.offersFullTime.specialization`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -99,7 +99,7 @@ function FullTimeOfferDetailsForm({
placeholder="e.g. L4, Junior" placeholder="e.g. L4, Junior"
required={true} required={true}
{...register(`offers.${index}.offersFullTime.level`, { {...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -112,7 +112,7 @@ function FullTimeOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.location`, { {...register(`offers.${index}.location`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
<FormMonthYearPicker <FormMonthYearPicker
@ -120,7 +120,7 @@ function FullTimeOfferDetailsForm({
monthRequired={true} monthRequired={true}
yearLabel="" yearLabel=""
{...register(`offers.${index}.monthYearReceived`, { {...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -135,7 +135,7 @@ function FullTimeOfferDetailsForm({
{...register( {...register(
`offers.${index}.offersFullTime.totalCompensation.currency`, `offers.${index}.offersFullTime.totalCompensation.currency`,
{ {
required: FieldError.Required, required: FieldError.REQUIRED,
}, },
)} )}
/> />
@ -153,8 +153,8 @@ function FullTimeOfferDetailsForm({
{...register( {...register(
`offers.${index}.offersFullTime.totalCompensation.value`, `offers.${index}.offersFullTime.totalCompensation.value`,
{ {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
}, },
)} )}
@ -171,7 +171,7 @@ function FullTimeOfferDetailsForm({
{...register( {...register(
`offers.${index}.offersFullTime.baseSalary.currency`, `offers.${index}.offersFullTime.baseSalary.currency`,
{ {
required: FieldError.Required, required: FieldError.REQUIRED,
}, },
)} )}
/> />
@ -185,8 +185,8 @@ function FullTimeOfferDetailsForm({
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.baseSalary.value`, { {...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -198,7 +198,7 @@ function FullTimeOfferDetailsForm({
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.bonus.currency`, { {...register(`offers.${index}.offersFullTime.bonus.currency`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
} }
@ -211,8 +211,8 @@ function FullTimeOfferDetailsForm({
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.bonus.value`, { {...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -226,7 +226,7 @@ function FullTimeOfferDetailsForm({
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.stocks.currency`, { {...register(`offers.${index}.offersFullTime.stocks.currency`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
} }
@ -239,8 +239,8 @@ function FullTimeOfferDetailsForm({
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.stocks.value`, { {...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -300,7 +300,7 @@ function InternshipOfferDetailsForm({
required={true} required={true}
{...register(`offers.${index}.offersIntern.title`, { {...register(`offers.${index}.offersIntern.title`, {
minLength: 1, minLength: 1,
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
<FormTextInput <FormTextInput
@ -310,7 +310,7 @@ function InternshipOfferDetailsForm({
required={true} required={true}
{...register(`offers.${index}.offersIntern.specialization`, { {...register(`offers.${index}.offersIntern.specialization`, {
minLength: 1, minLength: 1,
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -330,7 +330,7 @@ function InternshipOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.location`, { {...register(`offers.${index}.location`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -343,7 +343,7 @@ function InternshipOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.offersIntern.internshipCycle`, { {...register(`offers.${index}.offersIntern.internshipCycle`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
<FormSelect <FormSelect
@ -354,7 +354,7 @@ function InternshipOfferDetailsForm({
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.offersIntern.startYear`, { {...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -365,7 +365,7 @@ function InternshipOfferDetailsForm({
monthRequired={true} monthRequired={true}
yearLabel="" yearLabel=""
{...register(`offers.${index}.monthYearReceived`, { {...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required, required: FieldError.REQUIRED,
})} })}
/> />
</div> </div>
@ -380,7 +380,7 @@ function InternshipOfferDetailsForm({
{...register( {...register(
`offers.${index}.offersIntern.monthlySalary.currency`, `offers.${index}.offersIntern.monthlySalary.currency`,
{ {
required: FieldError.Required, required: FieldError.REQUIRED,
}, },
)} )}
/> />
@ -396,8 +396,8 @@ function InternshipOfferDetailsForm({
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersIntern.monthlySalary.value`, { {...register(`offers.${index}.offersIntern.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.Required, required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -448,7 +448,7 @@ function OfferDetailsFormArray({
{fields.map((item, index) => { {fields.map((item, index) => {
return ( return (
<div key={item.id}> <div key={item.id}>
{jobType === JobType.FullTime ? ( {jobType === JobType.FULLTIME ? (
<FullTimeOfferDetailsForm index={index} remove={remove} /> <FullTimeOfferDetailsForm index={index} remove={remove} />
) : ( ) : (
<InternshipOfferDetailsForm index={index} remove={remove} /> <InternshipOfferDetailsForm index={index} remove={remove} />
@ -464,7 +464,7 @@ function OfferDetailsFormArray({
variant="tertiary" variant="tertiary"
onClick={() => onClick={() =>
append( append(
jobType === JobType.FullTime jobType === JobType.FULLTIME
? defaultFullTimeOfferValues ? defaultFullTimeOfferValues
: defaultInternshipOfferValues, : defaultInternshipOfferValues,
) )
@ -474,8 +474,14 @@ function OfferDetailsFormArray({
); );
} }
export default function OfferDetailsForm() { type OfferDetailsFormProps = Readonly<{
const [jobType, setJobType] = useState(JobType.FullTime); defaultJobType?: JobType;
}>;
export default function OfferDetailsForm({
defaultJobType = JobType.FULLTIME,
}: OfferDetailsFormProps) {
const [jobType, setJobType] = useState(defaultJobType);
const [isDialogOpen, setDialogOpen] = useState(false); const [isDialogOpen, setDialogOpen] = useState(false);
const { control } = useFormContext(); const { control } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' }); const fieldArrayValues = useFieldArray({ control, name: 'offers' });
@ -483,17 +489,17 @@ export default function OfferDetailsForm() {
const toggleJobType = () => { const toggleJobType = () => {
remove(); remove();
if (jobType === JobType.FullTime) { if (jobType === JobType.FULLTIME) {
setJobType(JobType.Intern); setJobType(JobType.INTERN);
append(defaultInternshipOfferValues); append(defaultInternshipOfferValues);
} else { } else {
setJobType(JobType.FullTime); setJobType(JobType.FULLTIME);
append(defaultFullTimeOfferValues); append(defaultFullTimeOfferValues);
} }
}; };
const switchJobTypeLabel = () => const switchJobTypeLabel = () =>
jobType === JobType.FullTime ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME; jobType === JobType.FULLTIME ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
return ( return (
<div className="mb-5"> <div className="mb-5">
@ -506,9 +512,9 @@ export default function OfferDetailsForm() {
display="block" display="block"
label={JobTypeLabel.FULLTIME} label={JobTypeLabel.FULLTIME}
size="md" size="md"
variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'} variant={jobType === JobType.FULLTIME ? 'secondary' : 'tertiary'}
onClick={() => { onClick={() => {
if (jobType === JobType.FullTime) { if (jobType === JobType.FULLTIME) {
return; return;
} }
setDialogOpen(true); setDialogOpen(true);
@ -520,9 +526,9 @@ export default function OfferDetailsForm() {
display="block" display="block"
label={JobTypeLabel.INTERN} label={JobTypeLabel.INTERN}
size="md" size="md"
variant={jobType === JobType.Intern ? 'secondary' : 'tertiary'} variant={jobType === JobType.INTERN ? 'secondary' : 'tertiary'}
onClick={() => { onClick={() => {
if (jobType === JobType.Intern) { if (jobType === JobType.INTERN) {
return; return;
} }
setDialogOpen(true); setDialogOpen(true);

@ -3,18 +3,10 @@ import {
LightBulbIcon, LightBulbIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import type { EducationBackgroundType } from '~/components/offers/types'; import type { EducationDisplayData } from '~/components/offers/types';
type EducationEntity = {
endDate?: string;
field?: string;
school?: string;
startDate?: string;
type?: EducationBackgroundType;
};
type Props = Readonly<{ type Props = Readonly<{
education: EducationEntity; education: EducationDisplayData;
}>; }>;
export default function EducationCard({ export default function EducationCard({
@ -39,9 +31,7 @@ export default function EducationCard({
</div> </div>
{(startDate || endDate) && ( {(startDate || endDate) && (
<div className="font-light text-gray-400"> <div className="font-light text-gray-400">
<p>{`${startDate ? startDate : 'N/A'} - ${ <p>{`${startDate || 'N/A'} - ${endDate || 'N/A'}`}</p>
endDate ? endDate : 'N/A'
}`}</p>
</div> </div>
)} )}
</div> </div>

@ -6,10 +6,10 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { HorizontalDivider } from '@tih/ui'; import { HorizontalDivider } from '@tih/ui';
import type { OfferEntity } from '~/components/offers/types'; import type { OfferDisplayData } from '~/components/offers/types';
type Props = Readonly<{ type Props = Readonly<{
offer: OfferEntity; offer: OfferDisplayData;
}>; }>;
export default function OfferCard({ export default function OfferCard({
@ -58,18 +58,30 @@ export default function OfferCard({
} }
function BottomSection() { function BottomSection() {
if (
!totalCompensation &&
!monthlySalary &&
!negotiationStrategy &&
!otherComment
) {
return null;
}
return ( return (
<>
<HorizontalDivider />
<div className="px-8"> <div className="px-8">
<div className="flex flex-col py-2"> <div className="flex flex-col py-2">
{totalCompensation ||
(monthlySalary && (
<div className="flex flex-row"> <div className="flex flex-row">
<CurrencyDollarIcon className="mr-1 h-5" /> <CurrencyDollarIcon className="mr-1 h-5" />
<p> <p>
{totalCompensation {totalCompensation && `TC: ${totalCompensation}`}
? `TC: ${totalCompensation}` {monthlySalary && `Monthly Salary: ${monthlySalary}`}
: `Monthly Salary: ${monthlySalary}`}
</p> </p>
</div> </div>
))}
{totalCompensation && ( {totalCompensation && (
<div className="ml-6 flex flex-row font-light text-gray-400"> <div className="ml-6 flex flex-row font-light text-gray-400">
<p> <p>
@ -92,18 +104,18 @@ export default function OfferCard({
{otherComment && ( {otherComment && (
<div className="flex flex-col py-2"> <div className="flex flex-col py-2">
<div className="flex flex-row"> <div className="flex flex-row">
<ChatBubbleBottomCenterTextIcon className="h-8 w-8" /> <ChatBubbleBottomCenterTextIcon className="h-5 w-5" />
<span className="overflow-wrap ml-2">"{otherComment}"</span> <span className="overflow-wrap ml-2">"{otherComment}"</span>
</div> </div>
</div> </div>
)} )}
</div> </div>
</>
); );
} }
return ( return (
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md"> <div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md">
<UpperSection /> <UpperSection />
<HorizontalDivider />
<BottomSection /> <BottomSection />
</div> </div>
); );

@ -1,32 +1,30 @@
import { AcademicCapIcon, BriefcaseIcon } from '@heroicons/react/24/outline'; import { useState } from 'react';
import { Spinner } from '@tih/ui'; import {
AcademicCapIcon,
ArrowPathIcon,
BriefcaseIcon,
} from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
import EducationCard from '~/components/offers/profile/EducationCard'; import EducationCard from '~/components/offers/profile/EducationCard';
import OfferCard from '~/components/offers/profile/OfferCard'; import OfferCard from '~/components/offers/profile/OfferCard';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types'; import type {
import { EducationBackgroundType } from '~/components/offers/types'; BackgroundDisplayData,
OfferDisplayData,
} from '~/components/offers/types';
type ProfileHeaderProps = Readonly<{ import { trpc } from '~/utils/trpc';
background?: BackgroundCard;
isLoading: boolean; import { ProfileDetailTab } from '../constants';
offers: Array<OfferEntity>; import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
selectedTab: string;
import { ProfileAnalysis } from '~/types/offers';
type ProfileOffersProps = Readonly<{
offers: Array<OfferDisplayData>;
}>; }>;
export default function ProfileDetails({ function ProfileOffers({ offers }: ProfileOffersProps) {
background,
isLoading,
offers,
selectedTab,
}: ProfileHeaderProps) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
if (selectedTab === 'offers') {
if (offers.length !== 0) { if (offers.length !== 0) {
return ( return (
<> <>
@ -42,38 +40,137 @@ export default function ProfileDetails({
<span className="font-bold">No offer is attached.</span> <span className="font-bold">No offer is attached.</span>
</div> </div>
); );
}
type ProfileBackgroundProps = Readonly<{
background?: BackgroundDisplayData;
}>;
function ProfileBackground({ background }: ProfileBackgroundProps) {
if (!background?.experiences?.length && !background?.educations?.length) {
return (
<div className="mx-8 my-4">
<p>No background information available.</p>
</div>
);
} }
if (selectedTab === 'background') {
return ( return (
<> <>
{background?.experiences && background?.experiences.length > 0 && ( {background?.experiences?.length > 0 && (
<> <>
<div className="mx-8 my-4 flex flex-row"> <div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" /> <BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">Work Experience</span> <span className="font-bold">Work Experience</span>
</div> </div>
<OfferCard offer={background?.experiences[0]} /> <OfferCard offer={background.experiences[0]} />
</> </>
)} )}
{background?.educations && background?.educations.length > 0 && ( {background?.educations?.length > 0 && (
<> <>
<div className="mx-8 my-4 flex flex-row"> <div className="mx-8 my-4 flex flex-row">
<AcademicCapIcon className="mr-1 h-5" /> <AcademicCapIcon className="mr-1 h-5" />
<span className="font-bold">Education</span> <span className="font-bold">Education</span>
</div> </div>
<EducationCard <EducationCard education={background.educations[0]} />
education={{
endDate: background.educations[0].endDate,
field: background.educations[0].field,
school: background.educations[0].school,
startDate: background.educations[0].startDate,
type: EducationBackgroundType.Bachelor,
}}
/>
</> </>
)} )}
</> </>
); );
}
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 (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="mx-8 my-4">
<OfferAnalysis allAnalysis={analysis} isError={false} isLoading={false} />
{isEditable && (
<div className="flex justify-end">
<Button
addonPosition="start"
icon={ArrowPathIcon}
label="Refresh Analysis"
variant="secondary"
onClick={() => generateAnalysisMutation.mutate({ profileId })}
/>
</div>
)}
</div>
);
}
type ProfileDetailsProps = Readonly<{
analysis?: ProfileAnalysis;
background?: BackgroundDisplayData;
isEditable: boolean;
isLoading: boolean;
offers: Array<OfferDisplayData>;
profileId: string;
selectedTab: ProfileDetailTab;
}>;
export default function ProfileDetails({
analysis,
background,
isLoading,
offers,
selectedTab,
profileId,
isEditable,
}: ProfileDetailsProps) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
if (selectedTab === ProfileDetailTab.OFFERS) {
return <ProfileOffers offers={offers} />;
}
if (selectedTab === ProfileDetailTab.BACKGROUND) {
return <ProfileBackground background={background} />;
}
if (selectedTab === ProfileDetailTab.ANALYSIS) {
return (
<ProfileAnalysis
analysis={analysis}
isEditable={isEditable}
profileId={profileId}
/>
);
} }
return <div>Detail page for {selectedTab}</div>; return null;
} }

@ -1,7 +1,6 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
BookmarkSquareIcon,
BuildingOffice2Icon, BuildingOffice2Icon,
CalendarDaysIcon, CalendarDaysIcon,
PencilSquareIcon, PencilSquareIcon,
@ -10,17 +9,20 @@ import {
import { Button, Dialog, Spinner, Tabs } from '@tih/ui'; import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder'; 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 { getProfileEditPath } from '~/utils/offers/link';
import type { ProfileDetailTab } from '../constants';
import { profileDetailTabs } from '../constants';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
background?: BackgroundCard; background?: BackgroundDisplayData;
handleDelete: () => void; handleDelete: () => void;
isEditable: boolean; isEditable: boolean;
isLoading: boolean; isLoading: boolean;
selectedTab: string; selectedTab: ProfileDetailTab;
setSelectedTab: (tab: string) => void; setSelectedTab: (tab: ProfileDetailTab) => void;
}>; }>;
export default function ProfileHeader({ export default function ProfileHeader({
@ -42,14 +44,14 @@ export default function ProfileHeader({
function renderActionList() { function renderActionList() {
return ( return (
<div className="space-x-2"> <div className="space-x-2">
<Button {/* <Button
disabled={isLoading} disabled={isLoading}
icon={BookmarkSquareIcon} icon={BookmarkSquareIcon}
isLabelHidden={true} isLabelHidden={true}
label="Save to user account" label="Save to user account"
size="md" size="md"
variant="tertiary" variant="tertiary"
/> /> */}
<Button <Button
disabled={isLoading} disabled={isLoading}
icon={PencilSquareIcon} icon={PencilSquareIcon}
@ -109,6 +111,13 @@ export default function ProfileHeader({
</div> </div>
); );
} }
if (!background) {
return null;
}
const { experiences, totalYoe, specificYoes, profileName } = background;
return ( return (
<div className="h-40 bg-white p-4"> <div className="h-40 bg-white p-4">
<div className="justify-left flex h-1/2"> <div className="justify-left flex h-1/2">
@ -118,7 +127,7 @@ export default function ProfileHeader({
<div className="w-full"> <div className="w-full">
<div className="justify-left flex flex-1"> <div className="justify-left flex flex-1">
<h2 className="flex w-4/5 text-2xl font-bold"> <h2 className="flex w-4/5 text-2xl font-bold">
{background?.profileName ?? 'anonymous'} {profileName ?? 'anonymous'}
</h2> </h2>
{isEditable && ( {isEditable && (
<div className="flex h-8 w-1/5 justify-end"> <div className="flex h-8 w-1/5 justify-end">
@ -126,22 +135,26 @@ export default function ProfileHeader({
</div> </div>
)} )}
</div> </div>
{(experiences[0]?.companyName ||
experiences[0]?.jobLevel ||
experiences[0]?.jobTitle) && (
<div className="flex flex-row"> <div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" /> <BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span> <span className="mr-2 font-bold">Current:</span>
<span> <span>
{`${background?.experiences[0]?.companyName ?? '-'} ${ {`${experiences[0].companyName || ''} ${
background?.experiences[0]?.jobLevel || '' experiences[0].jobLevel || ''
} ${background?.experiences[0]?.jobTitle || ''}`} } ${experiences[0].jobTitle || ''}`}
</span> </span>
</div> </div>
)}
<div className="flex flex-row"> <div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" /> <CalendarDaysIcon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">YOE:</span> <span className="mr-2 font-bold">YOE:</span>
<span className="mr-4">{background?.totalYoe}</span> <span className="mr-4">{totalYoe}</span>
{background?.specificYoes && {specificYoes &&
background?.specificYoes.length > 0 && specificYoes.length > 0 &&
background?.specificYoes.map(({ domain, yoe }) => { specificYoes.map(({ domain, yoe }) => {
return ( return (
<span <span
key={domain} key={domain}
@ -155,20 +168,7 @@ export default function ProfileHeader({
<div className="mt-8"> <div className="mt-8">
<Tabs <Tabs
label="Profile Detail Navigation" label="Profile Detail Navigation"
tabs={[ tabs={profileDetailTabs}
{
label: 'Offers',
value: 'offers',
},
{
label: 'Background',
value: 'background',
},
{
label: 'Offer Engine Analysis',
value: 'offerEngineAnalysis',
},
]}
value={selectedTab} value={selectedTab}
onChange={(value) => setSelectedTab(value)} onChange={(value) => setSelectedTab(value)}
/> />

@ -1,6 +1,14 @@
export default function ProfilePhotoHolder() { type ProfilePhotoHolderProps = {
size?: 'lg' | 'sm';
};
export default function ProfilePhotoHolder({
size = 'lg',
}: ProfilePhotoHolderProps) {
const sizeMap = { lg: '16', sm: '12' };
return ( return (
<span className="inline-block h-16 w-16 overflow-hidden rounded-full bg-gray-100"> <span
className={`inline-block h-${sizeMap[size]} w-${sizeMap[size]} overflow-hidden rounded-full bg-gray-100`}>
<svg <svg
className="h-full w-full text-gray-300" className="h-full w-full text-gray-300"
fill="currentColor" fill="currentColor"

@ -1,14 +1,11 @@
import type { JobType } from '@prisma/client';
import type { MonthYear } from '~/components/shared/MonthYearPicker'; import type { MonthYear } from '~/components/shared/MonthYearPicker';
/* /*
* Offer Profile * Offer Profile
*/ */
export enum JobType {
FullTime = 'FULLTIME',
Intern = 'INTERN',
}
export const JobTypeLabel = { export const JobTypeLabel = {
FULLTIME: 'Full-time', FULLTIME: 'Full-time',
INTERN: 'Internship', INTERN: 'Internship',
@ -26,17 +23,20 @@ export enum EducationBackgroundType {
export type OffersProfilePostData = { export type OffersProfilePostData = {
background: BackgroundPostData; background: BackgroundPostData;
id?: string;
offers: Array<OfferPostData>; offers: Array<OfferPostData>;
}; };
export type OffersProfileFormData = { export type OffersProfileFormData = {
background: BackgroundPostData; background: BackgroundPostData;
id?: string;
offers: Array<OfferFormData>; offers: Array<OfferFormData>;
}; };
export type BackgroundPostData = { export type BackgroundPostData = {
educations: Array<EducationPostData>; educations: Array<EducationPostData>;
experiences: Array<ExperiencePostData>; experiences: Array<ExperiencePostData>;
id?: string;
specificYoes: Array<SpecificYoePostData>; specificYoes: Array<SpecificYoePostData>;
totalYoe: number; totalYoe: number;
}; };
@ -44,6 +44,7 @@ export type BackgroundPostData = {
type ExperiencePostData = { type ExperiencePostData = {
companyId?: string | null; companyId?: string | null;
durationInMonths?: number | null; durationInMonths?: number | null;
id?: string;
jobType?: string | null; jobType?: string | null;
level?: string | null; level?: string | null;
location?: string | null; location?: string | null;
@ -57,6 +58,7 @@ type ExperiencePostData = {
type EducationPostData = { type EducationPostData = {
endDate?: Date | null; endDate?: Date | null;
field?: string | null; field?: string | null;
id?: string;
school?: string | null; school?: string | null;
startDate?: Date | null; startDate?: Date | null;
type?: string | null; type?: string | null;
@ -64,6 +66,7 @@ type EducationPostData = {
type SpecificYoePostData = { type SpecificYoePostData = {
domain: string; domain: string;
id?: string;
yoe: number; yoe: number;
}; };
@ -72,7 +75,8 @@ type SpecificYoe = SpecificYoePostData;
export type OfferPostData = { export type OfferPostData = {
comments: string; comments: string;
companyId: string; companyId: string;
jobType: string; id?: string;
jobType: JobType;
location: string; location: string;
monthYearReceived: Date; monthYearReceived: Date;
negotiationStrategy: string; negotiationStrategy: string;
@ -87,6 +91,7 @@ export type OfferFormData = Omit<OfferPostData, 'monthYearReceived'> & {
export type OfferFullTimePostData = { export type OfferFullTimePostData = {
baseSalary: Money; baseSalary: Money;
bonus: Money; bonus: Money;
id?: string;
level: string; level: string;
specialization: string; specialization: string;
stocks: Money; stocks: Money;
@ -95,6 +100,7 @@ export type OfferFullTimePostData = {
}; };
export type OfferInternPostData = { export type OfferInternPostData = {
id?: string;
internshipCycle: string; internshipCycle: string;
monthlySalary: Money; monthlySalary: Money;
specialization: string; specialization: string;
@ -104,40 +110,41 @@ export type OfferInternPostData = {
export type Money = { export type Money = {
currency: string; currency: string;
id?: string;
value: number; value: number;
}; };
type EducationDisplay = { export type EducationDisplayData = {
endDate?: string; endDate?: string | null;
field: string; field?: string | null;
school: string; school?: string | null;
startDate?: string; startDate?: string | null;
type: string; type?: string | null;
}; };
export type OfferEntity = { export type OfferDisplayData = {
base?: string; base?: string | null;
bonus?: string; bonus?: string | null;
companyName?: string; companyName?: string | null;
duration?: string; duration?: number | null;
id?: string; id?: string;
jobLevel?: string; jobLevel?: string | null;
jobTitle?: string; jobTitle?: string | null;
location?: string; location?: string | null;
monthlySalary?: string; monthlySalary?: string | null;
negotiationStrategy?: string; negotiationStrategy?: string | null;
otherComment?: string; otherComment?: string | null;
receivedMonth?: string; receivedMonth?: string | null;
stocks?: string; stocks?: string | null;
totalCompensation?: string; totalCompensation?: string | null;
}; };
export type BackgroundCard = { export type BackgroundDisplayData = {
educations: Array<EducationDisplay>; educations: Array<EducationDisplayData>;
experiences: Array<OfferEntity>; experiences: Array<OfferDisplayData>;
profileName: string; profileName: string;
specificYoes: Array<SpecificYoe>; specificYoes: Array<SpecificYoe>;
totalYoe: string; totalYoe: number;
}; };
export type CommentEntity = { export type CommentEntity = {

@ -7,7 +7,7 @@ import {
import { TextInput } from '@tih/ui'; import { TextInput } from '@tih/ui';
import ContributeQuestionDialog from './ContributeQuestionDialog'; import ContributeQuestionDialog from './ContributeQuestionDialog';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm'; import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
export type ContributeQuestionCardProps = Pick< export type ContributeQuestionCardProps = Pick<
ContributeQuestionFormProps, ContributeQuestionFormProps,

@ -2,9 +2,9 @@ import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '@tih/ui'; import { HorizontalDivider } from '@tih/ui';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import ContributeQuestionForm from './ContributeQuestionForm';
import DiscardDraftDialog from './DiscardDraftDialog'; import DiscardDraftDialog from './DiscardDraftDialog';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
import ContributeQuestionForm from './forms/ContributeQuestionForm';
export type ContributeQuestionDialogProps = Pick< export type ContributeQuestionDialogProps = Pick<
ContributeQuestionFormProps, ContributeQuestionFormProps,
@ -60,14 +60,14 @@ export default function ContributeQuestionDialog({
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"> leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full"> <Dialog.Panel className="relative w-full max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8">
<div className="bg-white p-6 pt-5 sm:pb-4"> <div className="bg-white p-6 pt-5 sm:pb-4">
<div className="flex flex-1 items-stretch"> <div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:text-left"> <div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-medium leading-6 text-gray-900"> className="text-lg font-medium leading-6 text-gray-900">
Question Draft Contribute question
</Dialog.Title> </Dialog.Title>
<div className="w-full"> <div className="w-full">
<HorizontalDivider /> <HorizontalDivider />

@ -1,13 +1,15 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { ArrowSmallRightIcon } from '@heroicons/react/24/outline'; import { ArrowSmallRightIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Select } from '@tih/ui'; import { Button, Select } from '@tih/ui';
import { import { QUESTION_TYPES } from '~/utils/questions/constants';
COMPANIES, import useDefaultCompany from '~/utils/questions/useDefaultCompany';
LOCATIONS, import useDefaultLocation from '~/utils/questions/useDefaultLocation';
QUESTION_TYPES,
} from '~/utils/questions/constants'; import type { FilterChoice } from './filter/FilterSection';
import CompanyTypeahead from './typeahead/CompanyTypeahead';
import LocationTypeahead from './typeahead/LocationTypeahead';
export type LandingQueryData = { export type LandingQueryData = {
company: string; company: string;
@ -22,76 +24,109 @@ export type LandingComponentProps = {
export default function LandingComponent({ export default function LandingComponent({
onLanded: handleLandingQuery, onLanded: handleLandingQuery,
}: LandingComponentProps) { }: LandingComponentProps) {
const [landingQueryData, setLandingQueryData] = useState<LandingQueryData>({ const defaultCompany = useDefaultCompany();
company: 'Google', const defaultLocation = useDefaultLocation();
location: 'Singapore',
questionType: 'CODING', const [company, setCompany] = useState<FilterChoice | undefined>(
}); defaultCompany,
);
const [location, setLocation] = useState<FilterChoice | undefined>(
defaultLocation,
);
const handleChangeCompany = (company: string) => { const [questionType, setQuestionType] =
setLandingQueryData((prev) => ({ ...prev, company })); useState<QuestionsQuestionType>('CODING');
const handleChangeCompany = (newCompany: FilterChoice) => {
setCompany(newCompany);
}; };
const handleChangeLocation = (location: string) => { const handleChangeLocation = (newLocation: FilterChoice) => {
setLandingQueryData((prev) => ({ ...prev, location })); setLocation(newLocation);
}; };
const handleChangeType = (questionType: QuestionsQuestionType) => { const handleChangeType = (newQuestionType: QuestionsQuestionType) => {
setLandingQueryData((prev) => ({ ...prev, questionType })); setQuestionType(newQuestionType);
}; };
useEffect(() => {
if (company === undefined) {
setCompany(defaultCompany);
}
}, [defaultCompany, company]);
useEffect(() => {
if (location === undefined) {
setLocation(defaultLocation);
}
}, [defaultLocation, location]);
return ( return (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto bg-white"> <main className="flex flex-1 flex-col items-center overflow-y-auto bg-white">
<div className="pb-4"></div> <div className="flex flex-1 flex-col items-start justify-center gap-12 px-4">
<div className="flex flex-1 flex-col justify-center gap-3"> <header className="flex flex-col items-start gap-4">
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<img alt="app logo" className=" h-20 w-20" src="/logo.svg"></img> <h1 className="text-3xl font-semibold text-slate-900">
<h1 className="text-primary-600 p-4 text-center text-5xl font-bold">
Tech Interview Question Bank Tech Interview Question Bank
</h1> </h1>
<img alt="app logo" className="h-20 w-20" src="/logo.svg"></img>
</div> </div>
<p className="mx-auto max-w-lg p-6 text-center text-xl text-black sm:max-w-3xl"> <p className="mb-2 max-w-lg text-5xl font-semibold text-slate-900 sm:max-w-3xl">
Get to know the latest SWE interview questions asked by top companies Know the{' '}
<span className="text-primary-700">
latest SWE interview questions
</span>{' '}
asked by top companies.
</p> </p>
</header>
<div className="mx-auto flex max-w-lg items-baseline gap-3 p-4 text-center text-xl text-black sm:max-w-3xl"> <div className="flex flex-col items-start gap-3 text-xl font-semibold text-slate-900">
<p>Find</p> <p className="text-3xl">Find questions</p>
<div className=" space-x-2"> <div className="grid grid-cols-[auto_auto] items-baseline gap-x-4 gap-y-2">
<p className="text-slate-600">about</p>
<Select <Select
isLabelHidden={true} isLabelHidden={true}
label="Type" label="Type"
options={QUESTION_TYPES} options={QUESTION_TYPES}
value={landingQueryData.questionType} value={questionType}
onChange={(value) => { onChange={(value) => {
handleChangeType(value.toUpperCase() as QuestionsQuestionType); handleChangeType(value.toUpperCase() as QuestionsQuestionType);
}} }}
/> />
</div> <p className="text-slate-600">from</p>
<p>questions from</p> <CompanyTypeahead
<Select
isLabelHidden={true} isLabelHidden={true}
label="Company" value={company}
options={COMPANIES} onSelect={(value) => {
value={landingQueryData.company} handleChangeCompany(value);
onChange={handleChangeCompany} }}
/> />
<p>in</p> <p className="text-slate-600">in</p>
<Select <LocationTypeahead
isLabelHidden={true} isLabelHidden={true}
label="Location" value={location}
options={LOCATIONS} onSelect={(value) => {
value={landingQueryData.location} handleChangeLocation(value);
onChange={handleChangeLocation} }}
/> />
</div>
<Button <Button
addonPosition="end" addonPosition="end"
icon={ArrowSmallRightIcon} icon={ArrowSmallRightIcon}
label="Go" label="Go"
size="md" size="md"
variant="primary" variant="primary"
onClick={() => handleLandingQuery(landingQueryData)}></Button> onClick={() => {
if (company !== undefined && location !== undefined) {
return handleLandingQuery({
company: company.value,
location: location.value,
questionType,
});
}
}}
/>
</div> </div>
<div className="flex justify-center p-4"> <div className="flex justify-center">
<iframe <iframe
height={30} height={30}
src="https://ghbtns.com/github-btn.html?user=yangshun&amp;repo=tech-interview-handbook&amp;type=star&amp;count=true&amp;size=large" src="https://ghbtns.com/github-btn.html?user=yangshun&amp;repo=tech-interview-handbook&amp;type=star&amp;count=true&amp;size=large"

@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { Badge } from '@tih/ui';
import 'react-popper-tooltip/dist/styles.css';
type BadgeProps = ComponentProps<typeof Badge>;
export type QuestionAggregateBadgeProps = Omit<BadgeProps, 'label'> & {
statistics: Record<string, number>;
};
export default function QuestionAggregateBadge({
statistics,
...badgeProps
}: QuestionAggregateBadgeProps) {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({
interactive: true,
placement: 'bottom-start',
trigger: ['focus', 'hover'],
});
const mostCommonStatistic = useMemo(
() =>
Object.entries(statistics).reduce(
(mostCommon, [key, value]) => {
if (value > mostCommon.value) {
return { key, value };
}
return mostCommon;
},
{ key: '', value: 0 },
),
[statistics],
);
const sortedStatistics = useMemo(
() =>
Object.entries(statistics)
.sort((a, b) => b[1] - a[1])
.map(([key, value]) => ({ key, value })),
[statistics],
);
const additionalStatisticCount = Object.keys(statistics).length - 1;
const label = useMemo(() => {
if (additionalStatisticCount === 0) {
return mostCommonStatistic.key;
}
return `${mostCommonStatistic.key} (+${additionalStatisticCount})`;
}, [mostCommonStatistic, additionalStatisticCount]);
return (
<>
<button ref={setTriggerRef} className="rounded-full" type="button">
<Badge label={label} {...badgeProps} />
</button>
{visible && (
<div ref={setTooltipRef} {...getTooltipProps()}>
<div className="flex flex-col gap-2 rounded-md bg-white p-2 shadow-md">
<ul>
{sortedStatistics.map(({ key, value }) => (
<li
key={key}
className="flex justify-between gap-x-4 rtl:flex-row-reverse">
<span className="flex text-start font-semibold">{key}</span>
<span className="float-end">{value}</span>
</li>
))}
</ul>
</div>
</div>
)}
</>
);
}

@ -4,29 +4,41 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui'; import { Button, Select, TextInput } from '@tih/ui';
export type SortOption = { export type SortOption<Value> = {
label: string; label: string;
value: string; value: Value;
}; };
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = { type SortOrderProps<SortOrder> = {
onFilterOptionsToggle: () => void; onSortOrderChange?: (sortValue: SortOrder) => void;
onSortChange?: (sortValue: SortOptions[number]['value']) => void; sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOptions: SortOptions; sortOrderValue: SortOrder;
sortValue: SortOptions[number]['value']; };
type SortTypeProps<SortType> = {
onSortTypeChange?: (sortType: SortType) => void;
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
sortTypeValue: SortType;
}; };
export default function QuestionSearchBar< export type QuestionSearchBarProps<SortType, SortOrder> =
SortOptions extends Array<SortOption>, SortOrderProps<SortOrder> &
>({ SortTypeProps<SortType> & {
onSortChange, onFilterOptionsToggle: () => void;
sortOptions, };
sortValue,
export default function QuestionSearchBar<SortType, SortOrder>({
onSortOrderChange,
sortOrderOptions,
sortOrderValue,
onSortTypeChange,
sortTypeOptions,
sortTypeValue,
onFilterOptionsToggle, onFilterOptionsToggle,
}: QuestionSearchBarProps<SortOptions>) { }: QuestionSearchBarProps<SortType, SortOrder>) {
return ( return (
<div className="flex items-center gap-4"> <div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1"> <div className="flex-1 ">
<TextInput <TextInput
isLabelHidden={true} isLabelHidden={true}
label="Search by content" label="Search by content"
@ -35,17 +47,37 @@ export default function QuestionSearchBar<
startAddOnType="icon" startAddOnType="icon"
/> />
</div> </div>
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span aria-hidden={true} className="align-middle text-sm font-medium">
Sort by:
</span>
<Select <Select
display="inline" display="inline"
isLabelHidden={true}
label="Sort by" label="Sort by"
options={sortOptions} options={sortTypeOptions}
value={sortValue} value={sortTypeValue}
onChange={onSortChange} onChange={(value) => {
const chosenOption = sortTypeOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortTypeChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="flex items-center gap-2">
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortOrderChange?.(chosenOption.value);
}
}}
/> />
</div> </div>
<div className="lg:hidden"> <div className="lg:hidden">
@ -58,5 +90,6 @@ export default function QuestionSearchBar<
/> />
</div> </div>
</div> </div>
</div>
); );
} }

@ -1,9 +1,10 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [ const navigation: ProductNavigationItems = [
{ href: '/questions', name: 'My Lists' }, { href: '/questions/browse', name: 'Browse' },
{ href: '/questions', name: 'My Questions' }, { href: '/questions/lists', name: 'My Lists' },
{ href: '/questions', name: 'History' }, { href: '/questions/my-questions', name: 'My Questions' },
{ href: '/questions/history', name: 'History' },
]; ];
const config = { const config = {

@ -13,6 +13,7 @@ export type AnswerCardProps = {
commentCount?: number; commentCount?: number;
content: string; content: string;
createdAt: Date; createdAt: Date;
showHover?: boolean;
upvoteCount: number; upvoteCount: number;
votingButtonsSize: VotingButtonsProps['size']; votingButtonsSize: VotingButtonsProps['size'];
}; };
@ -26,10 +27,14 @@ export default function AnswerCard({
commentCount, commentCount,
votingButtonsSize, votingButtonsSize,
upvoteCount, upvoteCount,
showHover,
}: AnswerCardProps) { }: AnswerCardProps) {
const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId); const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
return ( return (
<article className="flex gap-4 rounded-md border bg-white p-2"> <article
className={`flex gap-4 rounded-md border bg-white p-2 ${hoverClass}`}>
<VotingButtons <VotingButtons
size={votingButtonsSize} size={votingButtonsSize}
upvoteCount={upvoteCount} upvoteCount={upvoteCount}

@ -1,26 +0,0 @@
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: false;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
return (
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={false}
showVoteButtons={true}
/>
);
}

@ -4,11 +4,11 @@ import type { AnswerCardProps } from './AnswerCard';
import AnswerCard from './AnswerCard'; import AnswerCard from './AnswerCard';
export type QuestionAnswerCardProps = Required< export type QuestionAnswerCardProps = Required<
Omit<AnswerCardProps, 'votingButtonsSize'> Omit<AnswerCardProps, 'showHover' | 'votingButtonsSize'>
>; >;
function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) { function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) {
return <AnswerCard {...props} votingButtonsSize="sm" />; return <AnswerCard {...props} showHover={true} votingButtonsSize="sm" />;
} }
const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref); const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref);

@ -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 (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
)}
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-2 text-slate-500">
<Badge label={company} variant="primary" />
<QuestionTypeBadge type={type} />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
onClick={onActionButtonClick}
/>
)}
</div>
<div className="ml-2">
<p className="line-clamp-2 text-ellipsis ">{content}</p>
</div>
{showUserStatistics && (
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
{/* <Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/> */}
</div>
)}
</div>
</article>
);
}

@ -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 (
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={true}
showVoteButtons={true}
/>
);
}
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
export default QuestionOverviewCard;

@ -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 (
<QuestionCard
{...rest}
actionButtonLabel="Yes, this is my question"
showActionButton={true}
onActionButtonClick={onSimilarQuestionClick}
/>
);
}

@ -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<string, number>;
content: string;
locations: Record<string, number>;
questionId: string;
roles: Record<string, number>;
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 && (
<VotingButtons
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
)}
<div className="flex flex-col items-start gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-2 text-slate-500">
<QuestionTypeBadge type={type} />
<QuestionAggregateBadge statistics={companies} variant="primary" />
<QuestionAggregateBadge statistics={locations} variant="success" />
<QuestionAggregateBadge statistics={roles} variant="danger" />
<p className="text-xs">{timestamp}</p>
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
onClick={onActionButtonClick}
/>
)}
</div>
<p className={clsx(truncateContent && 'line-clamp-2 text-ellipsis')}>
{content}
</p>
{!showReceivedForm &&
(showAnswerStatistics ||
showReceivedStatistics ||
showCreateEncounterButton) && (
<div className="flex gap-2">
{showAnswerStatistics && (
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
)}
{showReceivedStatistics && (
<Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/>
)}
{showCreateEncounterButton && (
<Button
addonPosition="start"
icon={CheckIcon}
label="I received this too"
size="sm"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
setShowReceivedForm(true);
}}
/>
)}
</div>
)}
{showReceivedForm && (
<CreateQuestionEncounterForm
onCancel={() => {
setShowReceivedForm(false);
}}
onSubmit={(data) => {
onReceivedSubmit?.(data);
setShowReceivedForm(false);
}}
/>
)}
</div>
</>
);
return (
<article
className={`group flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}>
{cardContent}
{showDeleteButton && (
<div className="invisible self-center fill-red-700 group-hover:visible">
<Button
icon={TrashIcon}
isLabelHidden={true}
label="Delete"
size="md"
variant="tertiary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
/>
</div>
)}
</article>
);
}

@ -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 (
<BaseQuestionCard
{...props}
showActionButton={false}
showAnswerStatistics={false}
showCreateEncounterButton={true}
showReceivedStatistics={false}
showVoteButtons={true}
truncateContent={false}
/>
);
}

@ -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 (
<BaseQuestionCard
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
showActionButton={false}
showAnswerStatistics={false}
showDeleteButton={true}
showHover={true}
showVoteButtons={false}
/>
);
}
const QuestionListCard = withHref(QuestionListCardWithoutHref);
export default QuestionListCard;

@ -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 (
<BaseQuestionCard
{...props}
showActionButton={false}
showAnswerStatistics={true}
showCreateEncounterButton={false}
showDeleteButton={false}
showHover={true}
showReceivedStatistics={true}
showVoteButtons={true}
/>
);
}
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
export default QuestionOverviewCard;

@ -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 (
<BaseQuestionCard
actionButtonLabel="Yes, this is my question"
showActionButton={true}
showAnswerStatistics={true}
showCreateEncounterButton={false}
showDeleteButton={false}
showHover={true}
showReceivedStatistics={true}
showVoteButtons={true}
onActionButtonClick={onSimilarQuestionClick}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(rest as any)}
/>
);
}

@ -1,14 +1,20 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { useMemo } from 'react';
import { CheckboxInput, Collapsible, RadioList, TextInput } from '@tih/ui'; import type { UseFormRegisterReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { CheckboxInput, Collapsible, RadioList } from '@tih/ui';
export type FilterOption<V extends string = string> = { export type FilterChoice<V extends string = string> = {
checked: boolean; id: string;
label: string; label: string;
value: V; value: V;
}; };
export type FilterOption<V extends string = string> = FilterChoice<V> & {
checked: boolean;
};
export type FilterChoices<V extends string = string> = ReadonlyArray< export type FilterChoices<V extends string = string> = ReadonlyArray<
Omit<FilterOption<V>, 'checked'> FilterChoice<V>
>; >;
type FilterSectionType<FilterOptions extends Array<FilterOption>> = type FilterSectionType<FilterOptions extends Array<FilterOption>> =
@ -30,42 +36,87 @@ export type FilterSectionProps<FilterOptions extends Array<FilterOption>> =
options: FilterOptions; options: FilterOptions;
} & ( } & (
| { | {
searchPlaceholder: string; renderInput: (props: {
field: UseFormRegisterReturn<'search'>;
onOptionChange: FilterSectionType<FilterOptions>['onOptionChange'];
options: FilterOptions;
}) => React.ReactNode;
showAll?: never; showAll?: never;
} }
| { | {
searchPlaceholder?: never; renderInput?: never;
showAll: true; showAll: true;
} }
); );
export type FilterSectionFormData = {
search: string;
};
export default function FilterSection< export default function FilterSection<
FilterOptions extends Array<FilterOption>, FilterOptions extends Array<FilterOption>,
>({ >({
label, label,
options, options,
searchPlaceholder,
showAll, showAll,
onOptionChange, onOptionChange,
isSingleSelect, isSingleSelect,
renderInput,
}: FilterSectionProps<FilterOptions>) { }: FilterSectionProps<FilterOptions>) {
const { register, reset } = useForm<FilterSectionFormData>();
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 ( return (
<div className="mx-2"> <div className="mx-2 py-2">
<Collapsible defaultOpen={true} label={label}> <Collapsible defaultOpen={true} label={collapsibleLabel}>
<div className="-mx-2 flex flex-col items-stretch gap-2"> <div className="-mx-2 flex flex-col items-stretch gap-2">
{!showAll && ( {!showAll && (
<TextInput <div className="z-10">
isLabelHidden={true} {renderInput({
label={label} field,
placeholder={searchPlaceholder} onOptionChange: async (
startAddOn={MagnifyingGlassIcon} optionValue: FilterOptions[number]['value'],
startAddOnType="icon" ) => {
/> reset();
return onOptionChange(optionValue, true);
},
options: autocompleteOptions,
})}
</div>
)} )}
{isSingleSelect ? ( {isSingleSelect ? (
<div className="px-1.5"> <div className="px-1.5">
<RadioList <RadioList
label="" isLabelHidden={true}
label={label}
value={options.find((option) => option.checked)?.value} value={options.find((option) => option.checked)?.value}
onChange={(value) => { onChange={(value) => {
onOptionChange(value); onOptionChange(value);
@ -81,7 +132,9 @@ export default function FilterSection<
</div> </div>
) : ( ) : (
<div className="px-1.5"> <div className="px-1.5">
{options.map((option) => ( {options
.filter((option) => showAll || option.checked)
.map((option) => (
<CheckboxInput <CheckboxInput
key={option.value} key={option.value}
label={option.label} label={option.label}

@ -1,26 +1,26 @@
import { startOfMonth } from 'date-fns'; import { startOfMonth } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { CalendarDaysIcon, UserIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { import {
Button, Button,
CheckboxInput, CheckboxInput,
Collapsible, HorizontalDivider,
Select, Select,
TextArea, TextArea,
TextInput,
} from '@tih/ui'; } from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants'; import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import { import {
useFormRegister, useFormRegister,
useSelectRegister, useSelectRegister,
} from '~/utils/questions/useFormRegister'; } from '~/utils/questions/useFormRegister';
import CompaniesTypeahead from '../shared/CompaniesTypeahead'; import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import type { Month } from '../shared/MonthYearPicker'; import LocationTypeahead from '../typeahead/LocationTypeahead';
import MonthYearPicker from '../shared/MonthYearPicker'; import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker';
export type ContributeQuestionData = { export type ContributeQuestionData = {
company: string; company: string;
@ -59,17 +59,9 @@ export default function ContributeQuestionForm({
}; };
return ( return (
<form <form
className=" flex flex-1 flex-col items-stretch justify-center pb-[50px]" className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
onSubmit={handleSubmit(onSubmit)}> onSubmit={handleSubmit(onSubmit)}>
<TextArea <div className="min-w-[113px] max-w-[113px] flex-1">
label="Question Prompt"
placeholder="Contribute a question"
required={true}
rows={5}
{...register('questionContent')}
/>
<div className="mt-3 mb-1 flex flex-wrap items-end gap-2">
<div className="mr-2 min-w-[113px] max-w-[113px] flex-1">
<Select <Select
defaultValue="coding" defaultValue="coding"
label="Type" label="Type"
@ -78,30 +70,48 @@ export default function ContributeQuestionForm({
{...selectRegister('questionType')} {...selectRegister('questionType')}
/> />
</div> </div>
<div className="min-w-[150px] max-w-[300px] flex-1"> <TextArea
label="Question Prompt"
placeholder="Contribute a question"
required={true}
rows={5}
{...register('questionContent')}
/>
<HorizontalDivider />
<h2 className="text-md text-primary-800 font-semibold">
Additional information
</h2>
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller <Controller
control={control} control={control}
name="company" name="location"
render={({ field }) => ( render={({ field }) => (
<CompaniesTypeahead <LocationTypeahead
onSelect={({ id }) => { required={true}
field.onChange(id); onSelect={(option) => {
field.onChange(option.value);
}} }}
{...field}
value={LOCATIONS.find(
(location) => location.value === field.value,
)}
/> />
)} )}
/> />
</div> </div>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<div className="min-w-[150px] max-w-[300px] flex-1">
<Controller <Controller
control={control} control={control}
name="date" name="date"
render={({ field }) => ( render={({ field }) => (
<MonthYearPicker <MonthYearPicker
monthRequired={true}
value={{ value={{
month: (field.value.getMonth() + 1) as Month, month: ((field.value.getMonth() as number) + 1) as Month,
year: field.value.getFullYear(), year: field.value.getFullYear(),
}} }}
yearRequired={true}
onChange={({ month, year }) => onChange={({ month, year }) =>
field.onChange(startOfMonth(new Date(year, month - 1))) field.onChange(startOfMonth(new Date(year, month - 1)))
} }
@ -110,28 +120,38 @@ export default function ContributeQuestionForm({
/> />
</div> </div>
</div> </div>
<Collapsible defaultOpen={true} label="Additional info"> <div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="justify-left flex flex-wrap items-end gap-2"> <div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<div className="min-w-[150px] max-w-[300px] flex-1"> <Controller
<TextInput control={control}
label="Location" name="company"
render={({ field }) => (
<CompanyTypeahead
required={true} required={true}
startAddOn={CalendarDaysIcon} onSelect={({ id }) => {
startAddOnType="icon" field.onChange(id);
{...register('location')} }}
/>
)}
/> />
</div> </div>
<div className="min-w-[150px] max-w-[200px] flex-1"> <div className="flex-1 sm:min-w-[150px] sm:max-w-[200px]">
<TextInput <Controller
label="Role" control={control}
name="role"
render={({ field }) => (
<RoleTypeahead
required={true} required={true}
startAddOn={UserIcon} onSelect={(option) => {
startAddOnType="icon" field.onChange(option.value);
{...register('role')} }}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/> />
</div> </div>
</div> </div>
</Collapsible>
{/* <div className="w-full"> {/* <div className="w-full">
<HorizontalDivider /> <HorizontalDivider />
</div> </div>
@ -151,15 +171,20 @@ export default function ContributeQuestionForm({
}} }}
/> />
</div> */} </div> */}
<div className="bg-primary-50 fixed bottom-0 left-0 w-full px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6"> <div
<div className="mb-1 flex"> className="bg-primary-50 flex w-full flex-col gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)] sm:flex-row sm:justify-between"
style={{
// Hack to make the background bleed outside the container
clipPath: 'inset(0 -100vmax)',
}}>
<div className="my-2 flex sm:my-0">
<CheckboxInput <CheckboxInput
label="I have checked that my question is new" label="I have checked that my question is new"
value={canSubmit} value={canSubmit}
onChange={handleCheckSimilarQuestions} onChange={handleCheckSimilarQuestions}
/> />
</div> </div>
<div className=" flex gap-x-2"> <div className="flex gap-x-2">
<button <button
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button" type="button"

@ -0,0 +1,148 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Button } from '@tih/ui';
import type { Month } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
export type CreateQuestionEncounterData = {
company: string;
location: string;
role: string;
seenAt: Date;
};
export type CreateQuestionEncounterFormProps = {
onCancel: () => void;
onSubmit: (data: CreateQuestionEncounterData) => void;
};
export default function CreateQuestionEncounterForm({
onCancel,
onSubmit,
}: CreateQuestionEncounterFormProps) {
const [step, setStep] = useState(0);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(
startOfMonth(new Date()),
);
return (
<div className="flex items-center gap-2">
<p className="font-md text-md text-slate-600">I saw this question at</p>
{step === 0 && (
<div>
<CompanyTypeahead
isLabelHidden={true}
placeholder="Other company"
suggestedCount={3}
onSelect={({ value: company }) => {
setSelectedCompany(company);
}}
onSuggestionClick={({ value: company }) => {
setSelectedCompany(company);
setStep(step + 1);
}}
/>
</div>
)}
{step === 1 && (
<div>
<LocationTypeahead
isLabelHidden={true}
placeholder="Other location"
suggestedCount={3}
onSelect={({ value: location }) => {
setSelectedLocation(location);
}}
onSuggestionClick={({ value: location }) => {
setSelectedLocation(location);
setStep(step + 1);
}}
/>
</div>
)}
{step === 2 && (
<div>
<RoleTypeahead
isLabelHidden={true}
placeholder="Other role"
suggestedCount={3}
onSelect={({ value: role }) => {
setSelectedRole(role);
}}
onSuggestionClick={({ value: role }) => {
setSelectedRole(role);
setStep(step + 1);
}}
/>
</div>
)}
{step === 3 && (
<MonthYearPicker
monthLabel=""
value={{
month: ((selectedDate?.getMonth() ?? 0) + 1) as Month,
year: selectedDate?.getFullYear() as number,
}}
yearLabel=""
onChange={(value) => {
setSelectedDate(
startOfMonth(new Date(value.year, value.month - 1)),
);
}}
/>
)}
{step < 3 && (
<Button
disabled={
(step === 0 && selectedCompany === null) ||
(step === 1 && selectedLocation === null) ||
(step === 2 && selectedRole === null)
}
label="Next"
variant="primary"
onClick={() => {
setStep(step + 1);
}}
/>
)}
{step === 3 && (
<Button
label="Submit"
variant="primary"
onClick={() => {
if (
selectedCompany &&
selectedLocation &&
selectedRole &&
selectedDate
) {
onSubmit({
company: selectedCompany,
location: selectedLocation,
role: selectedRole,
seenAt: selectedDate,
});
}
}}
/>
)}
<Button
label="Cancel"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
onCancel();
}}
/>
</div>
);
}

@ -0,0 +1,41 @@
import { useMemo, useState } from 'react';
import { trpc } from '~/utils/trpc';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type CompanyTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: companies } = trpc.useQuery([
'companies.list',
{
name: query,
},
]);
const companyOptions = useMemo(() => {
return (
companies?.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
);
}, [companies]);
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Company"
options={companyOptions}
onQueryChange={setQuery}
/>
);
}

@ -0,0 +1,39 @@
import type { ComponentProps } from 'react';
import { Button, Typeahead } from '@tih/ui';
import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{
onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number;
}> &
TypeaheadProps;
export default function ExpandedTypeahead({
suggestedCount = 0,
onSuggestionClick,
...typeaheadProps
}: ExpandedTypeaheadProps) {
const suggestions = typeaheadProps.options.slice(0, suggestedCount);
return (
<div className="flex flex-wrap gap-x-2">
{suggestions.map((suggestion) => (
<Button
key={suggestion.id}
label={suggestion.label}
variant="tertiary"
onClick={() => {
onSuggestionClick?.(suggestion);
}}
/>
))}
<div className="flex-1">
<Typeahead {...typeaheadProps} />
</div>
</div>
);
}

@ -0,0 +1,21 @@
import { LOCATIONS } from '~/utils/questions/constants';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type LocationTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function LocationTypeahead(props: LocationTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Location"
options={LOCATIONS}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
/>
);
}

@ -0,0 +1,21 @@
import { ROLES } from '~/utils/questions/constants';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type RoleTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function RoleTypeahead(props: RoleTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Role"
options={ROLES}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
/>
);
}

@ -3,12 +3,10 @@ import { useEffect, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf'; import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist'; import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import { import {
ArrowLeftIcon,
ArrowRightIcon,
MagnifyingGlassMinusIcon, MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon, MagnifyingGlassPlusIcon,
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui'; import { Button, Pagination, Spinner } from '@tih/ui';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`; pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
@ -45,7 +43,7 @@ export default function ResumePdf({ url }: Props) {
<div id="pdfView"> <div id="pdfView">
<div className="group relative"> <div className="group relative">
<Document <Document
className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-auto" className="flex h-[calc(100vh-16rem)] flex-row justify-center overflow-auto"
file={url} file={url}
loading={<Spinner display="block" size="lg" />} loading={<Spinner display="block" size="lg" />}
noData="" noData=""
@ -86,28 +84,17 @@ export default function ResumePdf({ url }: Props) {
</div> </div>
</Document> </Document>
</div> </div>
{numPages > 1 && (
<div className="flex flex-row items-center justify-between p-4"> <div className="flex justify-center p-4">
<Button <Pagination
disabled={pageNumber === 1} current={pageNumber}
icon={ArrowLeftIcon} end={numPages}
isLabelHidden={true} label="pagination"
label="Previous" start={1}
variant="tertiary" onSelect={(page) => setPageNumber(page)}
onClick={() => setPageNumber(pageNumber - 1)}
/>
<p className="text-md text-gray-600">
Page {pageNumber} of {numPages}
</p>
<Button
disabled={pageNumber >= numPages}
icon={ArrowRightIcon}
isLabelHidden={true}
label="Next"
variant="tertiary"
onClick={() => setPageNumber(pageNumber + 1)}
/> />
</div> </div>
)}
</div> </div>
); );
} }

@ -1,13 +0,0 @@
import { Badge } from '@tih/ui';
export default function ResumeReviewsTitle() {
return (
<div>
<h1 className="text-2xl font-bold">Resume Reviews</h1>
<Badge
label="Check out reviewed resumes or look for resumes to review"
variant="info"
/>
</div>
);
}

@ -65,7 +65,7 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
</div> </div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div> <div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div> </div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center" /> <ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" />
</div> </div>
</Link> </Link>
); );

@ -1,23 +1,12 @@
import { Spinner } from '@tih/ui';
import ResumeListItem from './ResumeListItem'; import ResumeListItem from './ResumeListItem';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
type Props = Readonly<{ type Props = Readonly<{
isLoading: boolean;
resumes: Array<Resume>; resumes: Array<Resume>;
}>; }>;
export default function ResumeListItems({ isLoading, resumes }: Props) { export default function ResumeListItems({ resumes }: Props) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return ( return (
<ul role="list"> <ul role="list">
{resumes.map((resumeObj: Resume) => ( {resumes.map((resumeObj: Resume) => (

@ -57,10 +57,10 @@ export const BROWSE_TABS_VALUES = {
export const SORT_OPTIONS: Record<string, string> = { export const SORT_OPTIONS: Record<string, string> = {
latest: 'Latest', latest: 'Latest',
popular: 'Popular', popular: 'Popular',
topComments: 'Top Comments', topComments: 'Most Comments',
}; };
export const ROLE: Array<FilterOption<RoleFilter>> = [ export const ROLES: Array<FilterOption<RoleFilter>> = [
{ {
label: 'Full-Stack Engineer', label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer', value: 'Full-Stack Engineer',
@ -72,7 +72,7 @@ export const ROLE: Array<FilterOption<RoleFilter>> = [
{ label: 'Android Engineer', value: 'Android Engineer' }, { label: 'Android Engineer', value: 'Android Engineer' },
]; ];
export const EXPERIENCE: Array<FilterOption<ExperienceFilter>> = [ export const EXPERIENCES: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Freshman', value: 'Freshman' }, { label: 'Freshman', value: 'Freshman' },
{ label: 'Sophomore', value: 'Sophomore' }, { label: 'Sophomore', value: 'Sophomore' },
{ label: 'Junior', value: 'Junior' }, { label: 'Junior', value: 'Junior' },
@ -91,16 +91,16 @@ export const EXPERIENCE: Array<FilterOption<ExperienceFilter>> = [
}, },
]; ];
export const LOCATION: Array<FilterOption<LocationFilter>> = [ export const LOCATIONS: Array<FilterOption<LocationFilter>> = [
{ label: 'Singapore', value: 'Singapore' }, { label: 'Singapore', value: 'Singapore' },
{ label: 'United States', value: 'United States' }, { label: 'United States', value: 'United States' },
{ label: 'India', value: 'India' }, { label: 'India', value: 'India' },
]; ];
export const INITIAL_FILTER_STATE: FilterState = { export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCE).map(({ value }) => value), experience: Object.values(EXPERIENCES).map(({ value }) => value),
location: Object.values(LOCATION).map(({ value }) => value), location: Object.values(LOCATIONS).map(({ value }) => value),
role: Object.values(ROLE).map(({ value }) => value), role: Object.values(ROLES).map(({ value }) => value),
}; };
export const SHORTCUTS: Array<Shortcut> = [ export const SHORTCUTS: Array<Shortcut> = [

@ -74,7 +74,7 @@ export default function ResumeCommentsList({
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
</div> </div>
) : ( ) : (
<div className="scrollbar-hide m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pt-14 pb-6"> <div className="m-2 flow-root h-[calc(100vh-17rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pt-14 pb-6">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => { {RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => { ? commentsQuery.data.filter((comment: ResumeComment) => {

@ -1,22 +1,21 @@
export default function SubmissionGuidelines() { export default function SubmissionGuidelines() {
return ( return (
<div className="mb-4 text-left text-sm text-slate-700"> <div className="text-left text-sm text-slate-700">
<h2 className="mb-2 text-xl font-medium">Submission Guidelines</h2> <h2 className="mb-2 text-xl font-medium">Submission Guidelines</h2>
<p> <p>
Before you submit, please review and acknolwedge our Before you submit, please review and acknowledge our
<span className="font-bold"> submission guidelines </span> <span className="font-bold"> submission guidelines </span>
stated below. stated below.
</p> </p>
<p> <p>
<span className="text-lg font-bold"> </span> <span className="text-lg font-bold"> </span>
Ensure that you do not divulge any of your Ensure that you do not divulge any of your{' '}
<span className="font-bold"> personal particulars</span>. <span className="font-bold">personal particulars</span>.
</p> </p>
<p> <p>
<span className="text-lg font-bold"> </span> <span className="text-lg font-bold"> </span>
Ensure that you do not divulge any Ensure that you do not divulge any{' '}
<span className="font-bold"> <span className="font-bold">
{' '}
company's proprietary and confidential information company's proprietary and confidential information
</span> </span>
. .

@ -56,7 +56,13 @@ const analysisOfferDtoMapper = (
const analysisOfferDto: AnalysisOffer = { const analysisOfferDto: AnalysisOffer = {
company: offersCompanyDtoMapper(offer.company), company: offersCompanyDtoMapper(offer.company),
id: offer.id, id: offer.id,
income: { baseCurrency: '', baseValue: -1, currency: '', value: -1 }, income: {
baseCurrency: '',
baseValue: -1,
currency: '',
id: '',
value: -1,
},
jobType: offer.jobType, jobType: offer.jobType,
level: offer.offersFullTime?.level ?? '', level: offer.offersFullTime?.level ?? '',
location: offer.location, location: offer.location,
@ -83,6 +89,7 @@ const analysisOfferDtoMapper = (
offer.offersFullTime.totalCompensation.value; offer.offersFullTime.totalCompensation.value;
analysisOfferDto.income.currency = analysisOfferDto.income.currency =
offer.offersFullTime.totalCompensation.currency; offer.offersFullTime.totalCompensation.currency;
analysisOfferDto.income.id = offer.offersFullTime.totalCompensation.id;
analysisOfferDto.income.baseValue = analysisOfferDto.income.baseValue =
offer.offersFullTime.totalCompensation.baseValue; offer.offersFullTime.totalCompensation.baseValue;
analysisOfferDto.income.baseCurrency = analysisOfferDto.income.baseCurrency =
@ -91,6 +98,7 @@ const analysisOfferDtoMapper = (
analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value; analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value;
analysisOfferDto.income.currency = analysisOfferDto.income.currency =
offer.offersIntern.monthlySalary.currency; offer.offersIntern.monthlySalary.currency;
analysisOfferDto.income.id = offer.offersIntern.monthlySalary.id;
analysisOfferDto.income.baseValue = analysisOfferDto.income.baseValue =
offer.offersIntern.monthlySalary.baseValue; offer.offersIntern.monthlySalary.baseValue;
analysisOfferDto.income.baseCurrency = analysisOfferDto.income.baseCurrency =
@ -255,13 +263,14 @@ export const valuationDtoMapper = (currency: {
baseCurrency: string; baseCurrency: string;
baseValue: number; baseValue: number;
currency: string; currency: string;
id?: string; id: string;
value: number; value: number;
}) => { }) => {
const valuationDto: Valuation = { const valuationDto: Valuation = {
baseCurrency: currency.baseCurrency, baseCurrency: currency.baseCurrency,
baseValue: currency.baseValue, baseValue: currency.baseValue,
currency: currency.currency, currency: currency.currency,
id: currency.id,
value: currency.value, value: currency.value,
}; };
return valuationDto; return valuationDto;
@ -595,11 +604,12 @@ export const dashboardOfferDtoMapper = (
baseCurrency: '', baseCurrency: '',
baseValue: -1, baseValue: -1,
currency: '', currency: '',
id: '',
value: -1, value: -1,
}), }),
monthYearReceived: offer.monthYearReceived, monthYearReceived: offer.monthYearReceived,
profileId: offer.profileId, profileId: offer.profileId,
title: offer.offersFullTime?.title ?? '', title: offer.offersFullTime?.title || offer.offersIntern?.title || '',
totalYoe: offer.profile.background?.totalYoe ?? -1, totalYoe: offer.profile.background?.totalYoe ?? -1,
}; };

@ -2,17 +2,21 @@ import Error from 'next/error';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { ProfileDetailTab } from '~/components/offers/constants';
import ProfileComments from '~/components/offers/profile/ProfileComments'; import ProfileComments from '~/components/offers/profile/ProfileComments';
import ProfileDetails from '~/components/offers/profile/ProfileDetails'; import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader'; import ProfileHeader from '~/components/offers/profile/ProfileHeader';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types'; import type {
BackgroundDisplayData,
OfferDisplayData,
} from '~/components/offers/types';
import { convertMoneyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { getProfilePath } from '~/utils/offers/link'; import { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { Profile, ProfileOffer } from '~/types/offers'; import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers';
export default function OfferProfile() { export default function OfferProfile() {
const ErrorPage = ( const ErrorPage = (
@ -21,10 +25,13 @@ export default function OfferProfile() {
const router = useRouter(); const router = useRouter();
const { offerProfileId, token = '' } = router.query; const { offerProfileId, token = '' } = router.query;
const [isEditable, setIsEditable] = useState(false); const [isEditable, setIsEditable] = useState(false);
const [background, setBackground] = useState<BackgroundCard>(); const [background, setBackground] = useState<BackgroundDisplayData>();
const [offers, setOffers] = useState<Array<OfferEntity>>([]); const [offers, setOffers] = useState<Array<OfferDisplayData>>([]);
const [selectedTab, setSelectedTab] = useState('offers'); const [selectedTab, setSelectedTab] = useState<ProfileDetailTab>(
ProfileDetailTab.OFFERS,
);
const [analysis, setAnalysis] = useState<ProfileAnalysis>();
const getProfileQuery = trpc.useQuery( const getProfileQuery = trpc.useQuery(
[ [
@ -44,11 +51,10 @@ export default function OfferProfile() {
setIsEditable(data?.isEditable ?? false); setIsEditable(data?.isEditable ?? false);
if (data?.offers) { const filteredOffers: Array<OfferDisplayData> = data
const filteredOffers: Array<OfferEntity> = data
? data?.offers.map((res: ProfileOffer) => { ? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) { if (res.offersFullTime) {
const filteredOffer: OfferEntity = { const filteredOffer: OfferDisplayData = {
base: convertMoneyToString(res.offersFullTime.baseSalary), base: convertMoneyToString(res.offersFullTime.baseSalary),
bonus: convertMoneyToString(res.offersFullTime.bonus), bonus: convertMoneyToString(res.offersFullTime.bonus),
companyName: res.company.name, companyName: res.company.name,
@ -56,8 +62,8 @@ export default function OfferProfile() {
jobLevel: res.offersFullTime.level, jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title, jobTitle: res.offersFullTime.title,
location: res.location, location: res.location,
negotiationStrategy: res.negotiationStrategy || '', negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments || '', otherComment: res.comments,
receivedMonth: formatDate(res.monthYearReceived), receivedMonth: formatDate(res.monthYearReceived),
stocks: convertMoneyToString(res.offersFullTime.stocks), stocks: convertMoneyToString(res.offersFullTime.stocks),
totalCompensation: convertMoneyToString( totalCompensation: convertMoneyToString(
@ -66,7 +72,7 @@ export default function OfferProfile() {
}; };
return filteredOffer; return filteredOffer;
} }
const filteredOffer: OfferEntity = { const filteredOffer: OfferDisplayData = {
companyName: res.company.name, companyName: res.company.name,
id: res.offersIntern!.id, id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title, jobTitle: res.offersIntern!.title,
@ -74,45 +80,50 @@ export default function OfferProfile() {
monthlySalary: convertMoneyToString( monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary, res.offersIntern!.monthlySalary,
), ),
negotiationStrategy: res.negotiationStrategy || '', negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments || '', otherComment: res.comments,
receivedMonth: formatDate(res.monthYearReceived), receivedMonth: formatDate(res.monthYearReceived),
}; };
return filteredOffer; return filteredOffer;
}) })
: []; : [];
setOffers(filteredOffers); setOffers(filteredOffers);
}
if (data?.background) { if (data?.background) {
const transformedBackground = { const transformedBackground = {
educations: data.background.educations.map((education) => ({ educations: data.background.educations.map((education) => ({
endDate: education.endDate ? formatDate(education.endDate) : '-', endDate: education.endDate ? formatDate(education.endDate) : null,
field: education.field || '-', field: education.field,
school: education.school || '-', school: education.school,
startDate: education.startDate startDate: education.startDate
? formatDate(education.startDate) ? formatDate(education.startDate)
: '-', : null,
type: education.type || '-', type: education.type,
})), })),
experiences: data.background.experiences.map((experience) => ({ experiences: data.background.experiences.map(
companyName: experience.company?.name ?? '-', (experience): OfferDisplayData => ({
duration: String(experience.durationInMonths) ?? '-', companyName: experience.company?.name,
jobLevel: experience.level ?? '', duration: experience.durationInMonths,
jobTitle: experience.title ?? '-', jobLevel: experience.level,
jobTitle: experience.title,
monthlySalary: experience.monthlySalary monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary) ? convertMoneyToString(experience.monthlySalary)
: '-', : null,
totalCompensation: experience.totalCompensation totalCompensation: experience.totalCompensation
? convertMoneyToString(experience.totalCompensation) ? convertMoneyToString(experience.totalCompensation)
: '-', : null,
})), }),
),
profileName: data.profileName, profileName: data.profileName,
specificYoes: data.background.specificYoes, specificYoes: data.background.specificYoes,
totalYoe: String(data.background.totalYoe) || '-', totalYoe: data.background.totalYoe,
}; };
setBackground(transformedBackground); setBackground(transformedBackground);
} }
if (data.analysis) {
setAnalysis(data.analysis);
}
}, },
}, },
); );
@ -153,9 +164,12 @@ export default function OfferProfile() {
/> />
<div className="h-4/5 w-full overflow-y-scroll pb-32"> <div className="h-4/5 w-full overflow-y-scroll pb-32">
<ProfileDetails <ProfileDetails
analysis={analysis}
background={background} background={background}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading} isLoading={getProfileQuery.isLoading}
offers={offers} offers={offers}
profileId={offerProfileId as string}
selectedTab={selectedTab} selectedTab={selectedTab}
/> />
</div> </div>

@ -1,9 +1,9 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { JobType } from '@prisma/client';
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm'; import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
import type { OffersProfileFormData } from '~/components/offers/types'; import type { OffersProfileFormData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import { Spinner } from '~/../../../packages/ui/dist'; import { Spinner } from '~/../../../packages/ui/dist';
import { getProfilePath } from '~/utils/offers/link'; import { getProfilePath } from '~/utils/offers/link';
@ -25,7 +25,7 @@ export default function OffersEditPage() {
console.error(error.message); console.error(error.message);
}, },
onSuccess(data) { onSuccess(data) {
const { educations, experiences, specificYoes, totalYoe } = const { educations, experiences, specificYoes, totalYoe, id } =
data.background!; data.background!;
setInitialData({ setInitialData({
@ -33,11 +33,13 @@ export default function OffersEditPage() {
educations, educations,
experiences: experiences:
experiences.length === 0 experiences.length === 0
? [{ jobType: JobType.FullTime }] ? [{ jobType: JobType.FULLTIME }]
: experiences, : experiences,
id,
specificYoes, specificYoes,
totalYoe, totalYoe,
}, },
id: data.id,
offers: data.offers.map((offer) => ({ offers: data.offers.map((offer) => ({
comments: offer.comments, comments: offer.comments,
companyId: offer.company.id, companyId: offer.company.id,
@ -67,7 +69,7 @@ export default function OffersEditPage() {
<Spinner className="m-10" display="block" size="lg" /> <Spinner className="m-10" display="block" size="lg" />
</div> </div>
)} )}
{!getProfileResult.isLoading && ( {!getProfileResult.isLoading && initialData && (
<OffersSubmissionForm <OffersSubmissionForm
initialOfferProfileValues={initialData} initialOfferProfileValues={initialData}
profileId={profile?.id} profileId={profile?.id}

@ -1,3 +1,4 @@
import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
@ -7,6 +8,7 @@ import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem'
import FullAnswerCard from '~/components/questions/card/FullAnswerCard'; import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import { APP_TITLE } from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -51,10 +53,6 @@ export default function QuestionPage() {
}, },
); );
const handleBackNavigation = () => {
router.back();
};
const handleSubmitComment = (data: AnswerCommentData) => { const handleSubmitComment = (data: AnswerCommentData) => {
resetComment(); resetComment();
addComment({ addComment({
@ -68,15 +66,22 @@ export default function QuestionPage() {
} }
return ( return (
<>
<Head>
<title>
{answer.content} - {APP_TITLE}
</title>
</Head>
<div className="flex w-full flex-1 items-stretch pb-4"> <div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4"> <div className="flex items-baseline gap-2 py-4 pl-4">
<Button <Button
addonPosition="start" addonPosition="start"
display="inline" display="inline"
href={`/questions/${router.query.questionId}/${router.query.questionSlug}`}
icon={ArrowSmallLeftIcon} icon={ArrowSmallLeftIcon}
label="Back" label="Back"
variant="secondary" variant="secondary"
onClick={handleBackNavigation}></Button> />
</div> </div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5"> <div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2"> <div className="flex max-w-7xl flex-1 flex-col gap-2">
@ -153,5 +158,6 @@ export default function QuestionPage() {
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }

@ -1,13 +1,15 @@
import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui'; import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem'; import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullQuestionCard from '~/components/questions/card/FullQuestionCard'; import FullQuestionCard from '~/components/questions/card/question/FullQuestionCard';
import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard'; import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -45,6 +47,11 @@ export default function QuestionPage() {
{ id: questionId as string }, { id: questionId as string },
]); ]);
const { data: aggregatedEncounters } = trpc.useQuery([
'questions.questions.encounters.getAggregatedEncounters',
{ questionId: questionId as string },
]);
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([ const { data: comments } = trpc.useQuery([
@ -74,9 +81,17 @@ export default function QuestionPage() {
}, },
}); });
const handleBackNavigation = () => { const { mutate: addEncounter } = trpc.useMutation(
router.back(); 'questions.questions.encounters.create',
}; {
onSuccess: () => {
utils.invalidateQueries(
'questions.questions.encounters.getAggregatedEncounters',
);
utils.invalidateQueries('questions.questions.getQuestionById');
},
},
);
const handleSubmitAnswer = (data: AnswerQuestionData) => { const handleSubmitAnswer = (data: AnswerQuestionData) => {
addAnswer({ addAnswer({
@ -99,27 +114,46 @@ export default function QuestionPage() {
} }
return ( return (
<>
<Head>
<title>
{question.content} - {APP_TITLE}
</title>
</Head>
<div className="flex w-full flex-1 items-stretch pb-4"> <div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4"> <div className="flex items-baseline gap-2 py-4 pl-4">
<Button <Button
addonPosition="start" addonPosition="start"
display="inline" display="inline"
href="/questions/browse"
icon={ArrowSmallLeftIcon} icon={ArrowSmallLeftIcon}
label="Back" label="Back"
variant="secondary" variant="secondary"
onClick={handleBackNavigation}></Button> />
</div> </div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5"> <div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2"> <div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard <FullQuestionCard
{...question} {...question}
companies={aggregatedEncounters?.companyCounts ?? {}}
locations={aggregatedEncounters?.locationCounts ?? {}}
questionId={question.id} questionId={question.id}
receivedCount={0} receivedCount={undefined}
roles={aggregatedEncounters?.roleCounts ?? {}}
timestamp={question.seenAt.toLocaleDateString(undefined, { timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
})} })}
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
onReceivedSubmit={(data) => {
addEncounter({
companyId: data.company,
location: data.location,
questionId: questionId as string,
role: data.role,
seenAt: data.seenAt,
});
}}
/> />
<div className="mx-2"> <div className="mx-2">
<Collapsible label={`${(comments ?? []).length} comment(s)`}> <Collapsible label={`${(comments ?? []).length} comment(s)`}>
@ -180,7 +214,7 @@ export default function QuestionPage() {
authorName={comment.user} authorName={comment.user}
content={comment.content} content={comment.content}
createdAt={comment.createdAt} createdAt={comment.createdAt}
upvoteCount={0} upvoteCount={comment.numVotes}
/> />
))} ))}
</Collapsible> </Collapsible>
@ -251,5 +285,6 @@ export default function QuestionPage() {
</div> </div>
</div> </div>
</div> </div>
</>
); );
} }

@ -0,0 +1,498 @@
import { subMonths, subYears } from 'date-fns';
import Head from 'next/head';
import Router, { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button, SlideOut, Typeahead } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import type { QuestionAge } from '~/utils/questions/constants';
import { SORT_TYPES } from '~/utils/questions/constants';
import { SORT_ORDERS } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants';
import { ROLES } from '~/utils/questions/constants';
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useSearchParam,
useSearchParamSingle,
} from '~/utils/questions/useSearchParam';
import { trpc } from '~/utils/trpc';
import { SortType } from '~/types/questions.d';
import { SortOrder } from '~/types/questions.d';
export default function QuestionsBrowsePage() {
const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchParam('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
areQuestionTypesInitialized,
] = useSearchParam<QuestionsQuestionType>('questionTypes', {
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
return (
QUESTION_TYPES.find(
(questionType) => questionType.value.toUpperCase() === uppercaseParam,
)?.value ?? null
);
},
});
const [
selectedQuestionAge,
setSelectedQuestionAge,
isQuestionAgeInitialized,
] = useSearchParamSingle<QuestionAge>('questionAge', {
defaultValue: 'all',
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
return (
QUESTION_AGES.find(
(questionAge) => questionAge.value.toUpperCase() === uppercaseParam,
)?.value ?? null
);
},
});
const [selectedRoles, setSelectedRoles, areRolesInitialized] =
useSearchParam('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchParam('locations');
const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', {
defaultValue: SortOrder.DESC,
paramToString: (value) => {
if (value === SortOrder.ASC) {
return 'ASC';
}
if (value === SortOrder.DESC) {
return 'DESC';
}
return null;
},
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'ASC') {
return SortOrder.ASC;
}
if (uppercaseParam === 'DESC') {
return SortOrder.DESC;
}
return null;
},
});
const [sortType, setSortType, isSortTypeInitialized] =
useSearchParamSingle<SortType>('sortType', {
defaultValue: SortType.TOP,
paramToString: (value) => {
if (value === SortType.NEW) {
return 'NEW';
}
if (value === SortType.TOP) {
return 'TOP';
}
return null;
},
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'NEW') {
return SortType.NEW;
}
if (uppercaseParam === 'TOP') {
return SortType.TOP;
}
return null;
},
});
const hasFilters = useMemo(
() =>
selectedCompanies.length > 0 ||
selectedQuestionTypes.length > 0 ||
selectedQuestionAge !== 'all' ||
selectedRoles.length > 0 ||
selectedLocations.length > 0,
[
selectedCompanies,
selectedQuestionTypes,
selectedQuestionAge,
selectedRoles,
selectedLocations,
],
);
const today = useMemo(() => new Date(), []);
const startDate = useMemo(() => {
return selectedQuestionAge === 'last-year'
? subYears(new Date(), 1)
: selectedQuestionAge === 'last-6-months'
? subMonths(new Date(), 6)
: selectedQuestionAge === 'last-month'
? subMonths(new Date(), 1)
: undefined;
}, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery(
[
'questions.questions.getQuestionsByFilter',
{
companyNames: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
sortOrder,
sortType,
startDate,
},
],
{
keepPreviousData: true,
},
);
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
},
},
);
const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const companyFilterOptions = useMemo(() => {
return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAge === questionAge.value,
}));
}, [selectedQuestionAge]);
const roleFilterOptions = useMemo(() => {
return ROLES.map((role) => ({
...role,
checked: selectedRoles.includes(role.value),
}));
}, [selectedRoles]);
const locationFilterOptions = useMemo(() => {
return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
const areSearchOptionsInitialized = useMemo(() => {
return (
areCompaniesInitialized &&
areQuestionTypesInitialized &&
isQuestionAgeInitialized &&
areRolesInitialized &&
areLocationsInitialized &&
isSortTypeInitialized &&
isSortOrderInitialized
);
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areRolesInitialized,
areLocationsInitialized,
isSortTypeInitialized,
isSortOrderInitialized,
]);
const { pathname } = router;
useEffect(() => {
if (areSearchOptionsInitialized) {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
Router.replace({
pathname,
query: {
companies: selectedCompanies,
locations: selectedLocations,
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
sortOrder: sortOrder === SortOrder.ASC ? 'ASC' : 'DESC',
sortType: sortType === SortType.TOP ? 'TOP' : 'NEW',
},
});
setLoaded(true);
}
}, [
areSearchOptionsInitialized,
loaded,
pathname,
selectedCompanies,
selectedRoles,
selectedLocations,
selectedQuestionAge,
selectedQuestionTypes,
sortOrder,
sortType,
]);
if (!loaded) {
return null;
}
const filterSidebar = (
<div className="divide-y divide-slate-200 px-4">
<Button
addonPosition="start"
className="my-4"
disabled={!hasFilters}
icon={Bars3BottomLeftIcon}
label="Clear filters"
variant="tertiary"
onClick={() => {
setSelectedCompanies([]);
setSelectedQuestionTypes([]);
setSelectedQuestionAge('all');
setSelectedRoles([]);
setSelectedLocations([]);
}}
/>
<FilterSection
label="Company"
options={companyFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
isLabelHidden={true}
label="Companies"
options={options}
placeholder="Search companies"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== optionValue),
);
}
}}
/>
<FilterSection
label="Question types"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
} else {
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
label="Roles"
options={roleFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
isLabelHidden={true}
label="Roles"
options={options}
placeholder="Search roles"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedRoles([...selectedRoles, optionValue]);
} else {
setSelectedRoles(
selectedRoles.filter((role) => role !== optionValue),
);
}
}}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
isLabelHidden={true}
label="Locations"
options={options}
placeholder="Search locations"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations(
selectedLocations.filter((location) => location !== optionValue),
);
}
}}
/>
</div>
);
return (
<>
<Head>
<title>Home - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-8">
<ContributeQuestionCard
onSubmit={(data) => {
createQuestion({
companyId: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
role: data.role,
seenAt: data.date,
});
}}
/>
<QuestionSearchBar
sortOrderOptions={SORT_ORDERS}
sortOrderValue={sortOrder}
sortTypeOptions={SORT_TYPES}
sortTypeValue={sortType}
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
}}
onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType}
/>
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
companies={{ [question.company]: 1 }}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={{ [question.location]: 1 }}
questionId={question.id}
receivedCount={question.receivedCount}
roles={{ [question.role]: 1 }}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
type={question.type}
upvoteCount={question.numVotes}
/>
))}
{questions?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />
<p>Nothing found.</p>
{hasFilters && <p>Try changing your search criteria.</p>}
</div>
)}
</div>
</div>
</div>
</section>
<aside className="hidden w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
<h2 className="px-4 text-xl font-semibold">Filter by</h2>
{filterSidebar}
</aside>
<SlideOut
className="lg:hidden"
enterFrom="end"
isShown={filterDrawerOpen}
size="sm"
title="Filter by"
onClose={() => {
setFilterDrawerOpen(false);
}}>
{filterSidebar}
</SlideOut>
</div>
</main>
</>
);
}

@ -0,0 +1,16 @@
import Head from 'next/head';
import { APP_TITLE } from '~/utils/questions/constants';
export default function HistoryPage() {
return (
<>
<Head>
<title>History - {APP_TITLE}</title>
</Head>
<div className="v-full flex w-full items-center justify-center">
<h1 className="text-center text-4xl font-bold">History</h1>
</div>
</>
);
}

@ -1,337 +1,34 @@
import { subMonths, subYears } from 'date-fns'; import Head from 'next/head';
import Router, { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import FilterSection from '~/components/questions/filter/FilterSection';
import type { LandingQueryData } from '~/components/questions/LandingComponent'; import type { LandingQueryData } from '~/components/questions/LandingComponent';
import LandingComponent from '~/components/questions/LandingComponent'; import LandingComponent from '~/components/questions/LandingComponent';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import type { QuestionAge } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useSearchFilter,
useSearchFilterSingle,
} from '~/utils/questions/useSearchFilter';
import { trpc } from '~/utils/trpc';
import { SortOrder, SortType } from '~/types/questions.d';
export default function QuestionsHomePage() { export default function QuestionsHomePage() {
const router = useRouter(); const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchFilter('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
areQuestionTypesInitialized,
] = useSearchFilter<QuestionsQuestionType>('questionTypes', {
queryParamToValue: (param) => {
return param.toUpperCase() as QuestionsQuestionType;
},
});
const [
selectedQuestionAge,
setSelectedQuestionAge,
isQuestionAgeInitialized,
] = useSearchFilterSingle<QuestionAge>('questionAge', {
defaultValue: 'all',
});
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchFilter('locations');
const today = useMemo(() => new Date(), []);
const startDate = useMemo(() => {
return selectedQuestionAge === 'last-year'
? subYears(new Date(), 1)
: selectedQuestionAge === 'last-6-months'
? subMonths(new Date(), 6)
: selectedQuestionAge === 'last-month'
? subMonths(new Date(), 1)
: undefined;
}, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery(
[
'questions.questions.getQuestionsByFilter',
{
companyNames: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: [],
// TODO: Implement sort order and sort type choices
sortOrder: SortOrder.DESC,
sortType: SortType.NEW,
startDate,
},
],
{
keepPreviousData: true,
},
);
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
},
},
);
const [hasLanded, setHasLanded] = useState(false);
const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const companyFilterOptions = useMemo(() => {
return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAge === questionAge.value,
}));
}, [selectedQuestionAge]);
const locationFilterOptions = useMemo(() => {
return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
const handleLandingQuery = async (data: LandingQueryData) => { const handleLandingQuery = async (data: LandingQueryData) => {
const { company, location, questionType } = data; const { company, location, questionType } = data;
setSelectedCompanies([company]); // Go to browse page
setSelectedLocations([location]); router.push({
setSelectedQuestionTypes([questionType as QuestionsQuestionType]); pathname: '/questions/browse',
setHasLanded(true);
};
const areFiltersInitialized = useMemo(() => {
return (
areCompaniesInitialized &&
areQuestionTypesInitialized &&
isQuestionAgeInitialized &&
areLocationsInitialized
);
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areLocationsInitialized,
]);
const { pathname } = router;
useEffect(() => {
if (areFiltersInitialized) {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
Router.replace({
pathname,
query: { query: {
companies: selectedCompanies, companies: [company],
locations: selectedLocations, locations: [location],
questionAge: selectedQuestionAge, questionTypes: [questionType],
questionTypes: selectedQuestionTypes,
}, },
}); });
const hasFilter = };
selectedCompanies.length > 0 ||
selectedLocations.length > 0 ||
selectedQuestionAge !== 'all' ||
selectedQuestionTypes.length > 0;
if (hasFilter) {
setHasLanded(true);
}
setLoaded(true);
}
}, [
areFiltersInitialized,
hasLanded,
loaded,
pathname,
selectedCompanies,
selectedLocations,
selectedQuestionAge,
selectedQuestionTypes,
]);
if (!loaded) {
return null;
}
const filterSidebar = (
<div className="mt-2 divide-y divide-slate-200 px-4">
<FilterSection
label="Company"
options={companyFilterOptions}
searchPlaceholder="Add company filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== optionValue),
);
}
}}
/>
<FilterSection
label="Question types"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
} else {
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
searchPlaceholder="Add location filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations(
selectedLocations.filter((location) => location !== optionValue),
);
}
}}
/>
</div>
);
return !hasLanded ? ( return (
<>
<Head>
<title>Home - {APP_TITLE}</title>
</Head>
<LandingComponent onLanded={handleLandingQuery}></LandingComponent> <LandingComponent onLanded={handleLandingQuery}></LandingComponent>
) : ( </>
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
<ContributeQuestionCard
onSubmit={(data) => {
createQuestion({
companyId: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
role: data.role,
seenAt: data.date,
});
}}
/>
<QuestionSearchBar
sortOptions={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
sortValue="most-recent"
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
}}
onSortChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
{(questions ?? []).map((question) => (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
company={question.company}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
location={question.location}
questionId={question.id}
receivedCount={0}
role={question.role}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
type={question.type} // TODO: Implement received count
upvoteCount={question.numVotes}
/>
))}
{questions?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />
<p>Nothing found. Try changing your search filters.</p>
</div>
)}
</div>
</div>
</section>
<aside className="hidden w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
<h2 className="px-4 text-xl font-semibold">Filter by</h2>
{filterSidebar}
</aside>
<SlideOut
className="lg:hidden"
enterFrom="end"
isShown={filterDrawerOpen}
size="sm"
title="Filter by"
onClose={() => {
setFilterDrawerOpen(false);
}}>
{filterSidebar}
</SlideOut>
</div>
</main>
); );
} }

@ -0,0 +1,179 @@
import Head from 'next/head';
import { useState } from 'react';
import { Menu } from '@headlessui/react';
import {
EllipsisVerticalIcon,
NoSymbolIcon,
PlusIcon,
} from '@heroicons/react/24/outline';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants';
import { SAMPLE_QUESTION } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
export default function ListPage() {
const questions = [
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
];
const lists = [
{ id: 1, name: 'list 1', questions },
{ id: 2, name: 'list 2', questions },
{ id: 3, name: 'list 3', questions },
{ id: 4, name: 'list 4', questions },
{ id: 5, name: 'list 5', questions },
];
const [selectedList, setSelectedList] = useState(
(lists ?? []).length > 0 ? lists[0].id : '',
);
const listOptions = (
<>
<ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200">
{lists.map((list) => (
<li
key={list.id}
className={`flex items-center hover:bg-gray-50 ${
selectedList === list.id ? 'bg-primary-100' : ''
}`}>
<button
className="flex w-full flex-1 justify-between "
type="button"
onClick={() => {
setSelectedList(list.id);
// eslint-disable-next-line no-console
console.log(selectedList);
}}>
<p className="text-primary-700 text-md p-3 font-medium">
{list.name}
</p>
</button>
<div>
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="inline-flex w-full justify-center rounded-md p-2 text-sm font-medium text-white">
<EllipsisVerticalIcon
aria-hidden="true"
className="hover:text-primary-700 mr-1 h-5 w-5 text-violet-400"
/>
</Menu.Button>
</div>
<Menu.Items className="w-18 absolute right-0 z-10 mr-2 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="px-1 py-1 ">
<Menu.Item>
{({ active }) => (
<button
className={`${
active
? 'bg-violet-500 text-white'
: 'text-gray-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
type="button">
Delete
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Menu>
</div>
</li>
))}
</ul>
{lists?.length === 0 && (
<div className="mx-2 flex items-center justify-center gap-2 rounded-md bg-slate-200 p-4 text-slate-600">
<p>You have yet to create a list</p>
</div>
)}
</>
);
return (
<>
<Head>
<title>My Lists - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<aside className="w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
<div className="mb-2 flex items-center justify-between">
<h2 className="px-4 text-xl font-semibold">My Lists</h2>
<div className="px-4">
<Button
icon={PlusIcon}
isLabelHidden={true}
label="Create"
size="md"
variant="tertiary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</div>
</div>
{listOptions}
</aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
{selectedList && (
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
<QuestionListCard
key={question.id}
companies={question.companies}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={question.locations}
questionId={question.id}
receivedCount={0}
roles={question.roles}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
},
)}
type={question.type}
onDelete={() => {
// eslint-disable-next-line no-console
console.log('delete');
}}
/>
))}
{questions?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" />
<p>You have no added any questions to your list yet.</p>
</div>
)}
</div>
)}
</div>
</div>
</section>
</div>
</main>
</>
);
}

@ -0,0 +1,16 @@
import Head from 'next/head';
import { APP_TITLE } from '~/utils/questions/constants';
export default function MyQuestionsPage() {
return (
<>
<Head>
<title>My Questions - {APP_TITLE}</title>
</Head>
<div className="v-full flex w-full items-center justify-center">
<h1 className="text-center text-4xl font-bold">My Questions</h1>
</div>
</>
);
}

@ -118,9 +118,9 @@ export default function ResumeReviewPage() {
<Head> <Head>
<title>{detailsQuery.data.title}</title> <title>{detailsQuery.data.title}</title>
</Head> </Head>
<main className="h-[calc(100vh-2rem)] flex-1 overflow-y-auto p-4"> <main className="h-[calc(100vh-2rem)] flex-1 space-y-2 overflow-y-auto py-4 px-8 xl:px-12 2xl:pr-16">
<div className="flex space-x-8"> <div className="flex justify-between">
<h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight"> <h1 className="text-2xl font-semibold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title} {detailsQuery.data.title}
</h1> </h1>
<div className="flex gap-4"> <div className="flex gap-4">
@ -157,12 +157,12 @@ export default function ResumeReviewPage() {
className="p h-10 rounded-md border border-gray-300 bg-white py-1 px-2 text-center" className="p h-10 rounded-md border border-gray-300 bg-white py-1 px-2 text-center"
type="button" type="button"
onClick={onEditButtonClick}> onClick={onEditButtonClick}>
<PencilSquareIcon className="h-6 w-6 text-indigo-500 hover:text-indigo-300" /> <PencilSquareIcon className="h-6 w-6 text-indigo-600 hover:text-indigo-300" />
</button> </button>
)} )}
</div> </div>
</div> </div>
<div className="flex flex-col pt-1 lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8"> <div className="flex flex-col lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8">
<div className="mt-2 flex items-center text-sm text-gray-500"> <div className="mt-2 flex items-center text-sm text-gray-500">
<BriefcaseIcon <BriefcaseIcon
aria-hidden="true" aria-hidden="true"
@ -206,11 +206,11 @@ export default function ResumeReviewPage() {
/> />
</div> </div>
)} )}
<div className="flex w-full flex-col py-4 lg:flex-row"> <div className="flex w-full flex-col gap-6 py-4 lg:flex-row">
<div className="w-full lg:w-[780px]"> <div className="w-full lg:w-[780px]">
<ResumePdf url={detailsQuery.data.url} /> <ResumePdf url={detailsQuery.data.url} />
</div> </div>
<div className="mx-8 grow"> <div className="grow">
<ResumeCommentsSection resumeId={resumeId as string} /> <ResumeCommentsSection resumeId={resumeId as string} />
</div> </div>
</div> </div>

@ -4,10 +4,10 @@ import { useSession } from 'next-auth/react';
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { Dialog, Disclosure, Transition } from '@headlessui/react'; import { Dialog, Disclosure, Transition } from '@headlessui/react';
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid'; import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
NewspaperIcon, NewspaperIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { import {
CheckboxInput, CheckboxInput,
@ -27,16 +27,15 @@ import type {
} from '~/components/resumes/browse/resumeFilters'; } from '~/components/resumes/browse/resumeFilters';
import { import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
EXPERIENCE, EXPERIENCES,
INITIAL_FILTER_STATE, INITIAL_FILTER_STATE,
isInitialFilterState, isInitialFilterState,
LOCATION, LOCATIONS,
ROLE, ROLES,
SHORTCUTS, SHORTCUTS,
SORT_OPTIONS, SORT_OPTIONS,
} from '~/components/resumes/browse/resumeFilters'; } from '~/components/resumes/browse/resumeFilters';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems'; import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton'; import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import useDebounceValue from '~/utils/resumes/useDebounceValue'; import useDebounceValue from '~/utils/resumes/useDebounceValue';
@ -44,22 +43,24 @@ import { trpc } from '~/utils/trpc';
import type { FilterState } from '../../components/resumes/browse/resumeFilters'; import type { FilterState } from '../../components/resumes/browse/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
const PAGE_LIMIT = 10; const PAGE_LIMIT = 10;
const filters: Array<Filter> = [ const filters: Array<Filter> = [
{ {
id: 'role', id: 'role',
label: 'Role', label: 'Role',
options: ROLE, options: ROLES,
}, },
{ {
id: 'experience', id: 'experience',
label: 'Experience', label: 'Experience',
options: EXPERIENCE, options: EXPERIENCES,
}, },
{ {
id: 'location', id: 'location',
label: 'Location', label: 'Location',
options: LOCATION, options: LOCATIONS,
}, },
]; ];
@ -122,14 +123,14 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
}, },
], ],
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.ALL, enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
staleTime: 5 * 60 * 1000, staleTime: STALE_TIME,
}, },
); );
const starredResumesQuery = trpc.useQuery( const starredResumesQuery = trpc.useQuery(
@ -140,7 +141,7 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
}, },
@ -148,7 +149,7 @@ export default function ResumeHomePage() {
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED, enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
retry: false, retry: false,
staleTime: 5 * 60 * 1000, staleTime: STALE_TIME,
}, },
); );
const myResumesQuery = trpc.useQuery( const myResumesQuery = trpc.useQuery(
@ -159,7 +160,7 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
}, },
@ -167,7 +168,7 @@ export default function ResumeHomePage() {
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.MY, enabled: tabsValue === BROWSE_TABS_VALUES.MY,
retry: false, retry: false,
staleTime: 5 * 60 * 1000, staleTime: STALE_TIME,
}, },
); );
@ -238,6 +239,11 @@ export default function ResumeHomePage() {
: Math.floor(numRecords / PAGE_LIMIT) + 1; : Math.floor(numRecords / PAGE_LIMIT) + 1;
}; };
const isFetchingResumes =
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching;
return ( return (
<> <>
<Head> <Head>
@ -271,7 +277,7 @@ export default function ResumeHomePage() {
leave="transition ease-in-out duration-300 transform" leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0" leaveFrom="translate-x-0"
leaveTo="translate-x-full"> leaveTo="translate-x-full">
<Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-auto bg-white py-4 pb-12 shadow-xl"> <Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-scroll bg-white py-4 pb-12 shadow-xl">
<div className="flex items-center justify-between px-4"> <div className="flex items-center justify-between px-4">
<h2 className="text-lg font-medium text-gray-900"> <h2 className="text-lg font-medium text-gray-900">
Shortcuts Shortcuts
@ -362,20 +368,16 @@ export default function ResumeHomePage() {
</Transition.Root> </Transition.Root>
</div> </div>
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-auto"> <main className="h-[calc(100vh-4rem)] flex-auto overflow-y-scroll px-8 pt-6 pb-4">
<div className="ml-4 py-4"> <div className="flex justify-start">
<ResumeReviewsTitle />
</div>
<div className="mx-8 mt-4 flex justify-start">
<div className="hidden w-1/6 pt-2 lg:block"> <div className="hidden w-1/6 pt-2 lg:block">
<h3 className="text-md mb-4 font-medium tracking-tight text-gray-900"> <h3 className="text-md font-medium tracking-tight text-gray-900">
Shortcuts: Shortcuts
</h3> </h3>
<div className="w-100 pt-4 sm:pr-0 md:pr-4"> <div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form> <form>
<ul <ul
className="flex flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900" className="flex w-11/12 flex-wrap justify-start gap-2 pb-6 text-sm font-medium text-gray-900"
role="list"> role="list">
{SHORTCUTS.map((shortcut) => ( {SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}> <li key={shortcut.name}>
@ -387,8 +389,8 @@ export default function ResumeHomePage() {
</li> </li>
))} ))}
</ul> </ul>
<h3 className="text-md my-4 font-medium tracking-tight text-gray-900"> <h3 className="text-md font-medium tracking-tight text-gray-900">
Explore these filters: Explore these filters
</h3> </h3>
{filters.map((filter) => ( {filters.map((filter) => (
<Disclosure <Disclosure
@ -528,16 +530,12 @@ export default function ResumeHomePage() {
</div> </div>
</div> </div>
</div> </div>
<div className="mb-6"> {isFetchingResumes ? (
{allResumesQuery.isLoading ||
starredResumesQuery.isLoading ||
myResumesQuery.isLoading ? (
<div className="w-full pt-4"> <div className="w-full pt-4">
{' '} {' '}
<Spinner display="block" size="lg" />{' '} <Spinner display="block" size="lg" />{' '}
</div> </div>
) : sessionData === null && ) : sessionData === null && tabsValue !== BROWSE_TABS_VALUES.ALL ? (
tabsValue !== BROWSE_TABS_VALUES.ALL ? (
<ResumeSignInButton <ResumeSignInButton
className="mt-8" className="mt-8"
text={getLoggedOutText(tabsValue)} text={getLoggedOutText(tabsValue)}
@ -553,15 +551,9 @@ export default function ResumeHomePage() {
</div> </div>
) : ( ) : (
<> <>
<ResumeListItems <ResumeListItems resumes={getTabResumes()} />
isLoading={ {getTabTotalPages() > 1 && (
allResumesQuery.isFetching || <div className="mt-4 flex justify-center">
starredResumesQuery.isFetching ||
myResumesQuery.isFetching
}
resumes={getTabResumes()}
/>
<div className="my-4 flex justify-center">
<Pagination <Pagination
current={currentPage} current={currentPage}
end={getTabTotalPages()} end={getTabTotalPages()}
@ -570,11 +562,11 @@ export default function ResumeHomePage() {
onSelect={(page) => setCurrentPage(page)} onSelect={(page) => setCurrentPage(page)}
/> />
</div> </div>
)}
</> </>
)} )}
</div> </div>
</div> </div>
</div>
</main> </main>
</> </>
); );

@ -18,11 +18,10 @@ import {
TextInput, TextInput,
} from '@tih/ui'; } from '@tih/ui';
import type { Filter } from '~/components/resumes/browse/resumeFilters';
import { import {
EXPERIENCE, EXPERIENCES,
LOCATION, LOCATIONS,
ROLE, ROLES,
} from '~/components/resumes/browse/resumeFilters'; } from '~/components/resumes/browse/resumeFilters';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines'; import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
@ -47,11 +46,7 @@ type IFormInput = {
title: string; title: string;
}; };
const selectors: Array<Filter> = [ type InputKeys = keyof IFormInput;
{ id: 'role', label: 'Role', options: ROLE },
{ id: 'experience', label: 'Experience Level', options: EXPERIENCE },
{ id: 'location', label: 'Location', options: LOCATION },
];
type InitFormDetails = { type InitFormDetails = {
additionalInfo?: string; additionalInfo?: string;
@ -225,6 +220,10 @@ export default function SubmitResumeForm({
} }
}, [errors?.file, invalidFileUploadError]); }, [errors?.file, invalidFileUploadError]);
const onValueChange = (section: InputKeys, value: string) => {
setValue(section, value.trim(), { shouldTouch: false });
};
return ( return (
<> <>
<Head> <Head>
@ -261,11 +260,13 @@ export default function SubmitResumeForm({
onClose={() => setIsDialogShown(false)}> onClose={() => setIsDialogShown(false)}>
Note that your current input will not be saved! Note that your current input will not be saved!
</Dialog> </Dialog>
<div className="mx-20 space-y-4 py-8"> <form
<form onSubmit={handleSubmit(onSubmit)}> className="mt-8 w-full max-w-screen-lg space-y-6 self-center rounded-lg bg-white p-10 shadow-lg"
<h1 className="mb-4 text-2xl font-bold">Upload a resume</h1> onSubmit={handleSubmit(onSubmit)}>
<h1 className="mb-4 text-center text-2xl font-semibold">
{isNewForm ? 'Upload a resume' : 'Update details'}
</h1>
{/* Title Section */} {/* Title Section */}
<div className="mb-4">
<TextInput <TextInput
{...register('title', { required: true })} {...register('title', { required: true })}
disabled={isLoading} disabled={isLoading}
@ -274,23 +275,39 @@ export default function SubmitResumeForm({
required={true} required={true}
onChange={(val) => setValue('title', val)} onChange={(val) => setValue('title', val)}
/> />
</div> <div className="flex gap-8">
{/* Selectors */}
{selectors.map((item) => (
<div key={item.id} className="mb-4">
<Select <Select
{...register(item.id, { required: true })} {...register('role', { required: true })}
defaultValue={undefined}
disabled={isLoading} disabled={isLoading}
label={item.label} label="Role"
options={item.options} options={ROLES}
placeholder=" "
required={true} required={true}
onChange={(val) => setValue(item.id, val)} onChange={(val) => setValue('role', val)}
/>
<Select
{...register('experience', { required: true })}
disabled={isLoading}
label="Experience Level"
options={EXPERIENCES}
placeholder=" "
required={true}
onChange={(val) => setValue('experience', val)}
/> />
</div> </div>
))} <Select
{...register('location', { required: true })}
disabled={isLoading}
label="Location"
options={LOCATIONS}
placeholder=" "
required={true}
onChange={(val) => setValue('location', val)}
/>
{/* Upload resume form */} {/* Upload resume form */}
{isNewForm && ( {isNewForm && (
<> <div className="space-y-2">
<p className="text-sm font-medium text-slate-700"> <p className="text-sm font-medium text-slate-700">
Upload resume (PDF format) Upload resume (PDF format)
<span aria-hidden="true" className="text-danger-500"> <span aria-hidden="true" className="text-danger-500">
@ -298,14 +315,11 @@ export default function SubmitResumeForm({
* *
</span> </span>
</p> </p>
<div className="mb-4">
<div <div
{...getRootProps()} {...getRootProps()}
className={clsx( className={clsx(
fileUploadError fileUploadError ? 'border-danger-600' : 'border-gray-300',
? 'border-danger-600' 'flex cursor-pointer justify-center rounded-md border-2 border-dashed bg-gray-100 py-4',
: 'border-gray-300',
'mt-2 flex cursor-pointer justify-center rounded-md border-2 border-dashed bg-gray-100 px-6 pt-5 pb-6',
)}> )}>
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
{resumeFile == null ? ( {resumeFile == null ? (
@ -321,14 +335,10 @@ export default function SubmitResumeForm({
<label <label
className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2" className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2"
htmlFor="file-upload"> htmlFor="file-upload">
<span className="mt-2 font-medium"> <span className="font-medium">Drop file here</span>
Drop file here
</span>
<span className="mr-1 ml-1 font-light">or</span> <span className="mr-1 ml-1 font-light">or</span>
<span className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-400"> <span className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-400">
{resumeFile == null {resumeFile == null ? 'Select file' : 'Replace file'}
? 'Select file'
: 'Replace file'}
</span> </span>
<input <input
{...register('file', { required: true })} {...register('file', { required: true })}
@ -348,24 +358,21 @@ export default function SubmitResumeForm({
</div> </div>
</div> </div>
{fileUploadError && ( {fileUploadError && (
<p className="text-danger-600 text-sm"> <p className="text-danger-600 text-sm">{fileUploadError}</p>
{fileUploadError}
</p>
)} )}
</div> </div>
</>
)} )}
{/* Additional Info Section */} {/* Additional Info Section */}
<div className="mb-8">
<TextArea <TextArea
{...register('additionalInfo')} {...(register('additionalInfo'), {})}
disabled={isLoading} disabled={isLoading}
label="Additional Information" label="Additional Information"
placeholder={ADDITIONAL_INFO_PLACEHOLDER} placeholder={ADDITIONAL_INFO_PLACEHOLDER}
onChange={(val) => setValue('additionalInfo', val)} onChange={(val) => onValueChange('additionalInfo', val)}
/> />
</div>
{/* Submission Guidelines */} {/* Submission Guidelines */}
{isNewForm && (
<>
<SubmissionGuidelines /> <SubmissionGuidelines />
<CheckboxInput <CheckboxInput
{...register('isChecked', { required: true })} {...register('isChecked', { required: true })}
@ -373,8 +380,10 @@ export default function SubmitResumeForm({
label="I have read and will follow the guidelines stated." label="I have read and will follow the guidelines stated."
onChange={(val) => setValue('isChecked', val)} onChange={(val) => setValue('isChecked', val)}
/> />
</>
)}
{/* Clear and Submit Buttons */} {/* Clear and Submit Buttons */}
<div className="mt-4 flex justify-end gap-4"> <div className="flex justify-end gap-4">
<Button <Button
addonPosition="start" addonPosition="start"
disabled={isLoading} disabled={isLoading}
@ -392,7 +401,6 @@ export default function SubmitResumeForm({
/> />
</div> </div>
</form> </form>
</div>
</section> </section>
</main> </main>
</> </>

@ -10,6 +10,7 @@ import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router'; import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router'; import { questionsAnswerRouter } from './questions-answer-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router'; import { questionsQuestionCommentRouter } from './questions-question-comment-router';
import { questionsQuestionEncounterRouter } from './questions-question-encounter-router';
import { questionsQuestionRouter } from './questions-question-router'; import { questionsQuestionRouter } from './questions-question-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-router'; import { resumeCommentsRouter } from './resumes/resumes-comments-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router'; import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
@ -40,6 +41,7 @@ export const appRouter = createRouter()
.merge('questions.answers.comments.', questionsAnswerCommentRouter) .merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter) .merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter) .merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
.merge('questions.questions.', questionsQuestionRouter) .merge('questions.questions.', questionsQuestionRouter)
.merge('offers.', offersRouter) .merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter) .merge('offers.profile.', offersProfileRouter)

@ -285,7 +285,6 @@ export const offersAnalysisRouter = createRouter()
OR: [ OR: [
{ {
offersFullTime: { offersFullTime: {
level: overallHighestOffer.offersFullTime?.level,
title: overallHighestOffer.offersFullTime?.title, title: overallHighestOffer.offersFullTime?.title,
}, },
offersIntern: { offersIntern: {
@ -322,18 +321,18 @@ export const offersAnalysisRouter = createRouter()
similarOffers, similarOffers,
); );
const overallPercentile = const overallPercentile =
similarOffers.length === 0 similarOffers.length <= 1
? 100 ? 100
: (100 * overallIndex) / similarOffers.length; : 100 - (100 * overallIndex) / (similarOffers.length - 1);
const companyIndex = searchOfferPercentile( const companyIndex = searchOfferPercentile(
overallHighestOffer, overallHighestOffer,
similarCompanyOffers, similarCompanyOffers,
); );
const companyPercentile = const companyPercentile =
similarCompanyOffers.length === 0 similarCompanyOffers.length <= 1
? 100 ? 100
: (100 * companyIndex) / similarCompanyOffers.length; : 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE // FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
// e.g. If there only 4 offers, it gives the 2nd and 3rd offer // e.g. If there only 4 offers, it gives the 2nd and 3rd offer

@ -385,7 +385,7 @@ export const offersProfileRouter = createRouter()
throw new trpc.TRPCError({ throw new trpc.TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Missing fields.', message: 'Missing fields in background experiences.',
}); });
}), }),
}, },
@ -533,7 +533,7 @@ export const offersProfileRouter = createRouter()
// Throw error // Throw error
throw new trpc.TRPCError({ throw new trpc.TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Missing fields.', message: 'Missing fields in offers.',
}); });
}), }),
), ),

@ -11,9 +11,10 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
questionId: z.string(), questionId: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const questionEncountersData = await ctx.prisma.questionsQuestionEncounter.findMany({ const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: { include: {
company : true, company: true,
}, },
where: { where: {
...input, ...input,
@ -22,7 +23,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
const companyCounts: Record<string, number> = {}; const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {}; const locationCounts: Record<string, number> = {};
const roleCounts:Record<string, number> = {}; const roleCounts: Record<string, number> = {};
for (let i = 0; i < questionEncountersData.length; i++) { for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i]; const encounter = questionEncountersData[i];
@ -41,16 +42,15 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
roleCounts[encounter.role] = 1; roleCounts[encounter.role] = 1;
} }
roleCounts[encounter.role] += 1; roleCounts[encounter.role] += 1;
} }
const questionEncounter:AggregatedQuestionEncounter = { const questionEncounter: AggregatedQuestionEncounter = {
companyCounts, companyCounts,
locationCounts, locationCounts,
roleCounts, roleCounts,
} };
return questionEncounter; return questionEncounter;
} },
}) })
.mutation('create', { .mutation('create', {
input: z.object({ input: z.object({
@ -58,7 +58,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
location: z.string(), location: z.string(),
questionId: z.string(), questionId: z.string(),
role: z.string(), role: z.string(),
seenAt: z.date() seenAt: z.date(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
@ -83,7 +83,8 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const questionEncounterToUpdate = await ctx.prisma.questionsQuestionEncounter.findUnique({ const questionEncounterToUpdate =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: { where: {
id: input.id, id: input.id,
}, },
@ -113,7 +114,8 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const questionEncounterToDelete = await ctx.prisma.questionsQuestionEncounter.findUnique({ const questionEncounterToDelete =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: { where: {
id: input.id, id: input.id,
}, },

@ -7,8 +7,6 @@ import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions'; import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d'; import { SortOrder, SortType } from '~/types/questions.d';
const TWO_WEEK_IN_MS = 12096e5;
export const questionsQuestionRouter = createProtectedRouter() export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', { .query('getQuestionsByFilter', {
input: z.object({ input: z.object({
@ -20,7 +18,7 @@ export const questionsQuestionRouter = createProtectedRouter()
roles: z.string().array(), roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder), sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType), sortType: z.nativeEnum(SortType),
startDate: z.date().default(new Date(Date.now() - TWO_WEEK_IN_MS)), startDate: z.date().optional(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const sortCondition = const sortCondition =
@ -99,6 +97,7 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
}, },
}); });
return questionsData.map((data) => { return questionsData.map((data) => {
const votes: number = data.votes.reduce( const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => { (previousValue: number, currentValue) => {
@ -125,6 +124,7 @@ export const questionsQuestionRouter = createProtectedRouter()
numAnswers: data._count.answers, numAnswers: data._count.answers,
numComments: data._count.comments, numComments: data._count.comments,
numVotes: votes, numVotes: votes,
receivedCount: data.encounters.length,
role: data.encounters[0].role ?? 'Unknown role', role: data.encounters[0].role ?? 'Unknown role',
seenAt: data.encounters[0].seenAt, seenAt: data.encounters[0].seenAt,
type: data.questionType, type: data.questionType,
@ -198,6 +198,7 @@ export const questionsQuestionRouter = createProtectedRouter()
numAnswers: questionData._count.answers, numAnswers: questionData._count.answers,
numComments: questionData._count.comments, numComments: questionData._count.comments,
numVotes: votes, numVotes: votes,
receivedCount: questionData.encounters.length,
role: questionData.encounters[0].role ?? 'Unknown role', role: questionData.encounters[0].role ?? 'Unknown role',
seenAt: questionData.encounters[0].seenAt, seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType, type: questionData.questionType,

@ -45,6 +45,7 @@ export type Valuation = {
baseCurrency: string; baseCurrency: string;
baseValue: number; baseValue: number;
currency: string; currency: string;
id: string;
value: number; value: number;
}; };

@ -9,6 +9,7 @@ export type Question = {
numAnswers: number; numAnswers: number;
numComments: number; numComments: number;
numVotes: number; numVotes: number;
receivedCount: number;
role: string; role: string;
seenAt: Date; seenAt: Date;
type: QuestionsQuestionType; type: QuestionsQuestionType;

@ -0,0 +1 @@
export type RequireAllOrNone<T> = T | { [K in keyof T]?: never };

@ -1,13 +1,19 @@
import type { QuestionsQuestionType } from '@prisma/client'; import { QuestionsQuestionType } from '@prisma/client';
import type { FilterChoices } from '~/components/questions/filter/FilterSection'; import type { FilterChoices } from '~/components/questions/filter/FilterSection';
import { SortOrder, SortType } from '~/types/questions.d';
export const APP_TITLE = 'Questions Bank';
export const COMPANIES: FilterChoices = [ export const COMPANIES: FilterChoices = [
{ {
id: 'Google',
label: 'Google', label: 'Google',
value: 'Google', value: 'Google',
}, },
{ {
id: 'Meta',
label: 'Meta', label: 'Meta',
value: 'Meta', value: 'Meta',
}, },
@ -16,14 +22,17 @@ export const COMPANIES: FilterChoices = [
// Code, design, behavioral // Code, design, behavioral
export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [ export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
{ {
id: 'CODING',
label: 'Coding', label: 'Coding',
value: 'CODING', value: 'CODING',
}, },
{ {
id: 'SYSTEM_DESIGN',
label: 'Design', label: 'Design',
value: 'SYSTEM_DESIGN', value: 'SYSTEM_DESIGN',
}, },
{ {
id: 'BEHAVIORAL',
label: 'Behavioral', label: 'Behavioral',
value: 'BEHAVIORAL', value: 'BEHAVIORAL',
}, },
@ -33,18 +42,22 @@ export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year';
export const QUESTION_AGES: FilterChoices<QuestionAge> = [ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
{ {
id: 'last-month',
label: 'Last month', label: 'Last month',
value: 'last-month', value: 'last-month',
}, },
{ {
id: 'last-6-months',
label: 'Last 6 months', label: 'Last 6 months',
value: 'last-6-months', value: 'last-6-months',
}, },
{ {
id: 'last-year',
label: 'Last year', label: 'Last year',
value: 'last-year', value: 'last-year',
}, },
{ {
id: 'all',
label: 'All', label: 'All',
value: 'all', value: 'all',
}, },
@ -52,37 +65,82 @@ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
export const LOCATIONS: FilterChoices = [ export const LOCATIONS: FilterChoices = [
{ {
id: 'Singapore',
label: 'Singapore', label: 'Singapore',
value: 'Singapore', value: 'Singapore',
}, },
{ {
id: 'Menlo Park',
label: 'Menlo Park', label: 'Menlo Park',
value: 'Menlo Park', value: 'Menlo Park',
}, },
{ {
id: 'California',
label: 'California', label: 'California',
value: 'california', value: 'California',
}, },
{ {
id: 'Hong Kong',
label: 'Hong Kong', label: 'Hong Kong',
value: 'Hong Kong', value: 'Hong Kong',
}, },
{ {
id: 'Taiwan',
label: 'Taiwan', label: 'Taiwan',
value: 'Taiwan', value: 'Taiwan',
}, },
] as const; ] as const;
export const ROLES: FilterChoices = [
{
id: 'Software Engineer',
label: 'Software Engineer',
value: 'Software Engineer',
},
{
id: 'Software Engineer Intern',
label: 'Software Engineer Intern',
value: 'Software Engineer Intern',
},
] as const;
export const SORT_ORDERS = [
{
label: 'Ascending',
value: SortOrder.ASC,
},
{
label: 'Descending',
value: SortOrder.DESC,
},
];
export const SORT_TYPES = [
{
label: 'New',
value: SortType.NEW,
},
{
label: 'Top',
value: SortType.TOP,
},
];
export const SAMPLE_QUESTION = { export const SAMPLE_QUESTION = {
answerCount: 10, answerCount: 10,
commentCount: 10, commentCount: 10,
company: 'Google', companies: { Google: 1 },
content: content:
'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and', 'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and',
location: 'Menlo Park, CA', createdAt: new Date(2014, 8, 1, 11, 30, 40),
id: '1',
locations: { 'Menlo Park, CA': 1 },
receivedCount: 12, receivedCount: 12,
role: 'Software Engineer', roles: { 'Software Engineer': 1 },
seenAt: new Date(2014, 8, 1, 11, 30, 40),
timestamp: 'Last month', timestamp: 'Last month',
type: QuestionsQuestionType.CODING,
upvoteCount: 5, upvoteCount: 5,
}; };

@ -0,0 +1,22 @@
import type { FilterChoice } from '~/components/questions/filter/FilterSection';
import { trpc } from '../trpc';
export default function useDefaultCompany(): FilterChoice | undefined {
const { data: companies } = trpc.useQuery([
'companies.list',
{
name: '',
},
]);
const company = companies?.[0];
if (company === undefined) {
return company;
}
return {
id: company.id,
label: company.name,
value: company.id,
};
}

@ -0,0 +1,7 @@
import type { FilterChoice } from '~/components/questions/filter/FilterSection';
import { LOCATIONS } from './constants';
export default function useDefaultLocation(): FilterChoice | undefined {
return LOCATIONS[0];
}

@ -1,65 +0,0 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
export const useSearchFilter = <Value extends string = string>(
name: string,
opts: {
defaultValues?: Array<Value>;
queryParamToValue?: (param: string) => Value;
} = {},
) => {
const { defaultValues, queryParamToValue = (param) => param } = opts;
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
const [filters, setFilters] = useState<Array<Value>>(defaultValues || []);
useEffect(() => {
if (router.isReady && !isInitialized) {
// Initialize from query params
const query = router.query[name];
if (query) {
const queryValues = Array.isArray(query) ? query : [query];
setFilters(queryValues.map(queryParamToValue) as Array<Value>);
} else {
// Try to load from local storage
const localStorageValue = localStorage.getItem(name);
if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue);
setFilters(loadedFilters);
}
}
setIsInitialized(true);
}
}, [isInitialized, name, queryParamToValue, router]);
const setFiltersCallback = useCallback(
(newFilters: Array<Value>) => {
setFilters(newFilters);
localStorage.setItem(name, JSON.stringify(newFilters));
},
[name],
);
return [filters, setFiltersCallback, isInitialized] as const;
};
export const useSearchFilterSingle = <Value extends string = string>(
name: string,
opts: {
defaultValue?: Value;
queryParamToValue?: (param: string) => Value;
} = {},
) => {
const { defaultValue, queryParamToValue } = opts;
const [filters, setFilters, isInitialized] = useSearchFilter(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
queryParamToValue,
});
return [
filters[0],
(value: Value) => setFilters([value]),
isInitialized,
] as const;
};

@ -0,0 +1,86 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
type SearchParamOptions<Value> = [Value] extends [string]
? {
defaultValues?: Array<Value>;
paramToString?: (value: Value) => string | null;
stringToParam?: (param: string) => Value | null;
}
: {
defaultValues?: Array<Value>;
paramToString: (value: Value) => string | null;
stringToParam: (param: string) => Value | null;
};
export const useSearchParam = <Value = string>(
name: string,
opts?: SearchParamOptions<Value>,
) => {
const {
defaultValues,
stringToParam = (param: string) => param,
paramToString: valueToQueryParam = (value: Value) => String(value),
} = opts ?? {};
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
const [filters, setFilters] = useState<Array<Value>>(defaultValues || []);
useEffect(() => {
if (router.isReady && !isInitialized) {
// Initialize from query params
const query = router.query[name];
if (query) {
const queryValues = Array.isArray(query) ? query : [query];
setFilters(
queryValues
.map(stringToParam)
.filter((value) => value !== null) as Array<Value>,
);
} else {
// Try to load from local storage
const localStorageValue = localStorage.getItem(name);
if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue);
setFilters(loadedFilters);
}
}
setIsInitialized(true);
}
}, [isInitialized, name, stringToParam, router]);
const setFiltersCallback = useCallback(
(newFilters: Array<Value>) => {
setFilters(newFilters);
localStorage.setItem(
name,
JSON.stringify(
newFilters.map(valueToQueryParam).filter((param) => param !== null),
),
);
},
[name, valueToQueryParam],
);
return [filters, setFiltersCallback, isInitialized] as const;
};
export const useSearchParamSingle = <Value = string>(
name: string,
opts?: Omit<SearchParamOptions<Value>, 'defaultValues'> & {
defaultValue?: Value;
},
) => {
const { defaultValue, ...restOpts } = opts ?? {};
const [filters, setFilters, isInitialized] = useSearchParam<Value>(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
...restOpts,
} as SearchParamOptions<Value>);
return [
filters[0],
(value: Value) => setFilters([value]),
isInitialized,
] as const;
};

@ -74,6 +74,10 @@ export const useQuestionVote = (id: string) => {
create: 'questions.questions.createVote', create: 'questions.questions.createVote',
deleteKey: 'questions.questions.deleteVote', deleteKey: 'questions.questions.deleteVote',
idKey: 'questionId', idKey: 'questionId',
invalidateKeys: [
'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionById',
],
query: 'questions.questions.getVote', query: 'questions.questions.getVote',
update: 'questions.questions.updateVote', update: 'questions.questions.updateVote',
}); });
@ -84,6 +88,10 @@ export const useAnswerVote = (id: string) => {
create: 'questions.answers.createVote', create: 'questions.answers.createVote',
deleteKey: 'questions.answers.deleteVote', deleteKey: 'questions.answers.deleteVote',
idKey: 'answerId', idKey: 'answerId',
invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById',
],
query: 'questions.answers.getVote', query: 'questions.answers.getVote',
update: 'questions.answers.updateVote', update: 'questions.answers.updateVote',
}); });
@ -94,6 +102,7 @@ export const useQuestionCommentVote = (id: string) => {
create: 'questions.questions.comments.createVote', create: 'questions.questions.comments.createVote',
deleteKey: 'questions.questions.comments.deleteVote', deleteKey: 'questions.questions.comments.deleteVote',
idKey: 'questionCommentId', idKey: 'questionCommentId',
invalidateKeys: ['questions.questions.comments.getQuestionComments'],
query: 'questions.questions.comments.getVote', query: 'questions.questions.comments.getVote',
update: 'questions.questions.comments.updateVote', update: 'questions.questions.comments.updateVote',
}); });
@ -104,6 +113,7 @@ export const useAnswerCommentVote = (id: string) => {
create: 'questions.answers.comments.createVote', create: 'questions.answers.comments.createVote',
deleteKey: 'questions.answers.comments.deleteVote', deleteKey: 'questions.answers.comments.deleteVote',
idKey: 'answerCommentId', idKey: 'answerCommentId',
invalidateKeys: ['questions.answers.comments.getAnswerComments'],
query: 'questions.answers.comments.getVote', query: 'questions.answers.comments.getVote',
update: 'questions.answers.comments.updateVote', update: 'questions.answers.comments.updateVote',
}); });
@ -113,29 +123,30 @@ type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
create: MutationKey; create: MutationKey;
deleteKey: MutationKey; deleteKey: MutationKey;
idKey: string; idKey: string;
invalidateKeys: Array<VoteQueryKey>;
query: VoteQueryKey; query: VoteQueryKey;
update: MutationKey; update: MutationKey;
}; };
type UseVoteMutationContext = {
currentData: any;
previousData: any;
};
export const useVote = <VoteQueryKey extends QueryKey = QueryKey>( export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
id: string, id: string,
opts: VoteProps<VoteQueryKey>, opts: VoteProps<VoteQueryKey>,
) => { ) => {
const { create, deleteKey, query, update, idKey } = opts; const { create, deleteKey, query, update, idKey, invalidateKeys } = opts;
const utils = trpc.useContext(); const utils = trpc.useContext();
const onVoteUpdate = useCallback(() => { const onVoteUpdate = useCallback(() => {
// TODO: Optimise query invalidation // TODO: Optimise query invalidation
utils.invalidateQueries([query, { [idKey]: id } as any]); utils.invalidateQueries([query, { [idKey]: id } as any]);
utils.invalidateQueries(['questions.questions.getQuestionsByFilter']); for (const invalidateKey of invalidateKeys) {
utils.invalidateQueries(['questions.questions.getQuestionById']); utils.invalidateQueries([invalidateKey]);
utils.invalidateQueries(['questions.answers.getAnswers']); }
utils.invalidateQueries(['questions.answers.getAnswerById']); }, [id, idKey, utils, query, invalidateKeys]);
utils.invalidateQueries([
'questions.questions.comments.getQuestionComments',
]);
utils.invalidateQueries(['questions.answers.comments.getAnswerComments']);
}, [id, idKey, utils, query]);
const { data } = trpc.useQuery([ const { data } = trpc.useQuery([
query, query,
@ -146,16 +157,87 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const backendVote = data as BackendVote; const backendVote = data as BackendVote;
const { mutate: createVote } = trpc.useMutation(create, { const { mutate: createVote } = trpc.useMutation<any, UseVoteMutationContext>(
onSuccess: onVoteUpdate, create,
}); {
const { mutate: updateVote } = trpc.useMutation(update, { onError: (err, variables, context) => {
onSuccess: onVoteUpdate, if (context !== undefined) {
}); utils.setQueryData([query], context.previousData);
}
},
onMutate: async (vote) => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
[query, { [idKey]: id } as any],
);
const { mutate: deleteVote } = trpc.useMutation(deleteKey, { utils.setQueryData(
onSuccess: onVoteUpdate, [
}); query,
{
[idKey]: id,
} as any,
],
vote as any,
);
return { currentData: vote, previousData };
},
onSettled: onVoteUpdate,
},
);
const { mutate: updateVote } = trpc.useMutation<any, UseVoteMutationContext>(
update,
{
onError: (error, variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
},
onMutate: async (vote) => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
[query, { [idKey]: id } as any],
);
utils.setQueryData(
[
query,
{
[idKey]: id,
} as any,
],
vote,
);
return { currentData: vote, previousData };
},
onSettled: onVoteUpdate,
},
);
const { mutate: deleteVote } = trpc.useMutation<any, UseVoteMutationContext>(
deleteKey,
{
onError: (err, variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
},
onMutate: async (vote) => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
utils.setQueryData(
[
query,
{
[idKey]: id,
} as any,
],
null as any,
);
return { currentData: null, previousData: vote };
},
onSettled: onVoteUpdate,
},
);
const { handleDownvote, handleUpvote } = createVoteCallbacks( const { handleDownvote, handleUpvote } = createVoteCallbacks(
backendVote ?? null, backendVote ?? null,

@ -10,7 +10,7 @@ module.exports = {
sans: ['Inter var', ...defaultTheme.fontFamily.sans], sans: ['Inter var', ...defaultTheme.fontFamily.sans],
}, },
colors: { colors: {
primary: colors.purple, primary: colors.indigo,
danger: colors.rose, danger: colors.rose,
info: colors.sky, info: colors.sky,
success: colors.emerald, success: colors.emerald,

@ -1240,6 +1240,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.18.3":
version "7.19.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.4.tgz#a42f814502ee467d55b38dd1c256f53a7b885c78"
integrity sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.12.7", "@babel/template@^7.18.10": "@babel/template@^7.12.7", "@babel/template@^7.18.10":
version "7.18.10" version "7.18.10"
resolved "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz" resolved "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz"
@ -2156,6 +2163,11 @@
resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz" resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz"
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
"@popperjs/core@^2.11.5", "@popperjs/core@^2.11.6":
version "2.11.6"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45"
integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
"@prisma/client@^4.4.0": "@prisma/client@^4.4.0":
version "4.4.0" version "4.4.0"
resolved "https://registry.npmjs.org/@prisma/client/-/client-4.4.0.tgz" resolved "https://registry.npmjs.org/@prisma/client/-/client-4.4.0.tgz"
@ -12197,7 +12209,7 @@ react-error-overlay@^6.0.11:
resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz" resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-fast-compare@^3.2.0: react-fast-compare@^3.0.1, react-fast-compare@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz" resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
@ -12275,6 +12287,23 @@ react-pdf@^5.7.2:
tiny-invariant "^1.0.0" tiny-invariant "^1.0.0"
tiny-warning "^1.0.0" tiny-warning "^1.0.0"
react-popper-tooltip@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/react-popper-tooltip/-/react-popper-tooltip-4.4.2.tgz#0dc4894b8e00ba731f89bd2d30584f6032ec6163"
integrity sha512-y48r0mpzysRTZAIh8m2kpZ8S1YPNqGtQPDrlXYSGvDS1c1GpG/NUXbsbIdfbhXfmSaRJuTcaT6N1q3CKuHRVbg==
dependencies:
"@babel/runtime" "^7.18.3"
"@popperjs/core" "^2.11.5"
react-popper "^2.3.0"
react-popper@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
dependencies:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-query@^3.39.2: react-query@^3.39.2:
version "3.39.2" version "3.39.2"
resolved "https://registry.npmjs.org/react-query/-/react-query-3.39.2.tgz" resolved "https://registry.npmjs.org/react-query/-/react-query-3.39.2.tgz"
@ -14744,6 +14773,13 @@ walker@^1.0.7, walker@~1.0.5:
dependencies: dependencies:
makeerror "1.0.12" makeerror "1.0.12"
warning@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
dependencies:
loose-envify "^1.0.0"
watchpack-chokidar2@^2.0.1: watchpack-chokidar2@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz" resolved "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz"

Loading…
Cancel
Save