[offers][fix] Refactor UI (#500)

pull/503/head
Ai Ling 2 years ago committed by GitHub
parent acc9ab00e1
commit 70006d4115
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -33,15 +33,15 @@ export default function OfferProfileCard({
location, location,
title, title,
previousCompanies, previousCompanies,
profileId,
}, },
}: OfferProfileCardProps) { }: OfferProfileCardProps) {
return ( return (
// <a <a
// className="my-5 block rounded-lg bg-white p-4 px-8 shadow-md" className="my-5 block rounded-lg border bg-white p-4 px-8 shadow-md"
// href={`/offers/profile/${id}`} href={`/offers/profile/${profileId}`}
// rel="noreferrer" rel="noreferrer"
// target="_blank"> 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 className="flex items-center gap-x-5">
<div> <div>
<ProfilePhotoHolder size="sm" /> <ProfilePhotoHolder size="sm" />
@ -82,6 +82,6 @@ export default function OfferProfileCard({
</p> </p>
</div> </div>
</div> </div>
</div> </a>
); );
} }

@ -1,5 +1,5 @@
import { signIn, useSession } from 'next-auth/react'; 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 { DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkIcon as BookmarkOutlineIcon } from '@heroicons/react/24/outline'; import { BookmarkIcon as BookmarkOutlineIcon } from '@heroicons/react/24/outline';
import { BookmarkIcon as BookmarkSolidIcon } from '@heroicons/react/24/solid'; 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'; import { trpc } from '~/utils/trpc';
type OfferProfileSaveProps = Readonly<{ type OfferProfileSaveProps = Readonly<{
isSavedQuery: UseQueryResult<boolean>;
profileId: string; profileId: string;
token?: string; token?: string;
}>; }>;
@ -18,10 +19,10 @@ type OfferProfileSaveProps = Readonly<{
export default function OffersProfileSave({ export default function OffersProfileSave({
profileId, profileId,
token, token,
isSavedQuery: { data: isSaved, isLoading },
}: OfferProfileSaveProps) { }: OfferProfileSaveProps) {
const { showToast } = useToast(); const { showToast } = useToast();
const { event: gaEvent } = useGoogleAnalytics(); const { event: gaEvent } = useGoogleAnalytics();
const [isSaved, setSaved] = useState(false);
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const saveMutation = trpc.useMutation( 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 trpcContext = trpc.useContext();
const handleSave = () => { const handleSave = () => {
if (status === 'unauthenticated') { if (status === 'unauthenticated') {
@ -125,9 +117,9 @@ export default function OffersProfileSave({
</p> </p>
<div className="mt-6"> <div className="mt-6">
<Button <Button
disabled={isSavedQuery.isLoading || isSaved} disabled={isLoading || isSaved}
icon={isSaved ? BookmarkSolidIcon : BookmarkOutlineIcon} icon={isSaved ? BookmarkSolidIcon : BookmarkOutlineIcon}
isLoading={saveMutation.isLoading || isSavedQuery.isLoading} isLoading={saveMutation.isLoading}
label={isSaved ? 'Added to account' : 'Add to your account'} label={isSaved ? 'Added to account' : 'Add to your account'}
size="sm" size="sm"
variant="secondary" variant="secondary"

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

@ -4,7 +4,7 @@ import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { Button, useToast } from '@tih/ui'; import { Button, Spinner, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumbs'; import type { BreadcrumbStep } from '~/components/offers/Breadcrumbs';
@ -116,7 +116,7 @@ export default function OffersSubmissionForm({
const { const {
handleSubmit, handleSubmit,
trigger, trigger,
formState: { isSubmitting, isSubmitSuccessful }, formState: { isSubmitting },
} = formMethods; } = formMethods;
const generateAnalysisMutation = trpc.useMutation( const generateAnalysisMutation = trpc.useMutation(
@ -124,6 +124,10 @@ export default function OffersSubmissionForm({
{ {
onError(error) { onError(error) {
console.error(error.message); console.error(error.message);
showToast({
title: 'Error generating analysis.',
variant: 'failure',
});
}, },
onSuccess() { onSuccess() {
router.push( router.push(
@ -174,7 +178,7 @@ export default function OffersSubmissionForm({
title: title:
editProfileId && editToken editProfileId && editToken
? 'Error updating offer profile.' ? 'Error updating offer profile.'
: 'Error creating offer profile', : 'Error creating offer profile.',
variant: 'failure', variant: 'failure',
}); });
}, },
@ -193,7 +197,7 @@ export default function OffersSubmissionForm({
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => { const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger(); const result = await trigger();
if (!result || isSubmitting || isSubmitSuccessful) { if (!result || isSubmitting || createOrUpdateMutation.isLoading) {
return; return;
} }
@ -272,7 +276,9 @@ export default function OffersSubmissionForm({
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 ref={pageRef} className="w-full">
<div className="flex justify-center"> <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="block w-full max-w-screen-md overflow-hidden rounded-lg sm:shadow-lg md:my-10">
@ -324,9 +330,16 @@ export default function OffersSubmissionForm({
}} }}
/> />
<Button <Button
disabled={isSubmitting || isSubmitSuccessful} disabled={
isSubmitting ||
createOrUpdateMutation.isLoading ||
generateAnalysisMutation.isLoading ||
generateAnalysisMutation.isSuccess
}
icon={ArrowRightIcon} icon={ArrowRightIcon}
isLoading={isSubmitting || isSubmitSuccessful} isLoading={
isSubmitting || createOrUpdateMutation.isLoading
}
label="Submit" label="Submit"
type="submit" type="submit"
variant="primary" variant="primary"

@ -279,23 +279,34 @@ function InternshipJobFields() {
})} })}
/> />
<Collapsible label="Add more details"> <Collapsible label="Add more details">
<CitiesTypeahead <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
label="Location" <CitiesTypeahead
value={{ label="Location"
id: watchCityId, value={{
label: watchCityName, id: watchCityId,
value: watchCityId, label: watchCityName,
}} value: watchCityId,
onSelect={(option) => { }}
if (option) { onSelect={(option) => {
setValue('background.experiences.0.cityId', option.value); if (option) {
setValue('background.experiences.0.cityName', option.label); setValue('background.experiences.0.cityId', option.value);
} else { setValue('background.experiences.0.cityName', option.label);
setValue('background.experiences.0.cityId', ''); } else {
setValue('background.experiences.0.cityName', ''); setValue('background.experiences.0.cityId', '');
} setValue('background.experiences.0.cityName', '');
}} }
/> }}
/>
<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> </Collapsible>
</> </>
); );

@ -3,11 +3,13 @@ import {
BuildingOfficeIcon, BuildingOfficeIcon,
MapPinIcon, MapPinIcon,
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client';
import { JobTypeLabel } from '~/components/offers/constants'; import { JobTypeLabel } from '~/components/offers/constants';
import type { OfferDisplayData } from '~/components/offers/types'; import type { OfferDisplayData } from '~/components/offers/types';
import { getLocationDisplayText } from '~/utils/offers/string'; import { getLocationDisplayText } from '~/utils/offers/string';
import { getDurationDisplayText } from '~/utils/offers/time';
type Props = Readonly<{ type Props = Readonly<{
offer: OfferDisplayData; offer: OfferDisplayData;
@ -75,9 +77,9 @@ export default function OfferCard({
<p>{receivedMonth}</p> <p>{receivedMonth}</p>
</div> </div>
)} )}
{duration && ( {!!duration && (
<div className="text-sm text-slate-500"> <div className="text-sm text-slate-500">
<p>{`${duration} months`}</p> <p>{getDurationDisplayText(duration)}</p>
</div> </div>
)} )}
</div> </div>
@ -99,24 +101,27 @@ export default function OfferCard({
return ( return (
<div className="border-t border-slate-200 px-4 py-5 sm:px-6"> <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"> <dl className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-4">
{totalCompensation && ( {jobType === JobType.FULLTIME
<div className="col-span-1"> ? totalCompensation && (
<dt className="text-sm font-medium text-slate-500"> <div className="col-span-1">
Total Compensation <dt className="text-sm font-medium text-slate-500">
</dt> Total Compensation
<dd className="mt-1 text-sm text-slate-900"> </dt>
{totalCompensation} <dd className="mt-1 text-sm text-slate-900">
</dd> {totalCompensation}
</div> </dd>
)} </div>
{monthlySalary && ( )
<div className="col-span-1"> : monthlySalary && (
<dt className="text-sm font-medium text-slate-500"> <div className="col-span-1">
Monthly Salary <dt className="text-sm font-medium text-slate-500">
</dt> Monthly Salary
<dd className="mt-1 text-sm text-slate-900">{monthlySalary}</dd> </dt>
</div> <dd className="mt-1 text-sm text-slate-900">
)} {monthlySalary}
</dd>
</div>
)}
{base && ( {base && (
<div className="col-span-1"> <div className="col-span-1">
<dt className="text-sm font-medium text-slate-500"> <dt className="text-sm font-medium text-slate-500">

@ -79,6 +79,7 @@ export default function OfferProfile() {
jobTitle: getLabelForJobTitleType( jobTitle: getLabelForJobTitleType(
res.offersFullTime.title as JobTitleType, res.offersFullTime.title as JobTitleType,
), ),
jobType: res.jobType,
location: res.location, location: res.location,
negotiationStrategy: res.negotiationStrategy, negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments, otherComment: res.comments,
@ -99,6 +100,7 @@ export default function OfferProfile() {
jobTitle: getLabelForJobTitleType( jobTitle: getLabelForJobTitleType(
res.offersIntern!.title as JobTitleType, res.offersIntern!.title as JobTitleType,
), ),
jobType: res.jobType,
location: res.location, location: res.location,
monthlySalary: convertMoneyToString( monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary, res.offersIntern!.monthlySalary,
@ -187,60 +189,54 @@ export default function OfferProfile() {
} }
} }
return ( return getProfileQuery.isError ? (
<> <div className="flex w-full justify-center">
{getProfileQuery.isError && ( <Error statusCode={404} title="Requested profile does not exist." />
<div className="flex w-full justify-center"> </div>
<Error statusCode={404} title="Requested profile does not exist" /> ) : 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>
) : (
<div className="w-full divide-x lg:flex">
<div className="divide-y lg:w-2/3">
<div className="h-fit">
<ProfileHeader
background={background}
handleDelete={handleDelete}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>
</div> </div>
)} <div>
{getProfileQuery.isLoading && ( <ProfileDetails
<div className="flex h-screen w-screen"> analysis={analysis}
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500"> background={background}
<Spinner display="block" size="lg" /> isEditable={isEditable}
<div className="text-center">Loading...</div> isLoading={getProfileQuery.isLoading}
</div> offers={offers}
profileId={offerProfileId as string}
selectedTab={selectedTab}
/>
</div> </div>
)} </div>
{!getProfileQuery.isLoading && !getProfileQuery.isError && ( <div
<div className="w-full divide-x lg:flex"> className="bg-white lg:fixed lg:right-0 lg:bottom-0 lg:w-1/3"
<div className="divide-y lg:w-2/3"> style={{ top: 64 }}>
<div className="h-fit"> <ProfileComments
<ProfileHeader isDisabled={deleteMutation.isLoading}
background={background} isEditable={isEditable}
handleDelete={handleDelete} isLoading={getProfileQuery.isLoading}
isEditable={isEditable} profileId={offerProfileId as string}
isLoading={getProfileQuery.isLoading} profileName={background?.profileName}
selectedTab={selectedTab} token={token as string}
setSelectedTab={setSelectedTab} />
/> </div>
</div> </div>
<div>
<ProfileDetails
analysis={analysis}
background={background}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
offers={offers}
profileId={offerProfileId as string}
selectedTab={selectedTab}
/>
</div>
</div>
<div
className="bg-white lg:fixed lg:right-0 lg:bottom-0 lg:w-1/3"
style={{ top: 64 }}>
<ProfileComments
isDisabled={deleteMutation.isLoading}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
profileId={offerProfileId as string}
profileName={background?.profileName}
token={token as string}
/>
</div>
</div>
)}
</>
); );
} }

@ -1,5 +1,6 @@
import Error from 'next/error'; import Error from 'next/error';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { EyeIcon } from '@heroicons/react/24/outline'; import { EyeIcon } from '@heroicons/react/24/outline';
@ -13,44 +14,43 @@ import OffersSubmissionAnalysis from '~/components/offers/offersSubmission/Offer
import { getProfilePath } from '~/utils/offers/link'; import { getProfilePath } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { ProfileAnalysis } from '~/types/offers';
export default function OffersSubmissionResult() { export default function OffersSubmissionResult() {
const router = useRouter(); const router = useRouter();
let { offerProfileId, token = '' } = router.query; let { offerProfileId, token = '' } = router.query;
offerProfileId = offerProfileId as string; offerProfileId = offerProfileId as string;
token = token as string; token = token as string;
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null); const { data: session } = useSession();
const [isValidToken, setIsValidToken] = useState(false);
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () => const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 }); pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const checkToken = trpc.useQuery( const checkToken = trpc.useQuery([
['offers.profile.isValidToken', { profileId: offerProfileId, token }], 'offers.profile.isValidToken',
{ { profileId: offerProfileId, token },
onSuccess(data) { ]);
setIsValidToken(data);
},
},
);
const getAnalysis = trpc.useQuery( const getAnalysis = trpc.useQuery([
['offers.analysis.get', { profileId: offerProfileId }], 'offers.analysis.get',
{ { profileId: offerProfileId },
onSuccess(data) { ]);
setAnalysis(data);
}, const isSavedQuery = trpc.useQuery([
}, `offers.profile.isSaved`,
); { profileId: offerProfileId, userId: session?.user?.id },
]);
const steps = [ const steps = [
<OffersProfileSave key={0} profileId={offerProfileId} token={token} />, <OffersProfileSave
key={0}
isSavedQuery={isSavedQuery}
profileId={offerProfileId}
token={token}
/>,
<OffersSubmissionAnalysis <OffersSubmissionAnalysis
key={1} key={1}
analysis={analysis} analysis={getAnalysis.data}
isError={getAnalysis.isError} isError={getAnalysis.isError}
isLoading={getAnalysis.isLoading} isLoading={getAnalysis.isLoading}
/>, />,
@ -77,71 +77,67 @@ export default function OffersSubmissionResult() {
scrollToTop(); scrollToTop();
}, [step]); }, [step]);
return ( return checkToken.isLoading || getAnalysis.isLoading ? (
<> <div className="flex h-screen w-screen">
{(checkToken.isLoading || getAnalysis.isLoading) && ( <div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
<div className="flex h-screen w-screen"> <Spinner display="block" size="lg" />
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500"> <div className="text-center">Loading...</div>
<Spinner display="block" size="lg" /> </div>
<div className="text-center">Loading...</div> </div>
) : 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">
<div className="flex justify-center bg-slate-100 px-4 py-4 sm:px-6 lg:px-8">
<Breadcrumbs
currentStep={step}
setStep={setStep}
steps={breadcrumbSteps}
/>
</div> </div>
</div> <div className="bg-white p-6 sm:p-10">
)} {steps[step]}
{checkToken.isSuccess && !isValidToken && ( {step === 0 && (
<Error <div className="flex justify-end">
statusCode={403} <Button
title="You do not have permissions to access this page" disabled={false}
/> icon={ArrowRightIcon}
)} label="Next"
{getAnalysis.isSuccess && ( variant="primary"
<div ref={pageRef} className="w-full"> onClick={() => setStep(step + 1)}
<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}
steps={breadcrumbSteps}
/> />
</div> </div>
<div className="bg-white p-6 sm:p-10"> )}
{steps[step]} {step === 1 && (
{step === 0 && ( <div className="flex items-center justify-between">
<div className="flex justify-end"> <Button
<Button addonPosition="start"
disabled={false} icon={ArrowLeftIcon}
icon={ArrowRightIcon} label="Previous"
label="Next" variant="secondary"
variant="primary" onClick={() => setStep(step - 1)}
onClick={() => setStep(step + 1)} />
/> <Button
</div> href={getProfilePath(
)} offerProfileId as string,
{step === 1 && ( token as string,
<div className="flex items-center justify-between"> )}
<Button icon={EyeIcon}
addonPosition="start" label="View your profile"
icon={ArrowLeftIcon} variant="primary"
label="Previous" />
variant="secondary"
onClick={() => setStep(step - 1)}
/>
<Button
href={getProfilePath(
offerProfileId as string,
token as string,
)}
icon={EyeIcon}
label="View your profile"
variant="primary"
/>
</div>
)}
</div> </div>
</div> )}
</div> </div>
</div> </div>
)} </div>
</> </div>
); );
} }

@ -55,3 +55,18 @@ export function getCurrentYear() {
export function convertToMonthYear(date: Date) { export function convertToMonthYear(date: Date) {
return { month: date.getMonth() + 1, year: date.getFullYear() } as MonthYear; 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;
}

Loading…
Cancel
Save