[offers][fix] Fix offers UI (#460)

pull/463/head
Ai Ling 2 years ago committed by GitHub
parent 42e990f180
commit a8fdca65cd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -19,12 +19,14 @@ type OfferAnalysisData = {
type OfferAnalysisContentProps = Readonly<{
analysis: OfferAnalysisData;
isSubmission: boolean;
tab: string;
}>;
function OfferAnalysisContent({
analysis: { offer, offerAnalysis },
tab,
isSubmission,
}: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) {
@ -46,16 +48,30 @@ function OfferAnalysisContent({
<>
<OfferPercentileAnalysisText
companyName={offer.company.name}
isSubmission={isSubmission}
offerAnalysis={offerAnalysis}
tab={tab}
/>
<p className="mt-5">Here are some of the top offers relevant to you:</p>
<p className="mt-5">
{isSubmission
? 'Here are some of the top offers relevant to you:'
: 'Relevant top offers:'}
</p>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard
key={topPercentileOffer.id}
offerProfile={topPercentileOffer}
/>
))}
{/* {offerAnalysis.topPercentileOffers.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
icon={EllipsisHorizontalIcon}
label="View more offers"
variant="tertiary"
/>
</div>
)} */}
</>
);
}
@ -64,12 +80,14 @@ type OfferAnalysisProps = Readonly<{
allAnalysis?: ProfileAnalysis | null;
isError: boolean;
isLoading: boolean;
isSubmission?: boolean;
}>;
export default function OfferAnalysis({
allAnalysis,
isError,
isLoading,
isSubmission = false,
}: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null);
@ -117,7 +135,11 @@ export default function OfferAnalysis({
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent analysis={analysis} tab={tab} />
<OfferAnalysisContent
analysis={analysis}
isSubmission={isSubmission}
tab={tab}
/>
</div>
)}
</div>

@ -4,6 +4,7 @@ import type { Analysis } from '~/types/offers';
type OfferPercentileAnalysisTextProps = Readonly<{
companyName: string;
isSubmission: boolean;
offerAnalysis: Analysis;
tab: string;
}>;
@ -12,18 +13,21 @@ export default function OfferPercentileAnalysisText({
tab,
companyName,
offerAnalysis: { noOfOffers, percentile },
isSubmission,
}: 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.
{isSubmission ? 'Your' : "This profile's"} 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.
{isSubmission ? 'Your' : 'The'} 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>
);
}

@ -12,6 +12,7 @@ import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
import ProfilePhotoHolder from '../profile/ProfilePhotoHolder';
import { JobTypeLabel } from '../types';
import type { AnalysisOffer } from '~/types/offers';
@ -34,7 +35,12 @@ export default function OfferProfileCard({
},
}: OfferProfileCardProps) {
return (
<div className="my-5 block rounded-lg bg-white p-4 px-8 shadow-md">
// <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">
<div className="flex items-center gap-x-5">
<div>
<ProfilePhotoHolder size="sm" />
@ -58,7 +64,8 @@ export default function OfferProfileCard({
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<p className="font-bold">
{getLabelForJobTitleType(title as JobTitleType)}
{getLabelForJobTitleType(title as JobTitleType)}{' '}
{`(${JobTypeLabel[jobType]})`}
</p>
<p>
Company: {company.name}, {location}

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

@ -27,6 +27,7 @@ import { trpc } from '~/utils/trpc';
const defaultOfferValues = {
comments: '',
companyId: '',
jobTitle: '',
jobType: JobType.FULLTIME,
location: '',
monthYearReceived: {
@ -39,11 +40,38 @@ const defaultOfferValues = {
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FULLTIME,
offersFullTime: {
baseSalary: {
currency: 'SGD',
value: null,
},
bonus: {
currency: 'SGD',
value: null,
},
level: '',
stocks: {
currency: 'SGD',
value: null,
},
totalCompensation: {
currency: 'SGD',
value: null,
},
},
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.INTERN,
offersIntern: {
internshipCycle: null,
monthlySalary: {
currency: 'SGD',
value: null,
},
startYear: null,
},
};
const defaultOfferProfileValues = {
@ -198,6 +226,32 @@ export default function OffersSubmissionForm({
scrollToTop();
}, [step]);
useEffect(() => {
const warningText =
'Leave this page? Changes that you made will not be saved.';
const handleWindowClose = (e: BeforeUnloadEvent) => {
e.preventDefault();
return (e.returnValue = warningText);
};
const handleRouteChange = (url: string) => {
if (url.includes('/offers/submit/result')) {
return;
}
if (window.confirm(warningText)) {
return;
}
router.events.emit('routeChangeError');
throw 'routeChange aborted.';
};
window.addEventListener('beforeunload', handleWindowClose);
router.events.on('routeChangeStart', handleRouteChange);
return () => {
window.removeEventListener('beforeunload', handleWindowClose);
router.events.off('routeChangeStart', handleRouteChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
@ -210,7 +264,7 @@ export default function OffersSubmissionForm({
/>
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
<form className="text-sm" onSubmit={handleSubmit(onSubmit)}>
{steps[step]}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{step === 0 && (

@ -11,6 +11,8 @@ import {
} from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import {
@ -92,23 +94,47 @@ function FullTimeJobFields() {
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
const watchJobTitle = useWatch({
name: 'background.experiences.0.title',
});
const watchCompanyId = useWatch({
name: 'background.experiences.0.companyId',
});
const watchCompanyName = useWatch({
name: 'background.experiences.0.companyName',
});
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<JobTitlesTypeahead
// @ts-ignore TODO(offers): handle potentially null value.
onSelect={({ value }) =>
setValue(`background.experiences.0.title`, value)
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.title', option.value);
}
}}
/>
</div>
<div>
<CompaniesTypeahead
// @ts-ignore TODO(offers): handle potentially null value.
onSelect={({ value }) =>
setValue(`background.experiences.0.companyId`, value)
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.companyId', option.value);
setValue('background.experiences.0.companyName', option.label);
}
}}
/>
</div>
</div>
@ -175,23 +201,46 @@ function InternshipJobFields() {
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
const watchJobTitle = useWatch({
name: 'background.experiences.0.title',
});
const watchCompanyId = useWatch({
name: 'background.experiences.0.companyId',
});
const watchCompanyName = useWatch({
name: 'background.experiences.0.companyName',
});
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<JobTitlesTypeahead
// @ts-ignore TODO(offers): handle potentially null value.
onSelect={({ value }) =>
setValue(`background.experiences.0.title`, value)
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.title', option.value);
}
}}
/>
</div>
<div>
<CompaniesTypeahead
// @ts-ignore TODO(offers): handle potentially null value.
onSelect={({ value }) =>
setValue(`background.experiences.0.companyId`, value)
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.companyId', option.value);
setValue('background.experiences.0.companyName', option.label);
}
}}
/>
</div>
</div>

@ -13,6 +13,8 @@ import { JobType } from '@prisma/client';
import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import {
@ -51,6 +53,15 @@ function FullTimeOfferDetailsForm({
}>();
const offerFields = formState.errors.offers?.[index];
const watchJobTitle = useWatch({
name: `offers.${index}.offersFullTime.title`,
});
const watchCompanyId = useWatch({
name: `offers.${index}.companyId`,
});
const watchCompanyName = useWatch({
name: `offers.${index}.companyName`,
});
const watchCurrency = useWatch({
name: `offers.${index}.offersFullTime.totalCompensation.currency`,
});
@ -70,10 +81,16 @@ function FullTimeOfferDetailsForm({
<div>
<JobTitlesTypeahead
required={true}
// @ts-ignore TODO(offers): handle potentially null value.
onSelect={({ value }) =>
setValue(`offers.${index}.offersFullTime.title`, value)
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.offersFullTime.title`, option.value);
}
}}
/>
</div>
<FormTextInput
@ -90,10 +107,17 @@ function FullTimeOfferDetailsForm({
<div>
<CompaniesTypeahead
required={true}
// @ts-ignore TODO(offers): handle potentially null value.
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.companyId`, option.value);
setValue(`offers.${index}.companyName`, option.label);
}
}}
/>
</div>
<FormSelect
@ -270,19 +294,34 @@ function InternshipOfferDetailsForm({
const { register, formState, setValue } = useFormContext<{
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
const watchJobTitle = useWatch({
name: `offers.${index}.offersIntern.title`,
});
const watchCompanyId = useWatch({
name: `offers.${index}.companyId`,
});
const watchCompanyName = useWatch({
name: `offers.${index}.companyName`,
});
return (
<div className="my-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<JobTitlesTypeahead
required={true}
// @ts-ignore TODO(offers): handle potentially null value.
onSelect={({ value }) =>
setValue(`offers.${index}.offersIntern.title`, value)
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.offersIntern.title`, option.value);
}
}}
/>
</div>
</div>
@ -290,10 +329,17 @@ function InternshipOfferDetailsForm({
<div>
<CompaniesTypeahead
required={true}
// @ts-ignore TODO(offers): handle potentially null value.
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.companyId`, option.value);
setValue(`offers.${index}.companyName`, option.label);
}
}}
/>
</div>
<FormSelect

@ -7,6 +7,7 @@ import {
import { HorizontalDivider } from '@tih/ui';
import type { OfferDisplayData } from '~/components/offers/types';
import { JobTypeLabel } from '~/components/offers/types';
type Props = Readonly<{
offer: OfferDisplayData;
@ -20,6 +21,7 @@ export default function OfferCard({
duration,
jobTitle,
jobLevel,
jobType,
location,
receivedMonth,
totalCompensation,
@ -40,7 +42,10 @@ export default function OfferCard({
</span>
</div>
<div className="ml-6 flex flex-row">
<p>{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}</p>
<p>
{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}{' '}
{jobType && `(${JobTypeLabel[jobType]})`}
</p>
</div>
</div>
{!duration && receivedMonth && (

@ -10,6 +10,7 @@ import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundDisplayData } from '~/components/offers/types';
import { JobTypeLabel } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link';
@ -95,8 +96,8 @@ export default function ProfileHeader({
title="Are you sure you want to delete this offer profile?"
onClose={() => setIsDialogOpen(false)}>
<div>
All comments will be gone. You will not be able to access or
recover it.
All information and comments in this offer profile will be
deleted. You will not be able to access or recover them.
</div>
</Dialog>
)}
@ -144,7 +145,11 @@ export default function ProfileHeader({
<span>
{`${experiences[0].companyName || ''} ${
experiences[0].jobLevel || ''
} ${experiences[0].jobTitle || ''}`}
} ${experiences[0].jobTitle || ''} ${
experiences[0].jobType
? `(${JobTypeLabel[experiences[0].jobType]})`
: ''
}`}
</span>
</div>
)}

@ -45,6 +45,7 @@ export type BackgroundPostData = {
type ExperiencePostData = {
companyId?: string | null;
companyName?: string | null;
durationInMonths?: number | null;
id?: string;
jobType?: string | null;
@ -76,6 +77,7 @@ type SpecificYoe = SpecificYoePostData;
export type OfferPostData = {
comments: string;
companyId: string;
companyName?: string;
id?: string;
jobType: JobType;
location: string;
@ -129,6 +131,7 @@ export type OfferDisplayData = {
id?: string;
jobLevel?: string | null;
jobTitle?: string | null;
jobType?: JobType;
location?: string | null;
monthlySalary?: string | null;
negotiationStrategy?: string | null;

@ -24,9 +24,6 @@ import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers';
export default function OfferProfile() {
const { showToast } = useToast();
const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." />
);
const router = useRouter();
const { offerProfileId, token = '' } = router.query;
const [isEditable, setIsEditable] = useState(false);
@ -126,6 +123,7 @@ export default function OfferProfile() {
jobTitle: experience.title
? getLabelForJobTitleType(experience.title as JobTitleType)
: null,
jobType: experience.jobType || undefined,
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: null,
@ -177,7 +175,11 @@ export default function OfferProfile() {
return (
<>
{getProfileQuery.isError && ErrorPage}
{getProfileQuery.isError && (
<div className="flex w-full justify-center">
<Error statusCode={404} title="Requested profile does not exist" />
</div>
)}
{!getProfileQuery.isError && (
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
<div className="h-full w-2/3 divide-y">

@ -1,3 +1,4 @@
import Error from 'next/error';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { JobType } from '@prisma/client';
@ -36,6 +37,7 @@ export default function OffersEditPage() {
? [{ jobType: JobType.FULLTIME }]
: experiences.map((exp) => ({
companyId: exp.company?.id,
companyName: exp.company?.name,
durationInMonths: exp.durationInMonths,
id: exp.id,
jobType: exp.jobType,
@ -53,6 +55,7 @@ export default function OffersEditPage() {
offers: data.offers.map((offer) => ({
comments: offer.comments,
companyId: offer.company.id,
companyName: offer.company.name,
id: offer.id,
jobType: offer.jobType,
location: offer.location,
@ -74,6 +77,11 @@ export default function OffersEditPage() {
return (
<>
{getProfileResult.isError && (
<div className="flex w-full justify-center">
<Error statusCode={404} title="Requested profile does not exist" />
</div>
)}
{getProfileResult.isLoading && (
<div className="flex w-full justify-center">
<Spinner className="m-10" display="block" size="lg" />

Loading…
Cancel
Save