+
diff --git a/apps/portal/src/components/offers/table/OffersTable.tsx b/apps/portal/src/components/offers/table/OffersTable.tsx
index b530466b..209a9ec8 100644
--- a/apps/portal/src/components/offers/table/OffersTable.tsx
+++ b/apps/portal/src/components/offers/table/OffersTable.tsx
@@ -1,14 +1,14 @@
import clsx from 'clsx';
import { useRouter } from 'next/router';
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { JobType } from '@prisma/client';
import { DropdownMenu, Spinner, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
+import type { OfferTableSortByType } from '~/components/offers/table/types';
import {
OfferTableFilterOptions,
- OfferTableSortBy,
OfferTableYoeOptions,
YOE_CATEGORY,
YOE_CATEGORY_PARAM,
@@ -16,6 +16,7 @@ import {
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
+import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
import { trpc } from '~/utils/trpc';
import OffersRow from './OffersRow';
@@ -25,16 +26,17 @@ import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_IN_PAGE = 10;
export type OffersTableProps = Readonly<{
companyFilter: string;
+ companyName?: string;
countryFilter: string;
jobTitleFilter: string;
}>;
export default function OffersTable({
countryFilter,
+ companyName,
companyFilter,
jobTitleFilter,
}: OffersTableProps) {
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
- const [selectedYoe, setSelectedYoe] = useState('');
const [jobType, setJobType] = useState
(JobType.FULLTIME);
const [pagination, setPagination] = useState({
currentPage: 0,
@@ -42,29 +44,62 @@ export default function OffersTable({
numOfPages: 0,
totalItems: 0,
});
+
const [offers, setOffers] = useState>([]);
- const [selectedFilter, setSelectedFilter] = useState(
- OfferTableFilterOptions[0].value,
- );
+
const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter();
- const { yoeCategory = '' } = router.query;
const [isLoading, setIsLoading] = useState(true);
- useEffect(() => {
- setPagination({
- currentPage: 0,
- numOfItems: 0,
- numOfPages: 0,
- totalItems: 0,
- });
- setIsLoading(true);
- }, [selectedYoe, currency, countryFilter, companyFilter, jobTitleFilter]);
+ const [
+ selectedYoeCategory,
+ setSelectedYoeCategory,
+ isYoeCategoryInitialized,
+ ] = useSearchParamSingle('yoeCategory');
+
+ const [selectedSortBy, setSelectedSortBy, isSortByInitialized] =
+ useSearchParamSingle('sortBy');
+
+ const areFilterParamsInitialized = useMemo(() => {
+ return isYoeCategoryInitialized && isSortByInitialized;
+ }, [isYoeCategoryInitialized, isSortByInitialized]);
+ const { pathname } = router;
useEffect(() => {
- setSelectedYoe(yoeCategory as YOE_CATEGORY);
- event?.preventDefault();
- }, [yoeCategory]);
+ if (areFilterParamsInitialized) {
+ router.replace(
+ {
+ pathname,
+ query: {
+ companyId: companyFilter,
+ companyName,
+ jobTitleId: jobTitleFilter,
+ sortBy: selectedSortBy,
+ yoeCategory: selectedYoeCategory,
+ },
+ },
+ undefined,
+ { shallow: true },
+ );
+ setPagination({
+ currentPage: 0,
+ numOfItems: 0,
+ numOfPages: 0,
+ totalItems: 0,
+ });
+ setIsLoading(true);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [
+ areFilterParamsInitialized,
+ currency,
+ countryFilter,
+ companyFilter,
+ jobTitleFilter,
+ selectedSortBy,
+ selectedYoeCategory,
+ pathname,
+ ]);
const { showToast } = useToast();
trpc.useQuery(
@@ -76,9 +111,11 @@ export default function OffersTable({
currency,
limit: NUMBER_OF_OFFERS_IN_PAGE,
offset: pagination.currentPage,
- sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
+ sortBy: selectedSortBy ?? '-monthYearReceived',
title: jobTitleFilter,
- yoeCategory: YOE_CATEGORY_PARAM[yoeCategory as string] ?? undefined,
+ yoeCategory: selectedYoeCategory
+ ? YOE_CATEGORY_PARAM[selectedYoeCategory as string]
+ : undefined,
},
],
{
@@ -104,39 +141,21 @@ export default function OffersTable({
align="start"
label={
OfferTableYoeOptions.filter(
- ({ value: itemValue }) => itemValue === selectedYoe,
- )[0].label
+ ({ value: itemValue }) => itemValue === selectedYoeCategory,
+ ).length > 0
+ ? OfferTableYoeOptions.filter(
+ ({ value: itemValue }) => itemValue === selectedYoeCategory,
+ )[0].label
+ : OfferTableYoeOptions[0].label
}
size="inherit">
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
{
- if (value === '') {
- router.replace(
- {
- pathname: router.pathname,
- query: undefined,
- },
- undefined,
- // Do not refresh the page
- { shallow: true },
- );
- } else {
- const params = new URLSearchParams({
- ['yoeCategory']: value,
- });
- router.replace(
- {
- pathname: location.pathname,
- search: params.toString(),
- },
- undefined,
- { shallow: true },
- );
- }
+ setSelectedYoeCategory(value);
gaEvent({
action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement',
@@ -161,17 +180,21 @@ export default function OffersTable({
align="end"
label={
OfferTableFilterOptions.filter(
- ({ value: itemValue }) => itemValue === selectedFilter,
- )[0].label
+ ({ value: itemValue }) => itemValue === selectedSortBy,
+ ).length > 0
+ ? OfferTableFilterOptions.filter(
+ ({ value: itemValue }) => itemValue === selectedSortBy,
+ )[0].label
+ : OfferTableFilterOptions[0].label
}
size="inherit">
{OfferTableFilterOptions.map(({ label: itemLabel, value }) => (
{
- setSelectedFilter(value);
+ setSelectedSortBy(value as OfferTableSortByType);
}}
/>
))}
@@ -187,7 +210,9 @@ export default function OffersTable({
'Company',
'Title',
'YOE',
- selectedYoe === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'Annual TC',
+ selectedYoeCategory === YOE_CATEGORY.INTERN
+ ? 'Monthly Salary'
+ : 'Annual TC',
'Date Offered',
'Actions',
];
diff --git a/apps/portal/src/components/offers/table/types.ts b/apps/portal/src/components/offers/table/types.ts
index 7fb5ad17..e9a8c42e 100644
--- a/apps/portal/src/components/offers/table/types.ts
+++ b/apps/portal/src/components/offers/table/types.ts
@@ -36,25 +36,24 @@ export const OfferTableYoeOptions = [
export const OfferTableFilterOptions = [
{
label: 'Latest Submitted',
- value: 'latest-submitted',
+ value: '-monthYearReceived',
},
{
label: 'Highest Salary',
- value: 'highest-salary',
+ value: '-totalCompensation',
},
{
label: 'Highest YOE first',
- value: 'highest-yoe-first',
+ value: '-totalYoe',
},
{
label: 'Lowest YOE first',
- value: 'lowest-yoe-first',
+ value: '+totalYoe',
},
];
-export const OfferTableSortBy: Record = {
- 'highest-salary': '-totalCompensation',
- 'highest-yoe-first': '-totalYoe',
- 'latest-submitted': '-monthYearReceived',
- 'lowest-yoe-first': '+totalYoe',
-};
+export type OfferTableSortByType =
+ | '-monthYearReceived'
+ | '-totalCompensation'
+ | '-totalYoe'
+ | '+totalYoe';
diff --git a/apps/portal/src/pages/offers/index.tsx b/apps/portal/src/pages/offers/index.tsx
index e84173f5..b1773a28 100644
--- a/apps/portal/src/pages/offers/index.tsx
+++ b/apps/portal/src/pages/offers/index.tsx
@@ -9,14 +9,23 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import Container from '~/components/shared/Container';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
+import { JobTitleLabels } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
+import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
+
export default function OffersHomePage() {
- const [jobTitleFilter, setJobTitleFilter] = useState('');
- const [companyFilter, setCompanyFilter] = useState('');
const [countryFilter, setCountryFilter] = useState('');
const { event: gaEvent } = useGoogleAnalytics();
+ const [selectedCompanyName, setSelectedCompanyName] =
+ useSearchParamSingle('companyName');
+ const [selectedCompanyId, setSelectedCompanyId] =
+ useSearchParamSingle('companyId');
+
+ const [selectedJobTitleId, setSelectedJobTitleId] =
+ useSearchParamSingle('jobTitleId');
+
return (
@@ -66,16 +75,25 @@ export default function OffersHomePage() {
isLabelHidden={true}
placeholder="All Job Titles"
textSize="inherit"
+ value={
+ selectedJobTitleId
+ ? {
+ id: selectedJobTitleId,
+ label: JobTitleLabels[selectedJobTitleId as JobTitleType],
+ value: selectedJobTitleId,
+ }
+ : null
+ }
onSelect={(option) => {
if (option) {
- setJobTitleFilter(option.value as JobTitleType);
+ setSelectedJobTitleId(option.id as JobTitleType);
gaEvent({
action: `offers.table_filter_job_title_${option.value}`,
category: 'engagement',
label: 'Filter by job title',
});
} else {
- setJobTitleFilter('');
+ setSelectedJobTitleId(null);
}
}}
/>
@@ -84,16 +102,27 @@ export default function OffersHomePage() {
isLabelHidden={true}
placeholder="All Companies"
textSize="inherit"
+ value={
+ selectedCompanyName
+ ? {
+ id: selectedCompanyId,
+ label: selectedCompanyName,
+ value: selectedCompanyId,
+ }
+ : null
+ }
onSelect={(option) => {
if (option) {
- setCompanyFilter(option.value);
+ setSelectedCompanyId(option.id);
+ setSelectedCompanyName(option.label);
gaEvent({
action: `offers.table_filter_company_${option.value}`,
category: 'engagement',
label: 'Filter by company',
});
} else {
- setCompanyFilter('');
+ setSelectedCompanyId('');
+ setSelectedCompanyName('');
}
}}
/>
@@ -102,9 +131,10 @@ export default function OffersHomePage() {
-
+ return getProfileQuery.isError ? (
+
+
+
+ ) : getProfileQuery.isLoading ? (
+
+ ) : (
+
+
+
- )}
- {getProfileQuery.isLoading && (
-
-
+
- )}
- {!getProfileQuery.isLoading && !getProfileQuery.isError && (
-
- )}
- >
+
+
+
);
}
diff --git a/apps/portal/src/pages/offers/submit/result/[offerProfileId].tsx b/apps/portal/src/pages/offers/submit/result/[offerProfileId].tsx
index f507283d..b8bde4a5 100644
--- a/apps/portal/src/pages/offers/submit/result/[offerProfileId].tsx
+++ b/apps/portal/src/pages/offers/submit/result/[offerProfileId].tsx
@@ -1,5 +1,6 @@
import Error from 'next/error';
import { useRouter } from 'next/router';
+import { useSession } from 'next-auth/react';
import { useEffect, useRef, useState } from 'react';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { EyeIcon } from '@heroicons/react/24/outline';
@@ -13,44 +14,43 @@ import OffersSubmissionAnalysis from '~/components/offers/offersSubmission/Offer
import { getProfilePath } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
-import type { ProfileAnalysis } from '~/types/offers';
-
export default function OffersSubmissionResult() {
const router = useRouter();
let { offerProfileId, token = '' } = router.query;
offerProfileId = offerProfileId as string;
token = token as string;
const [step, setStep] = useState(0);
- const [analysis, setAnalysis] = useState
(null);
- const [isValidToken, setIsValidToken] = useState(false);
+ const { data: session } = useSession();
const pageRef = useRef(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
- const checkToken = trpc.useQuery(
- ['offers.profile.isValidToken', { profileId: offerProfileId, token }],
- {
- onSuccess(data) {
- setIsValidToken(data);
- },
- },
- );
+ const checkToken = trpc.useQuery([
+ 'offers.profile.isValidToken',
+ { profileId: offerProfileId, token },
+ ]);
- const getAnalysis = trpc.useQuery(
- ['offers.analysis.get', { profileId: offerProfileId }],
- {
- onSuccess(data) {
- setAnalysis(data);
- },
- },
- );
+ const getAnalysis = trpc.useQuery([
+ 'offers.analysis.get',
+ { profileId: offerProfileId },
+ ]);
+
+ const isSavedQuery = trpc.useQuery([
+ `offers.profile.isSaved`,
+ { profileId: offerProfileId, userId: session?.user?.id },
+ ]);
const steps = [
- ,
+ ,
,
@@ -77,71 +77,67 @@ export default function OffersSubmissionResult() {
scrollToTop();
}, [step]);
- return (
- <>
- {(checkToken.isLoading || getAnalysis.isLoading) && (
-
-
-
-
Loading...
+ return checkToken.isLoading || getAnalysis.isLoading ? (
+
+ ) : checkToken.isError || getAnalysis.isError ? (
+
+ ) : checkToken.isSuccess && !checkToken.data ? (
+
+ ) : (
+
+
+
- )}
- {checkToken.isSuccess && !isValidToken && (
-
- )}
- {getAnalysis.isSuccess && (
-
-
-
-
-
+ {steps[step]}
+ {step === 0 && (
+
+
-
- {steps[step]}
- {step === 0 && (
-
-
- )}
- {step === 1 && (
-
-
- )}
+ )}
+ {step === 1 && (
+
+ setStep(step - 1)}
+ />
+
-
+ )}
- )}
- >
+
+
);
}
diff --git a/apps/portal/src/utils/offers/time.tsx b/apps/portal/src/utils/offers/time.tsx
index 5c7305dd..d61ef3a6 100644
--- a/apps/portal/src/utils/offers/time.tsx
+++ b/apps/portal/src/utils/offers/time.tsx
@@ -55,3 +55,18 @@ export function getCurrentYear() {
export function convertToMonthYear(date: Date) {
return { month: date.getMonth() + 1, year: date.getFullYear() } as MonthYear;
}
+
+export function getDurationDisplayText(months: number) {
+ const years = Math.floor(months / 12);
+ const monthsRemainder = months % 12;
+ let durationDisplay = '';
+ if (years > 0) {
+ durationDisplay = `${years} year${years > 1 ? 's' : ''}`;
+ }
+ if (monthsRemainder > 0) {
+ durationDisplay = durationDisplay.concat(
+ ` ${monthsRemainder} month${monthsRemainder > 1 ? 's' : ''}`,
+ );
+ }
+ return durationDisplay;
+}
diff --git a/apps/portal/src/utils/offers/useSearchParam.ts b/apps/portal/src/utils/offers/useSearchParam.ts
new file mode 100644
index 00000000..4dc6595e
--- /dev/null
+++ b/apps/portal/src/utils/offers/useSearchParam.ts
@@ -0,0 +1,79 @@
+import { useRouter } from 'next/router';
+import { useCallback, useEffect, useState } from 'react';
+
+type SearchParamOptions
= [Value] extends [string]
+ ? {
+ defaultValues?: Array;
+ paramToString?: (value: Value) => string | null;
+ stringToParam?: (param: string) => Value | null;
+ }
+ : {
+ defaultValues?: Array;
+ paramToString: (value: Value) => string | null;
+ stringToParam: (param: string) => Value | null;
+ };
+
+export const useSearchParam = (
+ name: string,
+ opts?: SearchParamOptions,
+) => {
+ 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>(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,
+ );
+ }
+ setIsInitialized(true);
+ }
+ }, [isInitialized, name, stringToParam, router]);
+
+ const setParamsCallback = useCallback(
+ (newParams: Array) => {
+ 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 = (
+ name: string,
+ opts?: Omit, 'defaultValues'> & {
+ defaultValue?: Value;
+ },
+) => {
+ const { defaultValue, ...restOpts } = opts ?? {};
+ const [params, setParams, isInitialized] = useSearchParam(name, {
+ defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
+ ...restOpts,
+ } as SearchParamOptions);
+
+ return [
+ params[0],
+ (value: Value) => setParams([value]),
+ isInitialized,
+ ] as const;
+};