Merge branch 'main' into questions/ui-fixes

pull/514/head
Jeff Sieu 3 years ago
commit 9bdcd31e45

@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `companyId` on the `OffersAnalysisUnit` table. All the data in the column will be lost.
- Added the required column `analysedOfferId` to the `OffersAnalysisUnit` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "OffersAnalysisUnit" DROP CONSTRAINT "OffersAnalysisUnit_companyId_fkey";
-- AlterTable
ALTER TABLE "OffersAnalysisUnit" DROP COLUMN "companyId",
ADD COLUMN "analysedOfferId" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "OffersAnalysisUnit" ADD CONSTRAINT "OffersAnalysisUnit_analysedOfferId_fkey" FOREIGN KEY ("analysedOfferId") REFERENCES "OffersOffer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

@ -104,7 +104,6 @@ model Company {
questionsQuestionEncounter QuestionsQuestionEncounter[]
OffersExperience OffersExperience[]
OffersOffer OffersOffer[]
OffersAnalysisUnit OffersAnalysisUnit[]
}
model Country {
@ -368,6 +367,7 @@ model OffersOffer {
offersAnalysis OffersAnalysis? @relation("HighestOverallOffer")
offersAnalysisUnit OffersAnalysisUnit[]
OffersAnalysisUnit OffersAnalysisUnit[] @relation("Analysed Offer")
}
model OffersIntern {
@ -419,8 +419,8 @@ model OffersAnalysis {
model OffersAnalysisUnit {
id String @id @default(cuid())
company Company @relation(fields: [companyId], references: [id])
companyId String
analysedOffer OffersOffer @relation("Analysed Offer", fields: [analysedOfferId], references: [id])
analysedOfferId String
percentile Float
noOfSimilarOffers Int

@ -0,0 +1,16 @@
import { emptyOption } from './constants';
export const EducationFieldLabels = [
'Business Analytics',
'Computer Science',
'Data Science and Analytics',
'Information Security',
'Information Systems',
];
export const EducationFieldOptions = [emptyOption].concat(
EducationFieldLabels.map((label) => ({
label,
value: label.replace(/\s+/g, '-').toLowerCase(),
})),
);

@ -0,0 +1,18 @@
import { emptyOption } from './constants';
export const EducationLevelLabels = [
'Bachelor',
'Diploma',
'Masters',
'PhD',
'Professional',
'Secondary',
'Self-taught',
];
export const EducationLevelOptions = [emptyOption].concat(
EducationLevelLabels.map((label) => ({
label,
value: label.replace(/\s+/g, '-').toLowerCase(),
})),
);

@ -0,0 +1,18 @@
import { emptyOption } from './constants';
export const InternshipCycleLabels = [
'Spring',
'Summer',
'Fall',
'Winter',
'Half year',
'Full year',
'Others',
];
export const InternshipCycleOptions = [emptyOption].concat(
InternshipCycleLabels.map((label) => ({
label,
value: label.replace(/\s+/g, '-').toLowerCase(),
})),
);

@ -1,7 +1,7 @@
import clsx from 'clsx';
import { JobType } from '@prisma/client';
import { JobTypeLabel } from './types';
import { JobTypeLabel } from '~/components/offers/constants';
type Props = Readonly<{
onChange: (jobType: JobType) => void;

@ -0,0 +1,8 @@
const NUM_YEARS = 5;
export const FutureYearsOptions = Array.from({ length: NUM_YEARS }, (_, i) => {
const year = new Date().getFullYear() + i;
return {
label: String(year),
value: year,
};
});

@ -1,78 +1,14 @@
import { EducationBackgroundType } from './types';
export const HOME_URL = '/offers';
export const emptyOption = '----';
export const JobTypeLabel = {
FULLTIME: 'Full-time',
INTERN: 'Internship',
};
export const internshipCycleOptions = [
{
label: 'Summer',
value: 'Summer',
},
{
label: 'Winter',
value: 'Winter',
},
{
label: 'Spring',
value: 'Spring',
},
{
label: 'Fall',
value: 'Fall',
},
{
label: 'Full year',
value: 'Full year',
},
];
export const yearOptions = [
{
label: '2021',
value: 2021,
},
{
label: '2022',
value: 2022,
},
{
label: '2023',
value: 2023,
},
{
label: '2024',
value: 2024,
},
];
export const educationLevelOptions = Object.entries(
EducationBackgroundType,
).map(([, value]) => ({
label: value,
value,
}));
export const educationFieldOptions = [
{
label: 'Computer Science',
value: 'Computer Science',
},
{
label: 'Information Security',
value: 'Information Security',
},
{
label: 'Information Systems',
value: 'Information Systems',
},
{
label: 'Business Analytics',
value: 'Business Analytics',
},
{
label: 'Data Science and Analytics',
value: 'Data Science and Analytics',
},
];
export const emptyOption = {
label: '',
value: '',
};
export enum FieldError {
NON_NEGATIVE_NUMBER = 'Please fill in a non-negative number in this field.',

@ -5,6 +5,7 @@ import {
} from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client';
import { JobTypeLabel } from '~/components/offers/constants';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
@ -33,32 +34,33 @@ export default function DashboardProfileCard({
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<h4 className="font-medium">
{getLabelForJobTitleType(title as JobTitleType)}
{getLabelForJobTitleType(title as JobTitleType)}{' '}
{jobType && <>({JobTypeLabel[jobType]})</>}
</h4>
<div className="mt-1 flex flex-col sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-4">
{company?.name && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<div className="mt-2 flex items-center text-sm text-slate-500">
<BuildingOfficeIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{company.name}
</div>
)}
{location && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<div className="mt-2 flex items-center text-sm text-slate-500">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{location.cityName}
</div>
)}
{level && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<div className="mt-2 flex items-center text-sm text-slate-500">
<ArrowTrendingUpIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{level}
</div>

@ -2,7 +2,7 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import type { ReactNode } from 'react';
import { HOME_URL } from '~/components/offers/types';
import { HOME_URL } from '../constants';
type LeftTextCardProps = Readonly<{
description: string;

@ -2,7 +2,7 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import type { ReactNode } from 'react';
import { HOME_URL } from '~/components/offers/types';
import { HOME_URL } from '../constants';
type RightTextCarddProps = Readonly<{
description: string;

@ -109,13 +109,13 @@ export default function OfferAnalysis({
return (
<div>
{isError && (
{isError ? (
<p className="m-10 text-center">
An error occurred while generating profile analysis.
</p>
)}
{isLoading && <Spinner className="m-10" display="block" size="lg" />}
{!isError && !isLoading && (
) : isLoading ? (
<Spinner className="m-10" display="block" size="lg" />
) : (
<div>
<Tabs
label="Result Navigation"

@ -9,10 +9,11 @@ import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency';
import { getCompanyDisplayText } from '~/utils/offers/string';
import { formatDate } from '~/utils/offers/time';
import { JobTypeLabel } from '../constants';
import ProfilePhotoHolder from '../profile/ProfilePhotoHolder';
import { JobTypeLabel } from '../types';
import type { AnalysisOffer } from '~/types/offers';
@ -32,15 +33,15 @@ export default function OfferProfileCard({
location,
title,
previousCompanies,
profileId,
},
}: OfferProfileCardProps) {
return (
// <a
// className="my-5 block rounded-lg bg-white p-4 px-8 shadow-md"
// href={`/offers/profile/${id}`}
// rel="noreferrer"
// target="_blank">
<div className="my-5 block rounded-lg bg-white p-4 px-8 shadow-lg">
<a
className="my-5 block rounded-lg border bg-white p-4 px-8 shadow-md"
href={`/offers/profile/${profileId}`}
rel="noreferrer"
target="_blank">
<div className="flex items-center gap-x-5">
<div>
<ProfilePhotoHolder size="sm" />
@ -69,7 +70,7 @@ export default function OfferProfileCard({
{getLabelForJobTitleType(title as JobTitleType)}{' '}
{`(${JobTypeLabel[jobType]})`}
</p>
<p>{`Company: ${company.name}, ${location}`}</p>
<p>{`Company: ${getCompanyDisplayText(company.name, location)}`}</p>
{level && <p>Level: {level}</p>}
</div>
<div className="col-span-1 row-span-3">
@ -81,6 +82,6 @@ export default function OfferProfileCard({
</p>
</div>
</div>
</div>
</a>
);
}

@ -1,5 +1,5 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import type { UseQueryResult } from 'react-query';
import { DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkIcon as BookmarkOutlineIcon } from '@heroicons/react/24/outline';
import { BookmarkIcon as BookmarkSolidIcon } from '@heroicons/react/24/solid';
@ -11,6 +11,7 @@ import { copyProfileLink, getProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
type OfferProfileSaveProps = Readonly<{
isSavedQuery: UseQueryResult<boolean>;
profileId: string;
token?: string;
}>;
@ -18,10 +19,10 @@ type OfferProfileSaveProps = Readonly<{
export default function OffersProfileSave({
profileId,
token,
isSavedQuery: { data: isSaved, isLoading },
}: OfferProfileSaveProps) {
const { showToast } = useToast();
const { event: gaEvent } = useGoogleAnalytics();
const [isSaved, setSaved] = useState(false);
const { data: session, status } = useSession();
const saveMutation = trpc.useMutation(
@ -47,15 +48,6 @@ export default function OffersProfileSave({
},
);
const isSavedQuery = trpc.useQuery(
[`offers.profile.isSaved`, { profileId, userId: session?.user?.id }],
{
onSuccess: (res) => {
setSaved(res);
},
},
);
const trpcContext = trpc.useContext();
const handleSave = () => {
if (status === 'unauthenticated') {
@ -93,7 +85,6 @@ export default function OffersProfileSave({
<div className="mt-4 flex gap-4">
<div className="grow">
<TextInput
disabled={true}
isLabelHidden={true}
label="Edit link"
value={getProfileLink(profileId, token)}
@ -126,9 +117,9 @@ export default function OffersProfileSave({
</p>
<div className="mt-6">
<Button
disabled={isSavedQuery.isLoading || isSaved}
disabled={isLoading || isSaved}
icon={isSaved ? BookmarkSolidIcon : BookmarkOutlineIcon}
isLoading={saveMutation.isLoading || isSavedQuery.isLoading}
isLoading={saveMutation.isLoading}
label={isSaved ? 'Added to account' : 'Add to your account'}
size="sm"
variant="secondary"

@ -28,6 +28,7 @@ export default function OffersSubmissionAnalysis({
allAnalysis={analysis}
isError={isError}
isLoading={isLoading}
isSubmission={true}
/>
)}
</div>

@ -4,11 +4,11 @@ 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 { Button, Spinner, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumb';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumbs';
import { Breadcrumbs } from '~/components/offers/Breadcrumbs';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type {
@ -102,8 +102,9 @@ export default function OffersSubmissionForm({
token: editToken,
});
const [isSubmitted, setIsSubmitted] = useState(false);
const { event: gaEvent } = useGoogleAnalytics();
const { event: gaEvent } = useGoogleAnalytics();
const { showToast } = useToast();
const router = useRouter();
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
@ -115,7 +116,7 @@ export default function OffersSubmissionForm({
const {
handleSubmit,
trigger,
formState: { isSubmitting, isSubmitSuccessful },
formState: { isSubmitting },
} = formMethods;
const generateAnalysisMutation = trpc.useMutation(
@ -123,6 +124,10 @@ export default function OffersSubmissionForm({
{
onError(error) {
console.error(error.message);
showToast({
title: 'Error generating analysis.',
variant: 'failure',
});
},
onSuccess() {
router.push(
@ -132,13 +137,7 @@ export default function OffersSubmissionForm({
},
);
const steps = [
<OfferDetailsForm
key={0}
defaultJobType={initialOfferProfileValues.offers[0].jobType}
/>,
<BackgroundForm key={1} />,
];
const steps = [<OfferDetailsForm key={0} />, <BackgroundForm key={1} />];
const breadcrumbSteps: Array<BreadcrumbStep> = [
{
@ -157,14 +156,14 @@ export default function OffersSubmissionForm({
},
];
const goToNextStep = async (currStep: number) => {
if (currStep === 0) {
const setStepWithValidation = async (nextStep: number) => {
if (nextStep === 1) {
const result = await trigger('offers');
if (!result) {
return;
}
}
setStep(step + 1);
setStep(nextStep);
};
const mutationpath =
@ -175,16 +174,30 @@ export default function OffersSubmissionForm({
const createOrUpdateMutation = trpc.useMutation([mutationpath], {
onError(error) {
console.error(error.message);
showToast({
title:
editProfileId && editToken
? 'Error updating offer profile.'
: 'Error creating offer profile.',
variant: 'failure',
});
},
onSuccess(data) {
setParams({ profileId: data.id, token: data.token });
setIsSubmitted(true);
showToast({
title:
editProfileId && editToken
? 'Offer profile updated successfully!'
: 'Offer profile created successfully!',
variant: 'success',
});
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result || isSubmitting || isSubmitSuccessful) {
if (!result || isSubmitting || createOrUpdateMutation.isLoading) {
return;
}
@ -263,14 +276,16 @@ export default function OffersSubmissionForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
return generateAnalysisMutation.isLoading ? (
<Spinner className="m-10" display="block" size="lg" />
) : (
<div ref={pageRef} className="w-full">
<div className="flex justify-center">
<div className="block w-full max-w-screen-md overflow-hidden rounded-lg sm:shadow-lg md:my-10">
<div className="flex justify-center bg-slate-100 px-4 py-4 sm:px-6 lg:px-8">
<Breadcrumbs
currentStep={step}
setStep={setStep}
setStep={setStepWithValidation}
steps={breadcrumbSteps}
/>
</div>
@ -288,7 +303,7 @@ export default function OffersSubmissionForm({
label="Next"
variant="primary"
onClick={() => {
goToNextStep(step);
setStepWithValidation(step + 1);
gaEvent({
action: 'offers.profile_submission_navigate_next',
category: 'submission',
@ -315,9 +330,16 @@ export default function OffersSubmissionForm({
}}
/>
<Button
disabled={isSubmitting || isSubmitSuccessful}
disabled={
isSubmitting ||
createOrUpdateMutation.isLoading ||
generateAnalysisMutation.isLoading ||
generateAnalysisMutation.isSuccess
}
icon={ArrowRightIcon}
isLoading={isSubmitting || isSubmitSuccessful}
isLoading={
isSubmitting || createOrUpdateMutation.isLoading
}
label="Submit"
type="submit"
variant="primary"

@ -2,12 +2,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
import { JobType } from '@prisma/client';
import { Collapsible, RadioList } from '@tih/ui';
import {
educationFieldOptions,
educationLevelOptions,
emptyOption,
FieldError,
} from '~/components/offers/constants';
import { FieldError } from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -20,6 +15,8 @@ import {
CURRENCY_OPTIONS,
} from '~/utils/offers/currency/CurrencyEnum';
import { EducationFieldOptions } from '../../EducationFields';
import { EducationLevelOptions } from '../../EducationLevels';
import FormRadioList from '../../forms/FormRadioList';
import FormSection from '../../forms/FormSection';
import FormSelect from '../../forms/FormSelect';
@ -134,6 +131,9 @@ function FullTimeJobFields() {
if (option) {
setValue('background.experiences.0.companyId', option.value);
setValue('background.experiences.0.companyName', option.label);
} else {
setValue('background.experiences.0.companyId', '');
setValue('background.experiences.0.companyName', '');
}
}}
/>
@ -279,6 +279,7 @@ function InternshipJobFields() {
})}
/>
<Collapsible label="Add more details">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<CitiesTypeahead
label="Location"
value={{
@ -296,6 +297,16 @@ function InternshipJobFields() {
}
}}
/>
<FormTextInput
errorMessage={experiencesField?.durationInMonths?.message}
label="Duration (months)"
type="number"
{...register(`background.experiences.0.durationInMonths`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/>
</div>
</Collapsible>
</>
);
@ -343,15 +354,13 @@ function EducationSection() {
<FormSelect
display="block"
label="Education Level"
options={educationLevelOptions}
placeholder={emptyOption}
options={EducationLevelOptions}
{...register(`background.educations.0.type`)}
/>
<FormSelect
display="block"
label="Field"
options={educationFieldOptions}
placeholder={emptyOption}
options={EducationFieldOptions}
{...register(`background.educations.0.field`)}
/>
</div>

@ -22,20 +22,16 @@ import {
defaultFullTimeOfferValues,
defaultInternshipOfferValues,
} from '../OffersSubmissionForm';
import {
emptyOption,
FieldError,
internshipCycleOptions,
yearOptions,
} from '../../constants';
import { FieldError, JobTypeLabel } from '../../constants';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormSection from '../../forms/FormSection';
import FormSelect from '../../forms/FormSelect';
import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../../forms/FormTextInput';
import { InternshipCycleOptions } from '../../InternshipCycles';
import JobTypeTabs from '../../JobTypeTabs';
import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../../types';
import { FutureYearsOptions } from '../../Years';
import {
Currency,
CURRENCY_OPTIONS,
@ -384,8 +380,7 @@ function InternshipOfferDetailsForm({
display="block"
errorMessage={offerFields?.offersIntern?.internshipCycle?.message}
label="Internship Cycle"
options={internshipCycleOptions}
placeholder={emptyOption}
options={InternshipCycleOptions}
required={true}
{...register(`offers.${index}.offersIntern.internshipCycle`, {
required: FieldError.REQUIRED,
@ -395,8 +390,7 @@ function InternshipOfferDetailsForm({
display="block"
errorMessage={offerFields?.offersIntern?.startYear?.message}
label="Internship Year"
options={yearOptions}
placeholder={emptyOption}
options={FutureYearsOptions}
required={true}
{...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.REQUIRED,
@ -522,14 +516,11 @@ function OfferDetailsFormArray({
);
}
type OfferDetailsFormProps = Readonly<{
defaultJobType?: JobType;
}>;
export default function OfferDetailsForm({
defaultJobType = JobType.FULLTIME,
}: OfferDetailsFormProps) {
const [jobType, setJobType] = useState(defaultJobType);
export default function OfferDetailsForm() {
const watchJobType = useWatch({
name: `offers.0.jobType`,
});
const [jobType, setJobType] = useState(watchJobType as JobType);
const [isDialogOpen, setDialogOpen] = useState(false);
const { control } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' });
@ -576,8 +567,8 @@ export default function OfferDetailsForm({
label="Switch"
variant="primary"
onClick={() => {
toggleJobType();
setDialogOpen(false);
toggleJobType();
}}
/>
}

@ -13,12 +13,12 @@ export default function EducationCard({
education: { type, field, startDate, endDate, school },
}: Props) {
return (
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md">
<div className="flex justify-between px-8">
<div className="flex flex-col">
<div className="flex flex-row">
<div className="block rounded-lg border border-slate-200 bg-white p-4 text-sm ">
<div className="flex justify-between">
<div>
<div className="flex items-center">
<LightBulbIcon className="mr-1 h-5" />
<span className="ml-1 font-bold">
<span className="text-semibold ml-1">
{field ? `${type ?? 'N/A'}, ${field}` : type ?? `N/A`}
</span>
</div>

@ -1,13 +1,15 @@
import {
BuildingOffice2Icon,
ChatBubbleBottomCenterTextIcon,
CurrencyDollarIcon,
ScaleIcon,
} from '@heroicons/react/24/outline';
import { HorizontalDivider } from '@tih/ui';
ArrowTrendingUpIcon,
BuildingOfficeIcon,
MapPinIcon,
} from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client';
import { JobTypeLabel } from '~/components/offers/constants';
import type { OfferDisplayData } from '~/components/offers/types';
import { JobTypeLabel } from '~/components/offers/types';
import { getLocationDisplayText } from '~/utils/offers/string';
import { getDurationDisplayText } from '~/utils/offers/time';
type Props = Readonly<{
offer: OfferDisplayData;
@ -33,34 +35,56 @@ export default function OfferCard({
}: Props) {
function UpperSection() {
return (
<div className="flex justify-between px-8">
<div className="flex flex-col">
<div className="flex flex-row">
<span>
<BuildingOffice2Icon className="mr-3 h-5" />
</span>
<span className="font-bold">
{location ? `${companyName}, ${location.cityName}` : companyName}
</span>
</div>
<div className="ml-8 flex flex-row">
<p>
{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}{' '}
{jobType && `(${JobTypeLabel[jobType]})`}
</p>
<div className="px-4 py-5 sm:px-6">
<div className="flex justify-between">
<div>
<h3 className="text-lg font-medium leading-6 text-slate-900">
{jobTitle} {jobType && <>({JobTypeLabel[jobType]})</>}
</h3>
<div className="mt-1 flex flex-row flex-wrap space-x-4 sm:mt-0">
{companyName && (
<div className="mt-2 flex items-center text-sm text-slate-500">
<BuildingOfficeIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{companyName}
</div>
)}
{location && (
<div className="mt-2 flex items-center text-sm text-slate-500">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{getLocationDisplayText(location)}
</div>
)}
{jobLevel && (
<div className="mt-2 flex items-center text-sm text-slate-500">
<ArrowTrendingUpIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{jobLevel}
</div>
)}
</div>
</div>
<div className="space-y-2">
{!duration && receivedMonth && (
<div className="font-light text-slate-400">
<div className="text-sm text-slate-500">
<p>{receivedMonth}</p>
</div>
)}
{duration && (
<div className="font-light text-slate-400">
<p>{`${duration} months`}</p>
{!!duration && (
<div className="text-sm text-slate-500">
<p>{getDurationDisplayText(duration)}</p>
</div>
)}
</div>
</div>
</div>
);
}
@ -75,60 +99,72 @@ export default function OfferCard({
}
return (
<>
<HorizontalDivider />
<div className="px-8">
<div className="flex flex-col py-2">
{(totalCompensation || monthlySalary) && (
<div className="flex flex-row">
<span>
<CurrencyDollarIcon className="mr-3 h-5" />
</span>
<span>
<p>
{totalCompensation && `TC: ${totalCompensation}`}
{monthlySalary && `Monthly Salary: ${monthlySalary}`}
</p>
</span>
<div className="border-t border-slate-200 px-4 py-5 sm:px-6">
<dl className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-4">
{jobType === JobType.FULLTIME
? totalCompensation && (
<div className="col-span-1">
<dt className="text-sm font-medium text-slate-500">
Total Compensation
</dt>
<dd className="mt-1 text-sm text-slate-900">
{totalCompensation}
</dd>
</div>
)
: monthlySalary && (
<div className="col-span-1">
<dt className="text-sm font-medium text-slate-500">
Monthly Salary
</dt>
<dd className="mt-1 text-sm text-slate-900">
{monthlySalary}
</dd>
</div>
)}
{(base || stocks || bonus) && totalCompensation && (
<div className="ml-8 flex flex-row font-light">
<p>
Base / year: {base ?? 'N/A'} Stocks / year:{' '}
{stocks ?? 'N/A'} Bonus / year: {bonus ?? 'N/A'}
</p>
{base && (
<div className="col-span-1">
<dt className="text-sm font-medium text-slate-500">
Base Salary
</dt>
<dd className="mt-1 text-sm text-slate-900">{base}</dd>
</div>
)}
{stocks && (
<div className="col-span-1">
<dt className="text-sm font-medium text-slate-500">Stocks</dt>
<dd className="mt-1 text-sm text-slate-900">{stocks}</dd>
</div>
{negotiationStrategy && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<span>
<ScaleIcon className="h-5 w-5" />
</span>
<span className="overflow-wrap ml-2">
"{negotiationStrategy}"
</span>
)}
{bonus && (
<div className="col-span-1">
<dt className="text-sm font-medium text-slate-500">Bonus</dt>
<dd className="mt-1 text-sm text-slate-900">{bonus}</dd>
</div>
)}
{negotiationStrategy && (
<div className="col-span-2">
<dt className="text-sm font-medium text-slate-500">
Negotiation Strategy
</dt>
<dd className="mt-1 text-sm text-slate-900">
{negotiationStrategy}
</dd>
</div>
)}
{otherComment && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<span>
<ChatBubbleBottomCenterTextIcon className="h-5 w-5" />
</span>
<span className="overflow-wrap ml-2">"{otherComment}"</span>
</div>
<div className="col-span-2">
<dt className="text-sm font-medium text-slate-500">Others</dt>
<dd className="mt-1 text-sm text-slate-900">{otherComment}</dd>
</div>
)}
</dl>
</div>
</>
);
}
return (
<div className="mx-8 my-4 block rounded-md border-b border-gray-300 bg-white py-4">
<div className="block rounded-lg border border-slate-200 bg-white">
<UpperSection />
<BottomSection />
</div>

@ -1,13 +1,7 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline';
import {
Button,
HorizontalDivider,
Spinner,
TextArea,
useToast,
} from '@tih/ui';
import { Button, Spinner, TextArea, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
@ -110,10 +104,10 @@ export default function ProfileComments({
);
}
return (
<div className="bh-white h-fit px-4 lg:h-[calc(100vh-4.5rem)] lg:overflow-y-auto">
<div className="bg-white pt-4 lg:sticky lg:top-0">
<div className="bh-white h-fit lg:h-[calc(100vh-4.5rem)] lg:overflow-y-auto">
<div className="border-b border-slate-200 bg-white p-4 lg:sticky lg:top-0">
<div className="flex justify-end">
<div className="grid w-fit grid-cols-1 space-y-2 md:grid-cols-2 md:grid-cols-2 md:space-y-0 md:space-x-4">
<div className="grid w-fit grid-cols-1 space-y-2 md:grid-cols-2 md:space-y-0 md:space-x-4">
<div className="col-span-1 flex justify-end">
{isEditable && (
<Tooltip tooltipContent="Copy this link to edit your profile later">
@ -169,7 +163,8 @@ export default function ProfileComments({
</div>
</div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
<div className="space-y-4">
<h2 className="text-2xl font-bold">Discussions</h2>
{isEditable || session?.user?.name ? (
<div>
<TextArea
@ -198,11 +193,9 @@ export default function ProfileComments({
/>
</div>
</div>
<HorizontalDivider />
</div>
) : (
<Button
className="mb-5"
display="block"
href={loginPageHref()}
label="Sign in to join discussion"
@ -210,16 +203,20 @@ export default function ProfileComments({
/>
)}
</div>
<div className="w-full">
</div>
<section className="w-full px-4">
<ul className="divide-y divide-slate-200" role="list">
{replies?.map((reply: Reply) => (
<li key={reply.id} className="py-6">
<ExpandableCommentCard
key={reply.id}
comment={reply}
profileId={profileId}
token={isEditable ? token : undefined}
/>
</li>
))}
</div>
</ul>
</section>
</div>
);
}

@ -25,19 +25,19 @@ type ProfileOffersProps = Readonly<{
}>;
function ProfileOffers({ offers }: ProfileOffersProps) {
if (offers.length !== 0) {
if (offers.length === 0) {
return (
<>
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>
<div className="p-4">
<p className="font-semibold">No offers are attached.</p>
</div>
);
}
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 className="space-y-4 p-4">
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</div>
);
}
@ -49,33 +49,37 @@ type ProfileBackgroundProps = Readonly<{
function ProfileBackground({ background }: ProfileBackgroundProps) {
if (!background?.experiences?.length && !background?.educations?.length) {
return (
<div className="mx-8 my-4">
<div className="p-4">
<p>No background information available.</p>
</div>
);
}
return (
<>
<div className="space-y-8 p-4">
{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 className="space-y-2">
<div className="flex items-center space-x-2 text-slate-500">
<BriefcaseIcon className="h-5" />
<h3 className="text-sm font-semibold uppercase tracking-wide">
Work Experience
</h3>
</div>
<OfferCard offer={background.experiences[0]} />
</>
</div>
)}
{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 className="space-y-2">
<div className="flex items-center space-x-2 text-slate-500">
<AcademicCapIcon className="h-5" />
<h3 className="text-sm font-semibold uppercase tracking-wide">
Education
</h3>
</div>
<EducationCard education={background.educations[0]} />
</>
</div>
)}
</>
</div>
);
}
@ -114,7 +118,7 @@ function ProfileAnalysis({
}
return (
<div className="mx-8 my-4">
<div className="space-y-4 p-4">
{!analysis ? (
<p>No analysis available.</p>
) : (
@ -165,12 +169,15 @@ export default function ProfileDetails({
</div>
);
}
if (selectedTab === ProfileDetailTab.OFFERS) {
return <ProfileOffers offers={offers} />;
}
if (selectedTab === ProfileDetailTab.BACKGROUND) {
return <ProfileBackground background={background} />;
}
if (selectedTab === ProfileDetailTab.ANALYSIS) {
return (
<ProfileAnalysis

@ -13,10 +13,10 @@ import { Button, Dialog, Spinner, Tabs, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type { ProfileDetailTab } from '~/components/offers/constants';
import { JobTypeLabel } from '~/components/offers/constants';
import { profileDetailTabs } from '~/components/offers/constants';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundDisplayData } from '~/components/offers/types';
import { JobTypeLabel } from '~/components/offers/types';
import Tooltip from '~/components/offers/util/Tooltip';
import { getProfileEditPath } from '~/utils/offers/link';
@ -233,20 +233,20 @@ export default function ProfileHeader({
const { experiences, totalYoe, specificYoes, profileName } = background;
return (
<div className="grid-rows-2 bg-white p-4">
<div className="flex grid grid-cols-5 md:grid-cols-7">
<div className="jsutify-start col-span-5 flex">
<div className="ml-0 mr-2 mt-2 h-16 w-16 md:mx-4">
<div className="grid-rows-2 bg-white">
<div className="grid grid-cols-5 p-4 md:grid-cols-7">
<div className="col-span-5 flex justify-start space-x-4">
<div className="mt-2 h-16 w-16">
<ProfilePhotoHolder />
</div>
<div>
<div className="space-y-1">
<h2 className="flex text-2xl font-bold">
{profileName ?? 'anonymous'}
</h2>
{(experiences[0]?.companyName ||
experiences[0]?.jobLevel ||
experiences[0]?.jobTitle) && (
<div className="flex flex-row text-slate-600">
<div className="flex items-center text-sm text-slate-600">
<span>
<BuildingOffice2Icon className="mr-2.5 h-5 w-5" />
</span>
@ -262,7 +262,7 @@ export default function ProfileHeader({
</p>
</div>
)}
<div className="flex flex-row text-slate-600">
<div className="flex items-center text-sm text-slate-600">
<CalendarDaysIcon className="mr-2.5 h-5" />
<p>
<span className="mr-2 font-bold">YOE:</span>
@ -286,7 +286,7 @@ export default function ProfileHeader({
</div>
)}
</div>
<div className="mt-4">
<div className="border-t border-slate-200 p-4">
<Tabs
label="Profile Detail Navigation"
tabs={profileDetailTabs}

@ -1,11 +1,11 @@
type ProfilePhotoHolderProps = Readonly<{
size?: 'lg' | 'sm';
size?: 'lg' | 'sm' | 'xs';
}>;
export default function ProfilePhotoHolder({
size = 'lg',
}: ProfilePhotoHolderProps) {
const sizeMap = { lg: '16', sm: '12' };
const sizeMap = { lg: '16', sm: '12', xs: '10' };
return (
<span
className={`inline-block h-${sizeMap[size]} w-${sizeMap[size]} overflow-hidden rounded-full bg-slate-100`}>

@ -1,14 +1,11 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import {
ChatBubbleBottomCenterIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Dialog, HorizontalDivider, TextArea, useToast } from '@tih/ui';
import { Button, Dialog, TextArea, useToast } from '@tih/ui';
import { timeSinceNow } from '~/utils/offers/time';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import { trpc } from '../../../../utils/trpc';
import { timeSinceNow } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { Reply } from '~/types/offers';
@ -125,48 +122,63 @@ export default function CommentCard({
}
return (
<>
<div className="flex pl-2">
<div className="flex w-full flex-col">
<div className="flex flex-row font-bold">
<div className="flex space-x-3">
<div className="flex-shrink-0">
{user?.image ? (
<img
alt={user?.name ?? user?.email ?? 'Unknown user'}
className="h-10 w-10 rounded-full"
src={user?.image}
/>
) : (
<ProfilePhotoHolder size="xs" />
)}
</div>
<div className="w-full">
<div className="text-sm">
<p className="font-medium text-slate-900">
{user?.name ?? 'unknown user'}
</p>
</div>
<div className="mt-1 text-sm text-slate-700">
<p className="break-all">{message}</p>
</div>
<div className="mt-2 mb-2 flex flex-row ">{message}</div>
<div className="flex flex-row items-center justify-start space-x-4 ">
<div className="flex flex-col text-sm font-light text-slate-400">{`${timeSinceNow(
createdAt,
)} ago`}</div>
<div className="mt-2 space-x-2 text-xs">
<span className="font-medium text-slate-500">
{timeSinceNow(createdAt)} ago
</span>{' '}
{replyLength > 0 && (
<div
className="text-primary-600 flex cursor-pointer flex-col text-sm hover:underline"
<>
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button
className="font-medium text-slate-900"
type="button"
onClick={handleExpanded}>
{isExpanded ? `Hide replies` : `View replies (${replyLength})`}
</div>
</button>
</>
)}
{!disableReply && (
<div className="flex flex-col">
<Button
icon={ChatBubbleBottomCenterIcon}
isLabelHidden={true}
label="Reply"
size="sm"
variant="tertiary"
onClick={() => setIsReplying(!isReplying)}
/>
</div>
<>
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button
className="font-medium text-slate-900"
type="button"
onClick={() => setIsReplying(!isReplying)}>
Reply
</button>
</>
)}
{deletable && (
<>
<Button
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button
className="font-medium text-slate-900"
disabled={deleteCommentMutation.isLoading}
icon={TrashIcon}
isLabelHidden={true}
isLoading={deleteCommentMutation.isLoading}
label="Delete"
size="sm"
variant="tertiary"
onClick={() => setIsDialogOpen(true)}
/>
type="button"
onClick={() => setIsDialogOpen(true)}>
{deleteCommentMutation.isLoading ? 'Deleting...' : 'Delete'}
</button>
{isDialogOpen && (
<Dialog
isShown={isDialogOpen}
@ -198,10 +210,17 @@ export default function CommentCard({
)}
</div>
{!disableReply && isReplying && (
<div className="mt-2 mr-2">
<div className="mt-4 mr-2">
<form
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
handleReply();
}}>
<TextArea
autoFocus={true}
isLabelHidden={true}
label="Comment"
label="Reply to comment"
placeholder="Type your reply here"
resize="none"
value={currentReply}
@ -225,11 +244,10 @@ export default function CommentCard({
/>
</div>
</div>
</form>
</div>
)}
</div>
</div>
<HorizontalDivider />
</>
);
}

@ -26,18 +26,20 @@ export default function ExpandableCommentCard({
replyLength={comment.replies?.length ?? 0}
token={token}
/>
{comment.replies && (
<div className="pl-8">
{isExpanded &&
comment.replies.map((reply) => (
{comment.replies && comment.replies.length > 0 && isExpanded && (
<div className="pt-4">
<ul className="space-y-4 pl-14" role="list">
{comment.replies.map((reply) => (
<li key={reply.id}>
<CommentCard
key={reply.id}
comment={reply}
disableReply={true}
profileId={profileId}
token={token}
/>
</li>
))}
</ul>
</div>
)}
</div>

@ -1,14 +1,14 @@
import clsx from 'clsx';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { JobType } from '@prisma/client';
import { DropdownMenu, Spinner } from '@tih/ui';
import { DropdownMenu, Spinner, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import type { OfferTableSortByType } from '~/components/offers/table/types';
import {
OfferTableFilterOptions,
OfferTableSortBy,
OfferTableYoeOptions,
YOE_CATEGORY,
YOE_CATEGORY_PARAM,
@ -16,6 +16,7 @@ import {
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
import { trpc } from '~/utils/trpc';
import OffersRow from './OffersRow';
@ -25,16 +26,17 @@ import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_IN_PAGE = 10;
export type OffersTableProps = Readonly<{
companyFilter: string;
companyName?: string;
countryFilter: string;
jobTitleFilter: string;
}>;
export default function OffersTable({
countryFilter,
companyName,
companyFilter,
jobTitleFilter,
}: OffersTableProps) {
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
const [selectedYoe, setSelectedYoe] = useState('');
const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME);
const [pagination, setPagination] = useState<Paging>({
currentPage: 0,
@ -42,16 +44,43 @@ export default function OffersTable({
numOfPages: 0,
totalItems: 0,
});
const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
const [selectedFilter, setSelectedFilter] = useState(
OfferTableFilterOptions[0].value,
);
const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter();
const { yoeCategory = '' } = router.query;
const [isLoading, setIsLoading] = useState(true);
const [
selectedYoeCategory,
setSelectedYoeCategory,
isYoeCategoryInitialized,
] = useSearchParamSingle<keyof typeof YOE_CATEGORY_PARAM>('yoeCategory');
const [selectedSortBy, setSelectedSortBy, isSortByInitialized] =
useSearchParamSingle<OfferTableSortByType>('sortBy');
const areFilterParamsInitialized = useMemo(() => {
return isYoeCategoryInitialized && isSortByInitialized;
}, [isYoeCategoryInitialized, isSortByInitialized]);
const { pathname } = router;
useEffect(() => {
if (areFilterParamsInitialized) {
router.replace(
{
pathname,
query: {
companyId: companyFilter,
companyName,
jobTitleId: jobTitleFilter,
sortBy: selectedSortBy,
yoeCategory: selectedYoeCategory,
},
},
undefined,
{ shallow: true },
);
setPagination({
currentPage: 0,
numOfItems: 0,
@ -59,13 +88,20 @@ export default function OffersTable({
totalItems: 0,
});
setIsLoading(true);
}, [selectedYoe, currency, countryFilter, companyFilter, jobTitleFilter]);
useEffect(() => {
setSelectedYoe(yoeCategory as YOE_CATEGORY);
event?.preventDefault();
}, [yoeCategory]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
areFilterParamsInitialized,
currency,
countryFilter,
companyFilter,
jobTitleFilter,
selectedSortBy,
selectedYoeCategory,
pathname,
]);
const { showToast } = useToast();
trpc.useQuery(
[
'offers.list',
@ -75,14 +111,19 @@ export default function OffersTable({
currency,
limit: NUMBER_OF_OFFERS_IN_PAGE,
offset: pagination.currentPage,
sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
sortBy: selectedSortBy ?? '-monthYearReceived',
title: jobTitleFilter,
yoeCategory: YOE_CATEGORY_PARAM[yoeCategory as string] ?? undefined,
yoeCategory: selectedYoeCategory
? YOE_CATEGORY_PARAM[selectedYoeCategory as string]
: undefined,
},
],
{
onError: (err) => {
alert(err);
onError: () => {
showToast({
title: 'Error loading the page.',
variant: 'failure',
});
},
onSuccess: (response: GetOffersResponse) => {
setOffers(response.data);
@ -95,44 +136,26 @@ export default function OffersTable({
function renderFilters() {
return (
<div className="flex items-center justify-between p-4 text-sm sm:grid-cols-4 md:text-base">
<div className="flex items-center justify-between p-4 text-xs text-slate-700 sm:grid-cols-4 sm:text-sm md:text-base">
<DropdownMenu
align="start"
label={
OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoe,
({ value: itemValue }) => itemValue === selectedYoeCategory,
).length > 0
? OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoeCategory,
)[0].label
: OfferTableYoeOptions[0].label
}
size="inherit">
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item
key={value}
isSelected={value === selectedYoe}
isSelected={value === selectedYoeCategory}
label={itemLabel}
onClick={() => {
if (value === '') {
router.replace(
{
pathname: router.pathname,
query: undefined,
},
undefined,
// Do not refresh the page
{ shallow: true },
);
} else {
const params = new URLSearchParams({
['yoeCategory']: value,
});
router.replace(
{
pathname: location.pathname,
search: params.toString(),
},
undefined,
{ shallow: true },
);
}
setSelectedYoeCategory(value);
gaEvent({
action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement',
@ -157,17 +180,21 @@ export default function OffersTable({
align="end"
label={
OfferTableFilterOptions.filter(
({ value: itemValue }) => itemValue === selectedFilter,
({ value: itemValue }) => itemValue === selectedSortBy,
).length > 0
? OfferTableFilterOptions.filter(
({ value: itemValue }) => itemValue === selectedSortBy,
)[0].label
: OfferTableFilterOptions[0].label
}
size="inherit">
{OfferTableFilterOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item
key={value}
isSelected={value === selectedFilter}
isSelected={value === selectedSortBy}
label={itemLabel}
onClick={() => {
setSelectedFilter(value);
setSelectedSortBy(value as OfferTableSortByType);
}}
/>
))}
@ -183,7 +210,9 @@ export default function OffersTable({
'Company',
'Title',
'YOE',
selectedYoe === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'Annual TC',
selectedYoeCategory === YOE_CATEGORY.INTERN
? 'Monthly Salary'
: 'Annual TC',
'Date Offered',
'Actions',
];
@ -200,13 +229,13 @@ export default function OffersTable({
}
return (
<thead className="text-slate-700">
<thead className="font-semibold">
<tr className="divide-x divide-slate-200">
{columns.map((header, index) => (
<th
key={header}
className={clsx(
'bg-slate-100 py-3 px-4',
'whitespace-nowrap bg-slate-100 py-3 px-4',
// Make last column sticky.
index === columns.length - 1 &&
'sticky right-0 drop-shadow md:drop-shadow-none',
@ -235,7 +264,7 @@ export default function OffersTable({
</div>
) : (
<div className="overflow-x-auto text-slate-600">
<table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left">
<table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left text-xs text-slate-700 sm:text-sm md:text-base">
{renderHeader()}
<tbody>
{offers.map((offer) => (

@ -26,7 +26,7 @@ export default function OffersTablePagination({
<div className="mb-2 text-sm font-normal text-slate-500 md:mb-0">
Showing
<span className="font-semibold text-slate-900">
{` ${startNumber} - ${endNumber} `}
{` ${endNumber > 0 ? startNumber : 0} - ${endNumber} `}
</span>
{`of `}
<span className="font-semibold text-slate-900">

@ -36,25 +36,24 @@ export const OfferTableYoeOptions = [
export const OfferTableFilterOptions = [
{
label: 'Latest Submitted',
value: 'latest-submitted',
value: '-monthYearReceived',
},
{
label: 'Highest Salary',
value: 'highest-salary',
value: '-totalCompensation',
},
{
label: 'Highest YOE first',
value: 'highest-yoe-first',
value: '-totalYoe',
},
{
label: 'Lowest YOE first',
value: 'lowest-yoe-first',
value: '+totalYoe',
},
];
export const OfferTableSortBy: Record<string, string> = {
'highest-salary': '-totalCompensation',
'highest-yoe-first': '-totalYoe',
'latest-submitted': '-monthYearReceived',
'lowest-yoe-first': '+totalYoe',
};
export type OfferTableSortByType =
| '-monthYearReceived'
| '-totalCompensation'
| '-totalYoe'
| '+totalYoe';

@ -4,27 +4,6 @@ import type { MonthYear } from '~/components/shared/MonthYearPicker';
import type { Location } from '~/types/offers';
export const HOME_URL = '/offers';
/*
* Offer Profile
*/
export const JobTypeLabel = {
FULLTIME: 'Full-time',
INTERN: 'Internship',
};
export enum EducationBackgroundType {
Bachelor = 'Bachelor',
Diploma = 'Diploma',
Masters = 'Masters',
PhD = 'PhD',
Professional = 'Professional',
Secondary = 'Secondary',
SelfTaught = 'Self-taught',
}
export type OffersProfilePostData = {
background: BackgroundPostData;
id?: string;

@ -0,0 +1,29 @@
export default function ResumeSubmissionGuidelines() {
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-5">
<div className="prose text-sm">
<h2 className="mt-0 text-xl font-medium">Submission Guidelines</h2>
<p>
Before submitting your resume for review, please review and
acknowledge our{' '}
<span className="font-medium">submission guidelines</span> stated
below.
</p>
<ul>
<li>
Ensure that you do not divulge any of your{' '}
<span className="font-medium">personal particulars</span>.
</li>
<li>
Ensure that you do not divulge any{' '}
<span className="font-medium">
company's proprietary and confidential information
</span>
.
</li>
<li>Proofread your resumes for grammatical/spelling errors.</li>
</ul>
</div>
</div>
);
}

@ -1,29 +0,0 @@
export default function SubmissionGuidelines() {
return (
<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 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>.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any{' '}
<span className="font-bold">
company's proprietary and confidential information
</span>
.
</p>
<p>
<span className="text-lg font-bold"> </span>
Proof-read your resumes to look for grammatical/spelling errors.
</p>
</div>
);
}

@ -87,6 +87,7 @@ const analysisOfferDtoMapper = (
background?.experiences
?.filter((exp) => exp.company != null)
.map((exp) => exp.company?.name ?? '') ?? [],
profileId: offer.profileId,
profileName,
title:
offer.jobType === JobType.FULLTIME
@ -95,7 +96,10 @@ const analysisOfferDtoMapper = (
totalYoe: background?.totalYoe ?? -1,
};
if (offer.offersFullTime?.totalCompensation) {
if (
offer.offersFullTime?.totalCompensation &&
offer.jobType === JobType.FULLTIME
) {
analysisOfferDto.income.value =
offer.offersFullTime.totalCompensation.value;
analysisOfferDto.income.currency =
@ -105,7 +109,10 @@ const analysisOfferDtoMapper = (
offer.offersFullTime.totalCompensation.baseValue;
analysisOfferDto.income.baseCurrency =
offer.offersFullTime.totalCompensation.baseCurrency;
} else if (offer.offersIntern?.monthlySalary) {
} else if (
offer.offersIntern?.monthlySalary &&
offer.jobType === JobType.INTERN
) {
analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value;
analysisOfferDto.income.currency =
offer.offersIntern.monthlySalary.currency;
@ -126,7 +133,14 @@ const analysisOfferDtoMapper = (
const analysisUnitDtoMapper = (
analysisUnit: OffersAnalysisUnit & {
analysedOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
};
topSimilarOffers: Array<
OffersOffer & {
company: Company;
@ -153,15 +167,51 @@ const analysisUnitDtoMapper = (
>;
},
) => {
const analysisDto: AnalysisUnit = {
companyName: analysisUnit.company.name,
const { analysedOffer } = analysisUnit;
const { jobType } = analysedOffer;
const analysisUnitDto: AnalysisUnit = {
companyId: analysedOffer.companyId,
companyName: analysedOffer.company.name,
income: valuationDtoMapper({
baseCurrency: '',
baseValue: -1,
currency: '',
id: '',
value: -1,
}),
jobType,
noOfOffers: analysisUnit.noOfSimilarOffers,
percentile: analysisUnit.percentile,
title:
jobType === JobType.FULLTIME && analysedOffer.offersFullTime != null
? analysedOffer.offersFullTime.title
: jobType === JobType.INTERN && analysedOffer.offersIntern != null
? analysedOffer.offersIntern.title
: '',
topPercentileOffers: analysisUnit.topSimilarOffers.map((offer) =>
analysisOfferDtoMapper(offer),
),
totalYoe: analysisUnit.analysedOffer.profile.background?.totalYoe ?? 0,
};
return analysisDto;
if (
analysedOffer.offersFullTime &&
analysedOffer.jobType === JobType.FULLTIME
) {
analysisUnitDto.income = valuationDtoMapper(
analysedOffer.offersFullTime.totalCompensation,
);
} else if (
analysedOffer.offersIntern &&
analysedOffer.jobType === JobType.INTERN
) {
analysisUnitDto.income = valuationDtoMapper(
analysedOffer.offersIntern.monthlySalary,
);
}
return analysisUnitDto;
};
const analysisHighestOfferDtoMapper = (
@ -190,7 +240,16 @@ export const profileAnalysisDtoMapper = (
| (OffersAnalysis & {
companyAnalysis: Array<
OffersAnalysisUnit & {
analysedOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topSimilarOffers: Array<
OffersOffer & {
company: Company;
@ -220,7 +279,16 @@ export const profileAnalysisDtoMapper = (
}
>;
overallAnalysis: OffersAnalysisUnit & {
analysedOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topSimilarOffers: Array<
OffersOffer & {
company: Company;
@ -369,11 +437,13 @@ export const experienceDtoMapper = (
experience.location != null
? locationDtoMapper(experience.location)
: null,
monthlySalary: experience.monthlySalary
monthlySalary:
experience.monthlySalary && experience.jobType === JobType.INTERN
? valuationDtoMapper(experience.monthlySalary)
: null,
title: experience.title,
totalCompensation: experience.totalCompensation
totalCompensation:
experience.totalCompensation && experience.jobType === JobType.FULLTIME
? valuationDtoMapper(experience.totalCompensation)
: null,
};
@ -460,11 +530,11 @@ export const profileOfferDtoMapper = (
location: locationDtoMapper(offer.location),
monthYearReceived: offer.monthYearReceived,
negotiationStrategy: offer.negotiationStrategy,
offersFullTime: offer.offersFullTime,
offersIntern: offer.offersIntern,
offersFullTime: null,
offersIntern: null,
};
if (offer.offersFullTime) {
if (offer.offersFullTime && offer.jobType === JobType.FULLTIME) {
profileOfferDto.offersFullTime = {
baseSalary:
offer.offersFullTime?.baseSalary != null
@ -485,7 +555,7 @@ export const profileOfferDtoMapper = (
offer.offersFullTime.totalCompensation,
),
};
} else if (offer.offersIntern) {
} else if (offer.offersIntern && offer.jobType === JobType.INTERN) {
profileOfferDto.offersIntern = {
id: offer.offersIntern.id,
internshipCycle: offer.offersIntern.internshipCycle,
@ -504,7 +574,18 @@ export const profileDtoMapper = (
| (OffersAnalysis & {
companyAnalysis: Array<
OffersAnalysisUnit & {
analysedOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background: OffersBackground | null;
};
};
topSimilarOffers: Array<
OffersOffer & {
company: Company;
@ -536,7 +617,16 @@ export const profileDtoMapper = (
}
>;
overallAnalysis: OffersAnalysisUnit & {
analysedOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topSimilarOffers: Array<
OffersOffer & {
company: Company;
@ -701,30 +791,27 @@ export const dashboardOfferDtoMapper = (
totalYoe: offer.profile.background?.totalYoe ?? -1,
};
if (offer.offersFullTime) {
if (offer.offersFullTime && offer.jobType === JobType.FULLTIME) {
dashboardOfferDto.income = valuationDtoMapper(
offer.offersFullTime.totalCompensation,
);
if (offer.offersFullTime.baseSalary) {
dashboardOfferDto.baseSalary = valuationDtoMapper(
offer.offersFullTime.baseSalary
offer.offersFullTime.baseSalary,
);
}
if (offer.offersFullTime.bonus) {
dashboardOfferDto.bonus = valuationDtoMapper(
offer.offersFullTime.bonus
);
dashboardOfferDto.bonus = valuationDtoMapper(offer.offersFullTime.bonus);
}
if (offer.offersFullTime.stocks) {
dashboardOfferDto.stocks = valuationDtoMapper(
offer.offersFullTime.stocks
offer.offersFullTime.stocks,
);
}
} else if (offer.offersIntern) {
} else if (offer.offersIntern && offer.jobType === JobType.INTERN) {
dashboardOfferDto.income = valuationDtoMapper(
offer.offersIntern.monthlySalary,
);
@ -736,12 +823,12 @@ export const dashboardOfferDtoMapper = (
export const getOffersResponseMapper = (
data: Array<DashboardOffer>,
paging: Paging,
jobType: JobType
jobType: JobType,
) => {
const getOffersResponse: GetOffersResponse = {
data,
jobType,
paging
paging,
};
return getOffersResponse;
};
@ -817,7 +904,10 @@ const userProfileOfferDtoMapper = (
: offer.offersIntern?.title ?? '',
};
if (offer.offersFullTime?.totalCompensation) {
if (
offer.offersFullTime?.totalCompensation &&
offer.jobType === JobType.FULLTIME
) {
mappedOffer.income.value = offer.offersFullTime.totalCompensation.value;
mappedOffer.income.currency =
offer.offersFullTime.totalCompensation.currency;
@ -826,7 +916,10 @@ const userProfileOfferDtoMapper = (
offer.offersFullTime.totalCompensation.baseValue;
mappedOffer.income.baseCurrency =
offer.offersFullTime.totalCompensation.baseCurrency;
} else if (offer.offersIntern?.monthlySalary) {
} else if (
offer.offersIntern?.monthlySalary &&
offer.jobType === JobType.INTERN
) {
mappedOffer.income.value = offer.offersIntern.monthlySalary.value;
mappedOffer.income.currency = offer.offersIntern.monthlySalary.currency;
mappedOffer.income.id = offer.offersIntern.monthlySalary.id;

@ -8,12 +8,12 @@ import {
UsersIcon,
} from '@heroicons/react/24/outline';
import { HOME_URL } from '~/components/offers/constants';
import offersAnalysis from '~/components/offers/features/images/offers-analysis.png';
import offersBrowse from '~/components/offers/features/images/offers-browse.png';
import offersProfile from '~/components/offers/features/images/offers-profile.png';
import LeftTextCard from '~/components/offers/features/LeftTextCard';
import RightTextCard from '~/components/offers/features/RightTextCard';
import { HOME_URL } from '~/components/offers/types';
const features = [
{

@ -9,14 +9,23 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import Container from '~/components/shared/Container';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { JobTitleLabels } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
export default function OffersHomePage() {
const [jobTitleFilter, setJobTitleFilter] = useState<JobTitleType | ''>('');
const [companyFilter, setCompanyFilter] = useState('');
const [countryFilter, setCountryFilter] = useState('');
const { event: gaEvent } = useGoogleAnalytics();
const [selectedCompanyName, setSelectedCompanyName] =
useSearchParamSingle('companyName');
const [selectedCompanyId, setSelectedCompanyId] =
useSearchParamSingle('companyId');
const [selectedJobTitleId, setSelectedJobTitleId] =
useSearchParamSingle<JobTitleType | null>('jobTitleId');
return (
<main className="flex-1 overflow-y-auto">
<Banner size="sm">
@ -59,23 +68,32 @@ export default function OffersHomePage() {
offers.
</div>
</div>
<div className="mt-6 flex flex-col items-center justify-center space-y-2 text-base text-slate-700 sm:mt-10 sm:flex-row sm:space-y-0 sm:space-x-4 sm:text-lg">
<div className="mt-6 flex flex-col items-center justify-center space-y-2 text-sm text-slate-700 sm:mt-10 sm:flex-row sm:space-y-0 sm:space-x-4 sm:text-lg">
<span>Viewing offers for</span>
<div className="flex items-center space-x-4">
<JobTitlesTypeahead
isLabelHidden={true}
placeholder="All Job Titles"
textSize="inherit"
value={
selectedJobTitleId
? {
id: selectedJobTitleId,
label: JobTitleLabels[selectedJobTitleId as JobTitleType],
value: selectedJobTitleId,
}
: null
}
onSelect={(option) => {
if (option) {
setJobTitleFilter(option.value as JobTitleType);
setSelectedJobTitleId(option.id as JobTitleType);
gaEvent({
action: `offers.table_filter_job_title_${option.value}`,
category: 'engagement',
label: 'Filter by job title',
});
} else {
setJobTitleFilter('');
setSelectedJobTitleId(null);
}
}}
/>
@ -84,16 +102,27 @@ export default function OffersHomePage() {
isLabelHidden={true}
placeholder="All Companies"
textSize="inherit"
value={
selectedCompanyName
? {
id: selectedCompanyId,
label: selectedCompanyName,
value: selectedCompanyId,
}
: null
}
onSelect={(option) => {
if (option) {
setCompanyFilter(option.value);
setSelectedCompanyId(option.id);
setSelectedCompanyName(option.label);
gaEvent({
action: `offers.table_filter_company_${option.value}`,
category: 'engagement',
label: 'Filter by company',
});
} else {
setCompanyFilter('');
setSelectedCompanyId('');
setSelectedCompanyName('');
}
}}
/>
@ -102,9 +131,10 @@ export default function OffersHomePage() {
</div>
<Container className="pb-20 pt-10">
<OffersTable
companyFilter={companyFilter}
companyFilter={selectedCompanyId}
companyName={selectedCompanyName}
countryFilter={countryFilter}
jobTitleFilter={jobTitleFilter}
jobTitleFilter={selectedJobTitleId ?? ''}
/>
</Container>
</main>

@ -6,6 +6,7 @@ import { Spinner, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { ProfileDetailTab } from '~/components/offers/constants';
import { HOME_URL } 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';
@ -13,7 +14,6 @@ import type {
BackgroundDisplayData,
OfferDisplayData,
} from '~/components/offers/types';
import { HOME_URL } from '~/components/offers/types';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
@ -79,6 +79,7 @@ export default function OfferProfile() {
jobTitle: getLabelForJobTitleType(
res.offersFullTime.title as JobTitleType,
),
jobType: res.jobType,
location: res.location,
negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments,
@ -99,6 +100,7 @@ export default function OfferProfile() {
jobTitle: getLabelForJobTitleType(
res.offersIntern!.title as JobTitleType,
),
jobType: res.jobType,
location: res.location,
monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary,
@ -187,22 +189,18 @@ export default function OfferProfile() {
}
}
return (
<>
{getProfileQuery.isError && (
return getProfileQuery.isError ? (
<div className="flex w-full justify-center">
<Error statusCode={404} title="Requested profile does not exist" />
<Error statusCode={404} title="Requested profile does not exist." />
</div>
)}
{getProfileQuery.isLoading && (
) : getProfileQuery.isLoading ? (
<div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
<Spinner display="block" size="lg" />
<div className="text-center">Loading...</div>
</div>
</div>
)}
{!getProfileQuery.isLoading && !getProfileQuery.isError && (
) : (
<div className="w-full divide-x lg:flex">
<div className="divide-y lg:w-2/3">
<div className="h-fit">
@ -215,7 +213,7 @@ export default function OfferProfile() {
setSelectedTab={setSelectedTab}
/>
</div>
<div className="pb-4">
<div>
<ProfileDetails
analysis={analysis}
background={background}
@ -240,7 +238,5 @@ export default function OfferProfile() {
/>
</div>
</div>
)}
</>
);
}

@ -1,46 +1,56 @@
import Error from 'next/error';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useEffect, useRef, useState } from 'react';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { EyeIcon } from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumb';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumbs';
import { Breadcrumbs } from '~/components/offers/Breadcrumbs';
import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
import OffersSubmissionAnalysis from '~/components/offers/offersSubmission/OffersSubmissionAnalysis';
import { getProfilePath } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
import type { ProfileAnalysis } from '~/types/offers';
export default function OffersSubmissionResult() {
const router = useRouter();
let { offerProfileId, token = '' } = router.query;
offerProfileId = offerProfileId as string;
token = token as string;
const [step, setStep] = useState(0);
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
const { data: session } = useSession();
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
// TODO: Check if the token is valid before showing this page
const getAnalysis = trpc.useQuery(
['offers.analysis.get', { profileId: offerProfileId }],
{
onSuccess(data) {
setAnalysis(data);
},
},
);
const checkToken = trpc.useQuery([
'offers.profile.isValidToken',
{ profileId: offerProfileId, token },
]);
const getAnalysis = trpc.useQuery([
'offers.analysis.get',
{ profileId: offerProfileId },
]);
const isSavedQuery = trpc.useQuery([
`offers.profile.isSaved`,
{ profileId: offerProfileId, userId: session?.user?.id },
]);
const steps = [
<OffersProfileSave key={0} profileId={offerProfileId} token={token} />,
<OffersProfileSave
key={0}
isSavedQuery={isSavedQuery}
profileId={offerProfileId}
token={token}
/>,
<OffersSubmissionAnalysis
key={1}
analysis={analysis}
analysis={getAnalysis.data}
isError={getAnalysis.isError}
isLoading={getAnalysis.isLoading}
/>,
@ -67,17 +77,21 @@ export default function OffersSubmissionResult() {
scrollToTop();
}, [step]);
return (
<>
{getAnalysis.isLoading && (
return checkToken.isLoading || getAnalysis.isLoading ? (
<div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
<Spinner display="block" size="lg" />
<div className="text-center">Loading...</div>
</div>
</div>
)}
{!getAnalysis.isLoading && (
) : checkToken.isError || getAnalysis.isError ? (
<Error statusCode={404} title="Error loading page" />
) : checkToken.isSuccess && !checkToken.data ? (
<Error
statusCode={403}
title="You do not have permissions to access this page"
/>
) : (
<div ref={pageRef} className="w-full">
<div className="flex justify-center">
<div className="block w-full max-w-screen-md overflow-hidden rounded-lg sm:shadow-lg md:my-10">
@ -125,7 +139,5 @@ export default function OffersSubmissionResult() {
</div>
</div>
</div>
)}
</>
);
}

@ -20,7 +20,8 @@ import {
} from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import ResumeSubmissionGuidelines from '~/components/resumes/submit-form/ResumeSubmissionGuidelines';
import Container from '~/components/shared/Container';
import loginPageHref from '~/components/shared/loginPageHref';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
@ -239,10 +240,10 @@ export default function SubmitResumeForm({
<Head>
<title>Upload a Resume</title>
</Head>
<Container variant="xs">
{status === 'loading' && (
<div className="w-full pt-4">
{' '}
<Spinner display="block" size="lg" />{' '}
<Spinner display="block" size="lg" />
</div>
)}
{status === 'authenticated' && (
@ -278,9 +279,9 @@ export default function SubmitResumeForm({
Note that your current input will not be saved!
</Dialog>
<form
className="mt-8 w-full max-w-screen-lg space-y-6 self-center rounded-lg bg-white p-10 shadow-lg"
className="w-full space-y-6 self-center bg-white p-10 md:my-8 md:rounded-lg md:shadow-lg"
onSubmit={handleSubmit(onSubmit)}>
<h1 className="mb-4 text-center text-2xl font-semibold">
<h1 className="mb-8 text-2xl font-bold text-slate-900 sm:text-center sm:text-4xl">
{isNewForm ? 'Upload a resume' : 'Update details'}
</h1>
{/* Title Section */}
@ -344,7 +345,7 @@ export default function SubmitResumeForm({
fileUploadError
? 'border-danger-600'
: 'border-slate-300',
'cursor-pointer flex-col items-center space-y-1 rounded-md border-2 border-dashed bg-slate-100 py-4 px-4 text-center',
'cursor-pointer flex-col items-center space-y-1 rounded-md border-2 border-dashed bg-slate-50 py-4 px-4 text-center',
)}>
<input
{...register('file', { required: true })}
@ -379,7 +380,9 @@ export default function SubmitResumeForm({
</p>
</div>
{fileUploadError && (
<p className="text-danger-600 text-sm">{fileUploadError}</p>
<p className="text-danger-600 text-sm">
{fileUploadError}
</p>
)}
</div>
)}
@ -394,8 +397,8 @@ export default function SubmitResumeForm({
/>
{/* Submission Guidelines */}
{isNewForm && (
<>
<SubmissionGuidelines />
<div className="space-y-4">
<ResumeSubmissionGuidelines />
<CheckboxInput
{...register('isChecked', { required: true })}
disabled={isLoading}
@ -404,7 +407,7 @@ export default function SubmitResumeForm({
? 'Please tick the checkbox after reading through the guidelines.'
: undefined
}
label="I have read and will follow the guidelines stated."
label="I have read and followed the above guidelines."
onChange={(val) => {
if (val) {
clearErrors('isChecked');
@ -412,7 +415,7 @@ export default function SubmitResumeForm({
setValue('isChecked', val);
}}
/>
</>
</div>
)}
{/* Clear and Submit Buttons */}
<div className="flex justify-end gap-4">
@ -436,6 +439,7 @@ export default function SubmitResumeForm({
</section>
</main>
)}
</Container>
</>
);
}

@ -2,9 +2,10 @@ import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { profileAnalysisDtoMapper } from '~/mappers/offers-mappers';
import { analysisInclusion } from '~/utils/offers/analysis/analysisInclusion';
import { createRouter } from '../context';
import { generateAnalysis } from '../../../utils/offers/analysisGeneration';
import { generateAnalysis } from '../../../utils/offers/analysis/analysisGeneration';
export const offersAnalysisRouter = createRouter()
.query('get', {
@ -13,139 +14,7 @@ export const offersAnalysisRouter = createRouter()
}),
async resolve({ ctx, input }) {
const analysis = await ctx.prisma.offersAnalysis.findFirst({
include: {
companyAnalysis: {
include: {
company: true,
topSimilarOffers: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
overallAnalysis: {
include: {
company: true,
topSimilarOffers: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
overallHighestOffer: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
},
include: analysisInclusion,
where: {
profileId: input.profileId,
},

@ -8,6 +8,7 @@ import {
createOfferProfileResponseMapper,
profileDtoMapper,
} from '~/mappers/offers-mappers';
import { analysisInclusion } from '~/utils/offers/analysis/analysisInclusion';
import { baseCurrencyString } from '~/utils/offers/currency';
import { convert } from '~/utils/offers/currency/currencyExchange';
import {
@ -165,139 +166,7 @@ export const offersProfileRouter = createRouter()
const result = await ctx.prisma.offersProfile.findFirst({
include: {
analysis: {
include: {
companyAnalysis: {
include: {
company: true,
topSimilarOffers: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
overallAnalysis: {
include: {
company: true,
topSimilarOffers: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
overallHighestOffer: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
},
include: analysisInclusion,
},
background: {
include: {
@ -972,7 +841,19 @@ export const offersProfileRouter = createRouter()
for (const exp of input.background.experiences) {
if (exp.id) {
// Update existing experience
const currentExp = await ctx.prisma.offersExperience.findFirst({
where: {
id: exp.id,
},
});
if (!currentExp) {
throw new trpc.TRPCError({
code: 'NOT_FOUND',
message: 'Experience does not exist',
});
}
await ctx.prisma.offersExperience.update({
data: {
companyId: exp.companyId, // TODO: check if can change with connect or whether there is a difference
@ -984,7 +865,77 @@ export const offersProfileRouter = createRouter()
id: exp.id,
},
});
if (currentExp.jobType === exp.jobType) {
// Update existing experience
if (exp.monthlySalary) {
await ctx.prisma.offersExperience.update({
data: {
monthlySalary: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
},
},
},
where: {
id: exp.id,
},
});
}
if (exp.totalCompensation) {
await ctx.prisma.offersExperience.update({
data: {
totalCompensation: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
},
},
},
where: {
id: exp.id,
},
});
}
} else if (exp.jobType === JobType.INTERN) {
// Add 1 remove the other
if (exp.monthlySalary) {
await ctx.prisma.offersExperience.update({
data: {
@ -1019,6 +970,16 @@ export const offersProfileRouter = createRouter()
});
}
await ctx.prisma.offersExperience.update({
data: {
totalCompensation: undefined,
totalCompensationId: null,
},
where: {
id: exp.id,
},
});
} else if (exp.jobType === JobType.FULLTIME) {
if (exp.totalCompensation) {
await ctx.prisma.offersExperience.update({
data: {
@ -1052,6 +1013,17 @@ export const offersProfileRouter = createRouter()
},
});
}
await ctx.prisma.offersExperience.update({
data: {
monthlySalary: undefined,
monthlySalaryId: null,
},
where: {
id: exp.id,
},
});
}
} else if (!exp.id) {
// Create new experience
if (exp.jobType === JobType.FULLTIME) {
@ -1581,6 +1553,18 @@ export const offersProfileRouter = createRouter()
for (const offerToUpdate of input.offers) {
if (offerToUpdate.id) {
// Update existing offer
const currentOffer = await ctx.prisma.offersOffer.findFirst({
where: {
id: offerToUpdate.id,
},
});
if (!currentOffer) {
throw new trpc.TRPCError({
code: 'NOT_FOUND',
message: 'Offer to update does not exist',
});
}
await ctx.prisma.offersOffer.update({
data: {
comments: offerToUpdate.comments,
@ -1606,6 +1590,7 @@ export const offersProfileRouter = createRouter()
},
});
if (currentOffer.jobType === offerToUpdate.jobType) {
if (offerToUpdate.offersIntern?.monthlySalary != null) {
await ctx.prisma.offersIntern.update({
data: {
@ -1637,7 +1622,8 @@ export const offersProfileRouter = createRouter()
},
},
},
startYear: offerToUpdate.offersIntern.startYear ?? undefined,
startYear:
offerToUpdate.offersIntern.startYear ?? undefined,
title: offerToUpdate.offersIntern.title,
},
where: {
@ -1670,7 +1656,170 @@ export const offersProfileRouter = createRouter()
),
currency:
offerToUpdate.offersFullTime.baseSalary.currency,
value: offerToUpdate.offersFullTime.baseSalary.value,
value:
offerToUpdate.offersFullTime.baseSalary.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.baseSalary.value,
offerToUpdate.offersFullTime.baseSalary.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.baseSalary.currency,
value:
offerToUpdate.offersFullTime.baseSalary.value,
},
},
},
},
where: {
id: offerToUpdate.offersFullTime.id,
},
});
}
if (offerToUpdate.offersFullTime.bonus != null) {
await ctx.prisma.offersFullTime.update({
data: {
bonus: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.bonus.value,
offerToUpdate.offersFullTime.bonus.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.bonus.currency,
value: offerToUpdate.offersFullTime.bonus.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.bonus.value,
offerToUpdate.offersFullTime.bonus.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.bonus.currency,
value: offerToUpdate.offersFullTime.bonus.value,
},
},
},
},
where: {
id: offerToUpdate.offersFullTime.id,
},
});
}
if (offerToUpdate.offersFullTime.stocks != null) {
await ctx.prisma.offersFullTime.update({
data: {
stocks: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.stocks.value,
offerToUpdate.offersFullTime.stocks.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.stocks.currency,
value: offerToUpdate.offersFullTime.stocks.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.stocks.value,
offerToUpdate.offersFullTime.stocks.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.stocks.currency,
value: offerToUpdate.offersFullTime.stocks.value,
},
},
},
},
where: {
id: offerToUpdate.offersFullTime.id,
},
});
}
await ctx.prisma.offersFullTime.update({
data: {
totalCompensation: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.totalCompensation
.value,
offerToUpdate.offersFullTime.totalCompensation
.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.totalCompensation
.currency,
value:
offerToUpdate.offersFullTime.totalCompensation
.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.totalCompensation
.value,
offerToUpdate.offersFullTime.totalCompensation
.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.totalCompensation
.currency,
value:
offerToUpdate.offersFullTime.totalCompensation
.value,
},
},
},
},
where: {
id: offerToUpdate.offersFullTime.id,
},
});
}
} else if (currentOffer.jobType === JobType.FULLTIME) {
if (offerToUpdate.offersFullTime?.totalCompensation != null) {
await ctx.prisma.offersFullTime.update({
data: {
level: offerToUpdate.offersFullTime.level ?? undefined,
title: offerToUpdate.offersFullTime.title,
},
where: {
id: offerToUpdate.offersFullTime.id,
},
});
if (offerToUpdate.offersFullTime.baseSalary != null) {
await ctx.prisma.offersFullTime.update({
data: {
baseSalary: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.baseSalary.value,
offerToUpdate.offersFullTime.baseSalary.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.baseSalary.currency,
value:
offerToUpdate.offersFullTime.baseSalary.value,
},
update: {
baseCurrency: baseCurrencyString,
@ -1681,7 +1830,8 @@ export const offersProfileRouter = createRouter()
),
currency:
offerToUpdate.offersFullTime.baseSalary.currency,
value: offerToUpdate.offersFullTime.baseSalary.value,
value:
offerToUpdate.offersFullTime.baseSalary.value,
},
},
},
@ -1703,7 +1853,8 @@ export const offersProfileRouter = createRouter()
offerToUpdate.offersFullTime.bonus.currency,
baseCurrencyString,
),
currency: offerToUpdate.offersFullTime.bonus.currency,
currency:
offerToUpdate.offersFullTime.bonus.currency,
value: offerToUpdate.offersFullTime.bonus.value,
},
update: {
@ -1713,7 +1864,8 @@ export const offersProfileRouter = createRouter()
offerToUpdate.offersFullTime.bonus.currency,
baseCurrencyString,
),
currency: offerToUpdate.offersFullTime.bonus.currency,
currency:
offerToUpdate.offersFullTime.bonus.currency,
value: offerToUpdate.offersFullTime.bonus.value,
},
},
@ -1766,7 +1918,8 @@ export const offersProfileRouter = createRouter()
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.totalCompensation.value,
offerToUpdate.offersFullTime.totalCompensation
.value,
offerToUpdate.offersFullTime.totalCompensation
.currency,
baseCurrencyString,
@ -1775,12 +1928,14 @@ export const offersProfileRouter = createRouter()
offerToUpdate.offersFullTime.totalCompensation
.currency,
value:
offerToUpdate.offersFullTime.totalCompensation.value,
offerToUpdate.offersFullTime.totalCompensation
.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.totalCompensation.value,
offerToUpdate.offersFullTime.totalCompensation
.value,
offerToUpdate.offersFullTime.totalCompensation
.currency,
baseCurrencyString,
@ -1789,7 +1944,8 @@ export const offersProfileRouter = createRouter()
offerToUpdate.offersFullTime.totalCompensation
.currency,
value:
offerToUpdate.offersFullTime.totalCompensation.value,
offerToUpdate.offersFullTime.totalCompensation
.value,
},
},
},
@ -1799,6 +1955,68 @@ export const offersProfileRouter = createRouter()
},
});
}
await ctx.prisma.offersOffer.update({
data: {
offersIntern: undefined,
offersInternId: null,
},
where: {
id: offerToUpdate.id,
},
});
} else if (currentOffer.jobType === JobType.INTERN) {
if (offerToUpdate.offersIntern?.monthlySalary != null) {
await ctx.prisma.offersIntern.update({
data: {
internshipCycle:
offerToUpdate.offersIntern.internshipCycle ?? undefined,
monthlySalary: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersIntern.monthlySalary.value,
offerToUpdate.offersIntern.monthlySalary.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersIntern.monthlySalary.currency,
value: offerToUpdate.offersIntern.monthlySalary.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersIntern.monthlySalary.value,
offerToUpdate.offersIntern.monthlySalary.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersIntern.monthlySalary.currency,
value: offerToUpdate.offersIntern.monthlySalary.value,
},
},
},
startYear:
offerToUpdate.offersIntern.startYear ?? undefined,
title: offerToUpdate.offersIntern.title,
},
where: {
id: offerToUpdate.offersIntern.id,
},
});
}
await ctx.prisma.offersOffer.update({
data: {
offersFullTime: undefined,
offersFullTimeId: null,
},
where: {
id: offerToUpdate.id,
},
});
}
} else {
// Create new offer
if (

@ -157,10 +157,15 @@ export type ProfileAnalysis = {
};
export type AnalysisUnit = {
companyId: string;
companyName: string;
income: Valuation;
jobType: JobType;
noOfOffers: number;
percentile: number;
title: string;
topPercentileOffers: Array<AnalysisOffer>;
totalYoe: number;
};
export type AnalysisHighestOffer = {
@ -181,6 +186,7 @@ export type AnalysisOffer = {
monthYearReceived: Date;
negotiationStrategy: string;
previousCompanies: Array<string>;
profileId: string;
profileName: string;
title: string;
totalYoe: number;

@ -15,7 +15,8 @@ import type {
} from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { profileAnalysisDtoMapper } from '../../mappers/offers-mappers';
import { analysisInclusion } from './analysisInclusion';
import { profileAnalysisDtoMapper } from '../../../mappers/offers-mappers';
type Offer = OffersOffer & {
company: Company;
@ -292,7 +293,7 @@ export const generateAnalysis = async (params: {
: similarCompanyOffers;
return {
companyId: companyOffer.companyId,
analysedOfferId: companyOffer.id,
noOfSimilarOffers: noOfSimilarCompanyOffers,
percentile: companyPercentile,
topSimilarOffers: topPercentileCompanyOffers,
@ -329,9 +330,9 @@ export const generateAnalysis = async (params: {
companyAnalysis: {
create: companyAnalysis.map((analysisUnit) => {
return {
company: {
analysedOffer: {
connect: {
id: analysisUnit.companyId,
id: analysisUnit.analysedOfferId,
},
},
noOfSimilarOffers: analysisUnit.noOfSimilarOffers,
@ -346,9 +347,9 @@ export const generateAnalysis = async (params: {
},
overallAnalysis: {
create: {
company: {
analysedOffer: {
connect: {
id: overallHighestOffer.companyId,
id: overallHighestOffer.id,
},
},
noOfSimilarOffers,
@ -371,139 +372,7 @@ export const generateAnalysis = async (params: {
},
},
},
include: {
companyAnalysis: {
include: {
company: true,
topSimilarOffers: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
overallAnalysis: {
include: {
company: true,
topSimilarOffers: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
overallHighestOffer: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
},
include: analysisInclusion,
});
return profileAnalysisDtoMapper(analysis);

@ -0,0 +1,171 @@
export const analysisInclusion = {
companyAnalysis: {
include: {
analysedOffer: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
topSimilarOffers: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
overallAnalysis: {
include: {
analysedOffer: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
topSimilarOffers: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
},
},
},
},
},
},
overallHighestOffer: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
};

@ -20,7 +20,7 @@ export default function CurrencySelector({
<Select
display="inline"
isLabelHidden={true}
label="Select fruit"
label="Currency"
name=""
options={currencyOptions}
value={selectedCurrency}

@ -0,0 +1,37 @@
import type { JobType } from '@prisma/client';
import { JobTypeLabel } from '~/components/offers/constants';
import type { Location } from '~/types/offers';
export function joinWithComma(...strings: Array<string | null | undefined>) {
return strings.filter((value) => !!value).join(', ');
}
export function getLocationDisplayText({ cityName, countryName }: Location) {
return cityName === countryName
? cityName
: joinWithComma(cityName, countryName);
}
export function getCompanyDisplayText(
companyName?: string | null,
location?: Location | null,
) {
if (!location) {
return companyName;
}
return joinWithComma(companyName, getLocationDisplayText(location));
}
export function getJobDisplayText(
jobTitle?: string | null,
jobLevel?: string | null,
jobType?: JobType | null,
) {
let jobDisplay = joinWithComma(jobTitle, jobLevel);
if (jobType) {
jobDisplay = jobDisplay.concat(` (${JobTypeLabel[jobType]})`);
}
return jobDisplay;
}

@ -55,3 +55,18 @@ export function getCurrentYear() {
export function convertToMonthYear(date: Date) {
return { month: date.getMonth() + 1, year: date.getFullYear() } as MonthYear;
}
export function getDurationDisplayText(months: number) {
const years = Math.floor(months / 12);
const monthsRemainder = months % 12;
let durationDisplay = '';
if (years > 0) {
durationDisplay = `${years} year${years > 1 ? 's' : ''}`;
}
if (monthsRemainder > 0) {
durationDisplay = durationDisplay.concat(
` ${monthsRemainder} month${monthsRemainder > 1 ? 's' : ''}`,
);
}
return durationDisplay;
}

@ -0,0 +1,79 @@
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 [params, setParams] = 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];
setParams(
queryValues
.map(stringToParam)
.filter((value) => value !== null) as Array<Value>,
);
}
setIsInitialized(true);
}
}, [isInitialized, name, stringToParam, router]);
const setParamsCallback = useCallback(
(newParams: Array<Value>) => {
setParams(newParams);
localStorage.setItem(
name,
JSON.stringify(
newParams.map(valueToQueryParam).filter((param) => param !== null),
),
);
},
[name, valueToQueryParam],
);
return [params, setParamsCallback, isInitialized] as const;
};
export const useSearchParamSingle = <Value = string>(
name: string,
opts?: Omit<SearchParamOptions<Value>, 'defaultValues'> & {
defaultValue?: Value;
},
) => {
const { defaultValue, ...restOpts } = opts ?? {};
const [params, setParams, isInitialized] = useSearchParam<Value>(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
...restOpts,
} as SearchParamOptions<Value>);
return [
params[0],
(value: Value) => setParams([value]),
isInitialized,
] as const;
};

@ -144,7 +144,7 @@ export default function Typeahead({
<Combobox.Input
aria-describedby={hasError ? errorId : undefined}
className={clsx(
'w-full border-none py-2 pl-3 pr-10 leading-5 focus:ring-0',
'w-full border-none py-2 pl-3 pr-10 text-[length:inherit] leading-5 focus:ring-0',
stateClasses[state].input,
textSizes[textSize],
'disabled:cursor-not-allowed disabled:bg-transparent disabled:text-slate-500',

Loading…
Cancel
Save