mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2026-06-16 06:56:36 +08:00
Merge branch 'main' into hongpo/add-question-text-match
This commit is contained in:
@@ -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() {
|
||||
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>
|
||||
</div>
|
||||
if (
|
||||
!totalCompensation &&
|
||||
!monthlySalary &&
|
||||
!negotiationStrategy &&
|
||||
!otherComment
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
{totalCompensation && (
|
||||
<div className="ml-6 flex flex-row font-light text-gray-400">
|
||||
<p>
|
||||
Base / year: {base} ⋅ Stocks / year: {stocks} ⋅ Bonus / year:{' '}
|
||||
{bonus}
|
||||
</p>
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
{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>
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (selectedTab === ProfileDetailTab.OFFERS) {
|
||||
return <ProfileOffers offers={offers} />;
|
||||
}
|
||||
if (selectedTab === ProfileDetailTab.BACKGROUND) {
|
||||
return <ProfileBackground background={background} />;
|
||||
}
|
||||
if (selectedTab === ProfileDetailTab.ANALYSIS) {
|
||||
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>
|
||||
<ProfileAnalysis
|
||||
analysis={analysis}
|
||||
isEditable={isEditable}
|
||||
profileId={profileId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (selectedTab === 'background') {
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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;
|
||||
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 BackgroundCard = {
|
||||
educations: Array<EducationDisplay>;
|
||||
experiences: Array<OfferEntity>;
|
||||
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 handleChangeCompany = (company: string) => {
|
||||
setLandingQueryData((prev) => ({ ...prev, company }));
|
||||
const [company, setCompany] = useState<FilterChoice | undefined>(
|
||||
defaultCompany,
|
||||
);
|
||||
const [location, setLocation] = useState<FilterChoice | undefined>(
|
||||
defaultLocation,
|
||||
);
|
||||
|
||||
const [questionType, setQuestionType] =
|
||||
useState<QuestionsQuestionType>('CODING');
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (company === undefined) {
|
||||
setCompany(defaultCompany);
|
||||
}
|
||||
}, [defaultCompany, company]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location === undefined) {
|
||||
setLocation(defaultLocation);
|
||||
}
|
||||
}, [defaultLocation, location]);
|
||||
|
||||
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>
|
||||
|
||||
<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">
|
||||
<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&repo=tech-interview-handbook&type=star&count=true&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>
|
||||
</form>
|
||||
|
||||
<Button
|
||||
disabled={!isCommentDirty || !isCommentValid}
|
||||
label="Post"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
{(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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
498
apps/portal/src/pages/questions/browse.tsx
Normal file
498
apps/portal/src/pages/questions/browse.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
apps/portal/src/pages/questions/history.tsx
Normal file
16
apps/portal/src/pages/questions/history.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
179
apps/portal/src/pages/questions/lists.tsx
Normal file
179
apps/portal/src/pages/questions/lists.tsx
Normal file
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
apps/portal/src/pages/questions/my-questions.tsx
Normal file
16
apps/portal/src/pages/questions/my-questions.tsx
Normal file
@@ -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')}
|
||||
disabled={isLoading}
|
||||
label="Additional Information"
|
||||
placeholder={ADDITIONAL_INFO_PLACEHOLDER}
|
||||
onChange={(val) => setValue('additionalInfo', val)}
|
||||
/>
|
||||
</div>
|
||||
{fileUploadError && (
|
||||
<p className="text-danger-600 text-sm">{fileUploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Submission Guidelines */}
|
||||
<SubmissionGuidelines />
|
||||
<CheckboxInput
|
||||
{...register('isChecked', { required: true })}
|
||||
)}
|
||||
{/* 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="I have read and will follow the guidelines stated."
|
||||
onChange={(val) => setValue('isChecked', val)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* 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,
|
||||
|
||||
1
apps/portal/src/types/offers.d.ts
vendored
1
apps/portal/src/types/offers.d.ts
vendored
@@ -45,6 +45,7 @@ export type Valuation = {
|
||||
baseCurrency: string;
|
||||
baseValue: number;
|
||||
currency: string;
|
||||
id: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
|
||||
1
apps/portal/src/types/questions.d.ts
vendored
1
apps/portal/src/types/questions.d.ts
vendored
@@ -9,6 +9,7 @@ export type Question = {
|
||||
numAnswers: number;
|
||||
numComments: number;
|
||||
numVotes: number;
|
||||
receivedCount: number;
|
||||
role: string;
|
||||
seenAt: Date;
|
||||
type: QuestionsQuestionType;
|
||||
|
||||
1
apps/portal/src/utils/questions/RequireAllOrNone.ts
Normal file
1
apps/portal/src/utils/questions/RequireAllOrNone.ts
Normal file
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
22
apps/portal/src/utils/questions/useDefaultCompany.ts
Normal file
22
apps/portal/src/utils/questions/useDefaultCompany.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
7
apps/portal/src/utils/questions/useDefaultLocation.ts
Normal file
7
apps/portal/src/utils/questions/useDefaultLocation.ts
Normal file
@@ -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;
|
||||
};
|
||||
86
apps/portal/src/utils/questions/useSearchParam.ts
Normal file
86
apps/portal/src/utils/questions/useSearchParam.ts
Normal file
@@ -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,
|
||||
|
||||
38
yarn.lock
38
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user