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", "react-query": "^3.39.2",
"read-excel-file": "^5.5.3", "read-excel-file": "^5.5.3",
"superjson": "^1.10.0", "superjson": "^1.10.0",
"xlsx": "^0.18.5",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"xlsx": "^0.18.5", "xlsx": "^0.18.5",
"zod": "^3.18.0" "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 id String @id
name String @unique name String @unique
code 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[] states State[]
questionsQuestionEncounters QuestionsQuestionEncounter[] questionsQuestionEncounters QuestionsQuestionEncounter[]
ResumesResume ResumesResume[]
} }
model State { model State {
@ -148,13 +151,14 @@ model ResumesResume {
// TODO: Update role, experience, location to use Enums // TODO: Update role, experience, location to use Enums
role String @db.Text role String @db.Text
experience String @db.Text experience String @db.Text
location String @db.Text locationId String
url String url String
additionalInfo String? @db.Text additionalInfo String? @db.Text
isResolved Boolean @default(false) isResolved Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
location Country @relation(fields: [locationId], references: [id], onDelete: Cascade)
stars ResumesStar[] stars ResumesStar[]
comments ResumesComment[] 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 = [ const navigation: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' }, { href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/features', name: 'Features' }, { href: '/offers/features', name: 'Features' },
{ href: '/offers/about', name: 'About' },
]; ];
const navigationAuthenticated: ProductNavigationItems = [ const navigationAuthenticated: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' }, { href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/dashboard', name: 'Your dashboard' }, { href: '/offers/dashboard', name: 'Your dashboard' },
{ href: '/offers/features', name: 'Features' }, { href: '/offers/features', name: 'Features' },
{ href: '/offers/about', name: 'About' },
]; ];
const config = { const config = {

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

@ -2,14 +2,14 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image'; import Image from 'next/image';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { HOME_URL } from '../constants';
type LeftTextCardProps = Readonly<{ type LeftTextCardProps = Readonly<{
buttonLabel: string;
description: string; description: string;
icon: ReactNode; icon: ReactNode;
imageAlt: string; imageAlt: string;
imageSrc: StaticImageData; imageSrc: StaticImageData;
title: string; title: string;
url: string;
}>; }>;
export default function LeftTextCard({ export default function LeftTextCard({
@ -18,6 +18,8 @@ export default function LeftTextCard({
imageAlt, imageAlt,
imageSrc, imageSrc,
title, title,
buttonLabel,
url,
}: LeftTextCardProps) { }: LeftTextCardProps) {
return ( 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"> <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"> <div className="mt-6">
<a <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" 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}> href={url}>
Get started {buttonLabel}
</a> </a>
</div> </div>
</div> </div>

@ -2,14 +2,14 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image'; import Image from 'next/image';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { HOME_URL } from '../constants';
type RightTextCarddProps = Readonly<{ type RightTextCarddProps = Readonly<{
buttonLabel: string;
description: string; description: string;
icon: ReactNode; icon: ReactNode;
imageAlt: string; imageAlt: string;
imageSrc: StaticImageData; imageSrc: StaticImageData;
title: string; title: string;
url: string;
}>; }>;
export default function RightTextCard({ export default function RightTextCard({
@ -18,6 +18,8 @@ export default function RightTextCard({
imageAlt, imageAlt,
imageSrc, imageSrc,
title, title,
url,
buttonLabel,
}: RightTextCarddProps) { }: RightTextCarddProps) {
return ( 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"> <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"> <div className="mt-6">
<a <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" 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}> href={url}>
Get started {buttonLabel}
</a> </a>
</div> </div>
</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 type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } 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< type Props = Omit<
ComponentProps<typeof JobTitlesTypeahead>, ComponentProps<typeof JobTitlesTypeahead>,
@ -21,11 +21,15 @@ export default function FormJobTitlesTypeahead({ name, ...props }: Props) {
return ( return (
<JobTitlesTypeahead <JobTitlesTypeahead
{...props} {...props}
value={{ value={
id: watchJobTitle, watchJobTitle
label: getLabelForJobTitleType(watchJobTitle as JobTitleType), ? {
value: watchJobTitle, id: watchJobTitle,
}} label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}
: null
}
onSelect={(option) => { onSelect={(option) => {
setValue(name, option?.value); setValue(name, option?.value);
}} }}

@ -275,7 +275,7 @@ export default function OffersTable({
{!offers || {!offers ||
(offers.length === 0 && ( (offers.length === 0 && (
<div className="py-16 text-lg"> <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>
))} ))}
</div> </div>
@ -290,4 +290,4 @@ export default function OffersTable({
/> />
</div> </div>
); );
} }

@ -3,26 +3,52 @@ import type { PropsWithChildren } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Fragment, useRef, useState } from 'react'; import { Fragment, useRef, useState } from 'react';
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid'; import { CheckIcon, HeartIcon, PlusIcon } from '@heroicons/react/20/solid';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import {
useAddQuestionToListAsync,
useCreateListAsync,
useRemoveQuestionFromListAsync,
} from '~/utils/questions/mutations';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import CreateListDialog from './CreateListDialog';
export type AddToListDropdownProps = { export type AddToListDropdownProps = {
questionId: string; 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({ export default function AddToListDropdown({
questionId, questionId,
}: AddToListDropdownProps) { }: AddToListDropdownProps) {
const { event } = useGoogleAnalytics();
const [menuOpened, setMenuOpened] = useState(false); const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef<HTMLDivElement>(null); 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 { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const listsWithQuestionData = useMemo(() => { const listsWithQuestionData = useMemo(() => {
@ -34,30 +60,8 @@ export default function AddToListDropdown({
})); }));
}, [lists, questionId]); }, [lists, questionId]);
const { mutateAsync: addQuestionToList } = trpc.useMutation( const addQuestionToList = useAddQuestionToListAsync();
'questions.lists.createQuestionEntry', const removeQuestionFromList = useRemoveQuestionFromListAsync();
{
// 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 addClickOutsideListener = () => { const addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true); document.addEventListener('click', handleClickOutside, true);
@ -110,63 +114,79 @@ export default function AddToListDropdown({
); );
return ( return (
<Menu ref={ref} as="div" className="relative inline-block text-left"> <div>
<div> <Menu ref={ref} as="div" className="relative inline-block text-left">
<Menu.Button as={CustomMenuButton}> <div>
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" /> <Menu.Button as={CustomMenuButton}>
Add to List <HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
</Menu.Button> Add to list
</div> </Menu.Button>
</div>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95" enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100" enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75" leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95" leaveTo="transform opacity-0 scale-95"
show={menuOpened}> show={menuOpened}>
<Menu.Items <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" 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}> static={true}>
{menuOpened && ( {menuOpened && (
<> <>
{(listsWithQuestionData ?? []).map((list) => ( {(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1"> <div key={list.id} className="py-1">
<Menu.Item> <DropdownButton
{({ active }) => ( onClick={() => {
<button if (list.hasQuestion) {
className={clsx( handleDeleteFromList(list.id);
active } else {
? 'bg-slate-100 text-slate-900' handleAddToList(list.id);
: 'text-slate-700', }
'group flex w-full items-center px-4 py-2 text-sm', }}>
)} <div className="flex w-full flex-1 justify-between">
type="button" <span className="flex-1 overflow-hidden text-ellipsis text-start">
onClick={() => { {list.name}
if (list.hasQuestion) { </span>
handleDeleteFromList(list.id);
} else {
handleAddToList(list.id);
}
}}>
{list.hasQuestion && ( {list.hasQuestion && (
<CheckIcon <CheckIcon
aria-hidden="true" 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} </div>
</button> </DropdownButton>
)} </div>
</Menu.Item> ))}
</div> <DropdownButton
))} onClick={() => {
</> setShow(true);
)} }}>
</Menu.Items> <PlusIcon
</Transition> aria-hidden="true"
</Menu> 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); useAnswerCommentVote(answerCommentId);
return ( return (
<div className="flex gap-4 border bg-white p-2 "> <div className="flex gap-4 rounded-md border bg-white p-2">
<VotingButtons <VotingButtons
size="sm" size="sm"
upvoteCount={upvoteCount} upvoteCount={upvoteCount}

@ -1,9 +1,4 @@
import { useState } from 'react'; import { useState } from 'react';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import { TextInput } from '@tih/ui'; import { TextInput } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
@ -32,44 +27,19 @@ export default function ContributeQuestionCard({
return ( return (
<div className="w-full"> <div className="w-full">
<button <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" type="button"
onClick={handleOpenContribute}> onClick={handleOpenContribute}>
<TextInput <div className="w-full">
disabled={true} <TextInput
isLabelHidden={true} disabled={true}
label="Question" isLabelHidden={true}
placeholder="Contribute a question" label="Question"
onChange={handleOpenContribute} placeholder="Contribute a question"
/> onChange={handleOpenContribute}
/>
</div>
<div className="flex flex-wrap items-end justify-start gap-2"> <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"> <h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute Contribute
</h1> </h1>

@ -20,15 +20,13 @@ export type LandingQueryData = {
companySlug: string; companySlug: string;
location: string; location: string;
questionType: QuestionsQuestionType; questionType: QuestionsQuestionType;
}; } | null;
export type LandingComponentProps = { export type LandingComponentProps = {
onLanded: (data: LandingQueryData) => void; onLanded: (data: LandingQueryData) => void;
}; };
export default function LandingComponent({ export default function LandingComponent({ onLanded }: LandingComponentProps) {
onLanded: handleLandingQuery,
}: LandingComponentProps) {
const defaultCompany = useDefaultCompany(); const defaultCompany = useDefaultCompany();
const defaultLocation = useDefaultLocation(); 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"> <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"> <div className="flex flex-1 flex-col items-start justify-center gap-12 px-4">
<header className="flex flex-col items-start gap-16"> <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 <img
alt="Questions Bank" alt="Questions Bank"
className="h-40 w-40" className="h-40 w-40"
src="/bank-logo.png" 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 Tech Interview Question Bank
</h1> </h1>
</div> </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{' '} Know the{' '}
<span className="text-primary-700"> <span className="text-primary-700">
latest SWE interview questions latest SWE interview questions
@ -118,22 +116,34 @@ export default function LandingComponent({
}} }}
/> />
</div> </div>
<Button <div className="flex items-center gap-2">
addonPosition="end" <Button
icon={ArrowSmallRightIcon} addonPosition="end"
label="Go" icon={ArrowSmallRightIcon}
size="md" label="Go"
variant="primary" size="md"
onClick={() => { variant="primary"
if (company !== undefined && location !== undefined) { onClick={() => {
return handleLandingQuery({ if (company !== undefined && location !== undefined) {
companySlug: companyOptionToSlug(company), onLanded({
location: locationOptionToSlug(location), companySlug: companyOptionToSlug(company),
questionType, location: locationOptionToSlug(location),
}); questionType,
} });
}} }
/> }}
/>
<Button
addonPosition="end"
icon={ArrowSmallRightIcon}
label="View all questions"
size="md"
variant="secondary"
onClick={() => {
onLanded(null);
}}
/>
</div>
</div> </div>
<div className="flex justify-center"> <div className="flex justify-center">
<iframe <iframe

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

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

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

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

@ -5,7 +5,7 @@ import { ArrowPathIcon } from '@heroicons/react/20/solid';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { CheckboxInput } 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 { QUESTION_TYPES } from '~/utils/questions/constants';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates'; import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
@ -187,11 +187,9 @@ export default function ContributeQuestionForm({
/> />
</div> </div>
</div> </div>
<div className="w-full">
<HorizontalDivider />
</div>
<h2 <h2
className="text-primary-900 mb-3 className="text-primary-900
text-lg font-semibold text-lg font-semibold
"> ">
Are these questions the same as yours? Are these questions the same as yours?
@ -243,11 +241,13 @@ export default function ContributeQuestionForm({
/> />
); );
})} })}
{similarQuestions?.length === 0 && ( {similarQuestions?.length === 0 &&
<p className="font-semibold text-slate-900"> contentToCheck?.length !== 0 &&
No similar questions found. questionContent === contentToCheck && (
</p> <p className="font-semibold text-slate-900">
)} No similar questions found.
</p>
)}
</div> </div>
<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" 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 { startOfMonth } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
import { CheckIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import type { Month } from '~/components/shared/MonthYearPicker'; import type { Month } from '~/components/shared/MonthYearPicker';
@ -22,7 +23,7 @@ export type CreateQuestionEncounterData = {
export type CreateQuestionEncounterFormProps = { export type CreateQuestionEncounterFormProps = {
onCancel: () => void; onCancel: () => void;
onSubmit: (data: CreateQuestionEncounterData) => void; onSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
}; };
export default function CreateQuestionEncounterForm({ export default function CreateQuestionEncounterForm({
@ -30,6 +31,8 @@ export default function CreateQuestionEncounterForm({
onSubmit, onSubmit,
}: CreateQuestionEncounterFormProps) { }: CreateQuestionEncounterFormProps) {
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null); const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<Location | null>( const [selectedLocation, setSelectedLocation] = useState<Location | null>(
@ -40,9 +43,18 @@ export default function CreateQuestionEncounterForm({
startOfMonth(new Date()), 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 ( return (
<div className="flex items-center gap-2"> <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'} I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
</p> </p>
{step === 0 && ( {step === 0 && (
@ -128,9 +140,10 @@ export default function CreateQuestionEncounterForm({
)} )}
{step === 3 && ( {step === 3 && (
<Button <Button
isLoading={loading}
label="Submit" label="Submit"
variant="primary" variant="primary"
onClick={() => { onClick={async () => {
if ( if (
selectedCompany && selectedCompany &&
selectedLocation && selectedLocation &&
@ -138,14 +151,20 @@ export default function CreateQuestionEncounterForm({
selectedDate selectedDate
) { ) {
const { cityId, stateId, countryId } = selectedLocation; const { cityId, stateId, countryId } = selectedLocation;
onSubmit({ setLoading(true);
cityId, try {
company: selectedCompany, await onSubmit({
countryId, cityId,
role: selectedRole, company: selectedCompany,
seenAt: selectedDate, countryId,
stateId, 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) { export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const { data: companies } = trpc.useQuery([ const { data: companies, isLoading } = trpc.useQuery([
'companies.list', 'companies.list',
{ {
name: query, name: query,
@ -33,6 +33,7 @@ export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
return ( return (
<ExpandedTypeahead <ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)} {...(props as ExpandedTypeaheadProps)}
isLoading={isLoading}
label="Company" label="Company"
options={companyOptions} options={companyOptions}
onQueryChange={setQuery} onQueryChange={setQuery}

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

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

@ -12,17 +12,7 @@ import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type { import { getFilterLabel } from '~/utils/resumes/resumeFilters';
ExperienceFilter,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
EXPERIENCES,
getFilterLabel,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import type { Resume } from '~/types/resume'; 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="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="sm:col-span-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{resumeInfo.title} <p className="truncate">{resumeInfo.title}</p>
<p <p
className={clsx( 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', 'w-auto items-center space-x-4 rounded-xl border px-2 py-1 text-xs font-medium',
resumeInfo.isResolved ? 'bg-slate-400' : 'bg-success-500', resumeInfo.isResolved ? 'bg-slate-300' : 'bg-success-100',
resumeInfo.isResolved ? 'text-slate-600' : 'text-success-700',
)}> )}>
<span className="opacity-100"> {resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</span>
</p> </p>
</div> </div>
<div className="text-primary-500 mt-2 flex items-center justify-start text-xs"> <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" aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0" className="mr-1.5 h-4 w-4 flex-shrink-0"
/> />
{getFilterLabel(ROLES, resumeInfo.role as RoleFilter)} {getFilterLabel('role', resumeInfo.role)}
</div> </div>
<div className="ml-4 flex"> <div className="ml-4 flex">
<AcademicCapIcon <AcademicCapIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0" className="mr-1.5 h-4 w-4 flex-shrink-0"
/> />
{getFilterLabel( {getFilterLabel('experience', resumeInfo.experience)}
EXPERIENCES,
resumeInfo.experience as ExperienceFilter,
)}
</div> </div>
</div> </div>
<div className="mt-4 flex justify-start text-xs text-slate-500"> <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, addSuffix: true,
})} by ${resumeInfo.user}`} })} by ${resumeInfo.user}`}
</div> </div>
<div className="mt-2 text-slate-400"> <div className="mt-2 text-slate-400">{resumeInfo.location}</div>
{getFilterLabel(LOCATIONS, resumeInfo.location as LocationFilter)}
</div>
</div> </div>
</div> </div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" /> <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 ( return (
<Typeahead <Typeahead
isLoading={isLoading}
label={label} label={label}
minQueryLength={3}
noResultsMessage="No cities found" noResultsMessage="No cities found"
nullable={true} nullable={true}
options={ options={

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

@ -17,11 +17,25 @@ type BaseProps = Pick<
type Props = BaseProps & type Props = BaseProps &
Readonly<{ Readonly<{
excludedValues?: Set<string>;
label?: string;
onSelect: (option: TypeaheadOption | null) => void; onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null; 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({ export default function CountriesTypeahead({
excludedValues,
label = 'Country',
onSelect, onSelect,
value, value,
...props ...props
@ -34,20 +48,42 @@ export default function CountriesTypeahead({
}, },
]); ]);
const { data } = countries; const { data, isLoading } = countries;
return ( return (
<Typeahead <Typeahead
label="Country" isLoading={isLoading}
label={label}
noResultsMessage="No countries found" noResultsMessage="No countries found"
nullable={true} nullable={true}
options={ options={(data ?? [])
data?.map(({ id, name }) => ({ // 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, id,
label: name, label: name,
value: id, value: id,
})) ?? [] }))
} .filter((option) => !excludedValues?.has(option.value))}
value={value} value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}

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

@ -17,31 +17,39 @@ type BaseProps = Pick<
type Props = BaseProps & type Props = BaseProps &
Readonly<{ Readonly<{
excludedValues?: Set<string>;
label?: string;
noResultsMessage?: string;
onSelect: (option: TypeaheadOption | null) => void; onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null; value?: TypeaheadOption | null;
}>; }>;
export default function JobTitlesTypeahead({ export default function JobTitlesTypeahead({
excludedValues,
label: labelProp = 'Job Title',
noResultsMessage = 'No available job titles.',
onSelect, onSelect,
value, value,
...props ...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels) const options = Object.entries(JobTitleLabels)
.map(([slug, label]) => ({ .map(([slug, { label, ranking }]) => ({
id: slug, id: slug,
label, label,
ranking,
value: slug, value: slug,
})) }))
.filter( .filter(({ label }) =>
({ label }) => label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()),
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1, )
); .filter((option) => !excludedValues?.has(option.value))
.sort((a, b) => b.ranking - a.ranking);
return ( return (
<Typeahead <Typeahead
label="Job Title" label={labelProp}
noResultsMessage="No available job titles." noResultsMessage={noResultsMessage}
nullable={true} nullable={true}
options={options} options={options}
value={value} 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, UsersIcon,
} from '@heroicons/react/24/outline'; } 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 offersAnalysis from '~/components/offers/features/images/offers-analysis.png';
import offersBrowse from '~/components/offers/features/images/offers-browse.png'; import offersBrowse from '~/components/offers/features/images/offers-browse.png';
import offersProfile from '~/components/offers/features/images/offers-profile.png'; import offersProfile from '~/components/offers/features/images/offers-profile.png';
@ -126,6 +126,7 @@ export default function LandingPage() {
/> />
<div className="relative"> <div className="relative">
<LeftTextCard <LeftTextCard
buttonLabel="View offers"
description="Filter relevant offers by job title, company, submission date, salary and more." description="Filter relevant offers by job title, company, submission date, salary and more."
icon={ icon={
<TableCellsIcon <TableCellsIcon
@ -133,27 +134,31 @@ export default function LandingPage() {
className="h-6 w-6 text-white" className="h-6 w-6 text-white"
/> />
} }
imageAlt="Offer table page" imageAlt="Browse page"
imageSrc={offersBrowse} imageSrc={offersBrowse}
title="Stay informed of recent offers" title="Stay informed of recent offers"
url={HOME_URL}
/> />
</div> </div>
<div className="mt-36"> <div className="mt-36">
<RightTextCard <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={ icon={
<ChartBarSquareIcon <ChartBarSquareIcon
aria-hidden="true" aria-hidden="true"
className="h-6 w-6 text-white" className="h-6 w-6 text-white"
/> />
} }
imageAlt="Customer profile user interface" imageAlt="Offers analysis page"
imageSrc={offersAnalysis} imageSrc={offersAnalysis}
title="Better understand your offers" title="Better understand your offers"
url={OFFERS_SUBMIT_URL}
/> />
</div> </div>
<div className="mt-36"> <div className="mt-36">
<LeftTextCard <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." 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={ icon={
<InformationCircleIcon <InformationCircleIcon
@ -161,9 +166,10 @@ export default function LandingPage() {
className="h-6 w-6 text-white" className="h-6 w-6 text-white"
/> />
} }
imageAlt="Offer table page" imageAlt="Offer profile page"
imageSrc={offersProfile} imageSrc={offersProfile}
title="Choosing an offer needs context" title="Choosing an offer needs context"
url={HOME_URL}
/> />
</div> </div>
</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"> <div className="mt-6 space-y-4 sm:flex sm:space-y-0 sm:space-x-5">
<a <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" 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 Get Started
</a> </a>
</div> </div>

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

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

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

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

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

@ -5,49 +5,32 @@ import {
EllipsisVerticalIcon, EllipsisVerticalIcon,
NoSymbolIcon, NoSymbolIcon,
PlusIcon, PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline'; } 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 QuestionListCard from '~/components/questions/card/question/QuestionListCard';
import type { CreateListFormData } from '~/components/questions/CreateListDialog'; import type { CreateListFormData } from '~/components/questions/CreateListDialog';
import CreateListDialog from '~/components/questions/CreateListDialog'; import CreateListDialog from '~/components/questions/CreateListDialog';
import DeleteListDialog from '~/components/questions/DeleteListDialog'; import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import {
useCreateListAsync,
useDeleteListAsync,
} from '~/utils/questions/mutations';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates'; import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
export default function ListPage() { export default function ListPage() {
const { event } = useGoogleAnalytics();
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']); const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const { mutateAsync: createList } = trpc.useMutation(
'questions.lists.create', const createListAsync = useCreateListAsync();
{ const deleteListAsync = useDeleteListAsync();
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 { mutateAsync: deleteQuestionEntry } = trpc.useMutation( const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
'questions.lists.deleteQuestionEntry', 'questions.lists.deleteQuestionEntry',
{ {
@ -65,7 +48,7 @@ export default function ListPage() {
const [listIdToDelete, setListIdToDelete] = useState(''); const [listIdToDelete, setListIdToDelete] = useState('');
const handleDeleteList = async (listId: string) => { const handleDeleteList = async (listId: string) => {
await deleteList({ await deleteListAsync({
id: listId, id: listId,
}); });
setShowDeleteListDialog(false); setShowDeleteListDialog(false);
@ -76,7 +59,7 @@ export default function ListPage() {
}; };
const handleCreateList = async (data: CreateListFormData) => { const handleCreateList = async (data: CreateListFormData) => {
await createList({ await createListAsync({
name: data.name, name: data.name,
}); });
setShowCreateListDialog(false); setShowCreateListDialog(false);
@ -100,7 +83,7 @@ export default function ListPage() {
selectedListIndex === index ? 'bg-primary-100' : '' selectedListIndex === index ? 'bg-primary-100' : ''
}`}> }`}>
<button <button
className="flex w-full flex-1 justify-between " className="flex w-full flex-1 justify-between"
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedListIndex(index); 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 ( return (
<> <>
<Head> <Head>
<title>My Lists - {APP_TITLE}</title> <title>My Lists - {APP_TITLE}</title>
</Head> </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"> <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"> <div className="mb-2 flex items-center justify-between">
<h2 className="px-4 text-xl font-semibold">My Lists</h2> <h2 className="px-4 text-xl font-semibold">My Lists</h2>
<div className="px-4"> <div className="px-4">{createButton}</div>
<Button
icon={PlusIcon}
isLabelHidden={true}
label="Create"
size="md"
variant="tertiary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleAddClick();
}}
/>
</div>
</div> </div>
{listOptions} {listOptions}
</aside> </aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto"> <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 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 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] && ( {lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4"> <div className="flex flex-col gap-4 pb-4">
{lists[selectedListIndex].questionEntries.map( {lists[selectedListIndex].questionEntries.map(

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

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

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

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

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

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

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

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

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

@ -36,6 +36,11 @@ export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
label: 'Behavioral', label: 'Behavioral',
value: 'BEHAVIORAL', value: 'BEHAVIORAL',
}, },
{
id: 'THEORY',
label: 'Theory',
value: 'THEORY',
},
] as const; ] as const;
export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year'; 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 = { export const SAMPLE_QUESTION = {
answerCount: 10, answerCount: 10,
commentCount: 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'; import type { AggregatedQuestionEncounter } from '~/types/questions';
@ -8,7 +9,7 @@ export default function relabelQuestionAggregates({
}: AggregatedQuestionEncounter) { }: AggregatedQuestionEncounter) {
const newRoleCounts = Object.fromEntries( const newRoleCounts = Object.fromEntries(
Object.entries(roleCounts).map(([roleId, count]) => [ Object.entries(roleCounts).map(([roleId, count]) => [
JobTitleLabels[roleId as keyof typeof JobTitleLabels], getLabelForJobTitleType(roleId as JobTitleType),
count, count,
]), ]),
); );

@ -1,9 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback } from 'react'; 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 { trpc } from '../trpc';
import type { Question } from '~/types/questions';
type UseVoteOptions = { type UseVoteOptions = {
setDownVote: () => void; setDownVote: () => void;
setNoVote: () => void; setNoVote: () => void;
@ -46,12 +49,78 @@ type MutationKey = Parameters<typeof trpc.useMutation>[0];
type QueryKey = Parameters<typeof trpc.useQuery>[0][0]; type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
export const useQuestionVote = (id: string) => { export const useQuestionVote = (id: string) => {
const utils = trpc.useContext();
return useVote(id, { return useVote(id, {
idKey: 'questionId', idKey: 'questionId',
invalidateKeys: [ 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', query: 'questions.questions.user.getVote',
setDownVoteKey: 'questions.questions.user.setDownVote', setDownVoteKey: 'questions.questions.user.setDownVote',
setNoVoteKey: 'questions.questions.user.setNoVote', setNoVoteKey: 'questions.questions.user.setNoVote',
@ -63,8 +132,8 @@ export const useAnswerVote = (id: string) => {
return useVote(id, { return useVote(id, {
idKey: 'answerId', idKey: 'answerId',
invalidateKeys: [ invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById', 'questions.answers.getAnswerById',
'questions.answers.getAnswers',
], ],
query: 'questions.answers.user.getVote', query: 'questions.answers.user.getVote',
setDownVoteKey: 'questions.answers.user.setDownVote', 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> = { type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
idKey: string; idKey: string;
invalidateKeys: Array<VoteQueryKey>; invalidateKeys: Array<QueryKey>;
onMutate?: InvalidateFunction;
// Invalidate: Partial<Record<QueryKey, InvalidateFunction | null>>;
query: VoteQueryKey; query: VoteQueryKey;
setDownVoteKey: MutationKey; setDownVoteKey: MutationKey;
setNoVoteKey: MutationKey; setNoVoteKey: MutationKey;
@ -116,6 +193,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { const {
idKey, idKey,
invalidateKeys, invalidateKeys,
onMutate,
query, query,
setDownVoteKey, setDownVoteKey,
setNoVoteKey, setNoVoteKey,
@ -125,11 +203,16 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const onVoteUpdate = useCallback(() => { const onVoteUpdate = useCallback(() => {
// TODO: Optimise query invalidation // TODO: Optimise query invalidation
utils.invalidateQueries([query, { [idKey]: id } as any]); // utils.invalidateQueries([query, { [idKey]: id } as any]);
for (const invalidateKey of invalidateKeys) { 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([ const { data } = trpc.useQuery([
query, query,
@ -143,7 +226,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>(
setUpVoteKey, setUpVoteKey,
{ {
onError: (err, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
@ -154,6 +237,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any], [query, { [idKey]: id } as any],
); );
const currentData = {
...(vote as any),
vote: Vote.UPVOTE,
} as BackendVote;
utils.setQueryData( utils.setQueryData(
[ [
query, query,
@ -161,9 +249,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id, [idKey]: id,
} as any, } 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, onSettled: onVoteUpdate,
}, },
@ -171,7 +261,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>(
setDownVoteKey, setDownVoteKey,
{ {
onError: (error, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
@ -182,6 +272,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any], [query, { [idKey]: id } as any],
); );
const currentData = {
...vote,
vote: Vote.DOWNVOTE,
} as BackendVote;
utils.setQueryData( utils.setQueryData(
[ [
query, query,
@ -189,9 +284,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id, [idKey]: id,
} as any, } as any,
], ],
vote, currentData as any,
); );
return { currentData: vote, previousData };
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
return { currentData, previousData };
}, },
onSettled: onVoteUpdate, onSettled: onVoteUpdate,
}, },
@ -200,23 +297,31 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>(
setNoVoteKey, setNoVoteKey,
{ {
onError: (err, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
}, },
onMutate: async (vote) => { onMutate: async () => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); 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, query,
{ {
[idKey]: id, [idKey]: id,
} as any, } as any,
], ],
null as any, currentData,
); );
return { currentData: null, previousData: vote };
await onMutate?.(previousData?.vote ?? null, null);
return { currentData, previousData };
}, },
onSettled: onVoteUpdate, 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 FilterId = 'experience' | 'location' | 'role';
export type FilterLabel = 'Experience' | 'Location' | 'Role'; export type FilterCounts = Record<FilterId, Record<string, number>>;
export type CustomFilter = { export type CustomFilter = {
isTop10: boolean;
isUnreviewed: 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> = { export type FilterOption<T> = {
label: string; label: string;
value: T; value: T;
@ -30,11 +19,11 @@ export type FilterOption<T> = {
export type Filter = { export type Filter = {
id: FilterId; id: FilterId;
label: FilterLabel; label: string;
options: Array<FilterOption<FilterValue>>;
}; };
export type FilterState = CustomFilter & Record<FilterId, Array<FilterValue>>; export type FilterState = CustomFilter &
Record<FilterId, Array<TypeaheadOption>>;
export type SortOrder = 'latest' | 'mostComments' | 'popular'; export type SortOrder = 'latest' | 'mostComments' | 'popular';
@ -45,6 +34,31 @@ export type Shortcut = {
sortOrder: SortOrder; 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 = { export const BROWSE_TABS_VALUES = {
ALL: 'all', ALL: 'all',
MY: 'my', MY: 'my',
@ -57,45 +71,86 @@ export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
{ label: 'Most Comments', value: 'mostComments' }, { label: 'Most Comments', value: 'mostComments' },
]; ];
export const ROLES: Array<FilterOption<RoleFilter>> = [ const INITIAL_ROLES_VALUES: Array<JobTitleType> = [
{ 'software-engineer',
label: 'Full-Stack Engineer', 'back-end-engineer',
value: 'Full-Stack Engineer', 'front-end-engineer',
}, 'full-stack-engineer',
{ label: 'Frontend Engineer', value: 'Frontend Engineer' }, 'ios-engineer',
{ label: 'Backend Engineer', value: 'Backend Engineer' }, 'android-engineer',
{ label: 'DevOps Engineer', value: 'DevOps Engineer' }, 'data-engineer',
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android 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>> = [ export const EXPERIENCES: Array<TypeaheadOption> = [
{ label: 'Internship', value: 'Internship' }, {
id: 'internship',
label: 'Internship',
value: 'internship',
},
{ {
id: 'entry-level',
label: 'Entry Level (0 - 2 years)', label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)', value: 'entry-level',
}, },
{ {
id: 'mid-level',
label: 'Mid Level (3 - 5 years)', label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)', value: 'mid-level',
}, },
{ {
id: 'senior-level',
label: 'Senior Level (5+ years)', label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)', value: 'senior-level',
}, },
]; ];
export const LOCATIONS: Array<FilterOption<LocationFilter>> = [ export const INITIAL_LOCATIONS: Array<TypeaheadOption> = [
{ label: 'Singapore', value: 'Singapore' }, {
{ label: 'United States', value: 'United States' }, id: '196',
{ label: 'India', value: 'India' }, 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 = { export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCES).map(({ value }) => value), experience: EXPERIENCES,
isTop10: false,
isUnreviewed: true, isUnreviewed: true,
location: Object.values(LOCATIONS).map(({ value }) => value), location: INITIAL_LOCATIONS,
role: Object.values(ROLES).map(({ value }) => value), role: INITIAL_ROLES,
}; };
export const SHORTCUTS: Array<Shortcut> = [ export const SHORTCUTS: Array<Shortcut> = [
@ -104,7 +159,7 @@ export const SHORTCUTS: Array<Shortcut> = [
...INITIAL_FILTER_STATE, ...INITIAL_FILTER_STATE,
isUnreviewed: false, isUnreviewed: false,
}, },
name: 'All', name: 'General',
sortOrder: 'latest', sortOrder: 'latest',
}, },
{ {
@ -118,7 +173,13 @@ export const SHORTCUTS: Array<Shortcut> = [
{ {
filters: { filters: {
...INITIAL_FILTER_STATE, ...INITIAL_FILTER_STATE,
experience: ['Entry Level (0 - 2 years)'], experience: [
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)',
value: 'entry-level',
},
],
isUnreviewed: false, isUnreviewed: false,
}, },
name: 'Fresh Grad', name: 'Fresh Grad',
@ -127,6 +188,7 @@ export const SHORTCUTS: Array<Shortcut> = [
{ {
filters: { filters: {
...INITIAL_FILTER_STATE, ...INITIAL_FILTER_STATE,
isTop10: true,
isUnreviewed: false, isUnreviewed: false,
}, },
name: 'Top 10', name: 'Top 10',
@ -136,26 +198,46 @@ export const SHORTCUTS: Array<Shortcut> = [
filters: { filters: {
...INITIAL_FILTER_STATE, ...INITIAL_FILTER_STATE,
isUnreviewed: false, isUnreviewed: false,
location: ['United States'], location: [
{
id: '231',
label: 'United States',
value: '231',
},
],
}, },
name: 'US Only', name: 'US Only',
sortOrder: 'latest', sortOrder: 'latest',
}, },
]; ];
export const isInitialFilterState = (filters: FilterState) => // We omit 'location' as its label should be fetched from the Country table.
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),
);
});
export const getFilterLabel = ( export const getFilterLabel = (
filters: Array< filterId: Omit<FilterId | 'sort', 'location'>,
FilterOption<ExperienceFilter | LocationFilter | RoleFilter | SortOrder> filterValue: SortOrder | string,
>, ): string | undefined => {
filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder, if (filterId === 'location') {
) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue; 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 { useId } from 'react';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { Combobox, Transition } from '@headlessui/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<{ export type TypeaheadOption = Readonly<{
// String value to uniquely identify the option. // String value to uniquely identify the option.
@ -27,7 +29,10 @@ type Attributes = Pick<
type Props = Readonly<{ type Props = Readonly<{
errorMessage?: React.ReactNode; errorMessage?: React.ReactNode;
isLabelHidden?: boolean; isLabelHidden?: boolean;
isLoading?: boolean;
label: string; label: string;
// Minimum query length before any results will be shown.
minQueryLength?: number;
noResultsMessage?: string; noResultsMessage?: string;
onQueryChange: ( onQueryChange: (
value: string, value: string,
@ -79,7 +84,9 @@ export default function Typeahead({
disabled = false, disabled = false,
errorMessage, errorMessage,
isLabelHidden, isLabelHidden,
isLoading = false,
label, label,
minQueryLength = 0,
noResultsMessage = 'No results', noResultsMessage = 'No results',
nullable = false, nullable = false,
options, options,
@ -143,6 +150,7 @@ export default function Typeahead({
)}> )}>
<Combobox.Input <Combobox.Input
aria-describedby={hasError ? errorId : undefined} aria-describedby={hasError ? errorId : undefined}
autoComplete="nope" // "off" doesn't work as intended sometimes, so we use a random string.
className={clsx( className={clsx(
'w-full border-none py-2 pl-3 pr-10 text-[length:inherit] leading-5 focus:ring-0', 'w-full border-none py-2 pl-3 pr-10 text-[length:inherit] leading-5 focus:ring-0',
stateClasses[state].input, stateClasses[state].input,
@ -159,53 +167,74 @@ export default function Typeahead({
}} }}
{...props} {...props}
/> />
<Combobox.Button className="absolute inset-y-0 right-0 flex items-center pr-2"> {isLoading ? (
<ChevronDownIcon <div className="absolute inset-y-0 right-0 flex items-center pr-2">
aria-hidden="true" <Spinner size="xs" />
className="h-5 w-5 text-slate-400" </div>
/> ) : (
</Combobox.Button> <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> </div>
<Transition {query.length >= minQueryLength && !isLoading && (
afterLeave={() => setQuery('')} <Transition
as={Fragment} afterLeave={() => setQuery('')}
leave="transition ease-in duration-100" as={Fragment}
leaveFrom="opacity-100" leave="transition ease-in duration-100"
leaveTo="opacity-0"> leaveFrom="opacity-100"
<Combobox.Options leaveTo="opacity-0">
className={clsx( <Combobox.Options
'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', className={clsx(
textSizes[textSize], '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"> {options.length === 0 && query !== '' ? (
{noResultsMessage} <div className="relative cursor-default select-none py-2 px-4 text-slate-700">
</div> {noResultsMessage}
) : ( </div>
options.map((option) => ( ) : (
<Combobox.Option options.map((option) => (
key={option.id} <Combobox.Option
className={({ active }) => key={option.id}
clsx( className={({ active }) =>
'relative cursor-default select-none py-2 px-4 text-slate-500', clsx(
active && 'bg-slate-100', 'relative cursor-default select-none py-2 px-4 text-slate-500',
) active && 'bg-slate-100',
} )
value={option}> }
{({ selected }) => ( value={option}>
<span {({ selected }) => (
className={clsx( <>
'block truncate', <span
selected ? 'font-medium' : 'font-normal', className={clsx(
)}> 'block truncate',
{option.label} selected && 'font-medium',
</span> )}>
)} {option.label}
</Combobox.Option> </span>
)) {selected && (
)} <span
</Combobox.Options> className={clsx(
</Transition> '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> </div>
</Combobox> </Combobox>
{errorMessage && ( {errorMessage && (

Loading…
Cancel
Save