Merge branch 'main' into weilin/question-list

pull/468/head
Jeff Sieu 3 years ago
commit bba856f1c2

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "QuestionsQuestion" ALTER COLUMN "lastSeenAt" DROP NOT NULL;

@ -5,4 +5,4 @@ ALTER TABLE "QuestionsQuestion" ADD COLUMN "contentSearch" TSVECTOR
STORED;
-- CreateIndex
CREATE INDEX "QuestionsQuestion_contentSearch_idx" ON "QuestionsQuestion" USING GIN("contentSearch");
CREATE INDEX "QuestionsQuestion_contentSearch_idx" ON "QuestionsQuestion" USING GIN("contentSearch");

@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "QuestionsQuestion_contentSearch_idx";
-- AlterTable
ALTER TABLE "QuestionsQuestion" ALTER COLUMN "contentSearch" DROP DEFAULT;
-- CreateIndex
CREATE INDEX "QuestionsQuestion_contentSearch_idx" ON "QuestionsQuestion"("contentSearch");

@ -1,7 +1,8 @@
// Refer to the Prisma schema docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
previewFeatures = ["interactiveTransactions"]
}
datasource db {
@ -402,7 +403,7 @@ model QuestionsQuestion {
userId String?
content String @db.Text
questionType QuestionsQuestionType
lastSeenAt DateTime
lastSeenAt DateTime?
upvotes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -414,9 +415,6 @@ model QuestionsQuestion {
answers QuestionsAnswer[]
QuestionsListQuestionEntry QuestionsListQuestionEntry[]
contentSearch Unsupported("TSVECTOR")?
@@index([contentSearch])
@@index([lastSeenAt, id])
@@index([upvotes, id])
}

@ -26,7 +26,12 @@ export default function ProductNavigation({ items, title, titleHref }: Props) {
return (
<nav aria-label="Global" className="flex h-full items-center space-x-8">
<Link className="text-primary-700 text-sm font-medium" href={titleHref}>
<Link
className="hover:text-primary-700 flex items-center gap-2 text-sm font-medium"
href={titleHref}>
{titleHref !== '/' && (
<img alt="TIH" className="h-8 w-auto" src="/logo.svg" />
)}
{title}
</Link>
<div className="hidden h-full items-center space-x-8 md:flex">
@ -79,7 +84,7 @@ export default function ProductNavigation({ items, title, titleHref }: Props) {
<Link
key={item.name}
className={clsx(
'hover:text-primary-600 inline-flex h-full items-center border-y-2 border-t-transparent text-sm font-medium text-slate-900',
'hover:text-primary-600 inline-flex h-full items-center border-y-2 border-t-transparent text-sm text-slate-900',
isActive ? 'border-b-primary-500' : 'border-b-transparent',
)}
href={item.href}

@ -1,15 +1,9 @@
import { useRouter } from 'next/router';
// Import { useState } from 'react';
// import { setTimeout } from 'timers';
import { DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { EyeIcon } from '@heroicons/react/24/outline';
import { Button, TextInput, useToast } from '@tih/ui';
import {
copyProfileLink,
getProfileLink,
getProfilePath,
} from '~/utils/offers/link';
import { copyProfileLink, getProfileLink } from '~/utils/offers/link';
type OfferProfileSaveProps = Readonly<{
profileId: string;
@ -23,7 +17,6 @@ export default function OffersProfileSave({
const { showToast } = useToast();
// Const [isSaving, setSaving] = useState(false);
// const [isSaved, setSaved] = useState(false);
const router = useRouter();
// Const saveProfile = () => {
// setSaving(true);
@ -82,14 +75,6 @@ export default function OffersProfileSave({
onClick={saveProfile}
/>
</div> */}
<div>
<Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div>
</div>
</div>
);

@ -0,0 +1,49 @@
import { useRouter } from 'next/router';
import { EyeIcon } from '@heroicons/react/24/outline';
import { Button } from '~/../../../packages/ui/dist';
import { getProfilePath } from '~/utils/offers/link';
import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
import type { ProfileAnalysis } from '~/types/offers';
type Props = Readonly<{
analysis?: ProfileAnalysis | null;
isError: boolean;
isLoading: boolean;
profileId?: string;
token?: string;
}>;
export default function OffersSubmissionAnalysis({
analysis,
isError,
isLoading,
profileId = '',
token = '',
}: Props) {
const router = useRouter();
return (
<div>
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result
</h5>
<OfferAnalysis
key={3}
allAnalysis={analysis}
isError={isError}
isLoading={isLoading}
/>
<div className="mt-8 text-center">
<Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div>
</div>
);
}

@ -15,16 +15,17 @@ import type {
} from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import {
cleanObject,
removeEmptyObjects,
removeInvalidMoneyData,
} from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
import OffersSubmissionAnalysis from './OffersSubmissionAnalysis';
import type {
CreateOfferProfileResponse,
ProfileAnalysis,
} from '~/types/offers';
import type { ProfileAnalysis } from '~/types/offers';
const defaultOfferValues = {
comments: '',
@ -73,15 +74,12 @@ type Props = Readonly<{
export default function OffersSubmissionForm({
initialOfferProfileValues = defaultOfferProfileValues,
profileId,
token,
profileId: editProfileId = '',
token: editToken = '',
}: Props) {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>({
id: profileId || '',
token: token || '',
});
const [profileId, setProfileId] = useState(editProfileId);
const [token, setToken] = useState(editToken);
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
@ -125,11 +123,7 @@ export default function OffersSubmissionForm({
},
{
component: (
<OffersProfileSave
key={2}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
/>
<OffersProfileSave key={2} profileId={profileId} token={token} />
),
hasNext: true,
hasPrevious: false,
@ -137,17 +131,13 @@ export default function OffersSubmissionForm({
},
{
component: (
<div>
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result
</h5>
<OfferAnalysis
key={3}
allAnalysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
/>
</div>
<OffersSubmissionAnalysis
analysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
profileId={profileId}
token={token}
/>
),
hasNext: false,
hasPrevious: true,
@ -184,7 +174,8 @@ export default function OffersSubmissionForm({
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setProfileId(data.id);
setToken(data.token);
setFormStep(formStep + 1);
scrollToTop();
},
@ -197,6 +188,7 @@ export default function OffersSubmissionForm({
}
data = removeInvalidMoneyData(data);
data.offers = removeEmptyObjects(data.offers);
const background = cleanObject(data.background);
background.specificYoes = data.background.specificYoes.filter(

@ -18,7 +18,6 @@ import {
CURRENCY_OPTIONS,
} from '~/utils/offers/currency/CurrencyEnum';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormRadioList from '../../forms/FormRadioList';
import FormSelect from '../../forms/FormSelect';
import FormTextInput from '../../forms/FormTextInput';
@ -235,7 +234,6 @@ function InternshipJobFields() {
function CurrentJobSection() {
const { register } = useFormContext();
const watchJobType = useWatch({
defaultValue: JobType.FULLTIME,
name: 'background.experiences.0.jobType',
});
@ -247,7 +245,7 @@ function CurrentJobSection() {
<div className="mb-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5">
<FormRadioList
defaultValue={JobType.FULLTIME}
defaultValue={watchJobType}
isLabelHidden={true}
label="Job Type"
orientation="horizontal"
@ -306,22 +304,6 @@ function EducationSection() {
{...register(`background.educations.0.school`)}
/>
</div>
<div className="grid grid-cols-2 space-x-3">
<FormMonthYearPicker
monthLabel="Candidature Start"
yearLabel=""
{...register(`background.educations.0.startDate`, {
required: FieldError.REQUIRED,
})}
/>
<FormMonthYearPicker
monthLabel="Candidature End"
yearLabel=""
{...register(`background.educations.0.endDate`, {
required: FieldError.REQUIRED,
})}
/>
</div>
</Collapsible>
</div>
</>

@ -113,7 +113,7 @@ export type BaseQuestionCardProps = ActionButtonProps &
content: string;
questionId: string;
showHover?: boolean;
timestamp: string;
timestamp: string | null;
truncateContent?: boolean;
type: QuestionsQuestionType;
};
@ -174,7 +174,7 @@ export default function BaseQuestionCard({
<QuestionAggregateBadge statistics={roles} variant="danger" />
</>
)}
<p className="text-xs">{timestamp}</p>
{timestamp !== null && <p className="text-xs">{timestamp}</p>}
{showAddToList && (
<div className="pl-4">
<AddToListDropdown questionId={questionId} />

@ -202,10 +202,10 @@ export default function ContributeQuestionForm({
key={question.id}
content={question.content}
questionId={question.id}
timestamp={question.lastSeenAt.toLocaleDateString(undefined, {
timestamp={question.lastSeenAt?.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
}) ?? null}
type={question.questionType}
onSimilarQuestionClick={() => {
// eslint-disable-next-line no-console

@ -7,6 +7,11 @@ const navigation: ProductNavigationItems = [
name: 'Browse',
},
{ children: [], href: '/resumes/submit', name: 'Submit for review' },
{
children: [],
href: '/resumes/about',
name: 'About Us',
},
{
children: [],
href: 'https://www.techinterviewhandbook.org/resume/',

@ -14,15 +14,15 @@ export default function ResumeUserBadge({
return (
<div className="group relative flex items-center justify-center">
<div
className="h-34 absolute left-6 z-10 hidden w-48 flex-col justify-center
className="absolute top-7 z-10 hidden h-36 w-48 flex-col justify-center
gap-1 rounded-xl bg-white pb-2 text-center drop-shadow-lg
before:absolute before:top-14 before:-translate-x-4
before:border-8 before:border-y-transparent before:border-l-transparent
before:border-r-white before:drop-shadow-lg before:content-['']
after:absolute after:left-1/2 after:top-[-11%] after:-translate-x-1/2
after:border-8 after:border-x-transparent after:border-t-transparent
after:border-b-slate-200 after:drop-shadow-sm after:content-['']
group-hover:flex">
<Icon className="h-16 w-full self-center rounded-t-xl bg-slate-200 py-2" />
<div className="px-2">
<div className="flex h-20 flex-col justify-evenly px-2">
<p className="font-medium">{title}</p>
<p className="text-sm">{description}.</p>
</div>

@ -55,7 +55,7 @@ export default function ResumeCommentsList({
<Spinner display="block" size="lg" />
</div>
) : (
<div className="mb-8 flow-root h-[calc(100vh-13rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden">
<div className="mb-8 flow-root h-[calc(100vh-13rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pb-16">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {
@ -65,44 +65,40 @@ export default function ResumeCommentsList({
const commentCount = comments.length;
return (
<div key={value} className="mb-4 space-y-4">
<div className="text-primary-800 flex flex-row items-center space-x-2">
<div key={value} className="space-y-6 pr-4">
<div className="text-primary-800 -mb-2 flex flex-row items-center space-x-2">
{renderIcon(value)}
<div className="w-fit text-lg font-medium">{label}</div>
</div>
<div className="w-full space-y-4 pr-4">
<div
className={clsx(
'space-y-2 rounded-md border-2 bg-white px-4 py-3 drop-shadow-md',
commentCount ? 'border-slate-300' : 'border-slate-300',
)}>
{commentCount > 0 ? (
comments.map((comment) => {
return (
<ResumeCommentListItem
key={comment.id}
comment={comment}
userId={sessionData?.user?.id}
/>
);
})
) : (
<div className="flex flex-row items-center text-sm">
<ChatBubbleLeftRightIcon className="mr-2 h-6 w-6 text-slate-500" />
<div
className={clsx(
'space-y-2 rounded-md border-2 bg-white px-4 py-3 drop-shadow-md',
commentCount ? 'border-slate-300' : 'border-slate-300',
)}>
{commentCount > 0 ? (
comments.map((comment) => {
return (
<ResumeCommentListItem
key={comment.id}
comment={comment}
userId={sessionData?.user?.id}
/>
);
})
) : (
<div className="flex flex-row items-center text-sm">
<ChatBubbleLeftRightIcon className="mr-2 h-6 w-6 text-slate-500" />
<div className="text-slate-500">
There are no comments for this section yet!
</div>
<div className="text-slate-500">
There are no comments for this section yet!
</div>
)}
</div>
</div>
)}
</div>
<div className="relative flex flex-row pr-6 pt-2">
<div className="flex-grow border-t border-gray-300" />
</div>
<hr className="border-gray-300" />
</div>
);
})}

@ -34,7 +34,17 @@ export default function OffersEditPage() {
experiences:
experiences.length === 0
? [{ jobType: JobType.FULLTIME }]
: experiences,
: experiences.map((exp) => ({
companyId: exp.company?.id,
durationInMonths: exp.durationInMonths,
id: exp.id,
jobType: exp.jobType,
level: exp.level,
location: exp.location,
monthlySalary: exp.monthlySalary,
title: exp.title,
totalCompensation: exp.totalCompensation,
})),
id,
specificYoes,
totalYoe,

@ -21,9 +21,24 @@ import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList
import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import type {
FilterOption,
LocationFilter,
} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
INITIAL_FILTER_STATE,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
import SubmitResumeForm from './submit';
import type {
ExperienceFilter,
RoleFilter,
} from '../../utils/resumes/resumeFilters';
export default function ResumeReviewPage() {
const ErrorPage = (
@ -57,7 +72,8 @@ export default function ResumeReviewPage() {
},
});
const userIsOwner =
session?.user?.id != null && session.user.id === detailsQuery.data?.userId;
session?.user?.id !== undefined &&
session.user.id === detailsQuery.data?.userId;
const [isEditMode, setIsEditMode] = useState(false);
const [showCommentsForm, setShowCommentsForm] = useState(false);
@ -79,6 +95,46 @@ export default function ResumeReviewPage() {
}
};
const onInfoTagClick = ({
locationLabel,
experienceLabel,
roleLabel,
}: {
experienceLabel?: string;
locationLabel?: string;
roleLabel?: string;
}) => {
const getFilterValue = (
label: string,
filterOptions: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter>
>,
) => filterOptions.find((option) => option.label === label)?.value;
router.push({
pathname: '/resumes/browse',
query: {
currentPage: JSON.stringify(1),
searchValue: JSON.stringify(''),
shortcutSelected: JSON.stringify('all'),
sortOrder: JSON.stringify('latest'),
tabsValue: JSON.stringify(BROWSE_TABS_VALUES.ALL),
userFilters: JSON.stringify({
...INITIAL_FILTER_STATE,
...(locationLabel && {
location: [getFilterValue(locationLabel, LOCATIONS)],
}),
...(roleLabel && {
role: [getFilterValue(roleLabel, ROLES)],
}),
...(experienceLabel && {
experience: [getFilterValue(experienceLabel, EXPERIENCES)],
}),
}),
},
});
};
const onEditButtonClick = () => {
setIsEditMode(true);
};
@ -199,21 +255,48 @@ export default function ResumeReviewPage() {
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{detailsQuery.data.role}
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
roleLabel: detailsQuery.data?.role,
})
}>
{detailsQuery.data.role}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{detailsQuery.data.location}
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
locationLabel: detailsQuery.data?.location,
})
}>
{detailsQuery.data.location}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{detailsQuery.data.experience}
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
experienceLabel: detailsQuery.data?.experience,
})
}>
{detailsQuery.data.experience}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<CalendarIcon

@ -0,0 +1,58 @@
export default function AboutUsPage() {
return (
<div className="my-10 flex w-full flex-col items-center overflow-y-auto">
<div className="flex justify-center text-4xl font-bold">
Resume Review Portal
</div>
<div className="mt-2 flex justify-center text-2xl font-bold">
<div className="font-display sm:text-md mx-auto max-w-xl text-2xl font-medium italic tracking-tight text-slate-900">
Resume reviews{' '}
<span className="text-primary-500 relative whitespace-nowrap">
<svg
aria-hidden="true"
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-blue-300/70"
preserveAspectRatio="none"
viewBox="0 0 418 42">
<path d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
</svg>
<span className="relative">made simple</span>
</span>
</div>
</div>
{/* About Us Section */}
<div className="mt-10 flex w-4/5 text-left text-3xl font-bold xl:w-1/2">
About Us 🤓
</div>
<div className="mt-6 flex w-4/5 text-lg xl:w-1/2">
As you apply for your dream jobs or internships, have you ever felt
unsure about your resume? Have you wondered about how others got past
resume screening in a breeze? Wonder no more!
</div>
<div className="mt-3 flex w-4/5 text-lg xl:w-1/2">
Tech Interview Handbook's very own Resume Review portal is here to help!
Simply submit your resume and collect invaluable feedback from our
community of Software Engineers, Hiring Managers and so many more...
</div>
{/* Feedback */}
<div className="mt-10 flex w-4/5 text-left text-3xl font-bold xl:w-1/2">
Feedback? New Features? BUGS?! 😱
</div>
<div className="mt-6 flex w-4/5 text-lg xl:w-1/2">
Submit your feedback
<a
className="ml-1 text-indigo-600 hover:text-indigo-400"
href="https://forms.gle/KgA6KWDD4XNa53uJA"
rel="noreferrer"
target="_blank">
here
</a>
</div>
</div>
);
}

@ -1,7 +1,7 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import Router, { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { Fragment, useEffect, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Dialog, Disclosure, Transition } from '@headlessui/react';
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import {
@ -20,11 +20,10 @@ import {
} from '@tih/ui';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import type {
Filter,
FilterId,
Shortcut,
} from '~/components/resumes/browse/resumeFilters';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import type { Filter, FilterId, Shortcut } from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
@ -34,14 +33,12 @@ import {
ROLES,
SHORTCUTS,
SORT_OPTIONS,
} from '~/components/resumes/browse/resumeFilters';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
} from '~/utils/resumes/resumeFilters';
import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
import type { FilterState } from '../../components/resumes/browse/resumeFilters';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
@ -101,19 +98,82 @@ const getEmptyDataText = (
export default function ResumeHomePage() {
const { data: sessionData } = useSession();
const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
const [sortOrder, setSortOrder] = useState('latest');
const [searchValue, setSearchValue] = useState('');
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
const [shortcutSelected, setShortcutSelected] = useState('All');
const [currentPage, setCurrentPage] = useState(1);
const [tabsValue, setTabsValue, isTabsValueInit] = useSearchParams(
'tabsValue',
BROWSE_TABS_VALUES.ALL,
);
const [sortOrder, setSortOrder, isSortOrderInit] = useSearchParams<SortOrder>(
'sortOrder',
'latest',
);
const [searchValue, setSearchValue, isSearchValueInit] = useSearchParams(
'searchValue',
'',
);
const [shortcutSelected, setShortcutSelected, isShortcutInit] =
useSearchParams('shortcutSelected', 'All');
const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams(
'currentPage',
1,
);
const [userFilters, setUserFilters, isUserFiltersInit] = useSearchParams(
'userFilters',
INITIAL_FILTER_STATE,
);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const skip = (currentPage - 1) * PAGE_LIMIT;
const isSearchOptionsInit = useMemo(() => {
return (
isTabsValueInit &&
isSortOrderInit &&
isSearchValueInit &&
isShortcutInit &&
isCurrentPageInit &&
isUserFiltersInit
);
}, [
isTabsValueInit,
isSortOrderInit,
isSearchValueInit,
isShortcutInit,
isCurrentPageInit,
isUserFiltersInit,
]);
useEffect(() => {
setCurrentPage(1);
}, [userFilters, sortOrder, searchValue]);
}, [userFilters, sortOrder, setCurrentPage, searchValue]);
useEffect(() => {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
if (!isSearchOptionsInit) {
return;
}
Router.replace({
pathname: router.pathname,
query: {
currentPage: JSON.stringify(currentPage),
searchValue: JSON.stringify(searchValue),
shortcutSelected: JSON.stringify(shortcutSelected),
sortOrder: JSON.stringify(sortOrder),
tabsValue: JSON.stringify(tabsValue),
userFilters: JSON.stringify(userFilters),
},
});
}, [
tabsValue,
sortOrder,
searchValue,
userFilters,
shortcutSelected,
currentPage,
router.pathname,
isSearchOptionsInit,
]);
const allResumesQuery = trpc.useQuery(
[
@ -431,7 +491,7 @@ export default function ResumeHomePage() {
{filter.options.map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 [&>div>div:nth-child(2)>label]:font-normal">
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 px-1 [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
@ -457,7 +517,7 @@ export default function ResumeHomePage() {
</div>
</div>
<div className="relative lg:left-64 lg:w-[calc(100%-16rem)]">
<div className="lg:border-grey-200 sticky top-0 z-10 flex flex-wrap items-center justify-between bg-gray-50 pt-6 pb-2 lg:border-b">
<div className="lg:border-grey-200 sticky top-0 z-0 flex flex-wrap items-center justify-between pt-6 pb-2 lg:border-b">
<div className="border-grey-200 mb-4 flex w-full justify-between border-b pb-2 lg:mb-0 lg:w-auto lg:border-none lg:pb-0">
<div>
<Tabs
@ -503,13 +563,18 @@ export default function ResumeHomePage() {
/>
</div>
<div>
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
{Object.entries(SORT_OPTIONS).map(([key, value]) => (
<DropdownMenu
align="end"
label={
SORT_OPTIONS.find(({ value }) => value === sortOrder)
?.label
}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item
key={key}
isSelected={sortOrder === key}
label={value}
onClick={() => setSortOrder(key)}></DropdownMenu.Item>
key={value}
isSelected={sortOrder === value}
label={label}
onClick={() => setSortOrder(value)}></DropdownMenu.Item>
))}
</DropdownMenu>
</div>

@ -19,14 +19,10 @@ import {
TextInput,
} from '@tih/ui';
import {
EXPERIENCES,
LOCATIONS,
ROLES,
} from '~/components/resumes/browse/resumeFilters';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
const FILE_SIZE_LIMIT_MB = 3;
@ -293,7 +289,7 @@ export default function SubmitResumeForm({
required={true}
onChange={(val) => onValueChange('title', val)}
/>
<div className="flex gap-8">
<div className="flex flex-wrap gap-6">
<Select
{...register('role', { required: true })}
defaultValue={undefined}
@ -339,7 +335,7 @@ export default function SubmitResumeForm({
fileUploadError
? 'border-danger-600'
: 'border-slate-300',
'flex cursor-pointer justify-center rounded-md border-2 border-dashed bg-slate-100 py-4',
'cursor-pointer flex-col items-center space-y-1 rounded-md border-2 border-dashed bg-slate-100 py-4 px-4 text-center',
)}>
<input
{...register('file', { required: true })}
@ -351,29 +347,27 @@ export default function SubmitResumeForm({
name="file-upload"
type="file"
/>
<div className="space-y-1 text-center">
{resumeFile == null ? (
<ArrowUpCircleIcon className="text-primary-500 m-auto h-10 w-10" />
) : (
<p
className="hover:text-primary-600 cursor-pointer underline underline-offset-1"
onClick={onClickDownload}>
{resumeFile.name}
</p>
)}
<label
className="focus-within:ring-primary-500 flex items-center rounded-md text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2"
htmlFor="file-upload">
<span className="font-medium">Drop file here</span>
<span className="mr-1 ml-1 font-light">or</span>
<span className="text-primary-600 hover:text-primary-400 cursor-pointer font-medium">
{resumeFile == null ? 'Select file' : 'Replace file'}
</span>
</label>
<p className="text-xs text-slate-500">
PDF up to {FILE_SIZE_LIMIT_MB}MB
{resumeFile == null ? (
<ArrowUpCircleIcon className="text-primary-500 m-auto h-10 w-10" />
) : (
<p
className="hover:text-primary-600 cursor-pointer underline underline-offset-1"
onClick={onClickDownload}>
{resumeFile.name}
</p>
</div>
)}
<label
className="focus-within:ring-primary-500 cursor-pointer text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2"
htmlFor="file-upload">
<span className="font-medium">Drop file here</span>
<span className="mr-1 ml-1 font-light">or</span>
<span className="text-primary-600 hover:text-primary-400 font-medium">
{resumeFile == null ? 'Select file' : 'Replace file'}
</span>
</label>
<p className="text-xs text-slate-500">
PDF up to {FILE_SIZE_LIMIT_MB}MB
</p>
</div>
{fileUploadError && (
<p className="text-danger-600 text-sm">{fileUploadError}</p>

@ -284,110 +284,116 @@ export const offersProfileRouter = createRouter()
})),
},
experiences: {
create: input.background.experiences.map(async (x) => {
if (
x.jobType === JobType.FULLTIME &&
x.totalCompensation?.currency != null &&
x.totalCompensation?.value != null
) {
if (x.companyId) {
return {
company: {
connect: {
id: x.companyId,
create: await Promise.all(
input.background.experiences.map(async (x) => {
if (x.jobType === JobType.FULLTIME) {
if (x.companyId) {
return {
company: {
connect: {
id: x.companyId,
},
},
},
durationInMonths: x.durationInMonths,
jobType: x.jobType,
level: x.level,
title: x.title,
totalCompensation:
x.totalCompensation != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.totalCompensation.value,
x.totalCompensation.currency,
baseCurrencyString,
),
currency: x.totalCompensation.currency,
value: x.totalCompensation.value,
},
}
: undefined,
};
}
return {
durationInMonths: x.durationInMonths,
jobType: x.jobType,
level: x.level,
location: x.location,
title: x.title,
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.totalCompensation.value,
x.totalCompensation.currency,
baseCurrencyString,
),
currency: x.totalCompensation.currency,
value: x.totalCompensation.value,
},
},
totalCompensation:
x.totalCompensation != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.totalCompensation.value,
x.totalCompensation.currency,
baseCurrencyString,
),
currency: x.totalCompensation.currency,
value: x.totalCompensation.value,
},
}
: undefined,
};
}
return {
durationInMonths: x.durationInMonths,
jobType: x.jobType,
level: x.level,
location: x.location,
title: x.title,
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.totalCompensation.value,
x.totalCompensation.currency,
baseCurrencyString,
),
currency: x.totalCompensation.currency,
value: x.totalCompensation.value,
},
},
};
}
if (
x.jobType === JobType.INTERN &&
x.monthlySalary?.currency != null &&
x.monthlySalary?.value != null
) {
if (x.companyId) {
return {
company: {
connect: {
id: x.companyId,
if (x.jobType === JobType.INTERN) {
if (x.companyId) {
return {
company: {
connect: {
id: x.companyId,
},
},
},
durationInMonths: x.durationInMonths,
jobType: x.jobType,
monthlySalary:
x.monthlySalary != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.monthlySalary.value,
x.monthlySalary.currency,
baseCurrencyString,
),
currency: x.monthlySalary.currency,
value: x.monthlySalary.value,
},
}
: undefined,
title: x.title,
};
}
return {
durationInMonths: x.durationInMonths,
jobType: x.jobType,
monthlySalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.monthlySalary.value,
x.monthlySalary.currency,
baseCurrencyString,
),
currency: x.monthlySalary.currency,
value: x.monthlySalary.value,
},
},
monthlySalary:
x.monthlySalary != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.monthlySalary.value,
x.monthlySalary.currency,
baseCurrencyString,
),
currency: x.monthlySalary.currency,
value: x.monthlySalary.value,
},
}
: undefined,
title: x.title,
};
}
return {
durationInMonths: x.durationInMonths,
jobType: x.jobType,
monthlySalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.monthlySalary.value,
x.monthlySalary.currency,
baseCurrencyString,
),
currency: x.monthlySalary.currency,
value: x.monthlySalary.value,
},
},
title: x.title,
};
}
throw new trpc.TRPCError({
code: 'BAD_REQUEST',
message: 'Missing fields in background experiences.',
});
}),
throw new trpc.TRPCError({
code: 'BAD_REQUEST',
message: 'Missing fields in background experiences.',
});
}),
)
},
specificYoes: {
create: input.background.specificYoes.map((x) => {
@ -542,7 +548,6 @@ export const offersProfileRouter = createRouter()
profileName: uniqueName,
},
});
return createOfferProfileResponseMapper(profile, token);
},
})
@ -710,6 +715,7 @@ export const offersProfileRouter = createRouter()
data: {
companyId: exp.companyId, // TODO: check if can change with connect or whether there is a difference
durationInMonths: exp.durationInMonths,
jobType: exp.jobType as JobType,
level: exp.level,
},
where: {
@ -818,18 +824,20 @@ export const offersProfileRouter = createRouter()
level: exp.level,
location: exp.location,
title: exp.title,
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
},
totalCompensation: exp.totalCompensation
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
}
: undefined,
},
},
},

@ -73,7 +73,7 @@ export const offersRouter = createRouter().query('list', {
const order = getOrder(input.sortBy.charAt(0));
const sortingKey = input.sortBy.substring(1);
let data = !yoeRange
const data = !yoeRange
? await ctx.prisma.offersOffer.findMany({
// Internship
include: {
@ -303,11 +303,18 @@ export const offersRouter = createRouter().query('list', {
},
});
const startRecordIndex: number = input.limit * input.offset;
const endRecordIndex: number =
startRecordIndex + input.limit <= data.length
? startRecordIndex + input.limit
: data.length;
let paginatedData = data.slice(startRecordIndex, endRecordIndex);
// CONVERTING
const currency = input.currency?.toUpperCase();
if (currency != null && currency in Currency) {
data = await Promise.all(
data.map(async (offer) => {
paginatedData = await Promise.all(
paginatedData.map(async (offer) => {
if (offer.offersFullTime?.totalCompensation != null) {
offer.offersFullTime.totalCompensation.value =
await convertWithDate(
@ -367,13 +374,6 @@ export const offersRouter = createRouter().query('list', {
);
}
const startRecordIndex: number = input.limit * input.offset;
const endRecordIndex: number =
startRecordIndex + input.limit <= data.length
? startRecordIndex + input.limit
: data.length;
const paginatedData = data.slice(startRecordIndex, endRecordIndex);
return getOffersResponseMapper(
paginatedData.map((offer) => dashboardOfferDtoMapper(offer)),
{

@ -281,6 +281,5 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}),
]);
return answerCommentVote;
},
});

@ -341,6 +341,5 @@ export const questionsAnswerRouter = createProtectedRouter()
}),
]);
return questionsAnswerVote;
},
});

@ -168,7 +168,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
const incrementValue: number = vote === Vote.UPVOTE ? 1 : -1;
const [ questionCommentVote ] = await ctx.prisma.$transaction([
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.create({
data: {
questionCommentId,

@ -4,6 +4,7 @@ import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import type { AggregatedQuestionEncounter } from '~/types/questions';
import { SortOrder } from '~/types/questions.d';
export const questionsQuestionEncounterRouter = createProtectedRouter()
.query('getAggregatedEncounters', {
@ -30,7 +31,8 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i];
latestSeenAt = latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 0;
@ -68,11 +70,42 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestionEncounter.create({
data: {
...input,
userId,
},
return await ctx.prisma.$transaction(async (tx) => {
const [questionToUpdate, questionEncounterCreated] = await Promise.all([
tx.questionsQuestion.findUnique({
where: {
id: input.questionId,
},
}),
tx.questionsQuestionEncounter.create({
data: {
...input,
userId,
},
}),
]);
if (questionToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question does not exist',
});
}
if (
questionToUpdate.lastSeenAt === null ||
questionToUpdate.lastSeenAt < input.seenAt
) {
await tx.questionsQuestion.update({
data: {
lastSeenAt: input.seenAt,
},
where: {
id: input.questionId,
},
});
}
return questionEncounterCreated;
});
},
})
@ -101,13 +134,45 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
});
}
return await ctx.prisma.questionsQuestionEncounter.update({
data: {
...input,
},
where: {
id: input.id,
},
return await ctx.prisma.$transaction(async (tx) => {
const [questionToUpdate, questionEncounterUpdated] = await Promise.all([
tx.questionsQuestion.findUnique({
where: {
id: questionEncounterToUpdate.questionId,
},
}),
tx.questionsQuestionEncounter.update({
data: {
...input,
},
where: {
id: input.id,
},
}),
]);
if (questionToUpdate!.lastSeenAt === questionEncounterToUpdate.seenAt) {
const latestEncounter =
await ctx.prisma.questionsQuestionEncounter.findFirst({
orderBy: {
seenAt: SortOrder.DESC,
},
where: {
questionId: questionToUpdate!.id,
},
});
await tx.questionsQuestion.update({
data: {
lastSeenAt: latestEncounter!.seenAt,
},
where: {
id: questionToUpdate!.id,
},
});
}
return questionEncounterUpdated;
});
},
})
@ -132,10 +197,44 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
});
}
return await ctx.prisma.questionsQuestionEncounter.delete({
where: {
id: input.id,
},
return await ctx.prisma.$transaction(async (tx) => {
const [questionToUpdate, questionEncounterDeleted] = await Promise.all([
tx.questionsQuestion.findUnique({
where: {
id: questionEncounterToDelete.questionId,
},
}),
tx.questionsQuestionEncounter.delete({
where: {
id: input.id,
},
}),
]);
if (questionToUpdate!.lastSeenAt === questionEncounterToDelete.seenAt) {
const latestEncounter =
await ctx.prisma.questionsQuestionEncounter.findFirst({
orderBy: {
seenAt: SortOrder.DESC,
},
where: {
questionId: questionToUpdate!.id,
},
});
const lastSeenVal = latestEncounter ? latestEncounter!.seenAt : null;
await tx.questionsQuestion.update({
data: {
lastSeenAt: lastSeenVal,
},
where: {
id: questionToUpdate!.id,
},
});
}
return questionEncounterDeleted;
});
},
});

@ -1,5 +1,4 @@
import { z } from 'zod';
import type { ResumesCommentVote } from '@prisma/client';
import { Vote } from '@prisma/client';
import { createRouter } from '../context';
@ -20,13 +19,13 @@ export const resumesCommentsVotesRouter = createRouter().query('list', {
},
});
let userVote: ResumesCommentVote | null = null;
let numVotes = 0;
votes.forEach((vote) => {
numVotes += vote.value === Vote.UPVOTE ? 1 : -1;
userVote = vote.userId === userId ? vote : null;
});
const userVotes = votes.filter((vote) => vote.userId === userId);
const userVote = userVotes.length > 0 ? userVotes[0] : null;
const numVotes = votes
.map((vote) => (vote.value === Vote.UPVOTE ? 1 : -1))
.reduce((result, current) => {
return result + current;
}, 0);
const resumeCommentVote: ResumeCommentVote = {
numVotes,

@ -32,6 +32,33 @@ export function cleanObject(object: any) {
return object;
}
/**
* Removes empty objects from an object.
* @param object
* @returns object without empty values or objects.
*/
export function removeEmptyObjects(object: any) {
Object.entries(object).forEach(([k, v]) => {
if ((v && typeof v === 'object') || Array.isArray(v)) {
removeEmptyObjects(v);
}
if (
v &&
typeof v === 'object' &&
!Object.keys(v).length &&
!Array.isArray(v)
) {
if (Array.isArray(object)) {
const index = object.indexOf(v);
object.splice(index, 1);
} else if (!(v instanceof Date)) {
delete object[k];
}
}
});
return object;
}
/**
* Removes invalid money data from an object.
* If currency is present but value is not present, money object is removed.

@ -1,9 +1,14 @@
import type { Config} from 'unique-names-generator';
import type { Config } from 'unique-names-generator';
import { countries, names } from 'unique-names-generator';
import { adjectives, animals,colors, uniqueNamesGenerator } from 'unique-names-generator';
import {
adjectives,
animals,
colors,
uniqueNamesGenerator,
} from 'unique-names-generator';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient()
const prisma = new PrismaClient();
const customConfig: Config = {
dictionaries: [adjectives, colors, animals],
@ -12,33 +17,34 @@ const customConfig: Config = {
};
export async function generateRandomName(): Promise<string> {
let uniqueName: string = uniqueNamesGenerator(customConfig);
let sameNameProfiles = await prisma.offersProfile.findMany({
where: {
profileName: uniqueName
}
})
while (sameNameProfiles.length !== 0) {
uniqueName = uniqueNamesGenerator(customConfig);
sameNameProfiles = await prisma.offersProfile.findMany({
where: {
profileName: uniqueName
}
})
}
return uniqueName
let uniqueName: string = uniqueNamesGenerator(customConfig);
let sameNameProfiles = await prisma.offersProfile.findMany({
where: {
profileName: uniqueName,
},
});
while (sameNameProfiles.length !== 0) {
uniqueName = uniqueNamesGenerator(customConfig);
sameNameProfiles = await prisma.offersProfile.findMany({
where: {
profileName: uniqueName,
},
});
}
return uniqueName;
}
const tokenConfig: Config = {
dictionaries: [adjectives, colors, animals, countries, names]
.sort((_a, _b) => 0.5 - Math.random()),
dictionaries: [adjectives, colors, animals, countries, names].sort(
(_a, _b) => 0.5 - Math.random(),
),
length: 5,
separator: '-',
};
export function generateRandomStringForToken(): string {
return uniqueNamesGenerator(tokenConfig)
}
return uniqueNamesGenerator(tokenConfig);
}

@ -4,7 +4,7 @@ export type CustomFilter = {
numComments: number;
};
type RoleFilter =
export type RoleFilter =
| 'Android Engineer'
| 'Backend Engineer'
| 'DevOps Engineer'
@ -12,7 +12,7 @@ type RoleFilter =
| 'Full-Stack Engineer'
| 'iOS Engineer';
type ExperienceFilter =
export type ExperienceFilter =
| 'Entry Level (0 - 2 years)'
| 'Freshman'
| 'Junior'
@ -21,7 +21,7 @@ type ExperienceFilter =
| 'Senior'
| 'Sophomore';
type LocationFilter = 'India' | 'Singapore' | 'United States';
export type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
@ -39,7 +39,7 @@ export type Filter = {
export type FilterState = Partial<CustomFilter> &
Record<FilterId, Array<FilterValue>>;
export type SortOrder = 'latest' | 'popular' | 'topComments';
export type SortOrder = 'latest' | 'mostComments' | 'popular';
export type Shortcut = {
customFilters?: CustomFilter;
@ -54,11 +54,17 @@ export const BROWSE_TABS_VALUES = {
STARRED: 'starred',
};
export const SORT_OPTIONS: Record<string, string> = {
latest: 'Latest',
popular: 'Popular',
topComments: 'Most Comments',
};
// Export const SORT_OPTIONS: Record<string, SortOrder> = {
// LATEST: 'latest',
// POPULAR: 'popular',
// TOPCOMMENTS: 'topComments',
// };
export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
{ label: 'Latest', value: 'latest' },
{ label: 'Popular', value: 'popular' },
{ label: 'Most Comments', value: 'mostComments' },
];
export const ROLES: Array<FilterOption<RoleFilter>> = [
{

@ -0,0 +1,26 @@
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
export const useSearchParams = <T>(name: string, defaultValue: T) => {
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
const [filters, setFilters] = useState(defaultValue);
useEffect(() => {
if (router.isReady && !isInitialized) {
// Initialize from url query params
const query = router.query[name];
if (query) {
const parsedQuery =
typeof query === 'string' ? JSON.parse(query) : query;
setFilters(parsedQuery);
}
setIsInitialized(true);
}
}, [isInitialized, name, router]);
return [filters, setFilters, isInitialized] as const;
};
export default useSearchParams;
Loading…
Cancel
Save