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

@ -110,9 +110,30 @@ export const educationFieldOptions = [
];
export enum FieldError {
NonNegativeNumber = 'Please fill in a non-negative number in this field.',
Number = 'Please fill in a number in this field.',
Required = 'Please fill in this field.',
NON_NEGATIVE_NUMBER = 'Please fill in a non-negative number in this field.',
NUMBER = 'Please fill in a number in this field.',
REQUIRED = 'Please fill in this field.',
}
export const OVERALL_TAB = 'Overall';
export enum ProfileDetailTab {
ANALYSIS = 'Offer Engine Analysis',
BACKGROUND = 'Background',
OFFERS = 'Offers',
}
export const profileDetailTabs = [
{
label: ProfileDetailTab.OFFERS,
value: ProfileDetailTab.OFFERS,
},
{
label: ProfileDetailTab.BACKGROUND,
value: ProfileDetailTab.BACKGROUND,
},
{
label: ProfileDetailTab.ANALYSIS,
value: ProfileDetailTab.ANALYSIS,
},
];

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

@ -2,30 +2,34 @@ import { useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OfferAnalysis from '~/components/offers/offersSubmission/analysis/OfferAnalysis';
import OfferProfileSave from '~/components/offers/offersSubmission/OfferProfileSave';
import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type {
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
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 = {
comments: '',
companyId: '',
jobType: JobType.FullTime,
jobType: JobType.FULLTIME,
location: '',
monthYearReceived: {
month: getCurrentMonth() as Month,
@ -36,18 +40,18 @@ const defaultOfferValues = {
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FullTime,
jobType: JobType.FULLTIME,
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Intern,
jobType: JobType.INTERN,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
experiences: [{ jobType: JobType.FULLTIME }],
specificYoes: [],
totalYoe: 0,
},
@ -78,6 +82,7 @@ export default function OffersSubmissionForm({
id: profileId || '',
token: token || '',
});
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
@ -88,9 +93,26 @@ export default function OffersSubmissionForm({
});
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> = [
{
component: <OfferDetailsForm key={0} />,
component: (
<OfferDetailsForm
key={0}
defaultJobType={initialOfferProfileValues.offers[0].jobType}
/>
),
hasNext: true,
hasPrevious: false,
label: 'Offer details',
@ -102,14 +124,21 @@ export default function OffersSubmissionForm({
label: 'Background',
},
{
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />,
component: (
<OfferAnalysis
key={2}
allAnalysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
/>
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: (
<OfferProfileSave
<OffersProfileSave
key={3}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
@ -139,15 +168,6 @@ export default function OffersSubmissionForm({
scrollToTop();
};
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const mutationpath =
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 { JobType } from '@prisma/client';
import { Collapsible, RadioList } from '@tih/ui';
import {
@ -10,7 +11,6 @@ import {
titleOptions,
} from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
@ -39,8 +39,8 @@ function YoeSection() {
required={true}
type="number"
{...register(`background.totalYoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
@ -52,7 +52,7 @@ function YoeSection() {
label="Specific YOE 1"
type="number"
{...register(`background.specificYoes.0.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/>
@ -68,7 +68,7 @@ function YoeSection() {
label="Specific YOE 2"
type="number"
{...register(`background.specificYoes.1.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/>
@ -128,7 +128,7 @@ function FullTimeJobFields() {
startAddOnType="label"
type="number"
{...register(`background.experiences.0.totalCompensation.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/>
@ -158,7 +158,7 @@ function FullTimeJobFields() {
label="Duration (months)"
type="number"
{...register(`background.experiences.0.durationInMonths`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/>
@ -211,7 +211,7 @@ function InternshipJobFields() {
startAddOnType="label"
type="number"
{...register(`background.experiences.0.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/>
@ -239,7 +239,7 @@ function InternshipJobFields() {
function CurrentJobSection() {
const { register } = useFormContext();
const watchJobType = useWatch({
defaultValue: JobType.FullTime,
defaultValue: JobType.FULLTIME,
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">
<FormRadioList
defaultValue={JobType.FullTime}
defaultValue={JobType.FULLTIME}
isLabelHidden={true}
label="Job Type"
orientation="horizontal"
@ -259,16 +259,16 @@ function CurrentJobSection() {
<RadioList.Item
key="Full-time"
label="Full-time"
value={JobType.FullTime}
value={JobType.FULLTIME}
/>
<RadioList.Item
key="Internship"
label="Internship"
value={JobType.Intern}
value={JobType.INTERN}
/>
</FormRadioList>
</div>
{watchJobType === JobType.FullTime ? (
{watchJobType === JobType.FULLTIME ? (
<FullTimeJobFields />
) : (
<InternshipJobFields />

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

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

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

@ -1,24 +1,155 @@
import { AcademicCapIcon, BriefcaseIcon } from '@heroicons/react/24/outline';
import { Spinner } from '@tih/ui';
import { useState } from 'react';
import {
AcademicCapIcon,
ArrowPathIcon,
BriefcaseIcon,
} from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
import EducationCard from '~/components/offers/profile/EducationCard';
import OfferCard from '~/components/offers/profile/OfferCard';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
import { EducationBackgroundType } from '~/components/offers/types';
import type {
BackgroundDisplayData,
OfferDisplayData,
} from '~/components/offers/types';
type ProfileHeaderProps = Readonly<{
background?: BackgroundCard;
import { trpc } from '~/utils/trpc';
import { ProfileDetailTab } from '../constants';
import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
import { ProfileAnalysis } from '~/types/offers';
type ProfileOffersProps = Readonly<{
offers: Array<OfferDisplayData>;
}>;
function ProfileOffers({ offers }: ProfileOffersProps) {
if (offers.length !== 0) {
return (
<>
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>
);
}
return (
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">No offer is attached.</span>
</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>
);
}
return (
<>
{background?.experiences?.length > 0 && (
<>
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">Work Experience</span>
</div>
<OfferCard offer={background.experiences[0]} />
</>
)}
{background?.educations?.length > 0 && (
<>
<div className="mx-8 my-4 flex flex-row">
<AcademicCapIcon className="mr-1 h-5" />
<span className="font-bold">Education</span>
</div>
<EducationCard education={background.educations[0]} />
</>
)}
</>
);
}
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<OfferEntity>;
selectedTab: string;
offers: Array<OfferDisplayData>;
profileId: string;
selectedTab: ProfileDetailTab;
}>;
export default function ProfileDetails({
analysis,
background,
isLoading,
offers,
selectedTab,
}: ProfileHeaderProps) {
profileId,
isEditable,
}: ProfileDetailsProps) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
@ -26,54 +157,20 @@ export default function ProfileDetails({
</div>
);
}
if (selectedTab === 'offers') {
if (offers.length !== 0) {
return (
<>
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>
);
}
return (
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">No offer is attached.</span>
</div>
);
if (selectedTab === ProfileDetailTab.OFFERS) {
return <ProfileOffers offers={offers} />;
}
if (selectedTab === ProfileDetailTab.BACKGROUND) {
return <ProfileBackground background={background} />;
}
if (selectedTab === 'background') {
if (selectedTab === ProfileDetailTab.ANALYSIS) {
return (
<>
{background?.experiences && background?.experiences.length > 0 && (
<>
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">Work Experience</span>
</div>
<OfferCard offer={background?.experiences[0]} />
</>
)}
{background?.educations && background?.educations.length > 0 && (
<>
<div className="mx-8 my-4 flex flex-row">
<AcademicCapIcon className="mr-1 h-5" />
<span className="font-bold">Education</span>
</div>
<EducationCard
education={{
endDate: background.educations[0].endDate,
field: background.educations[0].field,
school: background.educations[0].school,
startDate: background.educations[0].startDate,
type: EducationBackgroundType.Bachelor,
}}
/>
</>
)}
</>
<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 { useState } from 'react';
import {
BookmarkSquareIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
PencilSquareIcon,
@ -10,17 +9,20 @@ import {
import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundCard } from '~/components/offers/types';
import type { BackgroundDisplayData } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link';
import type { ProfileDetailTab } from '../constants';
import { profileDetailTabs } from '../constants';
type ProfileHeaderProps = Readonly<{
background?: BackgroundCard;
background?: BackgroundDisplayData;
handleDelete: () => void;
isEditable: boolean;
isLoading: boolean;
selectedTab: string;
setSelectedTab: (tab: string) => void;
selectedTab: ProfileDetailTab;
setSelectedTab: (tab: ProfileDetailTab) => void;
}>;
export default function ProfileHeader({
@ -42,14 +44,14 @@ export default function ProfileHeader({
function renderActionList() {
return (
<div className="space-x-2">
<Button
{/* <Button
disabled={isLoading}
icon={BookmarkSquareIcon}
isLabelHidden={true}
label="Save to user account"
size="md"
variant="tertiary"
/>
/> */}
<Button
disabled={isLoading}
icon={PencilSquareIcon}
@ -109,6 +111,13 @@ export default function ProfileHeader({
</div>
);
}
if (!background) {
return null;
}
const { experiences, totalYoe, specificYoes, profileName } = background;
return (
<div className="h-40 bg-white p-4">
<div className="justify-left flex h-1/2">
@ -118,7 +127,7 @@ export default function ProfileHeader({
<div className="w-full">
<div className="justify-left flex flex-1">
<h2 className="flex w-4/5 text-2xl font-bold">
{background?.profileName ?? 'anonymous'}
{profileName ?? 'anonymous'}
</h2>
{isEditable && (
<div className="flex h-8 w-1/5 justify-end">
@ -126,22 +135,26 @@ export default function ProfileHeader({
</div>
)}
</div>
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>
{`${background?.experiences[0]?.companyName ?? '-'} ${
background?.experiences[0]?.jobLevel || ''
} ${background?.experiences[0]?.jobTitle || ''}`}
</span>
</div>
{(experiences[0]?.companyName ||
experiences[0]?.jobLevel ||
experiences[0]?.jobTitle) && (
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>
{`${experiences[0].companyName || ''} ${
experiences[0].jobLevel || ''
} ${experiences[0].jobTitle || ''}`}
</span>
</div>
)}
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span className="mr-4">{background?.totalYoe}</span>
{background?.specificYoes &&
background?.specificYoes.length > 0 &&
background?.specificYoes.map(({ domain, yoe }) => {
<span className="mr-4">{totalYoe}</span>
{specificYoes &&
specificYoes.length > 0 &&
specificYoes.map(({ domain, yoe }) => {
return (
<span
key={domain}
@ -155,20 +168,7 @@ export default function ProfileHeader({
<div className="mt-8">
<Tabs
label="Profile Detail Navigation"
tabs={[
{
label: 'Offers',
value: 'offers',
},
{
label: 'Background',
value: 'background',
},
{
label: 'Offer Engine Analysis',
value: 'offerEngineAnalysis',
},
]}
tabs={profileDetailTabs}
value={selectedTab}
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 (
<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
className="h-full w-full text-gray-300"
fill="currentColor"

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

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

@ -2,9 +2,9 @@ import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '@tih/ui';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import ContributeQuestionForm from './ContributeQuestionForm';
import DiscardDraftDialog from './DiscardDraftDialog';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
import ContributeQuestionForm from './forms/ContributeQuestionForm';
export type ContributeQuestionDialogProps = Pick<
ContributeQuestionFormProps,
@ -60,14 +60,14 @@ export default function ContributeQuestionDialog({
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
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="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900">
Question Draft
Contribute question
</Dialog.Title>
<div className="w-full">
<HorizontalDivider />

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

@ -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';
import { Button, Select, TextInput } from '@tih/ui';
export type SortOption = {
export type SortOption<Value> = {
label: string;
value: string;
value: Value;
};
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = {
onFilterOptionsToggle: () => void;
onSortChange?: (sortValue: SortOptions[number]['value']) => void;
sortOptions: SortOptions;
sortValue: SortOptions[number]['value'];
type SortOrderProps<SortOrder> = {
onSortOrderChange?: (sortValue: SortOrder) => void;
sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOrderValue: SortOrder;
};
export default function QuestionSearchBar<
SortOptions extends Array<SortOption>,
>({
onSortChange,
sortOptions,
sortValue,
type SortTypeProps<SortType> = {
onSortTypeChange?: (sortType: SortType) => void;
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
sortTypeValue: SortType;
};
export type QuestionSearchBarProps<SortType, SortOrder> =
SortOrderProps<SortOrder> &
SortTypeProps<SortType> & {
onFilterOptionsToggle: () => void;
};
export default function QuestionSearchBar<SortType, SortOrder>({
onSortOrderChange,
sortOrderOptions,
sortOrderValue,
onSortTypeChange,
sortTypeOptions,
sortTypeValue,
onFilterOptionsToggle,
}: QuestionSearchBarProps<SortOptions>) {
}: QuestionSearchBarProps<SortType, SortOrder>) {
return (
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1 ">
<TextInput
isLabelHidden={true}
label="Search by content"
@ -35,27 +47,48 @@ export default function QuestionSearchBar<
startAddOnType="icon"
/>
</div>
<div className="flex items-center gap-2">
<span aria-hidden={true} className="align-middle text-sm font-medium">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={sortOptions}
value={sortValue}
onChange={onSortChange}
/>
</div>
<div className="lg:hidden">
<Button
addonPosition="start"
icon={AdjustmentsHorizontalIcon}
label="Filter options"
variant="tertiary"
onClick={onFilterOptionsToggle}
/>
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2">
<Select
display="inline"
label="Sort by"
options={sortTypeOptions}
value={sortTypeValue}
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 className="lg:hidden">
<Button
addonPosition="start"
icon={AdjustmentsHorizontalIcon}
label="Filter options"
variant="tertiary"
onClick={onFilterOptionsToggle}
/>
</div>
</div>
</div>
);

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

@ -13,6 +13,7 @@ export type AnswerCardProps = {
commentCount?: number;
content: string;
createdAt: Date;
showHover?: boolean;
upvoteCount: number;
votingButtonsSize: VotingButtonsProps['size'];
};
@ -26,10 +27,14 @@ export default function AnswerCard({
commentCount,
votingButtonsSize,
upvoteCount,
showHover,
}: AnswerCardProps) {
const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
return (
<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
size={votingButtonsSize}
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';
export type QuestionAnswerCardProps = Required<
Omit<AnswerCardProps, 'votingButtonsSize'>
Omit<AnswerCardProps, 'showHover' | 'votingButtonsSize'>
>;
function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) {
return <AnswerCard {...props} votingButtonsSize="sm" />;
return <AnswerCard {...props} showHover={true} votingButtonsSize="sm" />;
}
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 { CheckboxInput, Collapsible, RadioList, TextInput } from '@tih/ui';
import { useMemo } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { CheckboxInput, Collapsible, RadioList } from '@tih/ui';
export type FilterOption<V extends string = string> = {
checked: boolean;
export type FilterChoice<V extends string = string> = {
id: string;
label: string;
value: V;
};
export type FilterOption<V extends string = string> = FilterChoice<V> & {
checked: boolean;
};
export type FilterChoices<V extends string = string> = ReadonlyArray<
Omit<FilterOption<V>, 'checked'>
FilterChoice<V>
>;
type FilterSectionType<FilterOptions extends Array<FilterOption>> =
@ -30,42 +36,87 @@ export type FilterSectionProps<FilterOptions extends Array<FilterOption>> =
options: FilterOptions;
} & (
| {
searchPlaceholder: string;
renderInput: (props: {
field: UseFormRegisterReturn<'search'>;
onOptionChange: FilterSectionType<FilterOptions>['onOptionChange'];
options: FilterOptions;
}) => React.ReactNode;
showAll?: never;
}
| {
searchPlaceholder?: never;
renderInput?: never;
showAll: true;
}
);
export type FilterSectionFormData = {
search: string;
};
export default function FilterSection<
FilterOptions extends Array<FilterOption>,
>({
label,
options,
searchPlaceholder,
showAll,
onOptionChange,
isSingleSelect,
renderInput,
}: 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 (
<div className="mx-2">
<Collapsible defaultOpen={true} label={label}>
<div className="mx-2 py-2">
<Collapsible defaultOpen={true} label={collapsibleLabel}>
<div className="-mx-2 flex flex-col items-stretch gap-2">
{!showAll && (
<TextInput
isLabelHidden={true}
label={label}
placeholder={searchPlaceholder}
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
/>
<div className="z-10">
{renderInput({
field,
onOptionChange: async (
optionValue: FilterOptions[number]['value'],
) => {
reset();
return onOptionChange(optionValue, true);
},
options: autocompleteOptions,
})}
</div>
)}
{isSingleSelect ? (
<div className="px-1.5">
<RadioList
label=""
isLabelHidden={true}
label={label}
value={options.find((option) => option.checked)?.value}
onChange={(value) => {
onOptionChange(value);
@ -81,16 +132,18 @@ export default function FilterSection<
</div>
) : (
<div className="px-1.5">
{options.map((option) => (
<CheckboxInput
key={option.value}
label={option.label}
value={option.checked}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
{options
.filter((option) => showAll || option.checked)
.map((option) => (
<CheckboxInput
key={option.value}
label={option.label}
value={option.checked}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
)}
</div>

@ -1,26 +1,26 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { CalendarDaysIcon, UserIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import {
Button,
CheckboxInput,
Collapsible,
HorizontalDivider,
Select,
TextArea,
TextInput,
} from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import {
useFormRegister,
useSelectRegister,
} from '~/utils/questions/useFormRegister';
import CompaniesTypeahead from '../shared/CompaniesTypeahead';
import type { Month } from '../shared/MonthYearPicker';
import MonthYearPicker from '../shared/MonthYearPicker';
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker';
export type ContributeQuestionData = {
company: string;
@ -59,8 +59,17 @@ export default function ContributeQuestionForm({
};
return (
<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)}>
<div className="min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<TextArea
label="Question Prompt"
placeholder="Contribute a question"
@ -68,40 +77,41 @@ export default function ContributeQuestionForm({
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
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<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
control={control}
name="company"
name="location"
render={({ field }) => (
<CompaniesTypeahead
onSelect={({ id }) => {
field.onChange(id);
<LocationTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={LOCATIONS.find(
(location) => location.value === field.value,
)}
/>
)}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="date"
render={({ field }) => (
<MonthYearPicker
monthRequired={true}
value={{
month: (field.value.getMonth() + 1) as Month,
month: ((field.value.getMonth() as number) + 1) as Month,
year: field.value.getFullYear(),
}}
yearRequired={true}
onChange={({ month, year }) =>
field.onChange(startOfMonth(new Date(year, month - 1)))
}
@ -110,28 +120,38 @@ export default function ContributeQuestionForm({
/>
</div>
</div>
<Collapsible defaultOpen={true} label="Additional info">
<div className="justify-left flex flex-wrap items-end gap-2">
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Location"
required={true}
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('location')}
/>
</div>
<div className="min-w-[150px] max-w-[200px] flex-1">
<TextInput
label="Role"
required={true}
startAddOn={UserIcon}
startAddOnType="icon"
{...register('role')}
/>
</div>
<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
control={control}
name="company"
render={({ field }) => (
<CompanyTypeahead
required={true}
onSelect={({ id }) => {
field.onChange(id);
}}
/>
)}
/>
</div>
</Collapsible>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[200px]">
<Controller
control={control}
name="role"
render={({ field }) => (
<RoleTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/>
</div>
</div>
{/* <div className="w-full">
<HorizontalDivider />
</div>
@ -151,15 +171,20 @@ export default function ContributeQuestionForm({
}}
/>
</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 className="mb-1 flex">
<div
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
label="I have checked that my question is new"
value={canSubmit}
onChange={handleCheckSimilarQuestions}
/>
</div>
<div className=" flex gap-x-2">
<div className="flex gap-x-2">
<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"
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 type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import {
ArrowLeftIcon,
ArrowRightIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} 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`;
@ -45,7 +43,7 @@ export default function ResumePdf({ url }: Props) {
<div id="pdfView">
<div className="group relative">
<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}
loading={<Spinner display="block" size="lg" />}
noData=""
@ -86,28 +84,17 @@ export default function ResumePdf({ url }: Props) {
</div>
</Document>
</div>
<div className="flex flex-row items-center justify-between p-4">
<Button
disabled={pageNumber === 1}
icon={ArrowLeftIcon}
isLabelHidden={true}
label="Previous"
variant="tertiary"
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>
{numPages > 1 && (
<div className="flex justify-center p-4">
<Pagination
current={pageNumber}
end={numPages}
label="pagination"
start={1}
onSelect={(page) => setPageNumber(page)}
/>
</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 className="mt-2 text-slate-400">{resumeInfo.location}</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>
</Link>
);

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

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

@ -74,7 +74,7 @@ export default function ResumeCommentsList({
<Spinner display="block" size="lg" />
</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 }) => {
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {

@ -1,22 +1,21 @@
export default function SubmissionGuidelines() {
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>
<p>
Before you submit, please review and acknolwedge our
Before you submit, please review and acknowledge our
<span className="font-bold"> submission guidelines </span>
stated below.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any of your
<span className="font-bold"> personal particulars</span>.
Ensure that you do not divulge any of your{' '}
<span className="font-bold">personal particulars</span>.
</p>
<p>
<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">
{' '}
company's proprietary and confidential information
</span>
.

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

@ -2,17 +2,21 @@ import Error from 'next/error';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { ProfileDetailTab } from '~/components/offers/constants';
import ProfileComments from '~/components/offers/profile/ProfileComments';
import ProfileDetails from '~/components/offers/profile/ProfileDetails';
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 { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { Profile, ProfileOffer } from '~/types/offers';
import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers';
export default function OfferProfile() {
const ErrorPage = (
@ -21,10 +25,13 @@ export default function OfferProfile() {
const router = useRouter();
const { offerProfileId, token = '' } = router.query;
const [isEditable, setIsEditable] = useState(false);
const [background, setBackground] = useState<BackgroundCard>();
const [offers, setOffers] = useState<Array<OfferEntity>>([]);
const [background, setBackground] = useState<BackgroundDisplayData>();
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(
[
@ -44,75 +51,79 @@ export default function OfferProfile() {
setIsEditable(data?.isEditable ?? false);
if (data?.offers) {
const filteredOffers: Array<OfferEntity> = data
? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) {
const filteredOffer: OfferEntity = {
base: convertMoneyToString(res.offersFullTime.baseSalary),
bonus: convertMoneyToString(res.offersFullTime.bonus),
companyName: res.company.name,
id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title,
location: res.location,
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived),
stocks: convertMoneyToString(res.offersFullTime.stocks),
totalCompensation: convertMoneyToString(
res.offersFullTime.totalCompensation,
),
};
return filteredOffer;
}
const filteredOffer: OfferEntity = {
const filteredOffers: Array<OfferDisplayData> = data
? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) {
const filteredOffer: OfferDisplayData = {
base: convertMoneyToString(res.offersFullTime.baseSalary),
bonus: convertMoneyToString(res.offersFullTime.bonus),
companyName: res.company.name,
id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title,
id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title,
location: res.location,
monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary,
),
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments,
receivedMonth: formatDate(res.monthYearReceived),
stocks: convertMoneyToString(res.offersFullTime.stocks),
totalCompensation: convertMoneyToString(
res.offersFullTime.totalCompensation,
),
};
return filteredOffer;
})
: [];
setOffers(filteredOffers);
}
}
const filteredOffer: OfferDisplayData = {
companyName: res.company.name,
id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title,
location: res.location,
monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary,
),
negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments,
receivedMonth: formatDate(res.monthYearReceived),
};
return filteredOffer;
})
: [];
setOffers(filteredOffers);
if (data?.background) {
const transformedBackground = {
educations: data.background.educations.map((education) => ({
endDate: education.endDate ? formatDate(education.endDate) : '-',
field: education.field || '-',
school: education.school || '-',
endDate: education.endDate ? formatDate(education.endDate) : null,
field: education.field,
school: education.school,
startDate: education.startDate
? formatDate(education.startDate)
: '-',
type: education.type || '-',
})),
experiences: data.background.experiences.map((experience) => ({
companyName: experience.company?.name ?? '-',
duration: String(experience.durationInMonths) ?? '-',
jobLevel: experience.level ?? '',
jobTitle: experience.title ?? '-',
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: '-',
totalCompensation: experience.totalCompensation
? convertMoneyToString(experience.totalCompensation)
: '-',
: null,
type: education.type,
})),
experiences: data.background.experiences.map(
(experience): OfferDisplayData => ({
companyName: experience.company?.name,
duration: experience.durationInMonths,
jobLevel: experience.level,
jobTitle: experience.title,
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: null,
totalCompensation: experience.totalCompensation
? convertMoneyToString(experience.totalCompensation)
: null,
}),
),
profileName: data.profileName,
specificYoes: data.background.specificYoes,
totalYoe: String(data.background.totalYoe) || '-',
totalYoe: data.background.totalYoe,
};
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">
<ProfileDetails
analysis={analysis}
background={background}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
offers={offers}
profileId={offerProfileId as string}
selectedTab={selectedTab}
/>
</div>

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

@ -1,3 +1,4 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
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 FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import { APP_TITLE } from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
@ -51,10 +53,6 @@ export default function QuestionPage() {
},
);
const handleBackNavigation = () => {
router.back();
};
const handleSubmitComment = (data: AnswerCommentData) => {
resetComment();
addComment({
@ -68,90 +66,98 @@ export default function QuestionPage() {
}
return (
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
onClick={handleBackNavigation}></Button>
</div>
<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">
<FullAnswerCard
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
content={answer.content}
createdAt={answer.createdAt}
upvoteCount={answer.numVotes}
<>
<Head>
<title>
{answer.content} - {APP_TITLE}
</title>
</Head>
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
href={`/questions/${router.query.questionId}/${router.query.questionSlug}`}
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
/>
<div className="mx-2">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
</div>
<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">
<FullAnswerCard
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
content={answer.content}
createdAt={answer.createdAt}
upvoteCount={answer.numVotes}
/>
<div className="mx-2">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
</form>
{(comments ?? []).map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
))}
</div>
</div>
</div>
</div>
</div>
</>
);
}

@ -1,13 +1,15 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
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 FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
@ -45,6 +47,11 @@ export default function QuestionPage() {
{ id: questionId as string },
]);
const { data: aggregatedEncounters } = trpc.useQuery([
'questions.questions.encounters.getAggregatedEncounters',
{ questionId: questionId as string },
]);
const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([
@ -74,9 +81,17 @@ export default function QuestionPage() {
},
});
const handleBackNavigation = () => {
router.back();
};
const { mutate: addEncounter } = trpc.useMutation(
'questions.questions.encounters.create',
{
onSuccess: () => {
utils.invalidateQueries(
'questions.questions.encounters.getAggregatedEncounters',
);
utils.invalidateQueries('questions.questions.getQuestionById');
},
},
);
const handleSubmitAnswer = (data: AnswerQuestionData) => {
addAnswer({
@ -99,44 +114,125 @@ export default function QuestionPage() {
}
return (
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
onClick={handleBackNavigation}></Button>
</div>
<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">
<FullQuestionCard
{...question}
questionId={question.id}
receivedCount={0}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
upvoteCount={question.numVotes}
<>
<Head>
<title>
{question.content} - {APP_TITLE}
</title>
</Head>
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
href="/questions/browse"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
/>
<div className="mx-2">
<Collapsible label={`${(comments ?? []).length} comment(s)`}>
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
</div>
<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">
<FullQuestionCard
{...question}
companies={aggregatedEncounters?.companyCounts ?? {}}
locations={aggregatedEncounters?.locationCounts ?? {}}
questionId={question.id}
receivedCount={undefined}
roles={aggregatedEncounters?.roleCounts ?? {}}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
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">
<Collapsible label={`${(comments ?? []).length} comment(s)`}>
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
</Collapsible>
</div>
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
<TextArea
{...answerRegister('answerContent', {
minLength: 1,
required: true,
})}
label="Contribute your answer"
required={true}
resize="vertical"
rows={5}
/>
<div className="mt-3 mb-1 flex justify-between">
<div className="flex items-baseline justify-start gap-2">
<p>{(answers ?? []).length} answers</p>
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
@ -162,94 +258,33 @@ export default function QuestionPage() {
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={0}
<Button
disabled={!isDirty || !isValid}
label="Contribute"
type="submit"
variant="primary"
/>
))}
</Collapsible>
</div>
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
<TextArea
{...answerRegister('answerContent', {
minLength: 1,
required: true,
})}
label="Contribute your answer"
required={true}
resize="vertical"
rows={5}
/>
<div className="mt-3 mb-1 flex justify-between">
<div className="flex items-baseline justify-start gap-2">
<p>{(answers ?? []).length} answers</p>
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
</div>
<Button
disabled={!isDirty || !isValid}
label="Contribute"
type="submit"
variant="primary"
</form>
{(answers ?? []).map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
</div>
</form>
{(answers ?? []).map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
))}
))}
</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 Router, { 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 Head from 'next/head';
import { useRouter } from 'next/router';
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 LandingComponent from '~/components/questions/LandingComponent';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import type { QuestionAge } 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';
import { APP_TITLE } from '~/utils/questions/constants';
export default function QuestionsHomePage() {
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 { company, location, questionType } = data;
setSelectedCompanies([company]);
setSelectedLocations([location]);
setSelectedQuestionTypes([questionType as QuestionsQuestionType]);
setHasLanded(true);
// Go to browse page
router.push({
pathname: '/questions/browse',
query: {
companies: [company],
locations: [location],
questionTypes: [questionType],
},
});
};
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: {
companies: selectedCompanies,
locations: selectedLocations,
questionAge: selectedQuestionAge,
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 ? (
<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>
return (
<>
<Head>
<title>Home - {APP_TITLE}</title>
</Head>
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
</>
);
}

@ -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>
<title>{detailsQuery.data.title}</title>
</Head>
<main className="h-[calc(100vh-2rem)] flex-1 overflow-y-auto p-4">
<div className="flex space-x-8">
<h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
<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 justify-between">
<h1 className="text-2xl font-semibold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title}
</h1>
<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"
type="button"
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>
)}
</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">
<BriefcaseIcon
aria-hidden="true"
@ -206,11 +206,11 @@ export default function ResumeReviewPage() {
/>
</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]">
<ResumePdf url={detailsQuery.data.url} />
</div>
<div className="mx-8 grow">
<div className="grow">
<ResumeCommentsSection resumeId={resumeId as string} />
</div>
</div>

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

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

@ -10,6 +10,7 @@ import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
import { questionsQuestionEncounterRouter } from './questions-question-encounter-router';
import { questionsQuestionRouter } from './questions-question-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-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.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
.merge('questions.questions.', questionsQuestionRouter)
.merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter)

@ -285,7 +285,6 @@ export const offersAnalysisRouter = createRouter()
OR: [
{
offersFullTime: {
level: overallHighestOffer.offersFullTime?.level,
title: overallHighestOffer.offersFullTime?.title,
},
offersIntern: {
@ -322,18 +321,18 @@ export const offersAnalysisRouter = createRouter()
similarOffers,
);
const overallPercentile =
similarOffers.length === 0
similarOffers.length <= 1
? 100
: (100 * overallIndex) / similarOffers.length;
: 100 - (100 * overallIndex) / (similarOffers.length - 1);
const companyIndex = searchOfferPercentile(
overallHighestOffer,
similarCompanyOffers,
);
const companyPercentile =
similarCompanyOffers.length === 0
similarCompanyOffers.length <= 1
? 100
: (100 * companyIndex) / similarCompanyOffers.length;
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
// 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({
code: 'BAD_REQUEST',
message: 'Missing fields.',
message: 'Missing fields in background experiences.',
});
}),
},
@ -533,7 +533,7 @@ export const offersProfileRouter = createRouter()
// Throw error
throw new trpc.TRPCError({
code: 'BAD_REQUEST',
message: 'Missing fields.',
message: 'Missing fields in offers.',
});
}),
),

@ -11,46 +11,46 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const questionEncountersData = await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company : true,
},
where: {
...input,
},
});
const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company: true,
},
where: {
...input,
},
});
const companyCounts: 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++) {
const encounter = questionEncountersData[i];
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const questionEncounter:AggregatedQuestionEncounter = {
const questionEncounter: AggregatedQuestionEncounter = {
companyCounts,
locationCounts,
roleCounts,
}
};
return questionEncounter;
}
},
})
.mutation('create', {
input: z.object({
@ -58,7 +58,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
location: z.string(),
questionId: z.string(),
role: z.string(),
seenAt: z.date()
seenAt: z.date(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
@ -83,11 +83,12 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToUpdate = await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
const questionEncounterToUpdate =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToUpdate?.id !== userId) {
throw new TRPCError({
@ -113,11 +114,12 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToDelete = await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
const questionEncounterToDelete =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToDelete?.id !== userId) {
throw new TRPCError({
@ -132,4 +134,4 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
},
});
},
});
});

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

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

@ -9,6 +9,7 @@ export type Question = {
numAnswers: number;
numComments: number;
numVotes: number;
receivedCount: number;
role: string;
seenAt: Date;
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 { SortOrder, SortType } from '~/types/questions.d';
export const APP_TITLE = 'Questions Bank';
export const COMPANIES: FilterChoices = [
{
id: 'Google',
label: 'Google',
value: 'Google',
},
{
id: 'Meta',
label: 'Meta',
value: 'Meta',
},
@ -16,14 +22,17 @@ export const COMPANIES: FilterChoices = [
// Code, design, behavioral
export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
{
id: 'CODING',
label: 'Coding',
value: 'CODING',
},
{
id: 'SYSTEM_DESIGN',
label: 'Design',
value: 'SYSTEM_DESIGN',
},
{
id: 'BEHAVIORAL',
label: 'Behavioral',
value: 'BEHAVIORAL',
},
@ -33,18 +42,22 @@ export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year';
export const QUESTION_AGES: FilterChoices<QuestionAge> = [
{
id: 'last-month',
label: 'Last month',
value: 'last-month',
},
{
id: 'last-6-months',
label: 'Last 6 months',
value: 'last-6-months',
},
{
id: 'last-year',
label: 'Last year',
value: 'last-year',
},
{
id: 'all',
label: 'All',
value: 'all',
},
@ -52,37 +65,82 @@ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
export const LOCATIONS: FilterChoices = [
{
id: 'Singapore',
label: 'Singapore',
value: 'Singapore',
},
{
id: 'Menlo Park',
label: 'Menlo Park',
value: 'Menlo Park',
},
{
id: 'California',
label: 'California',
value: 'california',
value: 'California',
},
{
id: 'Hong Kong',
label: 'Hong Kong',
value: 'Hong Kong',
},
{
id: 'Taiwan',
label: 'Taiwan',
value: 'Taiwan',
},
] 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 = {
answerCount: 10,
commentCount: 10,
company: 'Google',
companies: { Google: 1 },
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',
location: 'Menlo Park, CA',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
id: '1',
locations: { 'Menlo Park, CA': 1 },
receivedCount: 12,
role: 'Software Engineer',
roles: { 'Software Engineer': 1 },
seenAt: new Date(2014, 8, 1, 11, 30, 40),
timestamp: 'Last month',
type: QuestionsQuestionType.CODING,
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',
deleteKey: 'questions.questions.deleteVote',
idKey: 'questionId',
invalidateKeys: [
'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionById',
],
query: 'questions.questions.getVote',
update: 'questions.questions.updateVote',
});
@ -84,6 +88,10 @@ export const useAnswerVote = (id: string) => {
create: 'questions.answers.createVote',
deleteKey: 'questions.answers.deleteVote',
idKey: 'answerId',
invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById',
],
query: 'questions.answers.getVote',
update: 'questions.answers.updateVote',
});
@ -94,6 +102,7 @@ export const useQuestionCommentVote = (id: string) => {
create: 'questions.questions.comments.createVote',
deleteKey: 'questions.questions.comments.deleteVote',
idKey: 'questionCommentId',
invalidateKeys: ['questions.questions.comments.getQuestionComments'],
query: 'questions.questions.comments.getVote',
update: 'questions.questions.comments.updateVote',
});
@ -104,6 +113,7 @@ export const useAnswerCommentVote = (id: string) => {
create: 'questions.answers.comments.createVote',
deleteKey: 'questions.answers.comments.deleteVote',
idKey: 'answerCommentId',
invalidateKeys: ['questions.answers.comments.getAnswerComments'],
query: 'questions.answers.comments.getVote',
update: 'questions.answers.comments.updateVote',
});
@ -113,29 +123,30 @@ type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
create: MutationKey;
deleteKey: MutationKey;
idKey: string;
invalidateKeys: Array<VoteQueryKey>;
query: VoteQueryKey;
update: MutationKey;
};
type UseVoteMutationContext = {
currentData: any;
previousData: any;
};
export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
id: string,
opts: VoteProps<VoteQueryKey>,
) => {
const { create, deleteKey, query, update, idKey } = opts;
const { create, deleteKey, query, update, idKey, invalidateKeys } = opts;
const utils = trpc.useContext();
const onVoteUpdate = useCallback(() => {
// TODO: Optimise query invalidation
utils.invalidateQueries([query, { [idKey]: id } as any]);
utils.invalidateQueries(['questions.questions.getQuestionsByFilter']);
utils.invalidateQueries(['questions.questions.getQuestionById']);
utils.invalidateQueries(['questions.answers.getAnswers']);
utils.invalidateQueries(['questions.answers.getAnswerById']);
utils.invalidateQueries([
'questions.questions.comments.getQuestionComments',
]);
utils.invalidateQueries(['questions.answers.comments.getAnswerComments']);
}, [id, idKey, utils, query]);
for (const invalidateKey of invalidateKeys) {
utils.invalidateQueries([invalidateKey]);
}
}, [id, idKey, utils, query, invalidateKeys]);
const { data } = trpc.useQuery([
query,
@ -146,16 +157,87 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const backendVote = data as BackendVote;
const { mutate: createVote } = trpc.useMutation(create, {
onSuccess: onVoteUpdate,
});
const { mutate: updateVote } = trpc.useMutation(update, {
onSuccess: onVoteUpdate,
});
const { mutate: createVote } = trpc.useMutation<any, UseVoteMutationContext>(
create,
{
onError: (err, 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],
);
const { mutate: deleteVote } = trpc.useMutation(deleteKey, {
onSuccess: onVoteUpdate,
});
utils.setQueryData(
[
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(
backendVote ?? null,

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

@ -1240,6 +1240,13 @@
dependencies:
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":
version "7.18.10"
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"
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":
version "4.4.0"
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"
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"
resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
@ -12275,6 +12287,23 @@ react-pdf@^5.7.2:
tiny-invariant "^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:
version "3.39.2"
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:
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:
version "2.0.1"
resolved "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz"

Loading…
Cancel
Save