[offers][feat] Integrate offers profile edit (#403)

* [offers][fix] Fix offer analysis and save

* [offers][fix] Fix profile view page

* [offers][feat] Add offers profile edit
pull/406/head
Ai Ling 2 years ago committed by GitHub
parent 0adec461d0
commit 11df1e1f1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,20 +5,20 @@ export const emptyOption = '----';
// TODO: use enums
export const titleOptions = [
{
label: 'Software engineer',
value: 'Software engineer',
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend engineer',
value: 'Frontend engineer',
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend engineer',
value: 'Backend engineer',
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: 'Full-stack engineer',
value: 'Full-stack engineer',
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
];
@ -95,10 +95,18 @@ export const educationFieldOptions = [
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 enum FieldError {

@ -1,13 +1,30 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import { setTimeout } from 'timers';
import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui';
export default function OfferProfileSave() {
import {
copyProfileLink,
getProfileLink,
getProfilePath,
} from '~/utils/offers/link';
type OfferProfileSaveProps = Readonly<{
profileId: string;
token?: string;
}>;
export default function OfferProfileSave({
profileId,
token,
}: OfferProfileSaveProps) {
const [linkCopied, setLinkCopied] = useState(false);
const [isSaving, setSaving] = useState(false);
const [isSaved, setSaved] = useState(false);
const router = useRouter();
const saveProfile = () => {
setSaving(true);
setTimeout(() => {
@ -27,13 +44,13 @@ export default function OfferProfileSave() {
To keep you offer profile strictly anonymous, only people who have the
link below can edit it.
</p>
<div className="mb-20 grid grid-cols-12 gap-4">
<div className="mb-5 grid grid-cols-12 gap-4">
<div className="col-span-11">
<TextInput
disabled={true}
isLabelHidden={true}
label="Edit link"
value="link.myprofile-auto-generate..."
value={getProfileLink(profileId, token)}
/>
</div>
<Button
@ -41,10 +58,12 @@ export default function OfferProfileSave() {
isLabelHidden={true}
label="Copy"
variant="primary"
onClick={() => setLinkCopied(true)}
onClick={() => {
copyProfileLink(profileId, token), setLinkCopied(true);
}}
/>
</div>
<div className="mb-5">
<div className="mb-20">
{linkCopied && (
<p className="text-purple-700">Link copied to clipboard!</p>
)}
@ -60,13 +79,18 @@ export default function OfferProfileSave() {
disabled={isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon}
isLoading={isSaving}
label="Save to user profile"
label={isSaved ? 'Saved to user profile' : 'Save to user profile'}
variant="primary"
onClick={saveProfile}
/>
</div>
<div className="mb-10">
<Button icon={EyeIcon} label="View your profile" variant="special" />
<Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div>
</div>
</div>

@ -0,0 +1,243 @@
import { useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OfferAnalysis from '~/components/offers/offersSubmission/analysis/OfferAnalysis';
import OfferProfileSave from '~/components/offers/offersSubmission/OfferProfileSave';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type {
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { CreateOfferProfileResponse } from '~/types/offers';
const defaultOfferValues = {
comments: '',
companyId: '',
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
month: getCurrentMonth() as Month,
year: getCurrentYear(),
},
negotiationStrategy: '',
};
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FullTime,
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
specificYoes: [],
totalYoe: 0,
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
type Props = Readonly<{
initialOfferProfileValues?: OffersProfileFormData;
profileId?: string;
token?: string;
}>;
export default function OffersSubmissionForm({
initialOfferProfileValues = defaultOfferProfileValues,
profileId,
token,
}: Props) {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>({
id: profileId || '',
token: token || '',
});
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: initialOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />,
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: (
<OfferProfileSave
key={3}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
/>
),
hasNext: false,
hasPrevious: false,
label: 'Save',
},
];
const formStepsLabels = formSteps.map((step) => step.label);
const nextStep = async (currStep: number) => {
if (currStep === 0) {
const result = await trigger('offers');
if (!result) {
return;
}
}
setFormStep(formStep + 1);
scrollToTop();
};
const previousStep = () => {
setFormStep(formStep - 1);
scrollToTop();
};
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const mutationpath =
profileId && token ? 'offers.profile.update' : 'offers.profile.create';
const createOrUpdateMutation = trpc.useMutation([mutationpath], {
onError(error) {
console.error(error.message);
},
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
}
data = removeInvalidMoneyData(data);
const background = cleanObject(data.background);
background.specificYoes = data.background.specificYoes.filter(
(specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
);
if (Object.entries(background.experiences[0]).length === 1) {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,
offer.monthYearReceived.month - 1, // Convert month to monthIndex
),
}));
if (profileId && token) {
createOrUpdateMutation.mutate({
background,
id: profileId,
offers,
token,
});
} else {
createOrUpdateMutation.mutate({ background, offers });
}
};
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
}

@ -1,13 +1,12 @@
import Error from 'next/error';
import { useEffect } from 'react';
import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import OfferPercentileAnalysis from '../analysis/OfferPercentileAnalysis';
import OfferProfileCard from '../analysis/OfferProfileCard';
import { OVERALL_TAB } from '../constants';
import OfferPercentileAnalysis from './OfferPercentileAnalysis';
import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../../constants';
import type {
Analysis,
@ -105,34 +104,32 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
];
return (
<>
{getAnalysisResult.isError && (
<Error
statusCode={404}
title="An error occurred while generating profile analysis."
/>
)}
{!getAnalysisResult.isError && analysis && (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
{getAnalysisResult.isLoading ? (
<Spinner className="m-10" display="block" size="lg" />
) : (
<div>
<Tabs
label="Result Navigation"
tabs={tabOptions}
value={tab}
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent analysis={analysis} tab={tab} />
</div>
)}
</div>
)}
</>
analysis && (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
{getAnalysisResult.isError && (
<p className="m-10 text-center">
An error occurred while generating profile analysis.
</p>
)}
{getAnalysisResult.isLoading && (
<Spinner className="m-10" display="block" size="lg" />
)}
{!getAnalysisResult.isError && !getAnalysisResult.isLoading && (
<div>
<Tabs
label="Result Navigation"
tabs={tabOptions}
value={tab}
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent analysis={analysis} tab={tab} />
</div>
)}
</div>
)
);
}

@ -3,7 +3,7 @@ import { UserCircleIcon } from '@heroicons/react/24/outline';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { formatDate } from '~/utils/offers/time';
import { JobType } from '../types';
import { JobType } from '../../types';
import type { AnalysisOffer } from '~/types/offers';

@ -15,9 +15,9 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
import FormRadioList from '../forms/FormRadioList';
import FormSelect from '../forms/FormSelect';
import FormTextInput from '../forms/FormTextInput';
import FormRadioList from '../../forms/FormRadioList';
import FormSelect from '../../forms/FormSelect';
import FormTextInput from '../../forms/FormTextInput';
function YoeSection() {
const { register, formState } = useFormContext<{

@ -16,8 +16,7 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import {
defaultFullTimeOfferValues,
defaultInternshipOfferValues,
} from '~/pages/offers/submit';
} from '../OffersSubmissionForm';
import {
emptyOption,
FieldError,
@ -25,15 +24,15 @@ import {
locationOptions,
titleOptions,
yearOptions,
} from '../constants';
import FormMonthYearPicker from '../forms/FormMonthYearPicker';
import FormSelect from '../forms/FormSelect';
import FormTextArea from '../forms/FormTextArea';
import FormTextInput from '../forms/FormTextInput';
import type { OfferFormData } from '../types';
import { JobTypeLabel } from '../types';
import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
} from '../../constants';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormSelect from '../../forms/FormSelect';
import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../../forms/FormTextInput';
import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../../types';
import { JobType } from '../../types';
import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{
index: number;

@ -5,6 +5,7 @@ import { Button, HorizontalDivider, Spinner, TextArea } from '@tih/ui';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import { copyProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
import type { OffersDiscussion, Reply } from '~/types/offers';
@ -84,19 +85,6 @@ export default function ProfileComments({
}
}
function handleCopyEditLink() {
// TODO: Add notification
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}?token=${token}`,
);
}
function handleCopyPublicLink() {
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}`,
);
}
if (isLoading) {
return (
<div className="col-span-10 pt-4">
@ -116,7 +104,7 @@ export default function ProfileComments({
label="Copy profile edit link"
size="sm"
variant="secondary"
onClick={handleCopyEditLink}
onClick={() => copyProfileLink(profileId, token)}
/>
)}
<Button
@ -127,7 +115,7 @@ export default function ProfileComments({
label="Copy public link"
size="sm"
variant="secondary"
onClick={handleCopyPublicLink}
onClick={() => copyProfileLink(profileId)}
/>
</div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>

@ -27,10 +27,10 @@ export default function ProfileDetails({
);
}
if (selectedTab === 'offers') {
if (offers && offers.length !== 0) {
if (offers.length !== 0) {
return (
<>
{[...offers].map((offer) => (
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>

@ -1,3 +1,4 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import {
BookmarkSquareIcon,
@ -11,6 +12,8 @@ import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundCard } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link';
type ProfileHeaderProps = Readonly<{
background?: BackgroundCard;
handleDelete: () => void;
@ -29,6 +32,12 @@ export default function ProfileHeader({
setSelectedTab,
}: ProfileHeaderProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const router = useRouter();
const { offerProfileId = '', token = '' } = router.query;
const handleEditClick = () => {
router.push(getProfileEditPath(offerProfileId as string, token as string));
};
function renderActionList() {
return (
@ -48,6 +57,7 @@ export default function ProfileHeader({
label="Edit"
size="md"
variant="tertiary"
onClick={handleEditClick}
/>
<Button
disabled={isLoading}
@ -119,9 +129,11 @@ export default function ProfileHeader({
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>{`${background?.experiences[0].companyName ?? '-'} ${
background?.experiences[0].jobLevel
} ${background?.experiences[0].jobTitle}`}</span>
<span>
{`${background?.experiences[0]?.companyName ?? '-'} ${
background?.experiences[0]?.jobLevel || ''
} ${background?.experiences[0]?.jobTitle || ''}`}
</span>
</div>
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" />

@ -1,6 +1,6 @@
import Link from 'next/link';
import { convertCurrencyToString } from '~/utils/offers/currency';
import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
import type { DashboardOffer } from '~/types/offers';
@ -21,7 +21,7 @@ export default function OfferTableRow({
</th>
<td className="py-4 px-6">{title}</td>
<td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertCurrencyToString(income)}</td>
<td className="py-4 px-6">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="space-x-4 py-4 px-6">
<Link

@ -1,6 +1,7 @@
import { useState } from 'react';
import { Select } from '@tih/ui';
import { titleOptions } from '~/components/offers/constants';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -20,24 +21,7 @@ export default function OffersHomePage() {
<Select
isLabelHidden={true}
label="Select a job title"
options={[
{
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
]}
options={titleOptions}
value={jobTitleFilter}
onChange={setjobTitleFilter}
/>

@ -7,7 +7,8 @@ import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
import { convertCurrencyToString } from '~/utils/offers/currency';
import { convertMoneyToString } from '~/utils/offers/currency';
import { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
@ -38,7 +39,7 @@ export default function OfferProfile() {
}
// If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') {
router.push(`/offers/profile/${offerProfileId}`);
router.push(getProfilePath(offerProfileId as string));
}
setIsEditable(data?.isEditable ?? false);
@ -48,10 +49,8 @@ export default function OfferProfile() {
? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) {
const filteredOffer: OfferEntity = {
base: convertCurrencyToString(
res.offersFullTime.baseSalary,
),
bonus: convertCurrencyToString(res.offersFullTime.bonus),
base: convertMoneyToString(res.offersFullTime.baseSalary),
bonus: convertMoneyToString(res.offersFullTime.bonus),
companyName: res.company.name,
id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level,
@ -60,12 +59,11 @@ export default function OfferProfile() {
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived),
stocks: convertCurrencyToString(res.offersFullTime.stocks),
totalCompensation: convertCurrencyToString(
stocks: convertMoneyToString(res.offersFullTime.stocks),
totalCompensation: convertMoneyToString(
res.offersFullTime.totalCompensation,
),
};
return filteredOffer;
}
const filteredOffer: OfferEntity = {
@ -73,7 +71,7 @@ export default function OfferProfile() {
id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title,
location: res.location,
monthlySalary: convertCurrencyToString(
monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary,
),
negotiationStrategy: res.negotiationStrategy || '',
@ -88,46 +86,29 @@ export default function OfferProfile() {
if (data?.background) {
const transformedBackground = {
educations: [
{
endDate: data?.background.educations[0].endDate
? formatDate(data.background.educations[0].endDate)
: '-',
field: data.background.educations[0].field || '-',
school: data.background.educations[0].school || '-',
startDate: data.background.educations[0].startDate
? formatDate(data.background.educations[0].startDate)
: '-',
type: data.background.educations[0].type || '-',
},
],
experiences: [
data.background.experiences &&
data.background.experiences.length > 0
? {
companyName:
data.background.experiences[0].company?.name ?? '-',
duration:
String(data.background.experiences[0].durationInMonths) ??
'-',
jobLevel: data.background.experiences[0].level ?? '',
jobTitle: data.background.experiences[0].title ?? '-',
monthlySalary: data.background.experiences[0].monthlySalary
? convertCurrencyToString(
data.background.experiences[0].monthlySalary,
)
: '-',
totalCompensation: data.background.experiences[0]
.totalCompensation
? convertCurrencyToString(
data.background.experiences[0].totalCompensation,
)
: '-',
}
: {},
],
educations: data.background.educations.map((education) => ({
endDate: education.endDate ? formatDate(education.endDate) : '-',
field: education.field || '-',
school: education.school || '-',
startDate: education.startDate
? formatDate(education.startDate)
: '-',
type: education.type || '-',
})),
experiences: data.background.experiences.map((experience) => ({
companyName: experience.company?.name ?? '-',
duration: String(experience.durationInMonths) ?? '-',
jobLevel: experience.level ?? '',
jobTitle: experience.title ?? '-',
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: '-',
totalCompensation: experience.totalCompensation
? convertMoneyToString(experience.totalCompensation)
: '-',
})),
profileName: data.profileName,
specificYoes: data.background.specificYoes ?? [],
specificYoes: data.background.specificYoes,
totalYoe: String(data.background.totalYoe) || '-',
};
setBackground(transformedBackground);

@ -0,0 +1,79 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
import type { OffersProfileFormData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import { Spinner } from '~/../../../packages/ui/dist';
import { getProfilePath } from '~/utils/offers/link';
import { convertToMonthYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
export default function OffersEditPage() {
const [initialData, setInitialData] = useState<OffersProfileFormData>();
const router = useRouter();
const { offerProfileId, token = '' } = router.query;
const getProfileResult = trpc.useQuery(
[
'offers.profile.listOne',
{ profileId: offerProfileId as string, token: token as string },
],
{
onError(error) {
console.error(error.message);
},
onSuccess(data) {
const { educations, experiences, specificYoes, totalYoe } =
data.background!;
setInitialData({
background: {
educations,
experiences:
experiences.length === 0
? [{ jobType: JobType.FullTime }]
: experiences,
specificYoes,
totalYoe,
},
offers: data.offers.map((offer) => ({
comments: offer.comments,
companyId: offer.company.id,
id: offer.id,
jobType: offer.jobType,
location: offer.location,
monthYearReceived: convertToMonthYear(offer.monthYearReceived),
negotiationStrategy: offer.negotiationStrategy,
offersFullTime: offer.offersFullTime,
offersIntern: offer.offersIntern,
})),
});
},
},
);
const profile = getProfileResult.data;
if (profile && !profile.isEditable) {
router.push(getProfilePath(profile.id));
}
return (
<>
{getProfileResult.isLoading && (
<div className="flex w-full justify-center">
<Spinner className="m-10" display="block" size="lg" />
</div>
)}
{!getProfileResult.isLoading && (
<OffersSubmissionForm
initialOfferProfileValues={initialData}
profileId={profile?.id}
token={profile?.editToken || undefined}
/>
)}
</>
);
}

@ -1,215 +1,5 @@
import { useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import BackgroundForm from '~/components/offers/offers-submission/BackgroundForm';
import OfferAnalysis from '~/components/offers/offers-submission/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/offers-submission/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/offers-submission/OfferProfileSave';
import type {
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { CreateOfferProfileResponse } from '~/types/offers';
const defaultOfferValues = {
comments: '',
companyId: '',
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
month: getCurrentMonth() as Month,
year: getCurrentYear(),
},
negotiationStrategy: '',
};
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FullTime,
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
specificYoes: [],
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
export default function OffersSubmissionPage() {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>();
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: defaultOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: (
<OfferAnalysis key={2} profileId={createProfileResponse?.id} />
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: <OfferProfileSave key={3} />,
hasNext: false,
hasPrevious: false,
label: 'Save',
},
];
const formStepsLabels = formSteps.map((step) => step.label);
const nextStep = async (currStep: number) => {
if (currStep === 0) {
const result = await trigger('offers');
if (!result) {
return;
}
}
setFormStep(formStep + 1);
scrollToTop();
};
const previousStep = () => {
setFormStep(formStep - 1);
scrollToTop();
};
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const createMutation = trpc.useMutation(['offers.profile.create'], {
onError(error) {
console.error(error.message);
},
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
}
data = removeInvalidMoneyData(data);
const background = cleanObject(data.background);
background.specificYoes = data.background.specificYoes.filter(
(specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
);
if (Object.entries(background.experiences[0]).length === 1) {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,
offer.monthYearReceived.month,
),
}));
const postData = { background, offers };
createMutation.mutate(postData);
};
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
return <OffersSubmissionForm />;
}

@ -216,7 +216,7 @@ export const offersAnalysisRouter = createRouter()
// TODO: Shift yoe out of background to make it mandatory
if (
!overallHighestOffer.profile.background ||
!overallHighestOffer.profile.background.totalYoe
overallHighestOffer.profile.background.totalYoe === undefined
) {
throw new TRPCError({
code: 'BAD_REQUEST',

@ -1,6 +1,6 @@
import type { Money } from '~/components/offers/types';
export function convertCurrencyToString({ currency, value }: Money) {
export function convertMoneyToString({ currency, value }: Money) {
if (!value) {
return '-';
}

@ -0,0 +1,19 @@
export function getProfileLink(profileId: string, token?: string) {
return `${window.location.origin}${getProfilePath(profileId, token)}`;
}
export function copyProfileLink(profileId: string, token?: string) {
// TODO: Add notification
navigator.clipboard.writeText(getProfileLink(profileId, token));
}
export function getProfilePath(profileId: string, token?: string) {
if (token) {
return `/offers/profile/${profileId}?token=${token}`;
}
return `/offers/profile/${profileId}`;
}
export function getProfileEditPath(profileId: string, token: string) {
return `/offers/profile/edit/${profileId}?token=${token}`;
}

@ -32,22 +32,20 @@ export function timeSinceNow(date: Date | number | string) {
export function formatDate(value: Date | number | string) {
const date = new Date(value);
// Const day = date.toLocaleString('default', { day: '2-digit' });
const month = date.toLocaleString('default', { month: 'short' });
const year = date.toLocaleString('default', { year: 'numeric' });
return `${month} ${year}`;
}
export function formatMonthYear({ month, year }: MonthYear) {
const monthString = month < 10 ? month.toString() : `0${month}`;
const yearString = year.toString();
return `${monthString}/${yearString}`;
}
export function getCurrentMonth() {
return getMonth(Date.now());
// `getMonth` returns a zero-based month index
return getMonth(Date.now()) + 1;
}
export function getCurrentYear() {
return getYear(Date.now());
}
export function convertToMonthYear(date: Date) {
return { month: date.getMonth() + 1, year: date.getFullYear() } as MonthYear;
}

Loading…
Cancel
Save