Merge branch 'main' into questions/ga

pull/513/head
Jeff Sieu 3 years ago
commit 6f9ce620fb

@ -43,7 +43,6 @@
"react-query": "^3.39.2",
"read-excel-file": "^5.5.3",
"superjson": "^1.10.0",
"xlsx": "^0.18.5",
"unique-names-generator": "^4.7.1",
"xlsx": "^0.18.5",
"zod": "^3.18.0"

@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `location` on the `ResumesResume` table. All the data in the column will be lost.
- Added the required column `locationId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable. Set default location to Singapore.
ALTER TABLE "ResumesResume" DROP COLUMN "location",
ADD COLUMN "locationId" TEXT NOT NULL DEFAULT '196';
-- AddForeignKey
ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Country"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ResumesResume" ALTER COLUMN "locationId" DROP DEFAULT;

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Country" ADD COLUMN "ranking" INTEGER DEFAULT 0;

@ -110,8 +110,11 @@ model Country {
id String @id
name String @unique
code String @unique
// The higher the value of the ranking, the higher it appears in the search results.
ranking Int? @default(0)
states State[]
questionsQuestionEncounters QuestionsQuestionEncounter[]
ResumesResume ResumesResume[]
}
model State {
@ -148,13 +151,14 @@ model ResumesResume {
// TODO: Update role, experience, location to use Enums
role String @db.Text
experience String @db.Text
location String @db.Text
locationId String
url String
additionalInfo String? @db.Text
isResolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
location Country @relation(fields: [locationId], references: [id], onDelete: Cascade)
stars ResumesStar[]
comments ResumesComment[]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

@ -3,12 +3,14 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/features', name: 'Features' },
{ href: '/offers/about', name: 'About' },
];
const navigationAuthenticated: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/dashboard', name: 'Your dashboard' },
{ href: '/offers/features', name: 'Features' },
{ href: '/offers/about', name: 'About' },
];
const config = {

@ -1,4 +1,5 @@
export const HOME_URL = '/offers';
export const OFFERS_SUBMIT_URL = '/offers/submit';
export const JobTypeLabel = {
FULLTIME: 'Full-time',
@ -37,4 +38,4 @@ export const profileDetailTabs = [
label: ProfileDetailTab.ANALYSIS,
value: ProfileDetailTab.ANALYSIS,
},
];
];

@ -2,14 +2,14 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import type { ReactNode } from 'react';
import { HOME_URL } from '../constants';
type LeftTextCardProps = Readonly<{
buttonLabel: string;
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: StaticImageData;
title: string;
url: string;
}>;
export default function LeftTextCard({
@ -18,6 +18,8 @@ export default function LeftTextCard({
imageAlt,
imageSrc,
title,
buttonLabel,
url,
}: LeftTextCardProps) {
return (
<div className="items-center lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
@ -36,8 +38,8 @@ export default function LeftTextCard({
<div className="mt-6">
<a
className="to-primary-500 inline-flex rounded-md border border-transparent bg-gradient-to-r from-purple-600 bg-origin-border px-4 py-2 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={HOME_URL}>
Get started
href={url}>
{buttonLabel}
</a>
</div>
</div>

@ -2,14 +2,14 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import type { ReactNode } from 'react';
import { HOME_URL } from '../constants';
type RightTextCarddProps = Readonly<{
buttonLabel: string;
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: StaticImageData;
title: string;
url: string;
}>;
export default function RightTextCard({
@ -18,6 +18,8 @@ export default function RightTextCard({
imageAlt,
imageSrc,
title,
url,
buttonLabel,
}: RightTextCarddProps) {
return (
<div className="items-center lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
@ -36,8 +38,8 @@ export default function RightTextCard({
<div className="mt-6">
<a
className="to-primary-500 inline-flex rounded-md border border-transparent bg-gradient-to-r from-purple-600 bg-origin-border px-4 py-2 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={HOME_URL}>
Get started
href={url}>
{buttonLabel}
</a>
</div>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 KiB

After

Width:  |  Height:  |  Size: 277 KiB

@ -3,7 +3,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypeahead';
type Props = Omit<
ComponentProps<typeof JobTitlesTypeahead>,
@ -21,11 +21,15 @@ export default function FormJobTitlesTypeahead({ name, ...props }: Props) {
return (
<JobTitlesTypeahead
{...props}
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
value={
watchJobTitle
? {
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}
: null
}
onSelect={(option) => {
setValue(name, option?.value);
}}

@ -275,7 +275,7 @@ export default function OffersTable({
{!offers ||
(offers.length === 0 && (
<div className="py-16 text-lg">
<div className="flex justify-center">No data yet🥺</div>
<div className="flex justify-center">No data yet 🥺</div>
</div>
))}
</div>
@ -290,4 +290,4 @@ export default function OffersTable({
/>
</div>
);
}
}

@ -3,26 +3,52 @@ import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';
import { Fragment, useRef, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { CheckIcon, HeartIcon, PlusIcon } from '@heroicons/react/20/solid';
import {
useAddQuestionToListAsync,
useCreateListAsync,
useRemoveQuestionFromListAsync,
} from '~/utils/questions/mutations';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc';
import CreateListDialog from './CreateListDialog';
export type AddToListDropdownProps = {
questionId: string;
};
export type DropdownButtonProps = PropsWithChildren<{
onClick: () => void;
}>;
function DropdownButton({ onClick, children }: DropdownButtonProps) {
return (
<Menu.Item>
{({ active }) => (
<button
className={clsx(
active ? 'bg-slate-100 text-slate-900' : 'text-slate-700',
'flex w-full items-center px-4 py-2 text-sm',
)}
type="button"
onClick={onClick}>
{children}
</button>
)}
</Menu.Item>
);
}
export default function AddToListDropdown({
questionId,
}: AddToListDropdownProps) {
const { event } = useGoogleAnalytics();
const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false);
const utils = trpc.useContext();
const createListAsync = useCreateListAsync();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const listsWithQuestionData = useMemo(() => {
@ -34,30 +60,8 @@ export default function AddToListDropdown({
}));
}, [lists, questionId]);
const { mutateAsync: addQuestionToList } = trpc.useMutation(
'questions.lists.createQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
event({
action: 'questions.lists',
category: 'engagement',
label: 'add question to list',
});
},
},
);
const { mutateAsync: removeQuestionFromList } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const addQuestionToList = useAddQuestionToListAsync();
const removeQuestionFromList = useRemoveQuestionFromListAsync();
const addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true);
@ -110,63 +114,79 @@ export default function AddToListDropdown({
);
return (
<Menu ref={ref} as="div" className="relative inline-block text-left">
<div>
<Menu.Button as={CustomMenuButton}>
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
Add to List
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
show={menuOpened}>
<Menu.Items
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
static={true}>
{menuOpened && (
<>
{(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1">
<Menu.Item>
{({ active }) => (
<button
className={clsx(
active
? 'bg-slate-100 text-slate-900'
: 'text-slate-700',
'group flex w-full items-center px-4 py-2 text-sm',
)}
type="button"
onClick={() => {
if (list.hasQuestion) {
handleDeleteFromList(list.id);
} else {
handleAddToList(list.id);
}
}}>
<div>
<Menu ref={ref} as="div" className="relative inline-block text-left">
<div>
<Menu.Button as={CustomMenuButton}>
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
Add to list
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
show={menuOpened}>
<Menu.Items
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
static={true}>
{menuOpened && (
<>
{(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1">
<DropdownButton
onClick={() => {
if (list.hasQuestion) {
handleDeleteFromList(list.id);
} else {
handleAddToList(list.id);
}
}}>
<div className="flex w-full flex-1 justify-between">
<span className="flex-1 overflow-hidden text-ellipsis text-start">
{list.name}
</span>
{list.hasQuestion && (
<CheckIcon
aria-hidden="true"
className="mr-3 h-5 w-5 text-slate-400 group-hover:text-slate-500"
className="h-5 w-5 text-slate-400"
/>
)}
{list.name}
</button>
)}
</Menu.Item>
</div>
))}
</>
)}
</Menu.Items>
</Transition>
</Menu>
</div>
</DropdownButton>
</div>
))}
<DropdownButton
onClick={() => {
setShow(true);
}}>
<PlusIcon
aria-hidden="true"
className="mr-3 h-5 w-5 text-slate-500"
/>
<span className="font-semibold text-slate-500">
Create new list
</span>
</DropdownButton>
</>
)}
</Menu.Items>
</Transition>
</Menu>
<CreateListDialog
show={show}
onCancel={() => {
setShow(false);
}}
onSubmit={async (data) => {
await createListAsync(data);
setShow(false);
}}
/>
</div>
);
}

@ -25,7 +25,7 @@ export default function AnswerCommentListItem({
useAnswerCommentVote(answerCommentId);
return (
<div className="flex gap-4 border bg-white p-2 ">
<div className="flex gap-4 rounded-md border bg-white p-2">
<VotingButtons
size="sm"
upvoteCount={upvoteCount}

@ -1,9 +1,4 @@
import { useState } from 'react';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import { TextInput } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
@ -32,44 +27,19 @@ export default function ContributeQuestionCard({
return (
<div className="w-full">
<button
className="flex w-full flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
className="flex w-full flex-1 justify-between gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
type="button"
onClick={handleOpenContribute}>
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
onChange={handleOpenContribute}
/>
<div className="w-full">
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
onChange={handleOpenContribute}
/>
</div>
<div className="flex flex-wrap items-end justify-start gap-2">
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Company"
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Question type"
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Date"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>

@ -20,15 +20,13 @@ export type LandingQueryData = {
companySlug: string;
location: string;
questionType: QuestionsQuestionType;
};
} | null;
export type LandingComponentProps = {
onLanded: (data: LandingQueryData) => void;
};
export default function LandingComponent({
onLanded: handleLandingQuery,
}: LandingComponentProps) {
export default function LandingComponent({ onLanded }: LandingComponentProps) {
const defaultCompany = useDefaultCompany();
const defaultLocation = useDefaultLocation();
@ -70,17 +68,17 @@ export default function LandingComponent({
<main className="flex flex-1 flex-col items-center overflow-y-auto bg-white">
<div className="flex flex-1 flex-col items-start justify-center gap-12 px-4">
<header className="flex flex-col items-start gap-16">
<div className="flex flex-col items-center self-stretch">
<div className="flex flex-col items-center">
<img
alt="Questions Bank"
className="h-40 w-40"
src="/bank-logo.png"
/>
<h1 className="text-center text-4xl font-bold text-slate-900">
<h1 className="text-primary-700 text-center text-5xl font-bold">
Tech Interview Question Bank
</h1>
</div>
<p className="mb-2 max-w-lg text-5xl font-semibold text-slate-900 sm:max-w-3xl">
<p className="mb-2 max-w-lg text-4xl font-semibold text-slate-900 sm:max-w-3xl">
Know the{' '}
<span className="text-primary-700">
latest SWE interview questions
@ -118,22 +116,34 @@ export default function LandingComponent({
}}
/>
</div>
<Button
addonPosition="end"
icon={ArrowSmallRightIcon}
label="Go"
size="md"
variant="primary"
onClick={() => {
if (company !== undefined && location !== undefined) {
return handleLandingQuery({
companySlug: companyOptionToSlug(company),
location: locationOptionToSlug(location),
questionType,
});
}
}}
/>
<div className="flex items-center gap-2">
<Button
addonPosition="end"
icon={ArrowSmallRightIcon}
label="Go"
size="md"
variant="primary"
onClick={() => {
if (company !== undefined && location !== undefined) {
onLanded({
companySlug: companyOptionToSlug(company),
location: locationOptionToSlug(location),
questionType,
});
}
}}
/>
<Button
addonPosition="end"
icon={ArrowSmallRightIcon}
label="View all questions"
size="md"
variant="secondary"
onClick={() => {
onLanded(null);
}}
/>
</div>
</div>
<div className="flex justify-center">
<iframe

@ -60,7 +60,7 @@ export default function QuestionAggregateBadge({
<Badge label={label} {...badgeProps} />
</button>
{visible && (
<div ref={setTooltipRef} {...getTooltipProps()}>
<div ref={setTooltipRef} {...getTooltipProps()} className="z-10">
<div className="flex flex-col gap-2 rounded-md bg-white p-2 shadow-md">
<ul>
{sortedStatistics.map(({ key, value }) => (

@ -8,16 +8,15 @@ export type SortOption<Value> = {
value: Value;
};
const sortTypeOptions = SORT_TYPES;
const sortOrderOptions = SORT_ORDERS;
type SortOrderProps<Order> = {
onSortOrderChange?: (sortValue: Order) => void;
sortOrderOptions?: Array<SortOption<Order>>;
sortOrderValue: Order;
};
type SortTypeProps<Type> = {
onSortTypeChange?: (sortType: Type) => void;
sortTypeOptions?: Array<SortOption<Type>>;
sortTypeValue: Type;
};
@ -29,17 +28,22 @@ export default function SortOptionsSelect({
sortOrderValue,
onSortTypeChange,
sortTypeValue,
sortOrderOptions,
sortTypeOptions,
}: SortOptionsSelectProps) {
const sortTypes = sortTypeOptions ?? SORT_TYPES;
const sortOrders = sortOrderOptions ?? SORT_ORDERS;
return (
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2">
<Select
display="inline"
label="Sort by"
options={sortTypeOptions}
options={sortTypes}
value={sortTypeValue}
onChange={(value) => {
const chosenOption = sortTypeOptions.find(
const chosenOption = sortTypes.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
@ -52,10 +56,10 @@ export default function SortOptionsSelect({
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
options={sortOrders}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
const chosenOption = sortOrders.find(
(option) => String(option.value) === value,
);
if (chosenOption) {

@ -90,7 +90,7 @@ type ReceivedStatisticsProps =
type CreateEncounterProps =
| {
createEncounterButtonText: string;
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
onReceivedSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
showCreateEncounterButton: true;
}
| {
@ -116,6 +116,7 @@ export type BaseQuestionCardProps = ActionButtonProps &
ReceivedStatisticsProps &
UpvoteProps & {
content: string;
hideCard?: boolean;
questionId: string;
showHover?: boolean;
timestamp: string | null;
@ -140,6 +141,7 @@ export default function BaseQuestionCard({
actionButtonLabel,
onActionButtonClick,
upvoteCount,
hideCard,
timestamp,
roles,
countries,
@ -152,7 +154,6 @@ export default function BaseQuestionCard({
}: BaseQuestionCardProps) {
const [showReceivedForm, setShowReceivedForm] = useState(false);
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
const locations = useMemo(() => {
if (countries === undefined) {
@ -263,9 +264,8 @@ export default function BaseQuestionCard({
onCancel={() => {
setShowReceivedForm(false);
}}
onSubmit={(data) => {
onReceivedSubmit?.(data);
setShowReceivedForm(false);
onSubmit={async (data) => {
await onReceivedSubmit?.(data);
}}
/>
)}
@ -275,7 +275,11 @@ export default function BaseQuestionCard({
return (
<article
className={`group flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}>
className={clsx(
'group flex gap-4 border-slate-300',
showHover && 'hover:bg-slate-50',
!hideCard && 'rounded-md border bg-white p-4',
)}>
{cardContent}
{showDeleteButton && (
<div className="fill-danger-700 invisible self-center group-hover:visible">

@ -28,6 +28,7 @@ export default function FullQuestionCard(props: QuestionOverviewCardProps) {
return (
<BaseQuestionCard
{...props}
hideCard={true}
showActionButton={false}
showAddToList={true}
showAggregateStatistics={true}

@ -5,7 +5,7 @@ import { ArrowPathIcon } from '@heroicons/react/20/solid';
import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { CheckboxInput } from '@tih/ui';
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
import { Button, Select, TextArea } from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
@ -187,11 +187,9 @@ export default function ContributeQuestionForm({
/>
</div>
</div>
<div className="w-full">
<HorizontalDivider />
</div>
<h2
className="text-primary-900 mb-3
className="text-primary-900
text-lg font-semibold
">
Are these questions the same as yours?
@ -243,11 +241,13 @@ export default function ContributeQuestionForm({
/>
);
})}
{similarQuestions?.length === 0 && (
<p className="font-semibold text-slate-900">
No similar questions found.
</p>
)}
{similarQuestions?.length === 0 &&
contentToCheck?.length !== 0 &&
questionContent === contentToCheck && (
<p className="font-semibold text-slate-900">
No similar questions found.
</p>
)}
</div>
<div
className="bg-primary-50 flex w-full flex-col gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)] sm:flex-row sm:justify-between"

@ -1,5 +1,6 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { CheckIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import type { Month } from '~/components/shared/MonthYearPicker';
@ -22,7 +23,7 @@ export type CreateQuestionEncounterData = {
export type CreateQuestionEncounterFormProps = {
onCancel: () => void;
onSubmit: (data: CreateQuestionEncounterData) => void;
onSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
};
export default function CreateQuestionEncounterForm({
@ -30,6 +31,8 @@ export default function CreateQuestionEncounterForm({
onSubmit,
}: CreateQuestionEncounterFormProps) {
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<Location | null>(
@ -40,9 +43,18 @@ export default function CreateQuestionEncounterForm({
startOfMonth(new Date()),
);
if (submitted) {
return (
<div className="font-md flex items-center gap-1 rounded-full border bg-slate-50 py-1 pl-2 pr-3 text-sm text-slate-500">
<CheckIcon className="h-5 w-5" />
<p>Thank you for your response</p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<p className="font-md text-md text-slate-600">
<p className="text-md text-slate-600">
I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
</p>
{step === 0 && (
@ -128,9 +140,10 @@ export default function CreateQuestionEncounterForm({
)}
{step === 3 && (
<Button
isLoading={loading}
label="Submit"
variant="primary"
onClick={() => {
onClick={async () => {
if (
selectedCompany &&
selectedLocation &&
@ -138,14 +151,20 @@ export default function CreateQuestionEncounterForm({
selectedDate
) {
const { cityId, stateId, countryId } = selectedLocation;
onSubmit({
cityId,
company: selectedCompany,
countryId,
role: selectedRole,
seenAt: selectedDate,
stateId,
});
setLoading(true);
try {
await onSubmit({
cityId,
company: selectedCompany,
countryId,
role: selectedRole,
seenAt: selectedDate,
stateId,
});
setSubmitted(true);
} finally {
setLoading(false);
}
}
}}
/>

@ -0,0 +1,30 @@
import type { PropsWithChildren } from 'react';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button } from '@tih/ui';
export type BackButtonLayoutProps = PropsWithChildren<{
href: string;
}>;
export default function BackButtonLayout({
href,
children,
}: BackButtonLayoutProps) {
return (
<div className="flex w-full flex-1 flex-col items-stretch gap-4 p-4 lg:flex-row">
<div>
<Button
addonPosition="start"
display="inline"
href={href}
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
/>
</div>
<div className="flex w-full justify-center overflow-y-auto">
{children}
</div>
</div>
);
}

@ -13,7 +13,7 @@ export type CompanyTypeaheadProps = Omit<
export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: companies } = trpc.useQuery([
const { data: companies, isLoading } = trpc.useQuery([
'companies.list',
{
name: query,
@ -33,6 +33,7 @@ export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
isLoading={isLoading}
label="Company"
options={companyOptions}
onQueryChange={setQuery}

@ -23,7 +23,7 @@ export default function LocationTypeahead({
}: LocationTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: locations } = trpc.useQuery([
const { data: locations, isLoading } = trpc.useQuery([
'locations.cities.list',
{
name: query,
@ -45,6 +45,7 @@ export default function LocationTypeahead({
return (
<ExpandedTypeahead
isLoading={isLoading}
{...({
onSuggestionClick: onSuggestionClick
? (option: TypeaheadOption) => {

@ -12,7 +12,7 @@ export type RoleTypeaheadProps = Omit<
>;
const ROLES: FilterChoices = Object.entries(JobTitleLabels).map(
([slug, label]) => ({
([slug, { label }]) => ({
id: slug,
label,
value: slug,
@ -26,7 +26,9 @@ export default function RoleTypeahead(props: RoleTypeaheadProps) {
{...(props as ExpandedTypeaheadProps)}
label="Role"
options={ROLES.filter((option) =>
option.label.toLowerCase().includes(query.toLowerCase()),
option.label
.toLocaleLowerCase()
.includes(query.trim().toLocaleLowerCase()),
)}
onQueryChange={setQuery}
/>

@ -12,17 +12,7 @@ import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type {
ExperienceFilter,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
EXPERIENCES,
getFilterLabel,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { getFilterLabel } from '~/utils/resumes/resumeFilters';
import type { Resume } from '~/types/resume';
@ -47,15 +37,14 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
<div className="col-span-7 grid gap-4 border-b border-slate-200 p-4 hover:bg-slate-100 sm:grid-cols-7">
<div className="sm:col-span-4">
<div className="flex items-center gap-3">
{resumeInfo.title}
<p className="truncate">{resumeInfo.title}</p>
<p
className={clsx(
'w-auto items-center space-x-4 rounded-xl border border-slate-300 px-2 py-1 text-xs font-medium text-white opacity-60',
resumeInfo.isResolved ? 'bg-slate-400' : 'bg-success-500',
'w-auto items-center space-x-4 rounded-xl border px-2 py-1 text-xs font-medium',
resumeInfo.isResolved ? 'bg-slate-300' : 'bg-success-100',
resumeInfo.isResolved ? 'text-slate-600' : 'text-success-700',
)}>
<span className="opacity-100">
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</span>
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</p>
</div>
<div className="text-primary-500 mt-2 flex items-center justify-start text-xs">
@ -64,17 +53,14 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(ROLES, resumeInfo.role as RoleFilter)}
{getFilterLabel('role', resumeInfo.role)}
</div>
<div className="ml-4 flex">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(
EXPERIENCES,
resumeInfo.experience as ExperienceFilter,
)}
{getFilterLabel('experience', resumeInfo.experience)}
</div>
</div>
<div className="mt-4 flex justify-start text-xs text-slate-500">
@ -102,9 +88,7 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
addSuffix: true,
})} by ${resumeInfo.user}`}
</div>
<div className="mt-2 text-slate-400">
{getFilterLabel(LOCATIONS, resumeInfo.location as LocationFilter)}
</div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div>
</div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" />

@ -0,0 +1,51 @@
import type { ComponentProps } from 'react';
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { EXPERIENCES } from '~/utils/resumes/resumeFilters';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,
| 'disabled'
| 'errorMessage'
| 'isLabelHidden'
| 'placeholder'
| 'required'
| 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
selectedValues?: Set<string>;
value?: TypeaheadOption | null;
}>;
export default function ResumeExperienceTypeahead({
onSelect,
selectedValues = new Set(),
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const options = EXPERIENCES.filter(
(option) => !selectedValues.has(option.value),
).filter(
({ label }) =>
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1,
);
return (
<Typeahead
label="Experiences"
noResultsMessage="No available experiences."
nullable={true}
options={options}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
{...props}
/>
);
}

@ -36,11 +36,13 @@ export default function CitiesTypeahead({
},
]);
const { data } = cities;
const { data, isLoading } = cities;
return (
<Typeahead
isLoading={isLoading}
label={label}
minQueryLength={3}
noResultsMessage="No cities found"
nullable={true}
options={

@ -34,10 +34,11 @@ export default function CompaniesTypeahead({
},
]);
const { data } = companies;
const { data, isLoading } = companies;
return (
<Typeahead
isLoading={isLoading}
label="Company"
noResultsMessage="No companies found"
nullable={true}

@ -17,11 +17,25 @@ type BaseProps = Pick<
type Props = BaseProps &
Readonly<{
excludedValues?: Set<string>;
label?: string;
onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null;
}>;
function stringPositionComparator(a: string, b: string, query: string): number {
const normalizedQueryString = query.trim().toLocaleLowerCase();
const positionA = a.toLocaleLowerCase().indexOf(normalizedQueryString);
const positionB = b.toLocaleLowerCase().indexOf(normalizedQueryString);
return (
(positionA === -1 ? 9999 : positionA) -
(positionB === -1 ? 9999 : positionB)
);
}
export default function CountriesTypeahead({
excludedValues,
label = 'Country',
onSelect,
value,
...props
@ -34,20 +48,42 @@ export default function CountriesTypeahead({
},
]);
const { data } = countries;
const { data, isLoading } = countries;
return (
<Typeahead
label="Country"
isLoading={isLoading}
label={label}
noResultsMessage="No countries found"
nullable={true}
options={
data?.map(({ id, name }) => ({
options={(data ?? [])
// Client-side sorting by position of query string appearing
// in the country name since we can't do that in Prisma.
.sort((a, b) => {
const normalizedQueryString = query.trim().toLocaleLowerCase();
if (
a.code.toLocaleLowerCase() === normalizedQueryString ||
b.code.toLocaleLowerCase() === normalizedQueryString
) {
return stringPositionComparator(
a.code,
b.code,
normalizedQueryString,
);
}
return stringPositionComparator(
a.name,
b.name,
normalizedQueryString,
);
})
.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
}
}))
.filter((option) => !excludedValues?.has(option.value))}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}

@ -1,56 +1,82 @@
export const JobTitleLabels = {
'ai-engineer': 'Artificial Intelligence (AI) Engineer',
'algorithms-engineer': 'Algorithms Engineer',
'android-engineer': 'Android Software Engineer',
'applications-engineer': 'Applications Engineer',
'back-end-engineer': 'Back End Engineer',
'business-analyst': 'Business Analyst',
'business-engineer': 'Business Engineer',
'capacity-engineer': 'Capacity Engineer',
'customer-engineer': 'Customer Engineer',
'data-analyst': 'Data Analyst',
'data-engineer': 'Data Engineer',
'data-scientist': 'Data Scientist',
'devops-engineer': 'DevOps Engineer',
'engineering-director': 'Engineering Director',
'engineering-manager': 'Engineering Manager',
'enterprise-engineer': 'Enterprise Engineer',
'forward-deployed-engineer': 'Forward Deployed Engineer',
'front-end-engineer': 'Front End Engineer',
'full-stack-engineer': 'Full Stack Engineer',
'gameplay-engineer': 'Gameplay Engineer',
'hardware-engineer': 'Hardware Engineer',
'infrastructure-engineer': 'Infrastructure Engineer',
'ios-engineer': 'iOS Software Engineer',
'machine-learning-engineer': 'Machine Learning (ML) Engineer',
'machine-learning-researcher': 'Machine Learning (ML) Researcher',
'mobile-engineer': 'Mobile Software Engineer (iOS + Android)',
'networks-engineer': 'Networks Engineer',
'partner-engineer': 'Partner Engineer',
'product-engineer': 'Product Engineer',
'product-manager': 'Product Manager',
'production-engineer': 'Production Engineer',
'project-manager': 'Project Manager',
'release-engineer': 'Release Engineer',
'research-engineer': 'Research Engineer',
'research-scientist': 'Research Scientist',
'rotational-engineer': 'Rotational Engineer',
'sales-engineer': 'Sales Engineer',
'security-engineer': 'Security Engineer',
'site-reliability-engineer': 'Site Reliability Engineer (SRE)',
'software-engineer': 'Software Engineer',
'solutions-architect': 'Solutions Architect',
'solutions-engineer': 'Solutions Engineer',
'systems-analyst': 'Systems Analyst',
'systems-engineer': 'Systems Engineer',
'tech-ops-engineer': 'Tech Ops Engineer',
'technical-program-manager': 'Technical Program Manager',
'test-engineer': 'QA/Test Engineer (SDET)',
'ux-engineer': 'User Experience (UX) Engineer',
type JobTitleData = Record<
string,
Readonly<{
label: string;
ranking: number;
}>
>;
export const JobTitleLabels: JobTitleData = {
'ai-engineer': { label: 'Artificial Intelligence (AI) Engineer', ranking: 5 },
'algorithms-engineer': { label: 'Algorithms Engineer', ranking: 0 },
'android-engineer': { label: 'Android Software Engineer', ranking: 8 },
'applications-engineer': { label: 'Applications Engineer', ranking: 0 },
'back-end-engineer': { label: 'Back End Engineer', ranking: 9 },
'business-analyst': { label: 'Business Analyst', ranking: 0 },
'business-engineer': { label: 'Business Engineer', ranking: 5 },
'capacity-engineer': { label: 'Capacity Engineer', ranking: 0 },
'customer-engineer': { label: 'Customer Engineer', ranking: 0 },
'data-analyst': { label: 'Data Analyst', ranking: 0 },
'data-engineer': { label: 'Data Engineer', ranking: 0 },
'data-scientist': { label: 'Data Scientist', ranking: 5 },
'devops-engineer': { label: 'DevOps Engineer', ranking: 0 },
'engineering-director': { label: 'Engineering Director', ranking: 0 },
'engineering-manager': { label: 'Engineering Manager', ranking: 0 },
'enterprise-engineer': { label: 'Enterprise Engineer', ranking: 0 },
'forward-deployed-engineer': {
label: 'Forward Deployed Engineer (FDE)',
ranking: 0,
},
'front-end-engineer': { label: 'Front End Engineer', ranking: 9 },
'full-stack-engineer': { label: 'Full Stack Engineer', ranking: 9 },
'gameplay-engineer': { label: 'Gameplay Engineer', ranking: 0 },
'hardware-engineer': { label: 'Hardware Engineer', ranking: 0 },
'infrastructure-engineer': { label: 'Infrastructure Engineer', ranking: 0 },
'ios-engineer': { label: 'iOS Software Engineer', ranking: 0 },
'machine-learning-engineer': {
label: 'Machine Learning (ML) Engineer',
ranking: 5,
},
'machine-learning-researcher': {
label: 'Machine Learning (ML) Researcher',
ranking: 0,
},
'mobile-engineer': {
label: 'Mobile Software Engineer (iOS + Android)',
ranking: 8,
},
'networks-engineer': { label: 'Networks Engineer', ranking: 0 },
'partner-engineer': { label: 'Partner Engineer', ranking: 0 },
'product-engineer': { label: 'Product Engineer', ranking: 7 },
'product-manager': { label: 'Product Manager', ranking: 0 },
'production-engineer': { label: 'Production Engineer', ranking: 8 },
'project-manager': { label: 'Project Manager', ranking: 0 },
'release-engineer': { label: 'Release Engineer', ranking: 0 },
'research-engineer': { label: 'Research Engineer', ranking: 6 },
'research-scientist': { label: 'Research Scientist', ranking: 7 },
'rotational-engineer': { label: 'Rotational Engineer', ranking: 0 },
'sales-engineer': { label: 'Sales Engineer', ranking: 0 },
'security-engineer': { label: 'Security Engineer', ranking: 7 },
'site-reliability-engineer': {
label: 'Site Reliability Engineer (SRE)',
ranking: 8,
},
'software-engineer': { label: 'Software Engineer', ranking: 10 },
'solutions-architect': { label: 'Solutions Architect', ranking: 0 },
'solutions-engineer': { label: 'Solutions Engineer', ranking: 0 },
'systems-analyst': { label: 'Systems Analyst', ranking: 0 },
'systems-engineer': { label: 'Systems Engineer', ranking: 0 },
'tech-ops-engineer': { label: 'Tech Ops Engineer', ranking: 0 },
'technical-program-manager': {
label: 'Technical Program Manager',
ranking: 0,
},
'test-engineer': { label: 'QA/Test Engineer (SDET)', ranking: 6 },
'ux-engineer': { label: 'User Experience (UX) Engineer', ranking: 0 },
};
export type JobTitleType = keyof typeof JobTitleLabels;
export function getLabelForJobTitleType(jobTitle: JobTitleType): string {
return JobTitleLabels[jobTitle];
return JobTitleLabels[jobTitle].label;
}

@ -17,31 +17,39 @@ type BaseProps = Pick<
type Props = BaseProps &
Readonly<{
excludedValues?: Set<string>;
label?: string;
noResultsMessage?: string;
onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null;
}>;
export default function JobTitlesTypeahead({
excludedValues,
label: labelProp = 'Job Title',
noResultsMessage = 'No available job titles.',
onSelect,
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels)
.map(([slug, label]) => ({
.map(([slug, { label, ranking }]) => ({
id: slug,
label,
ranking,
value: slug,
}))
.filter(
({ label }) =>
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1,
);
.filter(({ label }) =>
label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()),
)
.filter((option) => !excludedValues?.has(option.value))
.sort((a, b) => b.ranking - a.ranking);
return (
<Typeahead
label="Job Title"
noResultsMessage="No available job titles."
label={labelProp}
noResultsMessage={noResultsMessage}
nullable={true}
options={options}
value={value}

@ -0,0 +1,86 @@
import Container from '~/components/shared/Container';
const people = [
{
bio: 'I like to play games so I treat life like a game.',
imageUrl: '/team/bryann.jpg',
name: 'Bryann Yeap',
role: 'Back End Engineer',
},
{
bio: 'I am always up for sushi.',
imageUrl: '/team/ai-ling.jpg',
name: 'Hong Ai Ling',
role: 'Back End Engineer',
},
{
bio: 'I love to watch football and code.',
imageUrl: '/team/stuart.jpg',
name: 'Stuart Long',
role: 'Front End Engineer',
},
{
bio: 'Ziqing is a human who thrives under pressure, coffee and cat. In her own time, she likes playing the flute, building fun stuff with friends and watching animes.',
imageUrl: '/team/ziqing.jpg',
name: 'Zhang Ziqing',
role: 'Front End Engineer',
},
];
export default function AboutPage() {
return (
<div className="lg:py-18 bg-white py-12">
<Container variant="xs">
<div className="space-y-12">
<div className="space-y-8">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
About Tech Offers Repo
</h1>
<p className="text-lg text-slate-500">
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris
nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in
reprehenderit in voluptate velit esse cillum dolore eu fugiat
nulla pariatur. Excepteur sint occaecat cupidatat non proident,
sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
<div className="space-y-8">
<h2 className="text-2xl font-bold tracking-tight sm:text-3xl">
Meet the Team
</h2>
<ul
className="grid grid-cols-2 space-y-12 md:grid-cols-1 md:items-start md:gap-x-8 md:gap-y-12 md:space-y-0"
role="list">
{people.map((person) => (
<li key={person.name}>
<div className="space-y-4 sm:grid sm:grid-cols-4 sm:gap-6 sm:space-y-0 lg:gap-8">
<div className="aspect-w-2 aspect-h-2 h-0">
<img
alt=""
className="rounded-lg object-cover shadow-lg"
src={person.imageUrl}
/>
</div>
<div className="sm:col-span-3">
<div className="space-y-4">
<div className="space-y-1 text-lg font-medium leading-6">
<h3>{person.name}</h3>
<p className="text-primary-600">{person.role}</p>
</div>
<div className="text-lg">
<p className="text-slate-500">{person.bio}</p>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
</div>
</div>
</Container>
</div>
);
}

@ -8,7 +8,7 @@ import {
UsersIcon,
} from '@heroicons/react/24/outline';
import { HOME_URL } from '~/components/offers/constants';
import { HOME_URL, OFFERS_SUBMIT_URL } from '~/components/offers/constants';
import offersAnalysis from '~/components/offers/features/images/offers-analysis.png';
import offersBrowse from '~/components/offers/features/images/offers-browse.png';
import offersProfile from '~/components/offers/features/images/offers-profile.png';
@ -126,6 +126,7 @@ export default function LandingPage() {
/>
<div className="relative">
<LeftTextCard
buttonLabel="View offers"
description="Filter relevant offers by job title, company, submission date, salary and more."
icon={
<TableCellsIcon
@ -133,27 +134,31 @@ export default function LandingPage() {
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageAlt="Browse page"
imageSrc={offersBrowse}
title="Stay informed of recent offers"
url={HOME_URL}
/>
</div>
<div className="mt-36">
<RightTextCard
description="With our offer engine analysis, you can compare your offers with other offers on the market and make an informed decision."
buttonLabel="Analyse offers"
description="With our offer engine analysis, you can benchmark your offers against other offers on the market and make an informed decision."
icon={
<ChartBarSquareIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Customer profile user interface"
imageAlt="Offers analysis page"
imageSrc={offersAnalysis}
title="Better understand your offers"
url={OFFERS_SUBMIT_URL}
/>
</div>
<div className="mt-36">
<LeftTextCard
buttonLabel="View offer profiles"
description="An offer profile includes not only offers that a person received in their application cycle, but also background information such as education and work experience. Use offer profiles to help you better contextualize offers."
icon={
<InformationCircleIcon
@ -161,9 +166,10 @@ export default function LandingPage() {
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageAlt="Offer profile page"
imageSrc={offersProfile}
title="Choosing an offer needs context"
url={HOME_URL}
/>
</div>
</div>
@ -215,7 +221,7 @@ export default function LandingPage() {
<div className="mt-6 space-y-4 sm:flex sm:space-y-0 sm:space-x-5">
<a
className="to-primary-500 flex items-center justify-center rounded-md border border-transparent bg-gradient-to-r from-purple-600 bg-origin-border px-4 py-3 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={HOME_URL}>
href={OFFERS_SUBMIT_URL}>
Get Started
</a>
</div>

@ -9,8 +9,8 @@ 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 { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypeahead';
import { useSearchParamSingle } from '~/utils/offers/useSearchParam';
@ -79,7 +79,9 @@ export default function OffersHomePage() {
selectedJobTitleId
? {
id: selectedJobTitleId,
label: JobTitleLabels[selectedJobTitleId as JobTitleType],
label: getLabelForJobTitleType(
selectedJobTitleId as JobTitleType,
),
value: selectedJobTitleId,
}
: null
@ -139,4 +141,4 @@ export default function OffersHomePage() {
</Container>
</main>
);
}
}

@ -2,13 +2,13 @@ import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, TextArea } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import BackButtonLayout from '~/components/questions/layout/BackButtonLayout';
import PaginationLoadMoreButton from '~/components/questions/PaginationLoadMoreButton';
import SortOptionsSelect from '~/components/questions/SortOptionsSelect';
@ -111,83 +111,72 @@ export default function QuestionPage() {
{answer.content} - {APP_TITLE}
</title>
</Head>
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
href={`/questions/${router.query.questionId}/${router.query.questionSlug}`}
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
<BackButtonLayout
href={`/questions/${router.query.questionId}/${router.query.questionSlug}`}>
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullAnswerCard
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
content={answer.content}
createdAt={answer.createdAt}
upvoteCount={answer.numVotes}
/>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullAnswerCard
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
content={answer.content}
createdAt={answer.createdAt}
upvoteCount={answer.numVotes}
/>
<div className="mx-2">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
<div className="mx-2">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
<div className="my-3 flex justify-between">
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
</div>
</form>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={commentSortOrder}
sortTypeValue={commentSortType}
onSortOrderChange={setCommentSortOrder}
onSortTypeChange={setCommentSortType}
/>
</div>
</form>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={commentSortOrder}
sortTypeValue={commentSortType}
onSortOrderChange={setCommentSortOrder}
onSortTypeChange={setCommentSortType}
/>
</div>
</div>
{/* TODO: Allow to load more pages */}
{(answerCommentsData?.pages ?? []).flatMap(
({ processedQuestionAnswerCommentsData: comments }) =>
comments.map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={answerCommentInfiniteQuery} />
</div>
{/* TODO: Allow to load more pages */}
{(answerCommentsData?.pages ?? []).flatMap(
({ processedQuestionAnswerCommentsData: comments }) =>
comments.map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={answerCommentInfiniteQuery} />
</div>
</div>
</div>
</div>
</BackButtonLayout>
</>
);
}

@ -2,7 +2,6 @@ import Head from 'next/head';
import { useRouter } from 'next/router';
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, HorizontalDivider, TextArea } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -10,6 +9,7 @@ import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem'
import FullQuestionCard from '~/components/questions/card/question/FullQuestionCard';
import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import BackButtonLayout from '~/components/questions/layout/BackButtonLayout';
import PaginationLoadMoreButton from '~/components/questions/PaginationLoadMoreButton';
import SortOptionsSelect from '~/components/questions/SortOptionsSelect';
@ -151,7 +151,7 @@ export default function QuestionPage() {
},
);
const { mutate: addEncounter } = trpc.useMutation(
const { mutateAsync: addEncounterAsync } = trpc.useMutation(
'questions.questions.encounters.user.create',
{
onSuccess: () => {
@ -199,19 +199,9 @@ export default function QuestionPage() {
{question.content} - {APP_TITLE}
</title>
</Head>
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
href="/questions/browse"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
/>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<BackButtonLayout href="/questions/browse">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<div className="flex flex-col gap-2 rounded-md border bg-white p-4">
<FullQuestionCard
{...question}
companies={relabeledAggregatedEncounters?.companyCounts ?? {}}
@ -225,8 +215,8 @@ export default function QuestionPage() {
year: 'numeric',
})}
upvoteCount={question.numVotes}
onReceivedSubmit={(data) => {
addEncounter({
onReceivedSubmit={async (data) => {
await addEncounterAsync({
cityId: data.cityId,
companyId: data.company,
countryId: data.countryId,
@ -237,35 +227,15 @@ export default function QuestionPage() {
});
}}
/>
<div className="mx-2">
<Collapsible label={`${question.numComments} comment(s)`}>
<div className="mt-4 px-4">
<form
className="mb-2"
onSubmit={handleCommentSubmitClick(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{/* TODO: Add button to load more */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p>
<div className="ml-16 mr-2">
<Collapsible
defaultOpen={true}
label={
<div className="text-primary-700">{`${question.numComments} comment(s)`}</div>
}>
<div className="">
<div className="flex flex-col gap-2 text-black">
<div className="flex justify-end gap-2">
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={commentSortOrder}
@ -290,65 +260,93 @@ export default function QuestionPage() {
)),
)}
<PaginationLoadMoreButton query={commentInfiniteQuery} />
<form
className="mt-4"
onSubmit={handleCommentSubmitClick(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
</div>
</div>
</Collapsible>
</div>
<HorizontalDivider />
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
</div>
<HorizontalDivider />
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
<div className="flex flex-col gap-2">
<p className="text-md font-semibold">Contribute your answer</p>
<TextArea
{...answerRegister('answerContent', {
minLength: 1,
required: true,
})}
isLabelHidden={true}
label="Contribute your answer"
required={true}
resize="vertical"
rows={5}
/>
<div className="mt-3 mb-1 flex justify-between">
<Button
disabled={!isDirty || !isValid}
label="Contribute"
type="submit"
variant="primary"
/>
</div>
</form>
<div className="flex items-center justify-between gap-2">
<p className="text-xl">{question.numAnswers} answers</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={answerSortOrder}
sortTypeValue={answerSortType}
onSortOrderChange={setAnswerSortOrder}
onSortTypeChange={setAnswerSortType}
/>
</div>
</div>
{/* TODO: Add button to load more */}
{(answerData?.pages ?? []).flatMap(
({ processedAnswersData: answers }) =>
answers.map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={answerInfiniteQuery} />
<div className="mt-3 mb-1 flex justify-between">
<Button
disabled={!isDirty || !isValid}
label="Contribute"
type="submit"
variant="primary"
/>
</div>
</form>
<div className="flex items-center justify-between gap-2">
<p className="text-xl font-semibold">
{question.numAnswers} answers
</p>
<div className="flex items-end gap-2">
<SortOptionsSelect
sortOrderValue={answerSortOrder}
sortTypeValue={answerSortType}
onSortOrderChange={setAnswerSortOrder}
onSortTypeChange={setAnswerSortType}
/>
</div>
</div>
{/* TODO: Add button to load more */}
{(answerData?.pages ?? []).flatMap(
({ processedAnswersData: answers }) =>
answers.map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
)),
)}
<PaginationLoadMoreButton query={answerInfiniteQuery} />
</div>
</div>
</BackButtonLayout>
</>
);
}

@ -6,6 +6,7 @@ import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { useToast } from '@tih/ui';
import { Button, SlideOut } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -17,9 +18,11 @@ import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
import LocationTypeahead from '~/components/questions/typeahead/LocationTypeahead';
import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import type { QuestionAge } from '~/utils/questions/constants';
import { QUESTION_SORT_TYPES } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants';
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
@ -35,6 +38,30 @@ import type { Location } from '~/types/questions.d';
import { SortType } from '~/types/questions.d';
import { SortOrder } from '~/types/questions.d';
function sortOrderToString(value: SortOrder): string | null {
switch (value) {
case SortOrder.ASC:
return 'ASC';
case SortOrder.DESC:
return 'DESC';
default:
return null;
}
}
function sortTypeToString(value: SortType): string | null {
switch (value) {
case SortType.TOP:
return 'TOP';
case SortType.NEW:
return 'NEW';
case SortType.ENCOUNTERS:
return 'ENCOUNTERS';
default:
return null;
}
}
export default function QuestionsBrowsePage() {
const router = useRouter();
const { event } = useGoogleAnalytics();
@ -90,15 +117,7 @@ export default function QuestionsBrowsePage() {
const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', {
defaultValue: SortOrder.DESC,
paramToString: (value) => {
if (value === SortOrder.ASC) {
return 'ASC';
}
if (value === SortOrder.DESC) {
return 'DESC';
}
return null;
},
paramToString: sortOrderToString,
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'ASC') {
@ -114,15 +133,7 @@ export default function QuestionsBrowsePage() {
const [sortType, setSortType, isSortTypeInitialized] =
useSearchParamSingle<SortType>('sortType', {
defaultValue: SortType.TOP,
paramToString: (value) => {
if (value === SortType.NEW) {
return 'NEW';
}
if (value === SortType.TOP) {
return 'TOP';
}
return null;
},
paramToString: sortTypeToString,
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'NEW') {
@ -131,6 +142,9 @@ export default function QuestionsBrowsePage() {
if (uppercaseParam === 'TOP') {
return SortType.TOP;
}
if (uppercaseParam === 'ENCOUNTERS') {
return SortType.ENCOUNTERS;
}
return null;
},
});
@ -212,6 +226,10 @@ export default function QuestionsBrowsePage() {
category: 'engagement',
label: 'create_question',
});
showToast({
title: `Thank you for submitting your question!`,
variant: 'success',
});
},
},
);
@ -267,8 +285,8 @@ export default function QuestionsBrowsePage() {
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
sortOrder: sortOrder === SortOrder.ASC ? 'ASC' : 'DESC',
sortType: sortType === SortType.TOP ? 'TOP' : 'NEW',
sortOrder: sortOrderToString(sortOrder),
sortType: sortTypeToString(sortType),
},
});
@ -287,6 +305,8 @@ export default function QuestionsBrowsePage() {
sortType,
]);
const { showToast } = useToast();
const selectedCompanyOptions = useMemo(() => {
return selectedCompanySlugs.map((company) => {
const [id, label] = company.split('_');
@ -303,7 +323,7 @@ export default function QuestionsBrowsePage() {
return selectedRoles.map((role) => ({
checked: true,
id: role,
label: JobTitleLabels[role as keyof typeof JobTitleLabels],
label: getLabelForJobTitleType(role as JobTitleType),
value: role,
}));
}, [selectedRoles]);
@ -480,7 +500,7 @@ export default function QuestionsBrowsePage() {
<Head>
<title>Home - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<section className="min-h-0 flex-1 overflow-auto">
<div className="my-4 mx-auto flex max-w-3xl flex-col items-stretch justify-start gap-6">
@ -504,6 +524,7 @@ export default function QuestionsBrowsePage() {
<QuestionSearchBar
query={query}
sortOrderValue={sortOrder}
sortTypeOptions={QUESTION_SORT_TYPES}
sortTypeValue={sortType}
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);

@ -12,6 +12,14 @@ export default function QuestionsHomePage() {
const { event } = useGoogleAnalytics();
const handleLandingQuery = async (data: LandingQueryData) => {
if (data === null) {
// Go to browse page
router.push({
pathname: '/questions/browse',
});
return;
}
const { companySlug, location, questionType } = data;
// Go to browse page

@ -5,49 +5,32 @@ import {
EllipsisVerticalIcon,
NoSymbolIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Select } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
import type { CreateListFormData } from '~/components/questions/CreateListDialog';
import CreateListDialog from '~/components/questions/CreateListDialog';
import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useCreateListAsync,
useDeleteListAsync,
} from '~/utils/questions/mutations';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc';
export default function ListPage() {
const { event } = useGoogleAnalytics();
const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const { mutateAsync: createList } = trpc.useMutation(
'questions.lists.create',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
event({
action: 'questions.lists',
category: 'engagement',
label: 'create list',
});
},
},
);
const { mutateAsync: deleteList } = trpc.useMutation(
'questions.lists.delete',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const createListAsync = useCreateListAsync();
const deleteListAsync = useDeleteListAsync();
const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
@ -65,7 +48,7 @@ export default function ListPage() {
const [listIdToDelete, setListIdToDelete] = useState('');
const handleDeleteList = async (listId: string) => {
await deleteList({
await deleteListAsync({
id: listId,
});
setShowDeleteListDialog(false);
@ -76,7 +59,7 @@ export default function ListPage() {
};
const handleCreateList = async (data: CreateListFormData) => {
await createList({
await createListAsync({
name: data.name,
});
setShowCreateListDialog(false);
@ -100,7 +83,7 @@ export default function ListPage() {
selectedListIndex === index ? 'bg-primary-100' : ''
}`}>
<button
className="flex w-full flex-1 justify-between "
className="flex w-full flex-1 justify-between"
type="button"
onClick={() => {
setSelectedListIndex(index);
@ -153,36 +136,69 @@ export default function ListPage() {
</>
);
const createButton = (
<Button
icon={PlusIcon}
isLabelHidden={true}
label="Create"
size="md"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleAddClick();
}}
/>
);
return (
<>
<Head>
<title>My Lists - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<aside className="w-[300px] overflow-y-auto border-r bg-white py-4 lg:block">
<aside className="hidden w-[300px] overflow-y-auto border-r bg-white py-4 lg:block">
<div className="mb-2 flex items-center justify-between">
<h2 className="px-4 text-xl font-semibold">My Lists</h2>
<div className="px-4">
<Button
icon={PlusIcon}
isLabelHidden={true}
label="Create"
size="md"
variant="tertiary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddClick();
}}
/>
</div>
<div className="px-4">{createButton}</div>
</div>
{listOptions}
</aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
<div className="flex items-end gap-2 lg:hidden">
<div className="flex-1">
<Select
label="My Lists"
options={
lists?.map((list) => ({
label: list.name,
value: list.id,
})) ?? []
}
value={lists?.[selectedListIndex]?.id ?? ''}
onChange={(value) => {
setSelectedListIndex(
lists?.findIndex((list) => list.id === value) ?? 0,
);
}}
/>
</div>
<Button
icon={TrashIcon}
isLabelHidden={true}
label="Delete"
size="md"
variant="tertiary"
onClick={() => {
setShowDeleteListDialog(true);
setListIdToDelete(lists?.[selectedListIndex]?.id ?? '');
}}
/>
{createButton}
</div>
{lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4">
{lists[selectedListIndex].questionEntries.map(

@ -24,23 +24,17 @@ import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import loginPageHref from '~/components/shared/loginPageHref';
import type {
ExperienceFilter,
FilterOption,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
getTypeaheadOption,
INITIAL_FILTER_STATE,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
import SubmitResumeForm from './submit';
import type { JobTitleType } from '../../components/shared/JobTitles';
import { getLabelForJobTitleType } from '../../components/shared/JobTitles';
export default function ResumeReviewPage() {
const ErrorPage = (
@ -124,29 +118,24 @@ export default function ResumeReviewPage() {
};
const onInfoTagClick = ({
locationLabel,
experienceLabel,
roleLabel,
locationName,
locationValue,
experienceValue,
roleValue,
}: {
experienceLabel?: string;
locationLabel?: string;
roleLabel?: string;
experienceValue?: string;
locationName?: string;
locationValue?: string;
roleValue?: string;
}) => {
const getFilterValue = (
label: string,
filterOptions: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter>
>,
) => filterOptions.find((option) => option.label === label)?.value;
router.push({
pathname: '/resumes',
query: {
currentPage: JSON.stringify(1),
isFiltersOpen: JSON.stringify({
experience: experienceLabel !== undefined,
location: locationLabel !== undefined,
role: roleLabel !== undefined,
experience: experienceValue !== undefined,
location: locationValue !== undefined,
role: roleValue !== undefined,
}),
searchValue: JSON.stringify(''),
shortcutSelected: JSON.stringify('all'),
@ -154,14 +143,16 @@ export default function ResumeReviewPage() {
tabsValue: JSON.stringify(BROWSE_TABS_VALUES.ALL),
userFilters: JSON.stringify({
...INITIAL_FILTER_STATE,
...(locationLabel && {
location: [getFilterValue(locationLabel, LOCATIONS)],
...(locationValue && {
location: [
getTypeaheadOption('location', locationValue, locationName),
],
}),
...(roleLabel && {
role: [getFilterValue(roleLabel, ROLES)],
...(roleValue && {
role: [getTypeaheadOption('role', roleValue)],
}),
...(experienceLabel && {
experience: [getFilterValue(experienceLabel, EXPERIENCES)],
...(experienceValue && {
experience: [getTypeaheadOption('experience', experienceValue)],
}),
}),
},
@ -207,9 +198,19 @@ export default function ResumeReviewPage() {
initFormDetails={{
additionalInfo: detailsQuery.data.additionalInfo ?? '',
experience: detailsQuery.data.experience,
location: detailsQuery.data.location,
location: {
id: detailsQuery.data.locationId,
label: detailsQuery.data.location.name,
value: detailsQuery.data.locationId,
},
resumeId: resumeId as string,
role: detailsQuery.data.role,
role: {
id: detailsQuery.data.role,
label: getLabelForJobTitleType(
detailsQuery.data.role as JobTitleType,
),
value: detailsQuery.data.role,
},
title: detailsQuery.data.title,
url: detailsQuery.data.url,
}}
@ -325,13 +326,10 @@ export default function ResumeReviewPage() {
type="button"
onClick={() =>
onInfoTagClick({
roleLabel: detailsQuery.data?.role,
roleValue: detailsQuery.data?.role,
})
}>
{getFilterLabel(
ROLES,
detailsQuery.data.role as RoleFilter,
)}
{getFilterLabel('role', detailsQuery.data.role)}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
@ -344,13 +342,11 @@ export default function ResumeReviewPage() {
type="button"
onClick={() =>
onInfoTagClick({
locationLabel: detailsQuery.data?.location,
locationName: detailsQuery.data?.location.name,
locationValue: detailsQuery.data?.locationId,
})
}>
{getFilterLabel(
LOCATIONS,
detailsQuery.data.location as LocationFilter,
)}
{detailsQuery.data?.location.name}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
@ -363,12 +359,12 @@ export default function ResumeReviewPage() {
type="button"
onClick={() =>
onInfoTagClick({
experienceLabel: detailsQuery.data?.experience,
experienceValue: detailsQuery.data?.experience,
})
}>
{getFilterLabel(
EXPERIENCES,
detailsQuery.data.experience as ExperienceFilter,
'experience',
detailsQuery.data.experience,
)}
</button>
</div>

@ -9,6 +9,7 @@ import {
NewspaperIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { TypeaheadOption } from '@tih/ui';
import {
Button,
CheckboxInput,
@ -23,23 +24,17 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeExperienceTypeahead from '~/components/resumes/shared/ResumeExperienceTypeahead';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import loginPageHref from '~/components/shared/loginPageHref';
import type {
Filter,
FilterId,
FilterLabel,
Shortcut,
} from '~/utils/resumes/resumeFilters';
import type { Filter, FilterId, Shortcut } from '~/utils/resumes/resumeFilters';
import type { SortOrder } from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATIONS,
ROLES,
SHORTCUTS,
SORT_OPTIONS,
} from '~/utils/resumes/resumeFilters';
@ -47,7 +42,7 @@ import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
import JobTitlesTypeahead from '../../components/shared/JobTitlesTypeahead';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
@ -56,17 +51,14 @@ const filters: Array<Filter> = [
{
id: 'role',
label: 'Role',
options: ROLES,
},
{
id: 'experience',
label: 'Experience',
options: EXPERIENCES,
},
{
id: 'location',
label: 'Location',
options: LOCATIONS,
},
];
@ -81,20 +73,14 @@ const getLoggedOutText = (tabsValue: string) => {
}
};
const getEmptyDataText = (
tabsValue: string,
searchValue: string,
userFilters: FilterState,
) => {
const getEmptyDataText = (tabsValue: string, searchValue: string) => {
if (searchValue.length > 0) {
return 'Try tweaking your search text to see more resumes.';
}
if (!isInitialFilterState(userFilters)) {
return 'Try tweaking your filters to see more resumes.';
}
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL:
return "There's nothing to see here...";
return 'Oops, there is no resumes to see here. Maybe try tweaking your filters to see more.';
case BROWSE_TABS_VALUES.STARRED:
return 'You have not starred any resumes. Star one to see it here!';
case BROWSE_TABS_VALUES.MY:
@ -200,10 +186,11 @@ export default function ResumeHomePage() {
[
'resumes.resume.findAll',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isTop10: userFilters.isTop10,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -219,10 +206,11 @@ export default function ResumeHomePage() {
[
'resumes.resume.user.findUserStarred',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isTop10: userFilters.isTop10,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -239,10 +227,11 @@ export default function ResumeHomePage() {
[
'resumes.resume.user.findUserCreated',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isTop10: userFilters.isTop10,
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -264,31 +253,6 @@ export default function ResumeHomePage() {
}
};
const onFilterCheckboxChange = (
isChecked: boolean,
filterSection: FilterId,
filterValue: string,
) => {
if (isChecked) {
setUserFilters({
...userFilters,
[filterSection]: [...userFilters[filterSection], filterValue],
});
} else {
setUserFilters({
...userFilters,
[filterSection]: userFilters[filterSection].filter(
(value) => value !== filterValue,
),
});
}
gaEvent({
action: 'resumes.filter_checkbox_click',
category: 'engagement',
label: 'Select Filter',
});
};
const onClearFilterClick = (filterSection: FilterId) => {
setUserFilters({
...userFilters,
@ -354,12 +318,74 @@ export default function ResumeHomePage() {
return getTabQueryData()?.filterCounts;
};
const getFilterCount = (filter: FilterLabel, value: string) => {
const getFilterTypeahead = (filterId: FilterId) => {
const onSelect = (option: TypeaheadOption | null) => {
if (option === null) {
return;
}
setUserFilters({
...userFilters,
[filterId]: [...userFilters[filterId], option],
});
gaEvent({
action: 'resumes.filter_typeahead_click',
category: 'engagement',
label: 'Select Filter',
});
};
switch (filterId) {
case 'experience':
return (
<ResumeExperienceTypeahead
isLabelHidden={true}
placeholder="Select experiences"
selectedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
onSelect={onSelect}
/>
);
case 'location':
return (
<CountriesTypeahead
excludedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
isLabelHidden={true}
label="Location"
placeholder="Select countries"
onSelect={onSelect}
/>
);
case 'role':
return (
<JobTitlesTypeahead
excludedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
isLabelHidden={true}
label="Role"
noResultsMessage="No available roles."
placeholder="Select roles"
onSelect={onSelect}
/>
);
default:
return null;
}
};
const getFilterCount = (filterId: FilterId, value: string) => {
const filterCountsData = getTabFilterCounts();
if (!filterCountsData) {
if (
filterCountsData === undefined ||
filterCountsData[filterId] === undefined ||
filterCountsData[filterId][value] === undefined
) {
return 0;
}
return filterCountsData[filter][value];
return filterCountsData[filterId][value];
};
return (
@ -461,29 +487,28 @@ export default function ResumeHomePage() {
</h3>
<Disclosure.Panel className="space-y-4 pt-6">
<div className="space-y-3">
{filter.options.map((option) => (
{getFilterTypeahead(filter.id)}
{userFilters[filter.id].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 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
className="flex items-center px-1 text-sm">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
value={true}
onChange={() =>
setUserFilters({
...userFilters,
[filter.id]: userFilters[
filter.id
].filter(
({ value }) =>
value !== option.value,
),
})
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(
filter.label,
option.label,
)}
({getFilterCount(filter.id, option.value)}
)
</span>
</div>
@ -570,32 +595,32 @@ export default function ResumeHomePage() {
</Disclosure.Button>
</h3>
<Disclosure.Panel className="space-y-4 pt-4">
{getFilterTypeahead(filter.id)}
<CheckboxList
description=""
isLabelHidden={true}
label=""
orientation="vertical">
{filter.options.map((option) => (
{userFilters[filter.id].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 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
className="flex items-center px-1 text-sm">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
value={true}
onChange={() =>
setUserFilters({
...userFilters,
[filter.id]: userFilters[
filter.id
].filter(
({ value }) => value !== option.value,
),
})
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(filter.label, option.label)}
)
({getFilterCount(filter.id, option.value)})
</span>
</div>
))}
@ -660,7 +685,7 @@ export default function ResumeHomePage() {
</div>
<DropdownMenu
align="end"
label={getFilterLabel(SORT_OPTIONS, sortOrder)}>
label={getFilterLabel('sort', sortOrder)}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item
key={value}
@ -702,7 +727,7 @@ export default function ResumeHomePage() {
height={196}
width={196}
/>
{getEmptyDataText(tabsValue, searchValue, userFilters)}
{getEmptyDataText(tabsValue, searchValue)}
</div>
) : (
<div>

@ -7,8 +7,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { ArrowUpCircleIcon } from '@heroicons/react/24/outline';
import type { TypeaheadOption } from '@tih/ui';
import {
Button,
CheckboxInput,
@ -22,10 +23,12 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeSubmissionGuidelines from '~/components/resumes/submit-form/ResumeSubmissionGuidelines';
import Container from '~/components/shared/Container';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypeahead';
import loginPageHref from '~/components/shared/loginPageHref';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { EXPERIENCES } from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
const FILE_SIZE_LIMIT_MB = 3;
@ -41,19 +44,20 @@ type IFormInput = {
experience: string;
file: File;
isChecked: boolean;
location: string;
role: string;
location: TypeaheadOption;
role: TypeaheadOption;
title: string;
};
type InputKeys = keyof IFormInput;
type TypeAheadKeys = keyof Pick<IFormInput, 'location' | 'role'>;
type InitFormDetails = {
additionalInfo?: string;
experience: string;
location: string;
location: TypeaheadOption;
resumeId: string;
role: string;
role: TypeaheadOption;
title: string;
url: string;
};
@ -85,6 +89,7 @@ export default function SubmitResumeForm({
register,
handleSubmit,
setValue,
control,
reset,
watch,
clearErrors,
@ -94,8 +99,6 @@ export default function SubmitResumeForm({
additionalInfo: '',
experience: '',
isChecked: false,
location: '',
role: '',
title: '',
...initFormDetails,
},
@ -136,6 +139,11 @@ export default function SubmitResumeForm({
}, [router, status]);
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
if (!isDirty) {
onClose();
return;
}
setIsLoading(true);
let fileUrl = initFormDetails?.url ?? '';
@ -158,8 +166,8 @@ export default function SubmitResumeForm({
additionalInfo: data.additionalInfo,
experience: data.experience,
id: initFormDetails?.resumeId,
location: data.location,
role: data.role,
locationId: data.location.value,
role: data.role.value,
title: data.title,
url: fileUrl,
},
@ -235,6 +243,13 @@ export default function SubmitResumeForm({
setValue(section, value.trim(), { shouldDirty: true });
};
const onSelect = (section: TypeAheadKeys, option: TypeaheadOption | null) => {
if (option == null) {
return;
}
setValue(section, option, { shouldDirty: true });
};
return (
<>
<Head>
@ -299,35 +314,45 @@ export default function SubmitResumeForm({
required={true}
onChange={(val) => onValueChange('title', val)}
/>
<div className="flex flex-wrap gap-6">
<Select
{...register('role', { required: true })}
defaultValue={undefined}
disabled={isLoading}
label="Role"
options={ROLES}
placeholder=" "
required={true}
onChange={(val) => onValueChange('role', val)}
/>
<Select
{...register('experience', { required: true })}
disabled={isLoading}
label="Experience Level"
options={EXPERIENCES}
placeholder=" "
required={true}
onChange={(val) => onValueChange('experience', val)}
/>
</div>
<Controller
control={control}
name="location"
render={({ field: { value } }) => (
<CountriesTypeahead
disabled={isLoading}
label="Location"
placeholder="Enter a country"
required={true}
value={value}
onSelect={(option) => onSelect('location', option)}
/>
)}
rules={{ required: true }}
/>
<Controller
control={control}
name="role"
render={({ field: { value } }) => (
<JobTitlesTypeahead
disabled={isLoading}
label="Role"
noResultsMessage="No available roles."
placeholder="Select a role"
required={true}
value={value}
onSelect={(option) => onSelect('role', option)}
/>
)}
rules={{ required: true }}
/>
<Select
{...register('location', { required: true })}
{...register('experience', { required: true })}
disabled={isLoading}
label="Location"
options={LOCATIONS}
label="Experience Level"
options={EXPERIENCES}
placeholder=" "
required={true}
onChange={(val) => onValueChange('location', val)}
onChange={(val) => onValueChange('experience', val)}
/>
{/* Upload resume form */}
{isNewForm && (

@ -7,7 +7,7 @@ import { HorizontalDivider } from '@tih/ui';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypeahead';
import type {
Month,
MonthYearOptional,

@ -45,14 +45,24 @@ export const locationsRouter = createRouter()
async resolve({ ctx, input }) {
return await ctx.prisma.country.findMany({
orderBy: {
name: 'asc',
ranking: 'desc',
},
take: 10,
where: {
name: {
contains: input.name,
mode: 'insensitive',
},
OR: [
{
name: {
contains: input.name,
mode: 'insensitive',
},
},
{
code: {
contains: input.name,
mode: 'insensitive',
},
},
],
},
});
},

@ -235,9 +235,8 @@ export const questionsQuestionRouter = createRouter()
.$queryRaw`
SELECT id FROM "QuestionsQuestion"
WHERE
to_tsvector("content") @@ to_tsquery('english', ${query})
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC
LIMIT 3;
ts_rank_cd(to_tsvector("content"), to_tsquery(${query}), 32) > 0.1
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC;
`;
const relatedQuestionsIdArray = relatedQuestionsId.map(
@ -315,9 +314,8 @@ export const questionsQuestionRouter = createRouter()
.$queryRaw`
SELECT id FROM "QuestionsQuestion"
WHERE
to_tsvector("content") @@ to_tsquery('english', ${query})
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC
LIMIT 3;
ts_rank_cd(to_tsvector("content"), to_tsquery(${query}), 32) > 0.1
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC;
`;
}

@ -1,7 +1,8 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import type { FilterCounts } from '~/utils/resumes/resumeFilters';
import { resumeGetFilterCounts } from '~/utils/resumes/resumeGetFilterCounts';
import { createRouter } from '../context';
@ -11,6 +12,7 @@ export const resumesRouter = createRouter()
.query('findAll', {
input: z.object({
experienceFilters: z.string().array(),
isTop10: z.boolean(),
isUnreviewed: z.boolean(),
locationFilters: z.string().array(),
roleFilters: z.string().array(),
@ -27,19 +29,14 @@ export const resumesRouter = createRouter()
sortOrder,
isUnreviewed,
skip,
isTop10,
searchValue,
take,
} = input;
const userId = ctx.session?.user?.id;
const totalRecords = await ctx.prisma.resumesResume.count({
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
let totalRecords = 10;
let filterCounts = {} as FilterCounts;
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
@ -49,6 +46,11 @@ export const resumesRouter = createRouter()
},
},
comments: true,
location: {
select: {
name: true,
},
},
stars: {
where: {
OR: {
@ -74,12 +76,12 @@ export const resumesRouter = createRouter()
},
}
: { comments: { _count: 'desc' } },
skip,
skip: isTop10 ? 0 : skip,
take,
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -92,7 +94,8 @@ export const resumesRouter = createRouter()
id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
location: r.location.name,
locationId: r.locationId,
numComments: r._count.comments,
numStars: r._count.stars,
role: r.role,
@ -103,95 +106,76 @@ export const resumesRouter = createRouter()
return resume;
});
// Group by role and count, taking into account all role/experience/location/isUnreviewed filters and search value
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['role'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
// Map all nonzero counts from array to object where key = role and value = count
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
if (isTop10) {
filterCounts = resumeGetFilterCounts(mappedResumeData);
} else {
totalRecords = await ctx.prisma.resumesResume.count({
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
// Filter out roles with zero counts and map to object where key = role and value = 0
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
// Group by role and count, taking into account all role/experience/locationId/isUnreviewed filters and search value
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['role'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
// Combine to form singular role counts object
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
// Map all nonzero counts from array to object where key = role and value = count
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
};
filterCounts = {
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
}
return {
filterCounts,
@ -217,6 +201,11 @@ export const resumesRouter = createRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
stars: {
where: {
OR: {

@ -1,6 +1,7 @@
import { z } from 'zod';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import type { FilterCounts } from '~/utils/resumes/resumeFilters';
import { resumeGetFilterCounts } from '~/utils/resumes/resumeGetFilterCounts';
import { createProtectedRouter } from '../context';
@ -8,12 +9,11 @@ import type { Resume } from '~/types/resume';
export const resumesResumeUserRouter = createProtectedRouter()
.mutation('upsert', {
// TODO: Use enums for experience, location, role
input: z.object({
additionalInfo: z.string().optional(),
experience: z.string(),
id: z.string().optional(),
location: z.string(),
locationId: z.string(),
role: z.string(),
title: z.string(),
url: z.string(),
@ -25,7 +25,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
create: {
additionalInfo: input.additionalInfo,
experience: input.experience,
location: input.location,
locationId: input.locationId,
role: input.role,
title: input.title,
url: input.url,
@ -34,7 +34,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
update: {
additionalInfo: input.additionalInfo,
experience: input.experience,
location: input.location,
locationId: input.locationId,
role: input.role,
title: input.title,
url: input.url,
@ -66,6 +66,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
.query('findUserStarred', {
input: z.object({
experienceFilters: z.string().array(),
isTop10: z.boolean(),
isUnreviewed: z.boolean(),
locationFilters: z.string().array(),
roleFilters: z.string().array(),
@ -81,23 +82,16 @@ export const resumesResumeUserRouter = createProtectedRouter()
locationFilters,
experienceFilters,
searchValue,
isTop10,
sortOrder,
isUnreviewed,
skip,
take,
} = input;
const totalRecords = await ctx.prisma.resumesStar.count({
where: {
resume: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
userId,
},
});
let totalRecords = 10;
let filterCounts = {} as FilterCounts;
const resumeStarsData = await ctx.prisma.resumesStar.findMany({
include: {
resume: {
@ -108,6 +102,11 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
user: {
select: {
name: true,
@ -138,13 +137,13 @@ export const resumesResumeUserRouter = createProtectedRouter()
},
},
},
skip,
skip: isTop10 ? 0 : skip,
take,
where: {
resume: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -160,7 +159,8 @@ export const resumesResumeUserRouter = createProtectedRouter()
id: rs.resume.id,
isResolved: rs.resume.isResolved,
isStarredByUser: true,
location: rs.resume.location,
location: rs.resume.location.name,
locationId: rs.resume.locationId,
numComments: rs.resume._count.comments,
numStars: rs.resume._count.stars,
role: rs.resume.role,
@ -171,103 +171,91 @@ export const resumesResumeUserRouter = createProtectedRouter()
return resume;
});
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['role'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
stars: {
some: {
userId,
if (isTop10) {
filterCounts = resumeGetFilterCounts(mappedResumeData);
} else {
totalRecords = await ctx.prisma.resumesStar.count({
where: {
resume: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
userId,
},
title: { contains: searchValue, mode: 'insensitive' },
},
});
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
});
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
role: { in: roleFilters },
stars: {
some: {
userId,
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['role'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
stars: {
some: {
userId,
},
},
title: { contains: searchValue, mode: 'insensitive' },
},
title: { contains: searchValue, mode: 'insensitive' },
},
});
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
});
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
role: { in: roleFilters },
stars: {
some: {
userId,
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
role: { in: roleFilters },
stars: {
some: {
userId,
},
},
title: { contains: searchValue, mode: 'insensitive' },
},
title: { contains: searchValue, mode: 'insensitive' },
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
});
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
role: { in: roleFilters },
stars: {
some: {
userId,
},
},
title: { contains: searchValue, mode: 'insensitive' },
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
};
filterCounts = {
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
}
return { filterCounts, mappedResumeData, totalRecords };
},
@ -275,6 +263,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
.query('findUserCreated', {
input: z.object({
experienceFilters: z.string().array(),
isTop10: z.boolean(),
isUnreviewed: z.boolean(),
locationFilters: z.string().array(),
roleFilters: z.string().array(),
@ -291,20 +280,15 @@ export const resumesResumeUserRouter = createProtectedRouter()
experienceFilters,
sortOrder,
searchValue,
isTop10,
isUnreviewed,
take,
skip,
} = input;
const totalRecords = await ctx.prisma.resumesResume.count({
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
let totalRecords = 10;
let filterCounts = {} as FilterCounts;
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
@ -313,6 +297,11 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
stars: {
where: {
userId,
@ -336,12 +325,12 @@ export const resumesResumeUserRouter = createProtectedRouter()
},
}
: { comments: { _count: 'desc' } },
skip,
skip: isTop10 ? 0 : skip,
take,
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
@ -355,7 +344,8 @@ export const resumesResumeUserRouter = createProtectedRouter()
id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
location: r.location.name,
locationId: r.locationId,
numComments: r._count.comments,
numStars: r._count.stars,
role: r.role,
@ -366,91 +356,77 @@ export const resumesResumeUserRouter = createProtectedRouter()
return resume;
});
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['role'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
if (isTop10) {
filterCounts = resumeGetFilterCounts(mappedResumeData);
} else {
totalRecords = await ctx.prisma.resumesResume.count({
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['role'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
};
filterCounts = {
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
}
return { filterCounts, mappedResumeData, totalRecords };
},

@ -6,6 +6,7 @@ export type Resume = {
isResolved: boolean;
isStarredByUser: boolean;
location: string;
locationId: string;
numComments: number;
numStars: number;
role: string;

@ -36,6 +36,11 @@ export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
label: 'Behavioral',
value: 'BEHAVIORAL',
},
{
id: 'THEORY',
label: 'Theory',
value: 'THEORY',
},
] as const;
export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year';
@ -85,6 +90,21 @@ export const SORT_TYPES = [
},
];
export const QUESTION_SORT_TYPES = [
{
label: 'New',
value: SortType.NEW,
},
{
label: 'Top',
value: SortType.TOP,
},
{
label: 'Encounters',
value: SortType.ENCOUNTERS,
},
];
export const SAMPLE_QUESTION = {
answerCount: 10,
commentCount: 10,

@ -0,0 +1,74 @@
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import { trpc } from '../trpc';
export function useAddQuestionToListAsync() {
const { event } = useGoogleAnalytics();
const utils = trpc.useContext();
const { mutateAsync: addQuestionToListAsync } = trpc.useMutation(
'questions.lists.createQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
event({
action: 'questions.lists',
category: 'engagement',
label: 'add question to list',
});
},
},
);
return addQuestionToListAsync;
}
export function useRemoveQuestionFromListAsync() {
const utils = trpc.useContext();
const { mutateAsync: removeQuestionFromListAsync } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
return removeQuestionFromListAsync;
}
export function useCreateListAsync() {
const { event } = useGoogleAnalytics();
const utils = trpc.useContext();
const { mutateAsync: createListAsync } = trpc.useMutation(
'questions.lists.create',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
event({
action: 'questions.lists',
category: 'engagement',
label: 'create list',
});
},
},
);
return createListAsync;
}
export function useDeleteListAsync() {
const utils = trpc.useContext();
const { mutateAsync: deleteListAsync } = trpc.useMutation(
'questions.lists.delete',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
return deleteListAsync;
}

@ -1,4 +1,5 @@
import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import type { AggregatedQuestionEncounter } from '~/types/questions';
@ -8,7 +9,7 @@ export default function relabelQuestionAggregates({
}: AggregatedQuestionEncounter) {
const newRoleCounts = Object.fromEntries(
Object.entries(roleCounts).map(([roleId, count]) => [
JobTitleLabels[roleId as keyof typeof JobTitleLabels],
getLabelForJobTitleType(roleId as JobTitleType),
count,
]),
);

@ -1,9 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback } from 'react';
import type { Vote } from '@prisma/client';
import type { InfiniteData } from 'react-query';
import { Vote } from '@prisma/client';
import { trpc } from '../trpc';
import type { Question } from '~/types/questions';
type UseVoteOptions = {
setDownVote: () => void;
setNoVote: () => void;
@ -46,12 +49,78 @@ type MutationKey = Parameters<typeof trpc.useMutation>[0];
type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
export const useQuestionVote = (id: string) => {
const utils = trpc.useContext();
return useVote(id, {
idKey: 'questionId',
invalidateKeys: [
'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionById',
// 'questions.questions.getQuestionById',
// 'questions.questions.getQuestionsByFilterAndContent',
],
onMutate: async (previousVote, currentVote) => {
const questionQueries = utils.queryClient.getQueriesData([
'questions.questions.getQuestionsByFilterAndContent',
]);
const getVoteValue = (vote: Vote | null) => {
if (vote === Vote.UPVOTE) {
return 1;
}
if (vote === Vote.DOWNVOTE) {
return -1;
}
return 0;
};
const voteValueChange =
getVoteValue(currentVote) - getVoteValue(previousVote);
for (const [key, query] of questionQueries) {
if (query === undefined) {
continue;
}
const { pages, ...restQuery } = query as InfiniteData<{
data: Array<Question>;
}>;
const newQuery = {
pages: pages.map(({ data, ...restPage }) => ({
data: data.map((question) => {
if (question.id === id) {
const { numVotes, ...restQuestion } = question;
return {
numVotes: numVotes + voteValueChange,
...restQuestion,
};
}
return question;
}),
...restPage,
})),
...restQuery,
};
utils.queryClient.setQueryData(key, newQuery);
}
const prevQuestion = utils.queryClient.getQueryData([
'questions.questions.getQuestionById',
{
id,
},
]) as Question;
const newQuestion = {
...prevQuestion,
numVotes: prevQuestion.numVotes + voteValueChange,
};
utils.queryClient.setQueryData(
['questions.questions.getQuestionById', { id }],
newQuestion,
);
},
query: 'questions.questions.user.getVote',
setDownVoteKey: 'questions.questions.user.setDownVote',
setNoVoteKey: 'questions.questions.user.setNoVote',
@ -63,8 +132,8 @@ export const useAnswerVote = (id: string) => {
return useVote(id, {
idKey: 'answerId',
invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById',
'questions.answers.getAnswers',
],
query: 'questions.answers.user.getVote',
setDownVoteKey: 'questions.answers.user.setDownVote',
@ -95,9 +164,17 @@ export const useAnswerCommentVote = (id: string) => {
});
};
type InvalidateFunction = (
previousVote: Vote | null,
currentVote: Vote | null,
) => Promise<void>;
type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
idKey: string;
invalidateKeys: Array<VoteQueryKey>;
invalidateKeys: Array<QueryKey>;
onMutate?: InvalidateFunction;
// Invalidate: Partial<Record<QueryKey, InvalidateFunction | null>>;
query: VoteQueryKey;
setDownVoteKey: MutationKey;
setNoVoteKey: MutationKey;
@ -116,6 +193,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const {
idKey,
invalidateKeys,
onMutate,
query,
setDownVoteKey,
setNoVoteKey,
@ -125,11 +203,16 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const onVoteUpdate = useCallback(() => {
// TODO: Optimise query invalidation
utils.invalidateQueries([query, { [idKey]: id } as any]);
// utils.invalidateQueries([query, { [idKey]: id } as any]);
for (const invalidateKey of invalidateKeys) {
utils.invalidateQueries([invalidateKey]);
utils.invalidateQueries(invalidateKey);
// If (invalidateFunction === null) {
// utils.invalidateQueries([invalidateKey as QueryKey]);
// } else {
// invalidateFunction(utils, previousVote, currentVote);
// }
}
}, [id, idKey, utils, query, invalidateKeys]);
}, [utils, invalidateKeys]);
const { data } = trpc.useQuery([
query,
@ -143,7 +226,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>(
setUpVoteKey,
{
onError: (err, variables, context) => {
onError: (_error, _variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
@ -154,6 +237,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any],
);
const currentData = {
...(vote as any),
vote: Vote.UPVOTE,
} as BackendVote;
utils.setQueryData(
[
query,
@ -161,9 +249,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id,
} as any,
],
vote as any,
currentData as any,
);
return { currentData: vote, previousData };
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
return { currentData, previousData };
},
onSettled: onVoteUpdate,
},
@ -171,7 +261,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>(
setDownVoteKey,
{
onError: (error, variables, context) => {
onError: (_error, _variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
@ -182,6 +272,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any],
);
const currentData = {
...vote,
vote: Vote.DOWNVOTE,
} as BackendVote;
utils.setQueryData(
[
query,
@ -189,9 +284,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id,
} as any,
],
vote,
currentData as any,
);
return { currentData: vote, previousData };
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
return { currentData, previousData };
},
onSettled: onVoteUpdate,
},
@ -200,23 +297,31 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>(
setNoVoteKey,
{
onError: (err, variables, context) => {
onError: (_error, _variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
},
onMutate: async (vote) => {
onMutate: async () => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
utils.setQueryData(
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
[query, { [idKey]: id } as any],
);
const currentData: BackendVote | null = null;
utils.queryClient.setQueryData<BackendVote | null>(
[
query,
{
[idKey]: id,
} as any,
],
null as any,
currentData,
);
return { currentData: null, previousData: vote };
await onMutate?.(previousData?.vote ?? null, null);
return { currentData, previousData };
},
onSettled: onVoteUpdate,
},

@ -1,28 +1,17 @@
import type { TypeaheadOption } from '@tih/ui';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { JobTitleLabels } from '~/components/shared/JobTitles';
export type FilterId = 'experience' | 'location' | 'role';
export type FilterLabel = 'Experience' | 'Location' | 'Role';
export type FilterCounts = Record<FilterId, Record<string, number>>;
export type CustomFilter = {
isTop10: boolean;
isUnreviewed: boolean;
};
export type RoleFilter =
| 'Android Engineer'
| 'Backend Engineer'
| 'DevOps Engineer'
| 'Frontend Engineer'
| 'Full-Stack Engineer'
| 'iOS Engineer';
export type ExperienceFilter =
| 'Entry Level (0 - 2 years)'
| 'Internship'
| 'Mid Level (3 - 5 years)'
| 'Senior Level (5+ years)';
export type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
export type FilterOption<T> = {
label: string;
value: T;
@ -30,11 +19,11 @@ export type FilterOption<T> = {
export type Filter = {
id: FilterId;
label: FilterLabel;
options: Array<FilterOption<FilterValue>>;
label: string;
};
export type FilterState = CustomFilter & Record<FilterId, Array<FilterValue>>;
export type FilterState = CustomFilter &
Record<FilterId, Array<TypeaheadOption>>;
export type SortOrder = 'latest' | 'mostComments' | 'popular';
@ -45,6 +34,31 @@ export type Shortcut = {
sortOrder: SortOrder;
};
export const getTypeaheadOption = (
filterId: FilterId,
filterValue: string,
locationName?: string,
) => {
switch (filterId) {
case 'experience':
return EXPERIENCES.find(({ value }) => value === filterValue);
case 'role':
return {
id: filterValue,
label: getLabelForJobTitleType(filterValue as JobTitleType),
value: filterValue,
};
case 'location':
return {
id: filterValue,
label: locationName ?? '',
value: filterValue,
};
default:
break;
}
};
export const BROWSE_TABS_VALUES = {
ALL: 'all',
MY: 'my',
@ -57,45 +71,86 @@ export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
{ label: 'Most Comments', value: 'mostComments' },
];
export const ROLES: Array<FilterOption<RoleFilter>> = [
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{ label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ label: 'Backend Engineer', value: 'Backend Engineer' },
{ label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android Engineer' },
const INITIAL_ROLES_VALUES: Array<JobTitleType> = [
'software-engineer',
'back-end-engineer',
'front-end-engineer',
'full-stack-engineer',
'ios-engineer',
'android-engineer',
'data-engineer',
];
export const INITIAL_ROLES: Array<TypeaheadOption> = INITIAL_ROLES_VALUES.map(
(value) =>
getTypeaheadOption('role', value) ?? {
id: value,
label: value,
value,
},
);
export const EXPERIENCES: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Internship', value: 'Internship' },
export const EXPERIENCES: Array<TypeaheadOption> = [
{
id: 'internship',
label: 'Internship',
value: 'internship',
},
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
value: 'entry-level',
},
{
id: 'mid-level',
label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)',
value: 'mid-level',
},
{
id: 'senior-level',
label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)',
value: 'senior-level',
},
];
export const LOCATIONS: Array<FilterOption<LocationFilter>> = [
{ label: 'Singapore', value: 'Singapore' },
{ label: 'United States', value: 'United States' },
{ label: 'India', value: 'India' },
export const INITIAL_LOCATIONS: Array<TypeaheadOption> = [
{
id: '196',
label: 'Singapore',
value: '196',
},
{
id: '101',
label: 'India',
value: '101',
},
{
id: '231',
label: 'United States',
value: '231',
},
{
id: '230',
label: 'United Kingdom',
value: '230',
},
{
id: '102',
label: 'Indonesia',
value: '102',
},
{
id: '44',
label: 'China',
value: '44',
},
];
export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCES).map(({ value }) => value),
experience: EXPERIENCES,
isTop10: false,
isUnreviewed: true,
location: Object.values(LOCATIONS).map(({ value }) => value),
role: Object.values(ROLES).map(({ value }) => value),
location: INITIAL_LOCATIONS,
role: INITIAL_ROLES,
};
export const SHORTCUTS: Array<Shortcut> = [
@ -104,7 +159,7 @@ export const SHORTCUTS: Array<Shortcut> = [
...INITIAL_FILTER_STATE,
isUnreviewed: false,
},
name: 'All',
name: 'General',
sortOrder: 'latest',
},
{
@ -118,7 +173,13 @@ export const SHORTCUTS: Array<Shortcut> = [
{
filters: {
...INITIAL_FILTER_STATE,
experience: ['Entry Level (0 - 2 years)'],
experience: [
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)',
value: 'entry-level',
},
],
isUnreviewed: false,
},
name: 'Fresh Grad',
@ -127,6 +188,7 @@ export const SHORTCUTS: Array<Shortcut> = [
{
filters: {
...INITIAL_FILTER_STATE,
isTop10: true,
isUnreviewed: false,
},
name: 'Top 10',
@ -136,26 +198,46 @@ export const SHORTCUTS: Array<Shortcut> = [
filters: {
...INITIAL_FILTER_STATE,
isUnreviewed: false,
location: ['United States'],
location: [
{
id: '231',
label: 'United States',
value: '231',
},
],
},
name: 'US Only',
sortOrder: 'latest',
},
];
export const isInitialFilterState = (filters: FilterState) =>
Object.keys(filters).every((filter) => {
if (!['experience', 'location', 'role'].includes(filter)) {
return true;
}
return INITIAL_FILTER_STATE[filter as FilterId].every((value) =>
filters[filter as FilterId].includes(value),
);
});
// We omit 'location' as its label should be fetched from the Country table.
export const getFilterLabel = (
filters: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter | SortOrder>
>,
filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder,
) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue;
filterId: Omit<FilterId | 'sort', 'location'>,
filterValue: SortOrder | string,
): string | undefined => {
if (filterId === 'location') {
return filterValue;
}
let filters: Array<TypeaheadOption> = [];
switch (filterId) {
case 'experience':
filters = EXPERIENCES;
break;
case 'role':
filters = Object.entries(JobTitleLabels).map(([slug, { label }]) => ({
id: slug,
label,
value: slug,
}));
break;
case 'sort':
return SORT_OPTIONS.find(({ value }) => value === filterValue)?.label;
default:
break;
}
return filters.find(({ value }) => value === filterValue)?.label;
};

@ -0,0 +1,39 @@
import type { Resume } from '~/types/resume';
export function resumeGetFilterCounts(data: Array<Resume>) {
const roleCounts: Record<string, number> = {};
for (let i = 0; i < data.length; i++) {
const { role } = data[i];
if (!(role in roleCounts)) {
roleCounts[role] = 1;
} else {
roleCounts[role]++;
}
}
const experienceCounts: Record<string, number> = {};
for (let i = 0; i < data.length; i++) {
const { experience } = data[i];
if (!(experience in experienceCounts)) {
experienceCounts[experience] = 1;
} else {
experienceCounts[experience]++;
}
}
const locationCounts: Record<string, number> = {};
for (let i = 0; i < data.length; i++) {
const { locationId } = data[i];
if (!(locationId in locationCounts)) {
locationCounts[locationId] = 1;
} else {
locationCounts[locationId]++;
}
}
return {
experience: experienceCounts,
location: locationCounts,
role: roleCounts,
};
}

@ -3,7 +3,9 @@ import type { InputHTMLAttributes } from 'react';
import { useId } from 'react';
import { Fragment, useState } from 'react';
import { Combobox, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid';
import { Spinner } from '..';
export type TypeaheadOption = Readonly<{
// String value to uniquely identify the option.
@ -27,7 +29,10 @@ type Attributes = Pick<
type Props = Readonly<{
errorMessage?: React.ReactNode;
isLabelHidden?: boolean;
isLoading?: boolean;
label: string;
// Minimum query length before any results will be shown.
minQueryLength?: number;
noResultsMessage?: string;
onQueryChange: (
value: string,
@ -79,7 +84,9 @@ export default function Typeahead({
disabled = false,
errorMessage,
isLabelHidden,
isLoading = false,
label,
minQueryLength = 0,
noResultsMessage = 'No results',
nullable = false,
options,
@ -143,6 +150,7 @@ export default function Typeahead({
)}>
<Combobox.Input
aria-describedby={hasError ? errorId : undefined}
autoComplete="nope" // "off" doesn't work as intended sometimes, so we use a random string.
className={clsx(
'w-full border-none py-2 pl-3 pr-10 text-[length:inherit] leading-5 focus:ring-0',
stateClasses[state].input,
@ -159,53 +167,74 @@ export default function Typeahead({
}}
{...props}
/>
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</Combobox.Button>
{isLoading ? (
<div className="absolute inset-y-0 right-0 flex items-center pr-2">
<Spinner size="xs" />
</div>
) : (
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon
aria-hidden="true"
className="h-5 w-5 text-slate-400"
/>
</Combobox.Button>
)}
</div>
<Transition
afterLeave={() => setQuery('')}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Combobox.Options
className={clsx(
'absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none',
textSizes[textSize],
)}>
{options.length === 0 && query !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage}
</div>
) : (
options.map((option) => (
<Combobox.Option
key={option.id}
className={({ active }) =>
clsx(
'relative cursor-default select-none py-2 px-4 text-slate-500',
active && 'bg-slate-100',
)
}
value={option}>
{({ selected }) => (
<span
className={clsx(
'block truncate',
selected ? 'font-medium' : 'font-normal',
)}>
{option.label}
</span>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
{query.length >= minQueryLength && !isLoading && (
<Transition
afterLeave={() => setQuery('')}
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<Combobox.Options
className={clsx(
'absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none',
textSizes[textSize],
)}>
{options.length === 0 && query !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage}
</div>
) : (
options.map((option) => (
<Combobox.Option
key={option.id}
className={({ active }) =>
clsx(
'relative cursor-default select-none py-2 px-4 text-slate-500',
active && 'bg-slate-100',
)
}
value={option}>
{({ selected }) => (
<>
<span
className={clsx(
'block truncate',
selected && 'font-medium',
)}>
{option.label}
</span>
{selected && (
<span
className={clsx(
'absolute inset-y-0 right-0 flex items-center pr-4',
)}>
<CheckIcon
aria-hidden="true"
className="h-5 w-5"
/>
</span>
)}
</>
)}
</Combobox.Option>
))
)}
</Combobox.Options>
</Transition>
)}
</div>
</Combobox>
{errorMessage && (

Loading…
Cancel
Save