Merge branch 'main' into stuart/seed-db

* main:
  [offers][feat] revamp comments section
  [offers][feat] add job type for dashboard cards (#503)
  [offers][feat] add query params to offer table (#502)
  [offers][fix] Refactor UI (#500)
pull/501/head^2
Bryann Yeap Kok Keong 3 years ago
commit dcae3c1685

@ -5,6 +5,7 @@ import {
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { JobTypeLabel } from '~/components/offers/constants';
import type { JobTitleType } from '~/components/shared/JobTitles'; import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles'; import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
@ -33,7 +34,8 @@ export default function DashboardProfileCard({
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<div className="col-span-1 row-span-3"> <div className="col-span-1 row-span-3">
<h4 className="font-medium"> <h4 className="font-medium">
{getLabelForJobTitleType(title as JobTitleType)} {getLabelForJobTitleType(title as JobTitleType)}{' '}
{jobType && <>({JobTypeLabel[jobType]})</>}
</h4> </h4>
<div className="mt-1 flex flex-col sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-4"> <div className="mt-1 flex flex-col sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-4">
{company?.name && ( {company?.name && (

@ -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,6 +279,7 @@ function InternshipJobFields() {
})} })}
/> />
<Collapsible label="Add more details"> <Collapsible label="Add more details">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<CitiesTypeahead <CitiesTypeahead
label="Location" label="Location"
value={{ 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> </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,7 +101,8 @@ 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
? totalCompensation && (
<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">
Total Compensation Total Compensation
@ -108,13 +111,15 @@ export default function OfferCard({
{totalCompensation} {totalCompensation}
</dd> </dd>
</div> </div>
)} )
{monthlySalary && ( : monthlySalary && (
<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">
Monthly Salary Monthly Salary
</dt> </dt>
<dd className="mt-1 text-sm text-slate-900">{monthlySalary}</dd> <dd className="mt-1 text-sm text-slate-900">
{monthlySalary}
</dd>
</div> </div>
)} )}
{base && ( {base && (

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

@ -118,7 +118,7 @@ function ProfileAnalysis({
} }
return ( return (
<div className="p-4"> <div className="space-y-4 p-4">
{!analysis ? ( {!analysis ? (
<p>No analysis available.</p> <p>No analysis available.</p>
) : ( ) : (

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

@ -2,6 +2,8 @@ import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { Button, Dialog, TextArea, useToast } from '@tih/ui'; import { Button, Dialog, TextArea, useToast } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import { timeSinceNow } from '~/utils/offers/time'; import { timeSinceNow } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -121,14 +123,18 @@ export default function CommentCard({
return ( return (
<div className="flex space-x-3"> <div className="flex space-x-3">
{/* <div className="flex-shrink-0"> <div className="flex-shrink-0">
{user?.image ? (
<img <img
alt="" alt={user?.name ?? user?.email ?? 'Unknown user'}
className="h-10 w-10 rounded-full" className="h-10 w-10 rounded-full"
src={`https://images.unsplash.com/photo-${comment.imageId}?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80`} src={user?.image}
/> />
</div> */} ) : (
<div> <ProfilePhotoHolder size="xs" />
)}
</div>
<div className="w-full">
<div className="text-sm"> <div className="text-sm">
<p className="font-medium text-slate-900"> <p className="font-medium text-slate-900">
{user?.name ?? 'unknown user'} {user?.name ?? 'unknown user'}
@ -137,35 +143,35 @@ export default function CommentCard({
<div className="mt-1 text-sm text-slate-700"> <div className="mt-1 text-sm text-slate-700">
<p className="break-all">{message}</p> <p className="break-all">{message}</p>
</div> </div>
<div className="mt-2 space-x-2 text-sm"> <div className="mt-2 space-x-2 text-xs">
<span className="font-medium text-slate-500"> <span className="font-medium text-slate-500">
{timeSinceNow(createdAt)} ago {timeSinceNow(createdAt)} ago
</span>{' '} </span>{' '}
<span className="font-medium text-slate-500">&middot;</span>{' '}
{replyLength > 0 && ( {replyLength > 0 && (
<> <>
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button <button
className="font-medium text-slate-900" className="font-medium text-slate-900"
type="button" type="button"
onClick={handleExpanded}> onClick={handleExpanded}>
{isExpanded ? `Hide replies` : `View replies (${replyLength})`} {isExpanded ? `Hide replies` : `View replies (${replyLength})`}
</button> </button>
<span className="font-medium text-slate-500">&middot;</span>{' '}
</> </>
)} )}
{!disableReply && ( {!disableReply && (
<> <>
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button <button
className="font-medium text-slate-900" className="font-medium text-slate-900"
type="button" type="button"
onClick={() => setIsReplying(!isReplying)}> onClick={() => setIsReplying(!isReplying)}>
Reply Reply
</button> </button>
<span className="font-medium text-slate-500">&middot;</span>{' '}
</> </>
)} )}
{deletable && ( {deletable && (
<> <>
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button <button
className="font-medium text-slate-900" className="font-medium text-slate-900"
disabled={deleteCommentMutation.isLoading} disabled={deleteCommentMutation.isLoading}
@ -204,7 +210,7 @@ export default function CommentCard({
)} )}
</div> </div>
{!disableReply && isReplying && ( {!disableReply && isReplying && (
<div className="mt-2 mr-2"> <div className="mt-4 mr-2">
<form <form
onSubmit={(event) => { onSubmit={(event) => {
event.preventDefault(); event.preventDefault();
@ -212,8 +218,9 @@ export default function CommentCard({
handleReply(); handleReply();
}}> }}>
<TextArea <TextArea
autoFocus={true}
isLabelHidden={true} isLabelHidden={true}
label="Comment" label="Reply to comment"
placeholder="Type your reply here" placeholder="Type your reply here"
resize="none" resize="none"
value={currentReply} value={currentReply}

@ -28,8 +28,7 @@ export default function ExpandableCommentCard({
/> />
{comment.replies && comment.replies.length > 0 && isExpanded && ( {comment.replies && comment.replies.length > 0 && isExpanded && (
<div className="pt-4"> <div className="pt-4">
<div className="border-l-4 border-slate-200 pl-4"> <ul className="space-y-4 pl-14" role="list">
<ul className="space-y-4" role="list">
{comment.replies.map((reply) => ( {comment.replies.map((reply) => (
<li key={reply.id}> <li key={reply.id}>
<CommentCard <CommentCard
@ -42,7 +41,6 @@ export default function ExpandableCommentCard({
))} ))}
</ul> </ul>
</div> </div>
</div>
)} )}
</div> </div>
); );

@ -1,14 +1,14 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { DropdownMenu, Spinner, useToast } from '@tih/ui'; import { DropdownMenu, Spinner, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination'; import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import type { OfferTableSortByType } from '~/components/offers/table/types';
import { import {
OfferTableFilterOptions, OfferTableFilterOptions,
OfferTableSortBy,
OfferTableYoeOptions, OfferTableYoeOptions,
YOE_CATEGORY, YOE_CATEGORY,
YOE_CATEGORY_PARAM, YOE_CATEGORY_PARAM,
@ -16,6 +16,7 @@ import {
import { Currency } from '~/utils/offers/currency/CurrencyEnum'; import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector'; import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import OffersRow from './OffersRow'; import OffersRow from './OffersRow';
@ -25,16 +26,17 @@ import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_IN_PAGE = 10; const NUMBER_OF_OFFERS_IN_PAGE = 10;
export type OffersTableProps = Readonly<{ export type OffersTableProps = Readonly<{
companyFilter: string; companyFilter: string;
companyName?: string;
countryFilter: string; countryFilter: string;
jobTitleFilter: string; jobTitleFilter: string;
}>; }>;
export default function OffersTable({ export default function OffersTable({
countryFilter, countryFilter,
companyName,
companyFilter, companyFilter,
jobTitleFilter, jobTitleFilter,
}: OffersTableProps) { }: OffersTableProps) {
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
const [selectedYoe, setSelectedYoe] = useState('');
const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME); const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME);
const [pagination, setPagination] = useState<Paging>({ const [pagination, setPagination] = useState<Paging>({
currentPage: 0, currentPage: 0,
@ -42,16 +44,43 @@ export default function OffersTable({
numOfPages: 0, numOfPages: 0,
totalItems: 0, totalItems: 0,
}); });
const [offers, setOffers] = useState<Array<DashboardOffer>>([]); const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
const [selectedFilter, setSelectedFilter] = useState(
OfferTableFilterOptions[0].value,
);
const { event: gaEvent } = useGoogleAnalytics(); const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter(); const router = useRouter();
const { yoeCategory = '' } = router.query;
const [isLoading, setIsLoading] = useState(true); 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(() => { useEffect(() => {
if (areFilterParamsInitialized) {
router.replace(
{
pathname,
query: {
companyId: companyFilter,
companyName,
jobTitleId: jobTitleFilter,
sortBy: selectedSortBy,
yoeCategory: selectedYoeCategory,
},
},
undefined,
{ shallow: true },
);
setPagination({ setPagination({
currentPage: 0, currentPage: 0,
numOfItems: 0, numOfItems: 0,
@ -59,12 +88,18 @@ export default function OffersTable({
totalItems: 0, totalItems: 0,
}); });
setIsLoading(true); setIsLoading(true);
}, [selectedYoe, currency, countryFilter, companyFilter, jobTitleFilter]); }
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { }, [
setSelectedYoe(yoeCategory as YOE_CATEGORY); areFilterParamsInitialized,
event?.preventDefault(); currency,
}, [yoeCategory]); countryFilter,
companyFilter,
jobTitleFilter,
selectedSortBy,
selectedYoeCategory,
pathname,
]);
const { showToast } = useToast(); const { showToast } = useToast();
trpc.useQuery( trpc.useQuery(
@ -76,9 +111,11 @@ export default function OffersTable({
currency, currency,
limit: NUMBER_OF_OFFERS_IN_PAGE, limit: NUMBER_OF_OFFERS_IN_PAGE,
offset: pagination.currentPage, offset: pagination.currentPage,
sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived', sortBy: selectedSortBy ?? '-monthYearReceived',
title: jobTitleFilter, title: jobTitleFilter,
yoeCategory: YOE_CATEGORY_PARAM[yoeCategory as string] ?? undefined, yoeCategory: selectedYoeCategory
? YOE_CATEGORY_PARAM[selectedYoeCategory as string]
: undefined,
}, },
], ],
{ {
@ -104,39 +141,21 @@ export default function OffersTable({
align="start" align="start"
label={ label={
OfferTableYoeOptions.filter( OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoe, ({ value: itemValue }) => itemValue === selectedYoeCategory,
).length > 0
? OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedYoeCategory,
)[0].label )[0].label
: OfferTableYoeOptions[0].label
} }
size="inherit"> size="inherit">
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => ( {OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item <DropdownMenu.Item
key={value} key={value}
isSelected={value === selectedYoe} isSelected={value === selectedYoeCategory}
label={itemLabel} label={itemLabel}
onClick={() => { onClick={() => {
if (value === '') { setSelectedYoeCategory(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 },
);
}
gaEvent({ gaEvent({
action: `offers.table_filter_yoe_category_${value}`, action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement', category: 'engagement',
@ -161,17 +180,21 @@ export default function OffersTable({
align="end" align="end"
label={ label={
OfferTableFilterOptions.filter( OfferTableFilterOptions.filter(
({ value: itemValue }) => itemValue === selectedFilter, ({ value: itemValue }) => itemValue === selectedSortBy,
).length > 0
? OfferTableFilterOptions.filter(
({ value: itemValue }) => itemValue === selectedSortBy,
)[0].label )[0].label
: OfferTableFilterOptions[0].label
} }
size="inherit"> size="inherit">
{OfferTableFilterOptions.map(({ label: itemLabel, value }) => ( {OfferTableFilterOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item <DropdownMenu.Item
key={value} key={value}
isSelected={value === selectedFilter} isSelected={value === selectedSortBy}
label={itemLabel} label={itemLabel}
onClick={() => { onClick={() => {
setSelectedFilter(value); setSelectedSortBy(value as OfferTableSortByType);
}} }}
/> />
))} ))}
@ -187,7 +210,9 @@ export default function OffersTable({
'Company', 'Company',
'Title', 'Title',
'YOE', 'YOE',
selectedYoe === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'Annual TC', selectedYoeCategory === YOE_CATEGORY.INTERN
? 'Monthly Salary'
: 'Annual TC',
'Date Offered', 'Date Offered',
'Actions', 'Actions',
]; ];

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

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

@ -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,22 +189,18 @@ export default function OfferProfile() {
} }
} }
return ( return getProfileQuery.isError ? (
<>
{getProfileQuery.isError && (
<div className="flex w-full justify-center"> <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> </div>
)} ) : getProfileQuery.isLoading ? (
{getProfileQuery.isLoading && (
<div className="flex h-screen w-screen"> <div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500"> <div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
<div className="text-center">Loading...</div> <div className="text-center">Loading...</div>
</div> </div>
</div> </div>
)} ) : (
{!getProfileQuery.isLoading && !getProfileQuery.isError && (
<div className="w-full divide-x lg:flex"> <div className="w-full divide-x lg:flex">
<div className="divide-y lg:w-2/3"> <div className="divide-y lg:w-2/3">
<div className="h-fit"> <div className="h-fit">
@ -240,7 +238,5 @@ export default function OfferProfile() {
/> />
</div> </div>
</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,23 +77,21 @@ export default function OffersSubmissionResult() {
scrollToTop(); scrollToTop();
}, [step]); }, [step]);
return ( return checkToken.isLoading || getAnalysis.isLoading ? (
<>
{(checkToken.isLoading || getAnalysis.isLoading) && (
<div className="flex h-screen w-screen"> <div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500"> <div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
<div className="text-center">Loading...</div> <div className="text-center">Loading...</div>
</div> </div>
</div> </div>
)} ) : checkToken.isError || getAnalysis.isError ? (
{checkToken.isSuccess && !isValidToken && ( <Error statusCode={404} title="Error loading page" />
) : checkToken.isSuccess && !checkToken.data ? (
<Error <Error
statusCode={403} statusCode={403}
title="You do not have permissions to access this page" title="You do not have permissions to access this page"
/> />
)} ) : (
{getAnalysis.isSuccess && (
<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">
@ -141,7 +139,5 @@ export default function OffersSubmissionResult() {
</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;
}

@ -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;
};
Loading…
Cancel
Save