Merge branch 'main' into stuart/seed-db

pull/501/head^2
Stuart Long Chay Boon 3 years ago
commit 7381a3c83f

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

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ResumesResume" ADD COLUMN "isResolved" BOOLEAN NOT NULL DEFAULT false;

@ -2,6 +2,7 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
previewFeatures = ["interactiveTransactions"]
} }
datasource db { datasource db {
@ -119,6 +120,7 @@ model ResumesResume {
location String @db.Text location String @db.Text
url String url String
additionalInfo String? @db.Text additionalInfo String? @db.Text
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)
@ -402,7 +404,7 @@ model QuestionsQuestion {
userId String? userId String?
content String @db.Text content String @db.Text
questionType QuestionsQuestionType questionType QuestionsQuestionType
lastSeenAt DateTime lastSeenAt DateTime?
upvotes Int @default(0) upvotes Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

@ -13,6 +13,7 @@ import OffersNavigation from '~/components/offers/OffersNavigation';
import QuestionsNavigation from '~/components/questions/QuestionsNavigation'; import QuestionsNavigation from '~/components/questions/QuestionsNavigation';
import ResumesNavigation from '~/components/resumes/ResumesNavigation'; import ResumesNavigation from '~/components/resumes/ResumesNavigation';
import GoogleAnalytics from './GoogleAnalytics';
import MobileNavigation from './MobileNavigation'; import MobileNavigation from './MobileNavigation';
import type { ProductNavigationItems } from './ProductNavigation'; import type { ProductNavigationItems } from './ProductNavigation';
import ProductNavigation from './ProductNavigation'; import ProductNavigation from './ProductNavigation';
@ -106,6 +107,7 @@ export default function AppShell({ children }: Props) {
const router = useRouter(); const router = useRouter();
const currentProductNavigation: Readonly<{ const currentProductNavigation: Readonly<{
googleAnalyticsMeasurementID: string;
navigation: ProductNavigationItems; navigation: ProductNavigationItems;
showGlobalNav: boolean; showGlobalNav: boolean;
title: string; title: string;
@ -128,6 +130,8 @@ export default function AppShell({ children }: Props) {
})(); })();
return ( return (
<GoogleAnalytics
measurementID={currentProductNavigation.googleAnalyticsMeasurementID}>
<div className="flex h-full min-h-screen"> <div className="flex h-full min-h-screen">
{/* Narrow sidebar */} {/* Narrow sidebar */}
{currentProductNavigation.showGlobalNav && ( {currentProductNavigation.showGlobalNav && (
@ -207,5 +211,6 @@ export default function AppShell({ children }: Props) {
</div> </div>
</div> </div>
</div> </div>
</GoogleAnalytics>
); );
} }

@ -0,0 +1,102 @@
import { useRouter } from 'next/router';
import Script from 'next/script';
import { createContext, useContext, useEffect } from 'react';
type Context = Readonly<{
event: (payload: GoogleAnalyticsEventPayload) => void;
}>;
export const GoogleAnalyticsContext = createContext<Context>({
event,
});
// https://developers.google.com/analytics/devguides/collection/gtagjs/pages
function pageview(measurementID: string, url: string) {
// Don't log analytics during development.
if (process.env.NODE_ENV === 'development') {
return;
}
window.gtag('config', measurementID, {
page_path: url,
});
window.gtag('event', url, {
event_category: 'pageview',
event_label: document.title,
});
}
type GoogleAnalyticsEventPayload = Readonly<{
action: string;
category: string;
label: string;
value?: number;
}>;
// https://developers.google.com/analytics/devguides/collection/gtagjs/events
export function event({
action,
category,
label,
value,
}: GoogleAnalyticsEventPayload) {
// Don't log analytics during development.
if (process.env.NODE_ENV === 'development') {
return;
}
window.gtag('event', action, {
event_category: category,
event_label: label,
value,
});
}
type Props = Readonly<{
children: React.ReactNode;
measurementID: string;
}>;
export function useGoogleAnalytics() {
return useContext(GoogleAnalyticsContext);
}
export default function GoogleAnalytics({ children, measurementID }: Props) {
const router = useRouter();
useEffect(() => {
function handleRouteChange(url: string) {
pageview(measurementID, url);
}
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
};
}, [router.events, measurementID]);
return (
<GoogleAnalyticsContext.Provider value={{ event }}>
{children}
{/* Global Site Tag (gtag.js) - Google Analytics */}
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${measurementID}`}
strategy="afterInteractive"
/>
<Script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${measurementID}', {
page_path: window.location.pathname,
});
`,
}}
id="gtag-init"
strategy="afterInteractive"
/>
</GoogleAnalyticsContext.Provider>
);
}

@ -14,6 +14,7 @@ const navigation: ProductNavigationItems = [
]; ];
const config = { const config = {
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation, navigation,
showGlobalNav: true, showGlobalNav: true,
title: 'Tech Interview Handbook', title: 'Tech Interview Handbook',

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

@ -1,21 +1,43 @@
export type BreadcrumbStep = {
label: string;
step?: number;
};
type BreadcrumbsProps = Readonly<{ type BreadcrumbsProps = Readonly<{
currentStep: number; currentStep: number;
stepLabels: Array<string>; setStep: (nextStep: number) => void;
steps: Array<BreadcrumbStep>;
}>; }>;
export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) { function getPrimaryText(text: string) {
return <p className="text-primary-700 text-sm">{text}</p>;
}
function getSlateText(text: string) {
return <p className="text-sm text-slate-400">{text}</p>;
}
function getTextWithLink(text: string, onClickHandler: () => void) {
return (
<p
className="hover:text-primary-700 cursor-pointer text-sm text-slate-400 hover:underline hover:underline-offset-2"
onClick={onClickHandler}>
{text}
</p>
);
}
export function Breadcrumbs({ steps, currentStep, setStep }: BreadcrumbsProps) {
return ( return (
<div className="flex space-x-1"> <div className="flex space-x-1">
{stepLabels.map((label, index) => ( {steps.map(({ label, step }, index) => (
<div key={label} className="flex space-x-1"> <div key={label} className="flex space-x-1">
{index === currentStep ? ( {step === currentStep
<p className="text-primary-700 text-sm">{label}</p> ? getPrimaryText(label)
) : ( : step !== undefined
<p className="text-sm text-slate-400">{label}</p> ? getTextWithLink(label, () => setStep(step))
)} : getSlateText(label)}
{index !== stepLabels.length - 1 && ( {index !== steps.length - 1 && getSlateText('>')}
<p className="text-sm text-slate-400">{'>'}</p>
)}
</div> </div>
))} ))}
</div> </div>

@ -6,6 +6,8 @@ const navigation: ProductNavigationItems = [
]; ];
const config = { const config = {
// TODO: Change this to your own GA4 measurement ID.
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Offer Profile Repository', title: 'Offer Profile Repository',

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

@ -1,12 +1,13 @@
import { useRef, useState } from 'react'; import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumb';
import { Breadcrumbs } from '~/components/offers/Breadcrumb'; import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm'; import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm'; import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type { import type {
@ -23,10 +24,6 @@ import {
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time'; import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import OffersSubmissionAnalysis from './OffersSubmissionAnalysis';
import type { ProfileAnalysis } from '~/types/offers';
const defaultOfferValues = { const defaultOfferValues = {
comments: '', comments: '',
companyId: '', companyId: '',
@ -59,13 +56,6 @@ const defaultOfferProfileValues = {
offers: [defaultOfferValues], offers: [defaultOfferValues],
}; };
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
type Props = Readonly<{ type Props = Readonly<{
initialOfferProfileValues?: OffersProfileFormData; initialOfferProfileValues?: OffersProfileFormData;
profileId?: string; profileId?: string;
@ -77,11 +67,14 @@ export default function OffersSubmissionForm({
profileId: editProfileId = '', profileId: editProfileId = '',
token: editToken = '', token: editToken = '',
}: Props) { }: Props) {
const [formStep, setFormStep] = useState(0); const [step, setStep] = useState(0);
const [profileId, setProfileId] = useState(editProfileId); const [params, setParams] = useState({
const [token, setToken] = useState(editToken); profileId: editProfileId,
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null); token: editToken,
});
const [isSubmitted, setIsSubmitted] = useState(false);
const router = useRouter();
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () => const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 }); pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
@ -97,87 +90,61 @@ export default function OffersSubmissionForm({
onError(error) { onError(error) {
console.error(error.message); console.error(error.message);
}, },
onSuccess(data) { onSuccess() {
setAnalysis(data); router.push(
`/offers/submit/result/${params.profileId}?token=${params.token}`,
);
}, },
}, },
); );
const formSteps: Array<FormStep> = [ const steps = [
{
component: (
<OfferDetailsForm <OfferDetailsForm
key={0} key={0}
defaultJobType={initialOfferProfileValues.offers[0].jobType} defaultJobType={initialOfferProfileValues.offers[0].jobType}
/> />,
), <BackgroundForm key={1} />,
hasNext: true, ];
hasPrevious: false,
const breadcrumbSteps: Array<BreadcrumbStep> = [
{
label: 'Offers', label: 'Offers',
step: 0,
}, },
{ {
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background', label: 'Background',
step: 1,
}, },
{ {
component: (
<OffersProfileSave key={2} profileId={profileId} token={token} />
),
hasNext: true,
hasPrevious: false,
label: 'Save profile', label: 'Save profile',
}, },
{ {
component: (
<OffersSubmissionAnalysis
analysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
profileId={profileId}
token={token}
/>
),
hasNext: false,
hasPrevious: true,
label: 'Analysis', label: 'Analysis',
}, },
]; ];
const formStepsLabels = formSteps.map((step) => step.label); const goToNextStep = async (currStep: number) => {
const nextStep = async (currStep: number) => {
if (currStep === 0) { if (currStep === 0) {
const result = await trigger('offers'); const result = await trigger('offers');
if (!result) { if (!result) {
return; return;
} }
} }
setFormStep(formStep + 1); setStep(step + 1);
scrollToTop();
};
const previousStep = () => {
setFormStep(formStep - 1);
scrollToTop();
}; };
const mutationpath = const mutationpath =
profileId && token ? 'offers.profile.update' : 'offers.profile.create'; editProfileId && editToken
? 'offers.profile.update'
: 'offers.profile.create';
const createOrUpdateMutation = trpc.useMutation([mutationpath], { const createOrUpdateMutation = trpc.useMutation([mutationpath], {
onError(error) { onError(error) {
console.error(error.message); console.error(error.message);
}, },
onSuccess(data) { onSuccess(data) {
generateAnalysisMutation.mutate({ setParams({ profileId: data.id, token: data.token });
profileId: data?.id || '', setIsSubmitted(true);
});
setProfileId(data.id);
setToken(data.token);
setFormStep(formStep + 1);
scrollToTop();
}, },
}); });
@ -206,47 +173,64 @@ export default function OffersSubmissionForm({
), ),
})); }));
if (profileId && token) { if (params.profileId && params.token) {
createOrUpdateMutation.mutate({ createOrUpdateMutation.mutate({
background, background,
id: profileId, id: params.profileId,
offers, offers,
token, token: params.token,
}); });
} else { } else {
createOrUpdateMutation.mutate({ background, offers }); createOrUpdateMutation.mutate({ background, offers });
} }
}; };
useEffect(() => {
if (isSubmitted) {
generateAnalysisMutation.mutate({
profileId: params.profileId,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSubmitted, params]);
useEffect(() => {
scrollToTop();
}, [step]);
return ( return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll"> <div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center"> <div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg"> <div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end"> <div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} /> <Breadcrumbs
currentStep={step}
setStep={setStep}
steps={breadcrumbSteps}
/>
</div> </div>
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component} {steps[step]}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */} {/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && ( {step === 0 && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
disabled={false} disabled={false}
icon={ArrowRightIcon} icon={ArrowRightIcon}
label="Next" label="Next"
variant="secondary" variant="secondary"
onClick={() => nextStep(formStep)} onClick={() => goToNextStep(step)}
/> />
</div> </div>
)} )}
{formStep === 1 && ( {step === 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Button <Button
icon={ArrowLeftIcon} icon={ArrowLeftIcon}
label="Previous" label="Previous"
variant="secondary" variant="secondary"
onClick={previousStep} onClick={() => setStep(step - 1)}
/> />
<Button label="Submit" type="submit" variant="primary" />{' '} <Button label="Submit" type="submit" variant="primary" />{' '}
</div> </div>

@ -0,0 +1,160 @@
import clsx from 'clsx';
import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';
import { Fragment, useRef, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid';
import { trpc } from '~/utils/trpc';
export type AddToListDropdownProps = {
questionId: string;
};
export default function AddToListDropdown({
questionId,
}: AddToListDropdownProps) {
const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const listsWithQuestionData = useMemo(() => {
return lists?.map((list) => ({
...list,
hasQuestion: list.questionEntries.some(
(entry) => entry.question.id === questionId,
),
}));
}, [lists, questionId]);
const { mutateAsync: addQuestionToList } = trpc.useMutation(
'questions.lists.createQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const { mutateAsync: removeQuestionFromList } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true);
};
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) {
setMenuOpened(false);
document.removeEventListener('click', handleClickOutside, true);
}
};
const handleAddToList = async (listId: string) => {
await addQuestionToList({
listId,
questionId,
});
};
const handleDeleteFromList = async (listId: string) => {
const list = listsWithQuestionData?.find(
(listWithQuestion) => listWithQuestion.id === listId,
);
if (!list) {
return;
}
const entry = list.questionEntries.find(
(questionEntry) => questionEntry.question.id === questionId,
);
if (!entry) {
return;
}
await removeQuestionFromList({
id: entry.id,
});
};
const CustomMenuButton = ({ children }: PropsWithChildren<unknown>) => (
<button
className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-100"
type="button"
onClick={() => {
addClickOutsideListener();
setMenuOpened(!menuOpened);
}}>
{children}
</button>
);
return (
<Menu ref={ref} as="div" className="relative inline-block text-left">
<div>
<Menu.Button as={CustomMenuButton}>
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
Add to List
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
show={menuOpened}>
<Menu.Items
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
static={true}>
{menuOpened && (
<>
{(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1">
<Menu.Item>
{({ active }) => (
<button
className={clsx(
active
? 'bg-slate-100 text-slate-900'
: 'text-slate-700',
'group flex w-full items-center px-4 py-2 text-sm',
)}
type="button"
onClick={() => {
if (list.hasQuestion) {
handleDeleteFromList(list.id);
} else {
handleAddToList(list.id);
}
}}>
{list.hasQuestion && (
<CheckIcon
aria-hidden="true"
className="mr-3 h-5 w-5 text-slate-400 group-hover:text-slate-500"
/>
)}
{list.name}
</button>
)}
</Menu.Item>
</div>
))}
</>
)}
</Menu.Items>
</Transition>
</Menu>
);
}

@ -61,7 +61,7 @@ export default function ContributeQuestionDialog({
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"> leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative w-full max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8"> <Dialog.Panel className="relative w-full max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8">
<div className="bg-white p-6 pt-5 sm:pb-4"> <div className="bg-white px-6 pt-5">
<div className="flex flex-1 items-stretch"> <div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:text-left"> <div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title <Dialog.Title

@ -0,0 +1,75 @@
import { useForm } from 'react-hook-form';
import { Button, Dialog, TextInput } from '@tih/ui';
import { useFormRegister } from '~/utils/questions/useFormRegister';
export type CreateListFormData = {
name: string;
};
export type CreateListDialogProps = {
onCancel: () => void;
onSubmit: (data: CreateListFormData) => Promise<void>;
show: boolean;
};
export default function CreateListDialog({
show,
onCancel,
onSubmit,
}: CreateListDialogProps) {
const {
register: formRegister,
handleSubmit,
formState: { isSubmitting },
reset,
} = useForm<CreateListFormData>();
const register = useFormRegister(formRegister);
const handleDialogCancel = () => {
onCancel();
reset();
};
return (
<Dialog
isShown={show}
primaryButton={undefined}
title="Create question list"
onClose={handleDialogCancel}>
<form
className="mt-5 gap-2 sm:flex sm:items-center"
onSubmit={handleSubmit(async (data) => {
await onSubmit(data);
reset();
})}>
<div className="w-full sm:max-w-xs">
<TextInput
id="listName"
isLabelHidden={true}
{...register('name')}
autoComplete="off"
label="Name"
placeholder="List name"
type="text"
/>
</div>
<Button
display="inline"
label="Cancel"
size="md"
variant="tertiary"
onClick={handleDialogCancel}
/>
<Button
display="inline"
isLoading={isSubmitting}
label="Create"
size="md"
type="submit"
variant="primary"
/>
</form>
</Dialog>
);
}

@ -0,0 +1,29 @@
import { Button, Dialog } from '@tih/ui';
export type DeleteListDialogProps = {
onCancel: () => void;
onDelete: () => void;
show: boolean;
};
export default function DeleteListDialog({
show,
onCancel,
onDelete,
}: DeleteListDialogProps) {
return (
<Dialog
isShown={show}
primaryButton={
<Button label="Delete" variant="primary" onClick={onDelete} />
}
secondaryButton={
<Button label="Cancel" variant="tertiary" onClick={onCancel} />
}
title="Delete List"
onClose={onCancel}>
<p>
Are you sure you want to delete this list? This action cannot be undone.
</p>
</Dialog>
);
}

@ -118,7 +118,7 @@ export default function LandingComponent({
onClick={() => { onClick={() => {
if (company !== undefined && location !== undefined) { if (company !== undefined && location !== undefined) {
return handleLandingQuery({ return handleLandingQuery({
company: company.value, company: company.label,
location: location.value, location: location.value,
questionType, questionType,
}); });

@ -3,11 +3,13 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [ const navigation: ProductNavigationItems = [
{ href: '/questions/browse', name: 'Browse' }, { href: '/questions/browse', name: 'Browse' },
{ href: '/questions/lists', name: 'My Lists' }, { href: '/questions/lists', name: 'My Lists' },
{ href: '/questions/my-questions', name: 'My Questions' }, // { href: '/questions/my-questions', name: 'My Questions' },
{ href: '/questions/history', name: 'History' }, // { href: '/questions/history', name: 'History' },
]; ];
const config = { const config = {
// TODO: Change this to your own GA4 measurement ID.
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Questions Bank', title: 'Questions Bank',

@ -11,6 +11,7 @@ import { Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote'; import { useQuestionVote } from '~/utils/questions/useVote';
import AddToListDropdown from '../../AddToListDropdown';
import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm'; import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm';
import CreateQuestionEncounterForm from '../../forms/CreateQuestionEncounterForm'; import CreateQuestionEncounterForm from '../../forms/CreateQuestionEncounterForm';
import QuestionAggregateBadge from '../../QuestionAggregateBadge'; import QuestionAggregateBadge from '../../QuestionAggregateBadge';
@ -47,6 +48,20 @@ type AnswerStatisticsProps =
showAnswerStatistics?: false; showAnswerStatistics?: false;
}; };
type AggregateStatisticsProps =
| {
companies: Record<string, number>;
locations: Record<string, number>;
roles: Record<string, number>;
showAggregateStatistics: true;
}
| {
companies?: never;
locations?: never;
roles?: never;
showAggregateStatistics?: false;
};
type ActionButtonProps = type ActionButtonProps =
| { | {
actionButtonLabel: string; actionButtonLabel: string;
@ -79,19 +94,26 @@ type CreateEncounterProps =
showCreateEncounterButton?: false; showCreateEncounterButton?: false;
}; };
type AddToListProps =
| {
showAddToList: true;
}
| {
showAddToList?: false;
};
export type BaseQuestionCardProps = ActionButtonProps & export type BaseQuestionCardProps = ActionButtonProps &
AddToListProps &
AggregateStatisticsProps &
AnswerStatisticsProps & AnswerStatisticsProps &
CreateEncounterProps & CreateEncounterProps &
DeleteProps & DeleteProps &
ReceivedStatisticsProps & ReceivedStatisticsProps &
UpvoteProps & { UpvoteProps & {
companies: Record<string, number>;
content: string; content: string;
locations: Record<string, number>;
questionId: string; questionId: string;
roles: Record<string, number>;
showHover?: boolean; showHover?: boolean;
timestamp: string; timestamp: string | null;
truncateContent?: boolean; truncateContent?: boolean;
type: QuestionsQuestionType; type: QuestionsQuestionType;
}; };
@ -104,6 +126,7 @@ export default function BaseQuestionCard({
receivedCount, receivedCount,
type, type,
showVoteButtons, showVoteButtons,
showAggregateStatistics,
showAnswerStatistics, showAnswerStatistics,
showReceivedStatistics, showReceivedStatistics,
showCreateEncounterButton, showCreateEncounterButton,
@ -117,6 +140,7 @@ export default function BaseQuestionCard({
showHover, showHover,
onReceivedSubmit, onReceivedSubmit,
showDeleteButton, showDeleteButton,
showAddToList,
onDelete, onDelete,
truncateContent = true, truncateContent = true,
}: BaseQuestionCardProps) { }: BaseQuestionCardProps) {
@ -133,20 +157,35 @@ export default function BaseQuestionCard({
onUpvote={handleUpvote} onUpvote={handleUpvote}
/> />
)} )}
<div className="flex flex-col items-start gap-2"> <div className="flex flex-1 flex-col items-start gap-2">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between self-stretch">
<div className="flex items-baseline gap-2 text-slate-500"> <div className="flex items-center gap-2 text-slate-500">
{showAggregateStatistics && (
<>
<QuestionTypeBadge type={type} /> <QuestionTypeBadge type={type} />
<QuestionAggregateBadge statistics={companies} variant="primary" /> <QuestionAggregateBadge
<QuestionAggregateBadge statistics={locations} variant="success" /> statistics={companies}
variant="primary"
/>
<QuestionAggregateBadge
statistics={locations}
variant="success"
/>
<QuestionAggregateBadge statistics={roles} variant="danger" /> <QuestionAggregateBadge statistics={roles} variant="danger" />
<p className="text-xs">{timestamp}</p> </>
)}
{timestamp !== null && <p className="text-xs">{timestamp}</p>}
{showAddToList && (
<div className="pl-4">
<AddToListDropdown questionId={questionId} />
</div>
)}
</div> </div>
{showActionButton && ( {showActionButton && (
<Button <Button
label={actionButtonLabel} label={actionButtonLabel}
size="sm" size="sm"
variant="tertiary" variant="secondary"
onClick={onActionButtonClick} onClick={onActionButtonClick}
/> />
)} )}

@ -4,6 +4,8 @@ import BaseQuestionCard from './BaseQuestionCard';
export type QuestionOverviewCardProps = Omit< export type QuestionOverviewCardProps = Omit<
BaseQuestionCardProps & { BaseQuestionCardProps & {
showActionButton: false; showActionButton: false;
showAddToList: true;
showAggregateStatistics: true;
showAnswerStatistics: false; showAnswerStatistics: false;
showCreateEncounterButton: true; showCreateEncounterButton: true;
showDeleteButton: false; showDeleteButton: false;
@ -13,6 +15,8 @@ export type QuestionOverviewCardProps = Omit<
| 'actionButtonLabel' | 'actionButtonLabel'
| 'onActionButtonClick' | 'onActionButtonClick'
| 'showActionButton' | 'showActionButton'
| 'showAddToList'
| 'showAggregateStatistics'
| 'showAnswerStatistics' | 'showAnswerStatistics'
| 'showCreateEncounterButton' | 'showCreateEncounterButton'
| 'showDeleteButton' | 'showDeleteButton'
@ -25,6 +29,8 @@ export default function FullQuestionCard(props: QuestionOverviewCardProps) {
<BaseQuestionCard <BaseQuestionCard
{...props} {...props}
showActionButton={false} showActionButton={false}
showAddToList={true}
showAggregateStatistics={true}
showAnswerStatistics={false} showAnswerStatistics={false}
showCreateEncounterButton={true} showCreateEncounterButton={true}
showReceivedStatistics={false} showReceivedStatistics={false}

@ -6,6 +6,7 @@ import BaseQuestionCard from './BaseQuestionCard';
export type QuestionListCardProps = Omit< export type QuestionListCardProps = Omit<
BaseQuestionCardProps & { BaseQuestionCardProps & {
showActionButton: false; showActionButton: false;
showAggregateStatistics: true;
showAnswerStatistics: false; showAnswerStatistics: false;
showDeleteButton: true; showDeleteButton: true;
showVoteButtons: false; showVoteButtons: false;
@ -13,6 +14,7 @@ export type QuestionListCardProps = Omit<
| 'actionButtonLabel' | 'actionButtonLabel'
| 'onActionButtonClick' | 'onActionButtonClick'
| 'showActionButton' | 'showActionButton'
| 'showAggregateStatistics'
| 'showAnswerStatistics' | 'showAnswerStatistics'
| 'showDeleteButton' | 'showDeleteButton'
| 'showVoteButtons' | 'showVoteButtons'
@ -24,6 +26,7 @@ function QuestionListCardWithoutHref(props: QuestionListCardProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)} {...(props as any)}
showActionButton={false} showActionButton={false}
showAggregateStatistics={true}
showAnswerStatistics={false} showAnswerStatistics={false}
showDeleteButton={true} showDeleteButton={true}
showHover={true} showHover={true}

@ -6,6 +6,7 @@ import BaseQuestionCard from './BaseQuestionCard';
export type QuestionOverviewCardProps = Omit< export type QuestionOverviewCardProps = Omit<
BaseQuestionCardProps & { BaseQuestionCardProps & {
showActionButton: false; showActionButton: false;
showAggregateStatistics: true;
showAnswerStatistics: true; showAnswerStatistics: true;
showCreateEncounterButton: false; showCreateEncounterButton: false;
showDeleteButton: false; showDeleteButton: false;
@ -16,6 +17,7 @@ export type QuestionOverviewCardProps = Omit<
| 'onActionButtonClick' | 'onActionButtonClick'
| 'onDelete' | 'onDelete'
| 'showActionButton' | 'showActionButton'
| 'showAggregateStatistics'
| 'showAnswerStatistics' | 'showAnswerStatistics'
| 'showCreateEncounterButton' | 'showCreateEncounterButton'
| 'showDeleteButton' | 'showDeleteButton'
@ -28,6 +30,7 @@ function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
<BaseQuestionCard <BaseQuestionCard
{...props} {...props}
showActionButton={false} showActionButton={false}
showAggregateStatistics={true}
showAnswerStatistics={true} showAnswerStatistics={true}
showCreateEncounterButton={false} showCreateEncounterButton={false}
showDeleteButton={false} showDeleteButton={false}

@ -4,7 +4,8 @@ import BaseQuestionCard from './BaseQuestionCard';
export type SimilarQuestionCardProps = Omit< export type SimilarQuestionCardProps = Omit<
BaseQuestionCardProps & { BaseQuestionCardProps & {
showActionButton: true; showActionButton: true;
showAnswerStatistics: true; showAggregateStatistics: false;
showAnswerStatistics: false;
showCreateEncounterButton: false; showCreateEncounterButton: false;
showDeleteButton: false; showDeleteButton: false;
showHover: true; showHover: true;
@ -14,6 +15,7 @@ export type SimilarQuestionCardProps = Omit<
| 'actionButtonLabel' | 'actionButtonLabel'
| 'onActionButtonClick' | 'onActionButtonClick'
| 'showActionButton' | 'showActionButton'
| 'showAggregateStatistics'
| 'showAnswerStatistics' | 'showAnswerStatistics'
| 'showCreateEncounterButton' | 'showCreateEncounterButton'
| 'showDeleteButton' | 'showDeleteButton'
@ -30,12 +32,13 @@ export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
<BaseQuestionCard <BaseQuestionCard
actionButtonLabel="Yes, this is my question" actionButtonLabel="Yes, this is my question"
showActionButton={true} showActionButton={true}
showAnswerStatistics={true} showAggregateStatistics={false}
showAnswerStatistics={false}
showCreateEncounterButton={false} showCreateEncounterButton={false}
showDeleteButton={false} showDeleteButton={false}
showHover={true} showHover={true}
showReceivedStatistics={true} showReceivedStatistics={false}
showVoteButtons={true} showVoteButtons={false}
onActionButtonClick={onSimilarQuestionClick} onActionButtonClick={onSimilarQuestionClick}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(rest as any)} {...(rest as any)}

@ -1,14 +1,7 @@
import { startOfMonth } from 'date-fns'; import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
Button,
CheckboxInput,
HorizontalDivider,
Select,
TextArea,
} from '@tih/ui';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants'; import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import { import {
@ -50,14 +43,12 @@ export default function ContributeQuestionForm({
date: startOfMonth(new Date()), date: startOfMonth(new Date()),
}, },
}); });
const register = useFormRegister(formRegister); const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister); const selectRegister = useSelectRegister(formRegister);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return ( return (
<div className="flex flex-col justify-between gap-4">
<form <form
className="flex flex-1 flex-col items-stretch justify-center gap-y-4" className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
onSubmit={handleSubmit(onSubmit)}> onSubmit={handleSubmit(onSubmit)}>
@ -152,38 +143,15 @@ export default function ContributeQuestionForm({
/> />
</div> </div>
</div> </div>
{/* <div className="w-full"> <div className="w-full">
<HorizontalDivider /> <HorizontalDivider />
</div> </div>
<h1 className="mb-3">
Are these questions the same as yours? TODO:Change to list
</h1>
<div>
<SimilarQuestionCard
content="Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices"
location="Menlo Park, CA"
receivedCount={0}
role="Senior Engineering Manager"
timestamp="Today"
onSimilarQuestionClick={() => {
// eslint-disable-next-line no-console
console.log('hi!');
}}
/>
</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 justify-end gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)]"
style={{ style={{
// Hack to make the background bleed outside the container // Hack to make the background bleed outside the container
clipPath: 'inset(0 -100vmax)', clipPath: 'inset(0 -100vmax)',
}}> }}>
<div className="my-2 flex sm:my-0">
<CheckboxInput
label="I have checked that my question is new"
value={canSubmit}
onChange={handleCheckSimilarQuestions}
/>
</div>
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<button <button
className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
@ -193,12 +161,12 @@ export default function ContributeQuestionForm({
</button> </button>
<Button <Button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm" className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!canSubmit}
label="Contribute" label="Contribute"
type="submit" type="submit"
variant="primary"></Button> variant="primary"></Button>
</div> </div>
</div> </div>
</form> </form>
</div>
); );
} }

@ -24,23 +24,18 @@ export default function ResumePdf({ url }: Props) {
setNumPages(pdf.numPages); setNumPages(pdf.numPages);
}; };
useEffect(() => {
const onPageResize = () => { const onPageResize = () => {
setComponentWidth( setComponentWidth(
document.querySelector('#pdfView')?.getBoundingClientRect().width ?? document.querySelector('#pdfView')?.getBoundingClientRect().width ?? 780,
780,
); );
}; };
window.addEventListener('resize', onPageResize); useEffect(() => {
onPageResize();
return () => { }, [pageWidth]);
window.removeEventListener('resize', onPageResize);
};
}, []);
return ( return (
<div id="pdfView"> <div className="w-full" id="pdfView">
<div className="group relative"> <div className="group relative">
<Document <Document
className="flex h-[calc(100vh-16rem)] flex-row justify-center overflow-auto" className="flex h-[calc(100vh-16rem)] flex-row justify-center overflow-auto"
@ -84,7 +79,6 @@ export default function ResumePdf({ url }: Props) {
</div> </div>
</Document> </Document>
</div> </div>
{numPages > 1 && (
<div className="flex justify-center p-4"> <div className="flex justify-center p-4">
<Pagination <Pagination
current={pageNumber} current={pageNumber}
@ -94,7 +88,6 @@ export default function ResumePdf({ url }: Props) {
onSelect={(page) => setPageNumber(page)} onSelect={(page) => setPageNumber(page)}
/> />
</div> </div>
)}
</div> </div>
); );
} }

@ -7,6 +7,11 @@ const navigation: ProductNavigationItems = [
name: 'Browse', name: 'Browse',
}, },
{ children: [], href: '/resumes/submit', name: 'Submit for review' }, { children: [], href: '/resumes/submit', name: 'Submit for review' },
{
children: [],
href: '/resumes/about',
name: 'About Us',
},
{ {
children: [], children: [],
href: 'https://www.techinterviewhandbook.org/resume/', href: 'https://www.techinterviewhandbook.org/resume/',
@ -16,6 +21,8 @@ const navigation: ProductNavigationItems = [
]; ];
const config = { const config = {
// TODO: Change this to your own GA4 measurement ID.
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Resumes', title: 'Resumes',

@ -8,19 +8,30 @@ type Props = Readonly<{
userId: string; userId: string;
}>; }>;
const STALE_TIME = 60;
export default function ResumeUserBadges({ userId }: Props) { export default function ResumeUserBadges({ userId }: Props) {
const userReviewedResumeCountQuery = trpc.useQuery([ const userReviewedResumeCountQuery = trpc.useQuery(
'resumes.resume.findUserReviewedResumeCount', ['resumes.resume.findUserReviewedResumeCount', { userId }],
{ userId }, {
]); retry: false,
const userMaxResumeUpvoteCountQuery = trpc.useQuery([ staleTime: STALE_TIME,
'resumes.resume.findUserMaxResumeUpvoteCount', },
{ userId }, );
]); const userMaxResumeUpvoteCountQuery = trpc.useQuery(
const userTopUpvotedCommentCountQuery = trpc.useQuery([ ['resumes.resume.findUserMaxResumeUpvoteCount', { userId }],
'resumes.resume.findUserTopUpvotedCommentCount', {
{ userId }, retry: false,
]); staleTime: STALE_TIME,
},
);
const userTopUpvotedCommentCountQuery = trpc.useQuery(
['resumes.resume.findUserTopUpvotedCommentCount', { userId }],
{
retry: false,
staleTime: STALE_TIME,
},
);
const payload: BadgePayload = { const payload: BadgePayload = {
maxResumeUpvoteCount: userMaxResumeUpvoteCountQuery.data ?? 0, maxResumeUpvoteCount: userMaxResumeUpvoteCountQuery.data ?? 0,

@ -1,3 +1,4 @@
import clsx from 'clsx';
import formatDistanceToNow from 'date-fns/formatDistanceToNow'; import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Link from 'next/link'; import Link from 'next/link';
import type { UrlObject } from 'url'; import type { UrlObject } from 'url';
@ -9,6 +10,18 @@ import {
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline'; import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import type {
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';
type Props = Readonly<{ type Props = Readonly<{
@ -19,23 +32,36 @@ type Props = Readonly<{
export default function ResumeListItem({ href, resumeInfo }: Props) { export default function ResumeListItem({ href, resumeInfo }: Props) {
return ( return (
<Link href={href}> <Link href={href}>
<div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100"> <div
<div className="col-span-4"> className={clsx(
{resumeInfo.title} resumeInfo.isResolved ? 'opacity-50' : '',
'grid grid-cols-8',
)}>
<div className="col-span-7 grid gap-4 border-b border-slate-200 p-4 hover:bg-slate-100 sm:grid-cols-7">
<div className="sm:col-span-4">
<div className="flex items-center gap-3">
<p>{resumeInfo.title}</p>
{/* <p className="rounded-xl border border-slate-400 p-1 text-xs text-slate-500">
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</p> */}
</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">
<div className="flex"> <div className="flex">
<BriefcaseIcon <BriefcaseIcon
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"
/> />
{resumeInfo.role} {getFilterLabel(ROLES, resumeInfo.role as RoleFilter)}
</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"
/> />
{resumeInfo.experience} {getFilterLabel(
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">
@ -57,13 +83,16 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-3 self-center text-sm text-slate-500"> <div className="self-center text-sm text-slate-500 sm:col-span-3">
<div> <div>
{`Uploaded ${formatDistanceToNow(resumeInfo.createdAt, { {`Uploaded ${formatDistanceToNow(resumeInfo.createdAt, {
addSuffix: true, addSuffix: true,
})} by ${resumeInfo.user}`} })} by ${resumeInfo.user}`}
</div> </div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div> <div className="mt-2 text-slate-400">
{getFilterLabel(LOCATIONS, resumeInfo.location as LocationFilter)}
</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" />
</div> </div>

@ -53,18 +53,41 @@ export default function ResumeCommentsForm({
}, },
}, },
); );
const invalidateResumeQueries = () => {
trpcContext.invalidateQueries(['resumes.resume.findOne']);
trpcContext.invalidateQueries(['resumes.resume.findAll']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserStarred']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserCreated']);
};
const resolveMutation = trpc.useMutation('resumes.resume.user.resolve', {
onSuccess() {
invalidateResumeQueries();
},
});
// TODO: Give a feedback to the user if the action succeeds/fails // TODO: Give a feedback to the user if the action succeeds/fails
const onSubmit: SubmitHandler<IFormInput> = async (data) => { const onSubmit: SubmitHandler<IFormInput> = async (formData) => {
return await commentCreateMutation.mutate( return await commentCreateMutation.mutate(
{ {
resumeId, resumeId,
...data, ...formData,
}, },
{ {
onSuccess: () => { onSuccess: (data) => {
// Redirect back to comments section // Redirect back to comments section
setShowCommentsForm(false); setShowCommentsForm(false);
const { prevCount, newCount } = data;
// Auto mark resume as resolved once the total comments passes the 5 threshold
if (
(newCount >= 5 && prevCount < 5) ||
(newCount >= 15 && prevCount < 15)
) {
resolveMutation.mutate({
id: resumeId,
val: true,
});
}
}, },
}, },
); );

@ -48,14 +48,16 @@ export default function ResumeCommentsList({
} }
}; };
if (commentsQuery.isLoading) {
return ( return (
<div className="space-y-3">
{commentsQuery.isLoading ? (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
</div> </div>
) : ( );
<div className="mb-8 flow-root h-[calc(100vh-13rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pb-16"> }
return (
<div className="mb-8 flow-root h-[calc(100vh-13rem)] w-full flex-col space-y-10 overflow-y-auto overflow-x-hidden pb-16">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => { {RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => { ? commentsQuery.data.filter((comment: ResumeComment) => {
@ -65,13 +67,17 @@ export default function ResumeCommentsList({
const commentCount = comments.length; const commentCount = comments.length;
return ( return (
<div key={value} className="space-y-6 pr-4"> <div key={value} className="space-y-4 pr-4">
<div className="text-primary-800 -mb-2 flex flex-row items-center space-x-2"> {/* CommentHeader Section */}
<div className="text-primary-800 flex items-center space-x-2">
<hr className="flex-grow border-slate-800" />
{renderIcon(value)} {renderIcon(value)}
<div className="w-fit text-lg font-medium">{label}</div> <span className="w-fit text-lg font-medium">{label}</span>
<hr className="flex-grow border-slate-800" />
</div> </div>
{/* Comment Section */}
<div <div
className={clsx( className={clsx(
'space-y-2 rounded-md border-2 bg-white px-4 py-3 drop-shadow-md', 'space-y-2 rounded-md border-2 bg-white px-4 py-3 drop-shadow-md',
@ -97,13 +103,9 @@ export default function ResumeCommentsList({
</div> </div>
)} )}
</div> </div>
<hr className="border-gray-300" />
</div> </div>
); );
})} })}
</div> </div>
)}
</div>
); );
} }

@ -1,4 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ArrowDownCircleIcon, ArrowDownCircleIcon,
@ -21,6 +22,7 @@ export default function ResumeCommentVoteButtons({
const [downvoteAnimation, setDownvoteAnimation] = useState(false); const [downvoteAnimation, setDownvoteAnimation] = useState(false);
const trpcContext = trpc.useContext(); const trpcContext = trpc.useContext();
const router = useRouter();
// COMMENT VOTES // COMMENT VOTES
const commentVotesQuery = trpc.useQuery([ const commentVotesQuery = trpc.useQuery([
@ -47,6 +49,11 @@ export default function ResumeCommentVoteButtons({
); );
const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => { const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => {
if (!userId) {
router.push('/api/auth/signin');
return;
}
setAnimation(true); setAnimation(true);
if (commentVotesQuery.data?.userVote?.value === value) { if (commentVotesQuery.data?.userVote?.value === value) {
@ -74,7 +81,6 @@ export default function ResumeCommentVoteButtons({
<> <>
<button <button
disabled={ disabled={
!userId ||
commentVotesQuery.isLoading || commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading || commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading commentVotesDeleteMutation.isLoading
@ -103,7 +109,6 @@ export default function ResumeCommentVoteButtons({
<button <button
disabled={ disabled={
!userId ||
commentVotesQuery.isLoading || commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading || commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading commentVotesDeleteMutation.isLoading

@ -1,4 +1,4 @@
import Link from 'next/link'; import { Button } from '@tih/ui';
import { Container } from './Container'; import { Container } from './Container';
@ -14,13 +14,12 @@ export function CallToAction() {
It's free! Take charge of your resume game by learning from the top It's free! Take charge of your resume game by learning from the top
engineers in the field. engineers in the field.
</p> </p>
<Link href="/resumes/browse"> <Button
<button className="mt-4"
className="bg-primary-500 mt-4 rounded-md py-2 px-3 text-sm font-medium text-white" href="/resumes/browse"
type="button"> label="Start browsing now"
Start browsing now variant="primary"
</button> />
</Link>
</div> </div>
</Container> </Container>
</section> </section>

@ -1,4 +1,5 @@
import Link from 'next/link'; import Link from 'next/link';
import { Button } from '@tih/ui';
import { Container } from './Container'; import { Container } from './Container';
@ -24,13 +25,12 @@ export function Hero() {
your fellow engineers your fellow engineers
</p> </p>
<div className="mt-10 flex justify-center gap-x-4"> <div className="mt-10 flex justify-center gap-x-4">
<Link href="/resumes/browse"> <Button
<button href="/resumes/browse"
className="bg-primary-500 rounded-md py-2 px-3 text-sm font-medium text-white" label="Start browsing now"
type="button"> variant="primary"
Start browsing now />
</button> {/* TODO: Update video */}
</Link>
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"> <Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
<button <button
className="focus-visible:outline-primary-600 group inline-flex items-center justify-center rounded-md py-2 px-4 text-sm ring-1 ring-slate-200 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600" className="focus-visible:outline-primary-600 group inline-flex items-center justify-center rounded-md py-2 px-4 text-sm ring-1 ring-slate-200 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600"

@ -69,7 +69,7 @@ export function PrimaryFeatures() {
<div <div
key={feature.title} key={feature.title}
className={clsx( className={clsx(
'group relative rounded-full py-1 px-4 lg:rounded-r-none lg:rounded-l-xl lg:p-6', 'group relative rounded-full lg:rounded-r-none lg:rounded-l-xl lg:p-6',
selectedIndex === featureIndex selectedIndex === featureIndex
? 'bg-white lg:bg-white/10 lg:ring-1 lg:ring-inset lg:ring-white/10' ? 'bg-white lg:bg-white/10 lg:ring-1 lg:ring-inset lg:ring-white/10'
: 'hover:bg-white/10 lg:hover:bg-white/5', : 'hover:bg-white/10 lg:hover:bg-white/5',
@ -77,6 +77,7 @@ export function PrimaryFeatures() {
<h3> <h3>
<Tab <Tab
className={clsx( className={clsx(
'rounded-full py-1 px-4',
'font-display text-lg [&:not(:focus-visible)]:focus:outline-none', 'font-display text-lg [&:not(:focus-visible)]:focus:outline-none',
selectedIndex === featureIndex selectedIndex === featureIndex
? 'text-blue-600 lg:text-white' ? 'text-blue-600 lg:text-white'
@ -88,7 +89,7 @@ export function PrimaryFeatures() {
</h3> </h3>
<p <p
className={clsx( className={clsx(
'mt-2 hidden text-sm lg:block', 'mt-2 hidden px-4 text-sm lg:block',
selectedIndex === featureIndex selectedIndex === featureIndex
? 'text-white' ? 'text-white'
: 'text-blue-100 group-hover:text-white', : 'text-blue-100 group-hover:text-white',

@ -33,8 +33,9 @@ import type {
ProfileAnalysis, ProfileAnalysis,
ProfileOffer, ProfileOffer,
SpecificYoe, SpecificYoe,
Valuation, UserProfile,
} from '~/types/offers'; UserProfileOffer,
Valuation} from '~/types/offers';
const analysisOfferDtoMapper = ( const analysisOfferDtoMapper = (
offer: OffersOffer & { offer: OffersOffer & {
@ -526,8 +527,10 @@ export const profileDtoMapper = (
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null; offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
} }
>; >;
user: User | null;
}, },
inputToken: string | undefined, inputToken: string | undefined,
inputUserId: string | null | undefined
) => { ) => {
const profileDto: Profile = { const profileDto: Profile = {
analysis: profileAnalysisDtoMapper(profile.analysis), analysis: profileAnalysisDtoMapper(profile.analysis),
@ -535,6 +538,7 @@ export const profileDtoMapper = (
editToken: null, editToken: null,
id: profile.id, id: profile.id,
isEditable: false, isEditable: false,
isSaved: false,
offers: profile.offers.map((offer) => profileOfferDtoMapper(offer)), offers: profile.offers.map((offer) => profileOfferDtoMapper(offer)),
profileName: profile.profileName, profileName: profile.profileName,
}; };
@ -542,6 +546,20 @@ export const profileDtoMapper = (
if (inputToken === profile.editToken) { if (inputToken === profile.editToken) {
profileDto.editToken = profile.editToken ?? null; profileDto.editToken = profile.editToken ?? null;
profileDto.isEditable = true; profileDto.isEditable = true;
const users = profile.user
// TODO: BRYANN UNCOMMENT THIS ONCE U CHANGE THE SCHEMA
// for (let i = 0; i < users.length; i++) {
// if (users[i].id === inputUserId) {
// profileDto.isSaved = true
// }
// }
// TODO: REMOVE THIS ONCE U CHANGE THE SCHEMA
if (users?.id === inputUserId) {
profileDto.isSaved = true
}
} }
return profileDto; return profileDto;
@ -626,3 +644,84 @@ export const getOffersResponseMapper = (
}; };
return getOffersResponse; return getOffersResponse;
}; };
export const getUserProfileResponseMapper = (res: User & {
OffersProfile: Array<OffersProfile & {
offers: Array<OffersOffer & {
company: Company;
offersFullTime: (OffersFullTime & { totalCompensation: OffersCurrency }) | null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
}>;
}>;
} | null): Array<UserProfile> => {
if (res) {
return res.OffersProfile.map((profile) => {
return {
createdAt: profile.createdAt,
id: profile.id,
offers: profile.offers.map((offer) => {
return userProfileOfferDtoMapper(offer)
}),
profileName: profile.profileName,
token: profile.editToken
}
})
}
return []
}
const userProfileOfferDtoMapper = (
offer: OffersOffer & {
company: Company;
offersFullTime: (OffersFullTime & { totalCompensation: OffersCurrency }) | null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
}): UserProfileOffer => {
const mappedOffer: UserProfileOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
income: {
baseCurrency: '',
baseValue: -1,
currency: '',
id: '',
value: -1,
},
jobType: offer.jobType,
level: offer.offersFullTime?.level ?? '',
location: offer.location,
monthYearReceived: offer.monthYearReceived,
title:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.title ?? ''
: offer.offersIntern?.title ?? '',
}
if (offer.offersFullTime?.totalCompensation) {
mappedOffer.income.value =
offer.offersFullTime.totalCompensation.value;
mappedOffer.income.currency =
offer.offersFullTime.totalCompensation.currency;
mappedOffer.income.id = offer.offersFullTime.totalCompensation.id;
mappedOffer.income.baseValue =
offer.offersFullTime.totalCompensation.baseValue;
mappedOffer.income.baseCurrency =
offer.offersFullTime.totalCompensation.baseCurrency;
} else if (offer.offersIntern?.monthlySalary) {
mappedOffer.income.value = offer.offersIntern.monthlySalary.value;
mappedOffer.income.currency =
offer.offersIntern.monthlySalary.currency;
mappedOffer.income.id = offer.offersIntern.monthlySalary.id;
mappedOffer.income.baseValue =
offer.offersIntern.monthlySalary.baseValue;
mappedOffer.income.baseCurrency =
offer.offersIntern.monthlySalary.baseCurrency;
} else {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found',
});
}
return mappedOffer
}

@ -50,11 +50,11 @@ export default function OfferProfile() {
router.push(HOME_URL); router.push(HOME_URL);
} }
// If the profile is not editable with a wrong token, redirect to the profile page // If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') { if (!data.isEditable && token !== '') {
router.push(getProfilePath(offerProfileId as string)); router.push(getProfilePath(offerProfileId as string));
} }
setIsEditable(data?.isEditable ?? false); setIsEditable(data.isEditable);
const filteredOffers: Array<OfferDisplayData> = data const filteredOffers: Array<OfferDisplayData> = data
? data?.offers.map((res: ProfileOffer) => { ? data?.offers.map((res: ProfileOffer) => {

@ -0,0 +1,5 @@
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
export default function OffersSubmissionPage() {
return <OffersSubmissionForm />;
}

@ -0,0 +1,123 @@
import { useRouter } from 'next/router';
import { useEffect, useRef, useState } from 'react';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { EyeIcon } from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
import type { BreadcrumbStep } from '~/components/offers/Breadcrumb';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
import OffersSubmissionAnalysis from '~/components/offers/offersSubmission/OffersSubmissionAnalysis';
import { getProfilePath } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
import type { ProfileAnalysis } from '~/types/offers';
export default function OffersSubmissionResult() {
const router = useRouter();
let { offerProfileId, token = '' } = router.query;
offerProfileId = offerProfileId as string;
token = token as string;
const [step, setStep] = useState(0);
const [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
// TODO: Check if the token is valid before showing this page
const getAnalysis = trpc.useQuery(
['offers.analysis.get', { profileId: offerProfileId }],
{
onSuccess(data) {
setAnalysis(data);
},
},
);
const steps = [
<OffersProfileSave key={0} profileId={offerProfileId} token={token} />,
<OffersSubmissionAnalysis
key={1}
analysis={analysis}
isError={getAnalysis.isError}
isLoading={getAnalysis.isLoading}
/>,
];
const breadcrumbSteps: Array<BreadcrumbStep> = [
{
label: 'Offers',
},
{
label: 'Background',
},
{
label: 'Save profile',
step: 0,
},
{
label: 'Analysis',
step: 1,
},
];
useEffect(() => {
scrollToTop();
}, [step]);
return (
<>
{getAnalysis.isLoading && (
<Spinner className="m-10" display="block" size="lg" />
)}
{!getAnalysis.isLoading && (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs
currentStep={step}
setStep={setStep}
steps={breadcrumbSteps}
/>
</div>
{steps[step]}
{step === 0 && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => setStep(step + 1)}
/>
</div>
)}
{step === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={() => setStep(step - 1)}
/>
<Button
href={getProfilePath(
offerProfileId as string,
token as string,
)}
icon={EyeIcon}
label="View your profile"
variant="primary"
/>
</div>
)}
</div>
</div>
</div>
)}
</>
);
}

@ -16,7 +16,7 @@ function Test() {
}); });
const addToUserProfileMutation = trpc.useMutation( const addToUserProfileMutation = trpc.useMutation(
['offers.profile.addToUserProfile'], ['offers.user.profile.addToUserProfile'],
{ {
onError(err) { onError(err) {
alert(err); alert(err);
@ -85,7 +85,7 @@ function Test() {
addToUserProfileMutation.mutate({ addToUserProfileMutation.mutate({
profileId: 'cl9efyn9p004ww3u42mjgl1vn', profileId: 'cl9efyn9p004ww3u42mjgl1vn',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117', token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: 'cl9ehvpng0000w3ec2mpx0bdd', // UserId: 'cl9ehvpng0000w3ec2mpx0bdd',
}); });
}; };

@ -42,7 +42,7 @@ export default function QuestionPage() {
]); ]);
const { mutate: addComment } = trpc.useMutation( const { mutate: addComment } = trpc.useMutation(
'questions.answers.comments.create', 'questions.answers.comments.user.create',
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries([ utils.invalidateQueries([

@ -60,7 +60,7 @@ export default function QuestionPage() {
]); ]);
const { mutate: addComment } = trpc.useMutation( const { mutate: addComment } = trpc.useMutation(
'questions.questions.comments.create', 'questions.questions.comments.user.create',
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries( utils.invalidateQueries(
@ -75,14 +75,17 @@ export default function QuestionPage() {
{ questionId: questionId as string }, { questionId: questionId as string },
]); ]);
const { mutate: addAnswer } = trpc.useMutation('questions.answers.create', { const { mutate: addAnswer } = trpc.useMutation(
'questions.answers.user.create',
{
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries('questions.answers.getAnswers'); utils.invalidateQueries('questions.answers.getAnswers');
}, },
}); },
);
const { mutate: addEncounter } = trpc.useMutation( const { mutate: addEncounter } = trpc.useMutation(
'questions.questions.encounters.create', 'questions.questions.encounters.user.create',
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries( utils.invalidateQueries(

@ -9,7 +9,6 @@ import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard'; import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOption } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection'; import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar'; import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead'; import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
@ -184,7 +183,7 @@ export default function QuestionsBrowsePage() {
const utils = trpc.useContext(); const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation( const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create', 'questions.questions.user.create',
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter'); utils.invalidateQueries('questions.questions.getQuestionsByFilter');
@ -195,18 +194,6 @@ export default function QuestionsBrowsePage() {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const [selectedCompanyOptions, setSelectedCompanyOptions] = useState<
Array<FilterOption>
>([]);
const [selectedRoleOptions, setSelectedRoleOptions] = useState<
Array<FilterOption>
>([]);
const [selectedLocationOptions, setSelectedLocationOptions] = useState<
Array<FilterOption>
>([]);
const questionTypeFilterOptions = useMemo(() => { const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({ return QUESTION_TYPES.map((questionType) => ({
...questionType, ...questionType,
@ -275,9 +262,37 @@ export default function QuestionsBrowsePage() {
sortType, sortType,
]); ]);
const selectedCompanyOptions = useMemo(() => {
return selectedCompanies.map((company) => ({
checked: true,
id: company,
label: company,
value: company,
}));
}, [selectedCompanies]);
const selectedRoleOptions = useMemo(() => {
return selectedRoles.map((role) => ({
checked: true,
id: role,
label: role,
value: role,
}));
}, [selectedRoles]);
const selectedLocationOptions = useMemo(() => {
return selectedLocations.map((location) => ({
checked: true,
id: location,
label: location,
value: location,
}));
}, [selectedLocations]);
if (!loaded) { if (!loaded) {
return null; return null;
} }
const filterSidebar = ( const filterSidebar = (
<div className="divide-y divide-slate-200 px-4"> <div className="divide-y divide-slate-200 px-4">
<Button <Button
@ -293,9 +308,6 @@ export default function QuestionsBrowsePage() {
setSelectedQuestionAge('all'); setSelectedQuestionAge('all');
setSelectedRoles([]); setSelectedRoles([]);
setSelectedLocations([]); setSelectedLocations([]);
setSelectedCompanyOptions([]);
setSelectedRoleOptions([]);
setSelectedLocationOptions([]);
}} }}
/> />
<FilterSection <FilterSection
@ -306,8 +318,8 @@ export default function QuestionsBrowsePage() {
{...field} {...field}
clearOnSelect={true} clearOnSelect={true}
filterOption={(option) => { filterOption={(option) => {
return !selectedCompanyOptions.some((selectedOption) => { return !selectedCompanies.some((company) => {
return selectedOption.value === option.value; return company === option.value;
}); });
}} }}
isLabelHidden={true} isLabelHidden={true}
@ -323,19 +335,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => { onOptionChange={(option) => {
if (option.checked) { if (option.checked) {
setSelectedCompanies([...selectedCompanies, option.label]); setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanyOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else { } else {
setSelectedCompanies( setSelectedCompanies(
selectedCompanies.filter((company) => company !== option.label), selectedCompanies.filter((company) => company !== option.label),
); );
setSelectedCompanyOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.label !== option.label,
),
);
} }
}} }}
/> />
@ -347,8 +350,8 @@ export default function QuestionsBrowsePage() {
{...field} {...field}
clearOnSelect={true} clearOnSelect={true}
filterOption={(option) => { filterOption={(option) => {
return !selectedRoleOptions.some((selectedOption) => { return !selectedRoles.some((role) => {
return selectedOption.value === option.value; return role === option.value;
}); });
}} }}
isLabelHidden={true} isLabelHidden={true}
@ -364,19 +367,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => { onOptionChange={(option) => {
if (option.checked) { if (option.checked) {
setSelectedRoles([...selectedRoles, option.value]); setSelectedRoles([...selectedRoles, option.value]);
setSelectedRoleOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else { } else {
setSelectedRoles( setSelectedRoles(
selectedCompanies.filter((role) => role !== option.value), selectedCompanies.filter((role) => role !== option.value),
); );
setSelectedRoleOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
);
} }
}} }}
/> />
@ -413,8 +407,8 @@ export default function QuestionsBrowsePage() {
{...field} {...field}
clearOnSelect={true} clearOnSelect={true}
filterOption={(option) => { filterOption={(option) => {
return !selectedLocationOptions.some((selectedOption) => { return !selectedLocations.some((location) => {
return selectedOption.value === option.value; return location === option.value;
}); });
}} }}
isLabelHidden={true} isLabelHidden={true}
@ -430,19 +424,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => { onOptionChange={(option) => {
if (option.checked) { if (option.checked) {
setSelectedLocations([...selectedLocations, option.value]); setSelectedLocations([...selectedLocations, option.value]);
setSelectedLocationOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else { } else {
setSelectedLocations( setSelectedLocations(
selectedLocations.filter((role) => role !== option.value), selectedLocations.filter((role) => role !== option.value),
); );
setSelectedLocationOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
);
} }
}} }}
/> />

@ -8,60 +8,90 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard'; import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
import type { CreateListFormData } from '~/components/questions/CreateListDialog';
import CreateListDialog from '~/components/questions/CreateListDialog';
import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist'; import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import { SAMPLE_QUESTION } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import { trpc } from '~/utils/trpc';
export default function ListPage() { export default function ListPage() {
const questions = [ const utils = trpc.useContext();
SAMPLE_QUESTION, const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
SAMPLE_QUESTION, const { mutateAsync: createList } = trpc.useMutation(
SAMPLE_QUESTION, 'questions.lists.create',
SAMPLE_QUESTION, {
SAMPLE_QUESTION, onSuccess: () => {
SAMPLE_QUESTION, // TODO: Add optimistic update
SAMPLE_QUESTION, utils.invalidateQueries(['questions.lists.getListsByUser']);
SAMPLE_QUESTION, },
SAMPLE_QUESTION, },
SAMPLE_QUESTION, );
SAMPLE_QUESTION, const { mutateAsync: deleteList } = trpc.useMutation(
SAMPLE_QUESTION, 'questions.lists.delete',
SAMPLE_QUESTION, {
SAMPLE_QUESTION, onSuccess: () => {
SAMPLE_QUESTION, // TODO: Add optimistic update
]; utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const lists = [ const [selectedListIndex, setSelectedListIndex] = useState(0);
{ id: 1, name: 'list 1', questions }, const [showDeleteListDialog, setShowDeleteListDialog] = useState(false);
{ id: 2, name: 'list 2', questions }, const [showCreateListDialog, setShowCreateListDialog] = useState(false);
{ id: 3, name: 'list 3', questions },
{ id: 4, name: 'list 4', questions }, const [listIdToDelete, setListIdToDelete] = useState('');
{ id: 5, name: 'list 5', questions },
]; const handleDeleteList = async (listId: string) => {
await deleteList({
id: listId,
});
setShowDeleteListDialog(false);
};
const handleDeleteListCancel = () => {
setShowDeleteListDialog(false);
};
const handleCreateList = async (data: CreateListFormData) => {
await createList({
name: data.name,
});
setShowCreateListDialog(false);
};
const handleCreateListCancel = () => {
setShowCreateListDialog(false);
};
const [selectedList, setSelectedList] = useState(
(lists ?? []).length > 0 ? lists[0].id : '',
);
const listOptions = ( const listOptions = (
<> <>
<ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200"> <ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200">
{lists.map((list) => ( {(lists ?? []).map((list, index) => (
<li <li
key={list.id} key={list.id}
className={`flex items-center hover:bg-slate-50 ${ className={`flex items-center hover:bg-slate-50 ${
selectedList === list.id ? '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={() => {
setSelectedList(list.id); setSelectedListIndex(index);
// eslint-disable-next-line no-console
console.log(selectedList);
}}> }}>
<p className="text-primary-700 text-md p-3 font-medium"> <p className="text-primary-700 text-md p-3 pl-6 font-medium">
{list.name} {list.name}
</p> </p>
</button> </button>
@ -85,7 +115,11 @@ export default function ListPage() {
? 'bg-violet-500 text-white' ? 'bg-violet-500 text-white'
: 'text-slate-900' : 'text-slate-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} } group flex w-full items-center rounded-md px-2 py-2 text-sm`}
type="button"> type="button"
onClick={() => {
setShowDeleteListDialog(true);
setListIdToDelete(list.id);
}}>
Delete Delete
</button> </button>
)} )}
@ -104,6 +138,7 @@ export default function ListPage() {
)} )}
</> </>
); );
return ( return (
<> <>
<Head> <Head>
@ -111,7 +146,7 @@ export default function ListPage() {
</Head> </Head>
<main className="flex flex-1 flex-col items-stretch"> <main className="flex 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-l bg-white py-4 lg:block"> <aside className="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">
@ -124,6 +159,7 @@ export default function ListPage() {
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setShowCreateListDialog(true);
}} }}
/> />
</div> </div>
@ -133,20 +169,27 @@ export default function ListPage() {
<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">
{selectedList && ( {lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4"> <div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => ( {lists[selectedListIndex].questionEntries.map(
({ question, id: entryId }) => (
<QuestionListCard <QuestionListCard
key={question.id} key={question.id}
companies={question.companies} companies={
question.aggregatedQuestionEncounters.companyCounts
}
content={question.content} content={question.content}
href={`/questions/${question.id}/${createSlug( href={`/questions/${question.id}/${createSlug(
question.content, question.content,
)}`} )}`}
locations={question.locations} locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id} questionId={question.id}
receivedCount={0} receivedCount={question.receivedCount}
roles={question.roles} roles={
question.aggregatedQuestionEncounters.roleCounts
}
timestamp={question.seenAt.toLocaleDateString( timestamp={question.seenAt.toLocaleDateString(
undefined, undefined,
{ {
@ -156,21 +199,33 @@ export default function ListPage() {
)} )}
type={question.type} type={question.type}
onDelete={() => { onDelete={() => {
// eslint-disable-next-line no-console deleteQuestionEntry({ id: entryId });
console.log('delete');
}} }}
/> />
))} ),
{questions?.length === 0 && ( )}
{lists[selectedListIndex].questionEntries?.length === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600"> <div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" /> <NoSymbolIcon className="h-6 w-6" />
<p>You have no added any questions to your list yet.</p> <p>
You have not added any questions to your list yet.
</p>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </div>
</div> </div>
<DeleteListDialog
show={showDeleteListDialog}
onCancel={handleDeleteListCancel}
onDelete={() => {
handleDeleteList(listIdToDelete);
}}></DeleteListDialog>
<CreateListDialog
show={showCreateListDialog}
onCancel={handleCreateListCancel}
onSubmit={handleCreateList}></CreateListDialog>
</section> </section>
</div> </div>
</main> </main>

@ -3,12 +3,13 @@ import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Error from 'next/error'; import Error from 'next/error';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { signIn, useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { import {
AcademicCapIcon, AcademicCapIcon,
BriefcaseIcon, BriefcaseIcon,
CalendarIcon, CalendarIcon,
CheckCircleIcon,
InformationCircleIcon, InformationCircleIcon,
MapPinIcon, MapPinIcon,
PencilSquareIcon, PencilSquareIcon,
@ -21,6 +22,20 @@ import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList
import ResumePdf from '~/components/resumes/ResumePdf'; import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText'; import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import type {
ExperienceFilter,
FilterOption,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
INITIAL_FILTER_STATE,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import SubmitResumeForm from './submit'; import SubmitResumeForm from './submit';
@ -42,22 +57,32 @@ export default function ResumeReviewPage() {
); );
const starMutation = trpc.useMutation('resumes.resume.star', { const starMutation = trpc.useMutation('resumes.resume.star', {
onSuccess() { onSuccess() {
utils.invalidateQueries(['resumes.resume.findOne']); invalidateResumeQueries();
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
}, },
}); });
const unstarMutation = trpc.useMutation('resumes.resume.unstar', { const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
onSuccess() { onSuccess() {
invalidateResumeQueries();
},
});
const resolveMutation = trpc.useMutation('resumes.resume.user.resolve', {
onSuccess() {
invalidateResumeQueries();
},
});
const invalidateResumeQueries = () => {
utils.invalidateQueries(['resumes.resume.findOne']); utils.invalidateQueries(['resumes.resume.findOne']);
utils.invalidateQueries(['resumes.resume.findAll']); utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']); utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']); utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
}, };
});
const userIsOwner = const userIsOwner =
session?.user?.id != null && session.user.id === detailsQuery.data?.userId; session?.user?.id !== undefined &&
session.user.id === detailsQuery.data?.userId;
const isResumeResolved = detailsQuery.data?.isResolved;
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const [showCommentsForm, setShowCommentsForm] = useState(false); const [showCommentsForm, setShowCommentsForm] = useState(false);
@ -79,31 +104,75 @@ export default function ResumeReviewPage() {
} }
}; };
const onInfoTagClick = ({
locationLabel,
experienceLabel,
roleLabel,
}: {
experienceLabel?: string;
locationLabel?: string;
roleLabel?: string;
}) => {
const getFilterValue = (
label: string,
filterOptions: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter>
>,
) => filterOptions.find((option) => option.label === label)?.value;
router.push({
pathname: '/resumes/browse',
query: {
currentPage: JSON.stringify(1),
searchValue: JSON.stringify(''),
shortcutSelected: JSON.stringify('all'),
sortOrder: JSON.stringify('latest'),
tabsValue: JSON.stringify(BROWSE_TABS_VALUES.ALL),
userFilters: JSON.stringify({
...INITIAL_FILTER_STATE,
...(locationLabel && {
location: [getFilterValue(locationLabel, LOCATIONS)],
}),
...(roleLabel && {
role: [getFilterValue(roleLabel, ROLES)],
}),
...(experienceLabel && {
experience: [getFilterValue(experienceLabel, EXPERIENCES)],
}),
}),
},
});
};
const onEditButtonClick = () => { const onEditButtonClick = () => {
setIsEditMode(true); setIsEditMode(true);
}; };
const onResolveButtonClick = () => {
resolveMutation.mutate({
id: resumeId as string,
val: !isResumeResolved,
});
};
const renderReviewButton = () => { const renderReviewButton = () => {
if (session === null) { if (session === null) {
return ( return (
<div className=" flex h-10 justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-[400] hover:cursor-pointer hover:bg-slate-50"> <Button
<a className="h-10 shadow-md"
display="block"
href="/api/auth/signin" href="/api/auth/signin"
onClick={(event) => { label="Sign in to join discussion"
event.preventDefault(); variant="primary"
signIn(); />
}}>
Sign in to join discussion
</a>
</div>
); );
} }
return ( return (
<Button <Button
className="h-10 py-2 shadow-md" className="h-10 shadow-md"
display="block" display="block"
label="Add your review" label="Add your review"
variant="tertiary" variant="primary"
onClick={() => setShowCommentsForm(true)} onClick={() => setShowCommentsForm(true)}
/> />
); );
@ -122,10 +191,7 @@ export default function ResumeReviewPage() {
url: detailsQuery.data.url, url: detailsQuery.data.url,
}} }}
onClose={() => { onClose={() => {
utils.invalidateQueries(['resumes.resume.findOne']); invalidateResumeQueries();
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
setIsEditMode(false); setIsEditMode(false);
}} }}
/> />
@ -146,23 +212,49 @@ export default function ResumeReviewPage() {
<Head> <Head>
<title>{detailsQuery.data.title}</title> <title>{detailsQuery.data.title}</title>
</Head> </Head>
<main className="h-[calc(100vh-2rem)] flex-1 space-y-2 overflow-y-auto py-4 px-8 xl:px-12 2xl:pr-16"> <main className="h-full flex-1 space-y-2 overflow-y-auto py-4 px-8 xl:px-12 2xl:pr-16">
<div className="flex justify-between"> <div className="flex justify-between">
<h1 className="pr-2 text-2xl font-semibold leading-7 text-slate-900 sm:truncate sm:text-3xl sm:tracking-tight"> <h1 className="pr-2 text-2xl font-semibold leading-7 text-slate-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title} {detailsQuery.data.title}
</h1> </h1>
<div className="flex gap-3 xl:pr-4"> <div className="flex gap-3 xl:pr-4">
{userIsOwner && ( {userIsOwner && (
<>
<Button
addonPosition="start"
className="h-10 shadow-md"
icon={PencilSquareIcon}
label="Edit"
variant="tertiary"
onClick={onEditButtonClick}
/>
<button <button
className="h-10 rounded-md border border-slate-300 bg-white py-1 px-2 text-center shadow-md hover:bg-slate-50" className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 focus:ring-slate-600 disabled:hover:bg-white"
disabled={resolveMutation.isLoading}
type="button" type="button"
onClick={onEditButtonClick}> onClick={onResolveButtonClick}>
<PencilSquareIcon className="text-primary-600 h-6 w-6" /> <div className="-ml-1 mr-2 h-5 w-5">
{resolveMutation.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<CheckCircleIcon
aria-hidden="true"
className={
isResumeResolved
? 'text-slate-500'
: 'text-success-600'
}
/>
)}
</div>
{isResumeResolved
? 'Reopen for review'
: 'Mark as reviewed'}
</button> </button>
</>
)} )}
<button <button
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 disabled:hover:bg-white" className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 focus:ring-slate-600 disabled:hover:bg-white"
disabled={starMutation.isLoading || unstarMutation.isLoading} disabled={starMutation.isLoading || unstarMutation.isLoading}
type="button" type="button"
onClick={onStarButtonClick}> onClick={onStarButtonClick}>
@ -183,13 +275,12 @@ export default function ResumeReviewPage() {
/> />
)} )}
</div> </div>
Star {detailsQuery.data?.stars.length ? 'Starred' : 'Star'}
</span> </span>
<span className="relative -ml-px inline-flex"> <span className="relative -ml-px inline-flex">
{detailsQuery.data?._count.stars} {detailsQuery.data?._count.stars}
</span> </span>
</button> </button>
<div className="hidden xl:block">{renderReviewButton()}</div> <div className="hidden xl:block">{renderReviewButton()}</div>
</div> </div>
</div> </div>
@ -199,21 +290,54 @@ export default function ResumeReviewPage() {
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/> />
{detailsQuery.data.role} <button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
roleLabel: detailsQuery.data?.role,
})
}>
{getFilterLabel(ROLES, detailsQuery.data.role as RoleFilter)}
</button>
</div> </div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<MapPinIcon <MapPinIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/> />
{detailsQuery.data.location} <button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
locationLabel: detailsQuery.data?.location,
})
}>
{getFilterLabel(
LOCATIONS,
detailsQuery.data.location as LocationFilter,
)}
</button>
</div> </div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<AcademicCapIcon <AcademicCapIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/> />
{detailsQuery.data.experience} <button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
experienceLabel: detailsQuery.data?.experience,
})
}>
{getFilterLabel(
EXPERIENCES,
detailsQuery.data.experience as ExperienceFilter,
)}
</button>
</div> </div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<CalendarIcon <CalendarIcon
@ -243,21 +367,17 @@ export default function ResumeReviewPage() {
<ResumePdf url={detailsQuery.data.url} /> <ResumePdf url={detailsQuery.data.url} />
</div> </div>
<div className="grow"> <div className="grow">
<div className="relative p-2 xl:hidden"> <div className="mb-6 space-y-4 xl:hidden">
<div {renderReviewButton()}
aria-hidden="true" <div className="flex items-center space-x-2">
className="absolute inset-0 flex items-center"> <hr className="flex-grow border-slate-300" />
<div className="w-full border-t border-slate-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-slate-50 px-3 text-lg font-medium text-slate-900"> <span className="bg-slate-50 px-3 text-lg font-medium text-slate-900">
Reviews Reviews
</span> </span>
<hr className="flex-grow border-slate-300" />
</div> </div>
</div> </div>
<div className="mb-4 xl:hidden">{renderReviewButton()}</div>
{showCommentsForm ? ( {showCommentsForm ? (
<ResumeCommentsForm <ResumeCommentsForm
resumeId={resumeId as string} resumeId={resumeId as string}

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

@ -1,7 +1,7 @@
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import Router, { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useMemo, useState } from 'react';
import { Dialog, Disclosure, Transition } from '@headlessui/react'; import { Dialog, Disclosure, Transition } from '@headlessui/react';
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid'; import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import { import {
@ -10,6 +10,7 @@ import {
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { import {
Button,
CheckboxInput, CheckboxInput,
CheckboxList, CheckboxList,
DropdownMenu, DropdownMenu,
@ -20,28 +21,31 @@ import {
} from '@tih/ui'; } from '@tih/ui';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill'; import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import type { import type {
Filter, Filter,
FilterId, FilterId,
FilterLabel,
Shortcut, Shortcut,
} from '~/components/resumes/browse/resumeFilters'; } from '~/utils/resumes/resumeFilters';
import { import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
EXPERIENCES, EXPERIENCES,
getFilterLabel,
INITIAL_FILTER_STATE, INITIAL_FILTER_STATE,
isInitialFilterState, isInitialFilterState,
LOCATIONS, LOCATIONS,
ROLES, ROLES,
SHORTCUTS, SHORTCUTS,
SORT_OPTIONS, SORT_OPTIONS,
} from '~/components/resumes/browse/resumeFilters'; } from '~/utils/resumes/resumeFilters';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import useDebounceValue from '~/utils/resumes/useDebounceValue'; import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { FilterState } from '../../components/resumes/browse/resumeFilters'; import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000; const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800; const DEBOUNCE_DELAY = 800;
@ -101,19 +105,89 @@ const getEmptyDataText = (
export default function ResumeHomePage() { export default function ResumeHomePage() {
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const router = useRouter(); const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL); const [tabsValue, setTabsValue, isTabsValueInit] = useSearchParams(
const [sortOrder, setSortOrder] = useState('latest'); 'tabsValue',
const [searchValue, setSearchValue] = useState(''); BROWSE_TABS_VALUES.ALL,
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE); );
const [shortcutSelected, setShortcutSelected] = useState('All'); const [sortOrder, setSortOrder, isSortOrderInit] = useSearchParams<SortOrder>(
const [currentPage, setCurrentPage] = useState(1); 'sortOrder',
'latest',
);
const [searchValue, setSearchValue, isSearchValueInit] = useSearchParams(
'searchValue',
'',
);
const [shortcutSelected, setShortcutSelected, isShortcutInit] =
useSearchParams('shortcutSelected', 'All');
const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams(
'currentPage',
1,
);
const [userFilters, setUserFilters, isUserFiltersInit] = useSearchParams(
'userFilters',
INITIAL_FILTER_STATE,
);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false); const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const skip = (currentPage - 1) * PAGE_LIMIT; const skip = (currentPage - 1) * PAGE_LIMIT;
const isSearchOptionsInit = useMemo(() => {
return (
isTabsValueInit &&
isSortOrderInit &&
isSearchValueInit &&
isShortcutInit &&
isCurrentPageInit &&
isUserFiltersInit
);
}, [
isTabsValueInit,
isSortOrderInit,
isSearchValueInit,
isShortcutInit,
isCurrentPageInit,
isUserFiltersInit,
]);
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [userFilters, sortOrder, searchValue]); }, [userFilters, sortOrder, setCurrentPage, searchValue]);
useEffect(() => {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
if (!isSearchOptionsInit) {
return;
}
Router.replace({
pathname: router.pathname,
query: {
currentPage: JSON.stringify(currentPage),
searchValue: JSON.stringify(searchValue),
shortcutSelected: JSON.stringify(shortcutSelected),
sortOrder: JSON.stringify(sortOrder),
tabsValue: JSON.stringify(tabsValue),
userFilters: JSON.stringify(userFilters),
},
});
}, [
tabsValue,
sortOrder,
searchValue,
userFilters,
shortcutSelected,
currentPage,
router.pathname,
isSearchOptionsInit,
]);
const filterCountsQuery = trpc.useQuery(
['resumes.resume.getTotalFilterCounts'],
{
staleTime: STALE_TIME,
},
);
const allResumesQuery = trpc.useQuery( const allResumesQuery = trpc.useQuery(
[ [
@ -175,6 +249,14 @@ export default function ResumeHomePage() {
}, },
); );
const getFilterCount = (filter: FilterLabel, value: string) => {
if (filterCountsQuery.isLoading) {
return 0;
}
const filterCountsData = filterCountsQuery.data!;
return filterCountsData[filter][value];
};
const onSubmitResume = () => { const onSubmitResume = () => {
if (sessionData === null) { if (sessionData === null) {
router.push('/api/auth/signin'); router.push('/api/auth/signin');
@ -203,6 +285,13 @@ export default function ResumeHomePage() {
} }
}; };
const onClearFilterClick = (filterSection: FilterId) => {
setUserFilters({
...userFilters,
[filterSection]: [],
});
};
const onShortcutChange = ({ const onShortcutChange = ({
sortOrder: shortcutSortOrder, sortOrder: shortcutSortOrder,
filters: shortcutFilters, filters: shortcutFilters,
@ -283,7 +372,7 @@ export default function ResumeHomePage() {
<Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-scroll bg-white py-4 pb-12 shadow-xl"> <Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-scroll bg-white py-4 pb-12 shadow-xl">
<div className="flex items-center justify-between px-4"> <div className="flex items-center justify-between px-4">
<h2 className="text-lg font-medium text-slate-900"> <h2 className="text-lg font-medium text-slate-900">
Shortcuts Quick access
</h2> </h2>
<button <button
className="-mr-2 flex h-10 w-10 items-center justify-center rounded-md bg-white p-2 text-slate-400" className="-mr-2 flex h-10 w-10 items-center justify-center rounded-md bg-white p-2 text-slate-400"
@ -296,7 +385,7 @@ export default function ResumeHomePage() {
<form className="mt-4 border-t border-slate-200"> <form className="mt-4 border-t border-slate-200">
<ul <ul
className="flex flex-wrap justify-start gap-4 px-4 py-3 font-medium text-slate-900" className="flex w-11/12 flex-wrap justify-start gap-2 px-4 py-4 font-medium text-slate-900"
role="list"> role="list">
{SHORTCUTS.map((shortcut) => ( {SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}> <li key={shortcut.name}>
@ -313,7 +402,7 @@ export default function ResumeHomePage() {
<Disclosure <Disclosure
key={filter.id} key={filter.id}
as="div" as="div"
className="border-t border-slate-200 px-4 py-6"> className="border-t border-slate-200 px-4 pt-6 pb-4">
{({ open }) => ( {({ open }) => (
<> <>
<h3 className="-mx-2 -my-3 flow-root"> <h3 className="-mx-2 -my-3 flow-root">
@ -336,14 +425,17 @@ export default function ResumeHomePage() {
</span> </span>
</Disclosure.Button> </Disclosure.Button>
</h3> </h3>
<Disclosure.Panel className="pt-6"> <Disclosure.Panel className="space-y-4 pt-6">
<div className="space-y-6"> <div className="space-y-3">
{filter.options.map((option) => ( {filter.options.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 [&>div>div:nth-child(2)>label]:font-normal"> className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput <CheckboxInput
label={option.label} label={`${option.label} (${getFilterCount(
filter.label,
option.label,
)})`}
value={userFilters[filter.id].includes( value={userFilters[filter.id].includes(
option.value, option.value,
)} )}
@ -358,6 +450,11 @@ export default function ResumeHomePage() {
</div> </div>
))} ))}
</div> </div>
<p
className="cursor-pointer text-sm text-slate-500 underline"
onClick={() => onClearFilterClick(filter.id)}>
Clear
</p>
</Disclosure.Panel> </Disclosure.Panel>
</> </>
)} )}
@ -371,11 +468,12 @@ export default function ResumeHomePage() {
</Transition.Root> </Transition.Root>
</div> </div>
<main className="h-[calc(100vh-4rem)] flex-auto px-8 pb-4"> <main className="h-full flex-auto px-8 pb-4">
<div className="flex justify-start"> <div className="flex justify-start">
<div className="fixed top-0 bottom-0 mt-24 hidden w-64 overflow-auto lg:block"> <div className="fixed top-0 bottom-0 mt-24 hidden w-64 overflow-auto lg:block">
{/* Quick Access Section */}
<h3 className="text-md font-medium tracking-tight text-gray-900"> <h3 className="text-md font-medium tracking-tight text-gray-900">
Shortcuts Quick access
</h3> </h3>
<div className="w-100 pt-4 sm:pr-0 md:pr-4"> <div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form> <form>
@ -392,6 +490,7 @@ export default function ResumeHomePage() {
</li> </li>
))} ))}
</ul> </ul>
{/* Filter Section */}
<h3 className="text-md font-medium tracking-tight text-slate-900"> <h3 className="text-md font-medium tracking-tight text-slate-900">
Explore these filters Explore these filters
</h3> </h3>
@ -399,7 +498,7 @@ export default function ResumeHomePage() {
<Disclosure <Disclosure
key={filter.id} key={filter.id}
as="div" as="div"
className="border-b border-slate-200 py-6"> className="border-b border-slate-200 pt-6 pb-4">
{({ open }) => ( {({ open }) => (
<> <>
<h3 className="-my-3 flow-root"> <h3 className="-my-3 flow-root">
@ -422,7 +521,7 @@ export default function ResumeHomePage() {
</span> </span>
</Disclosure.Button> </Disclosure.Button>
</h3> </h3>
<Disclosure.Panel className="pt-4"> <Disclosure.Panel className="space-y-4 pt-4">
<CheckboxList <CheckboxList
description="" description=""
isLabelHidden={true} isLabelHidden={true}
@ -431,9 +530,12 @@ export default function ResumeHomePage() {
{filter.options.map((option) => ( {filter.options.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 [&>div>div:nth-child(2)>label]:font-normal"> className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 px-1 [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput <CheckboxInput
label={option.label} label={`${option.label} (${getFilterCount(
filter.label,
option.label,
)})`}
value={userFilters[filter.id].includes( value={userFilters[filter.id].includes(
option.value, option.value,
)} )}
@ -448,6 +550,11 @@ export default function ResumeHomePage() {
</div> </div>
))} ))}
</CheckboxList> </CheckboxList>
<p
className="cursor-pointer text-sm text-slate-500 underline"
onClick={() => onClearFilterClick(filter.id)}>
Clear
</p>
</Disclosure.Panel> </Disclosure.Panel>
</> </>
)} )}
@ -457,8 +564,8 @@ export default function ResumeHomePage() {
</div> </div>
</div> </div>
<div className="relative lg:left-64 lg:w-[calc(100%-16rem)]"> <div className="relative lg:left-64 lg:w-[calc(100%-16rem)]">
<div className="lg:border-grey-200 sticky top-0 z-10 flex flex-wrap items-center justify-between bg-gray-50 pt-6 pb-2 lg:border-b"> <div className="lg:border-grey-200 sticky top-0 z-10 flex flex-wrap items-center justify-between pt-6 pb-2 lg:border-b">
<div className="border-grey-200 mb-4 flex w-full justify-between border-b pb-2 lg:mb-0 lg:w-auto lg:border-none lg:pb-0"> <div className="border-grey-200 mb-4 flex w-full justify-between border-b pb-2 lg:mb-0 lg:w-auto lg:border-none xl:pb-0">
<div> <div>
<Tabs <Tabs
label="Resume Browse Tabs" label="Resume Browse Tabs"
@ -480,16 +587,8 @@ export default function ResumeHomePage() {
onChange={onTabChange} onChange={onTabChange}
/> />
</div> </div>
<div>
<button
className="bg-primary-500 ml-4 rounded-md py-2 px-3 text-sm font-medium text-white lg:hidden"
type="button"
onClick={onSubmitResume}>
Submit Resume
</button>
</div>
</div> </div>
<div className="flex flex-wrap items-center justify-start gap-8"> <div className="flex flex-wrap items-center justify-start gap-4 lg:gap-6">
<div className="w-64"> <div className="w-64">
<TextInput <TextInput
isLabelHidden={true} isLabelHidden={true}
@ -502,32 +601,31 @@ export default function ResumeHomePage() {
onChange={setSearchValue} onChange={setSearchValue}
/> />
</div> </div>
<div> <DropdownMenu
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}> align="end"
{Object.entries(SORT_OPTIONS).map(([key, value]) => ( label={getFilterLabel(SORT_OPTIONS, sortOrder)}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item <DropdownMenu.Item
key={key} key={value}
isSelected={sortOrder === key} isSelected={sortOrder === value}
label={value} label={label}
onClick={() => setSortOrder(key)}></DropdownMenu.Item> onClick={() => setSortOrder(value)}></DropdownMenu.Item>
))} ))}
</DropdownMenu> </DropdownMenu>
</div> <Button
<button className="lg:hidden"
className="-m-2 text-slate-400 hover:text-slate-500 lg:hidden" icon={FunnelIcon}
type="button" isLabelHidden={true}
onClick={() => setMobileFiltersOpen(true)}> label="Filters"
<span className="sr-only">Filters</span> variant="tertiary"
<FunnelIcon aria-hidden="true" className="h-6 w-6" /> onClick={() => setMobileFiltersOpen(true)}
</button> />
<div> <Button
<button className="whitespace-pre-wrap px-2 lg:block"
className="bg-primary-500 hidden w-36 rounded-md py-2 px-3 text-sm font-medium text-white lg:block" label="Submit Resume"
type="button" variant="primary"
onClick={onSubmitResume}> onClick={onSubmitResume}
Submit Resume />
</button>
</div>
</div> </div>
</div> </div>
{isFetchingResumes ? ( {isFetchingResumes ? (

@ -11,7 +11,7 @@ export default function Home() {
<title>Resume Review</title> <title>Resume Review</title>
</Head> </Head>
<main className="h-[calc(100vh-2rem)] w-full overflow-y-auto"> <main className="h-full w-full overflow-y-auto">
<Hero /> <Hero />
<PrimaryFeatures /> <PrimaryFeatures />
<CallToAction /> <CallToAction />

@ -19,14 +19,10 @@ import {
TextInput, TextInput,
} from '@tih/ui'; } from '@tih/ui';
import {
EXPERIENCES,
LOCATIONS,
ROLES,
} from '~/components/resumes/browse/resumeFilters';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines'; import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
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 { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
const FILE_SIZE_LIMIT_MB = 3; const FILE_SIZE_LIMIT_MB = 3;
@ -172,6 +168,9 @@ export default function SubmitResumeForm({
onSuccess() { onSuccess() {
if (isNewForm) { if (isNewForm) {
trpcContext.invalidateQueries('resumes.resume.findAll'); trpcContext.invalidateQueries('resumes.resume.findAll');
trpcContext.invalidateQueries(
'resumes.resume.getTotalFilterCounts',
);
router.push('/resumes/browse'); router.push('/resumes/browse');
} else { } else {
onClose(); onClose();

@ -6,12 +6,19 @@ import { offersRouter } from './offers/offers';
import { offersAnalysisRouter } from './offers/offers-analysis-router'; import { offersAnalysisRouter } from './offers/offers-analysis-router';
import { offersCommentsRouter } from './offers/offers-comments-router'; import { offersCommentsRouter } from './offers/offers-comments-router';
import { offersProfileRouter } from './offers/offers-profile-router'; import { offersProfileRouter } from './offers/offers-profile-router';
import { offersUserProfileRouter } from './offers/offers-user-profile-router';
import { protectedExampleRouter } from './protected-example-router'; import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router'; import { questionsAnswerCommentRouter } from './questions/questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router'; import { questionsAnswerCommentUserRouter } from './questions/questions-answer-comment-user-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router'; import { questionsAnswerRouter } from './questions/questions-answer-router';
import { questionsQuestionEncounterRouter } from './questions-question-encounter-router'; import { questionsAnswerUserRouter } from './questions/questions-answer-user-router';
import { questionsQuestionRouter } from './questions-question-router'; import { questionsListRouter } from './questions/questions-list-router';
import { questionsQuestionCommentRouter } from './questions/questions-question-comment-router';
import { questionsQuestionCommentUserRouter } from './questions/questions-question-comment-user-router';
import { questionsQuestionEncounterRouter } from './questions/questions-question-encounter-router';
import { questionsQuestionEncounterUserRouter } from './questions/questions-question-encounter-user-router';
import { questionsQuestionRouter } from './questions/questions-question-router';
import { questionsQuestionUserRouter } from './questions/questions-question-user-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-router'; import { resumeCommentsRouter } from './resumes/resumes-comments-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router'; import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router'; import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router';
@ -39,14 +46,27 @@ export const appRouter = createRouter()
.merge('resumes.comments.votes.', resumesCommentsVotesRouter) .merge('resumes.comments.votes.', resumesCommentsVotesRouter)
.merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter) .merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter) .merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.comments.user.', questionsAnswerCommentUserRouter)
.merge('questions.answers.', questionsAnswerRouter) .merge('questions.answers.', questionsAnswerRouter)
.merge('questions.answers.user.', questionsAnswerUserRouter)
.merge('questions.lists.', questionsListRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter) .merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge(
'questions.questions.comments.user.',
questionsQuestionCommentUserRouter,
)
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter) .merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
.merge(
'questions.questions.encounters.user.',
questionsQuestionEncounterUserRouter,
)
.merge('questions.questions.', questionsQuestionRouter) .merge('questions.questions.', questionsQuestionRouter)
.merge('questions.questions.user.', questionsQuestionUserRouter)
.merge('offers.', offersRouter) .merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter) .merge('offers.profile.', offersProfileRouter)
.merge('offers.analysis.', offersAnalysisRouter) .merge('offers.analysis.', offersAnalysisRouter)
.merge('offers.comments.', offersCommentsRouter); .merge('offers.comments.', offersCommentsRouter)
.merge('offers.user.profile.', offersUserProfileRouter);
// Export type definition of API // Export type definition of API
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

@ -4,7 +4,6 @@ import { JobType } from '@prisma/client';
import * as trpc from '@trpc/server'; import * as trpc from '@trpc/server';
import { import {
addToProfileResponseMapper,
createOfferProfileResponseMapper, createOfferProfileResponseMapper,
profileDtoMapper, profileDtoMapper,
} from '~/mappers/offers-mappers'; } from '~/mappers/offers-mappers';
@ -107,6 +106,7 @@ export const offersProfileRouter = createRouter()
input: z.object({ input: z.object({
profileId: z.string(), profileId: z.string(),
token: z.string().optional(), token: z.string().optional(),
userId: z.string().nullish(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const result = await ctx.prisma.offersProfile.findFirst({ const result = await ctx.prisma.offersProfile.findFirst({
@ -229,6 +229,7 @@ export const offersProfileRouter = createRouter()
}, },
}, },
}, },
user: true,
}, },
where: { where: {
id: input.profileId, id: input.profileId,
@ -236,7 +237,7 @@ export const offersProfileRouter = createRouter()
}); });
if (result) { if (result) {
return profileDtoMapper(result, input.token); return profileDtoMapper(result, input.token, input.userId);
} }
throw new trpc.TRPCError({ throw new trpc.TRPCError({
@ -284,7 +285,8 @@ export const offersProfileRouter = createRouter()
})), })),
}, },
experiences: { experiences: {
create: input.background.experiences.map(async (x) => { create: await Promise.all(
input.background.experiences.map(async (x) => {
if (x.jobType === JobType.FULLTIME) { if (x.jobType === JobType.FULLTIME) {
if (x.companyId) { if (x.companyId) {
return { return {
@ -392,6 +394,7 @@ export const offersProfileRouter = createRouter()
message: 'Missing fields in background experiences.', message: 'Missing fields in background experiences.',
}); });
}), }),
)
}, },
specificYoes: { specificYoes: {
create: input.background.specificYoes.map((x) => { create: input.background.specificYoes.map((x) => {
@ -546,7 +549,6 @@ export const offersProfileRouter = createRouter()
profileName: uniqueName, profileName: uniqueName,
}, },
}); });
return createOfferProfileResponseMapper(profile, token); return createOfferProfileResponseMapper(profile, token);
}, },
}) })
@ -1406,44 +1408,6 @@ export const offersProfileRouter = createRouter()
}); });
} }
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid token.',
});
},
})
.mutation('addToUserProfile', {
input: z.object({
profileId: z.string(),
token: z.string(),
userId: z.string(),
}),
async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
id: input.profileId,
},
});
const profileEditToken = profile?.editToken;
if (profileEditToken === input.token) {
const updated = await ctx.prisma.offersProfile.update({
data: {
user: {
connect: {
id: input.userId,
},
},
},
where: {
id: input.profileId,
},
});
return addToProfileResponseMapper(updated);
}
throw new trpc.TRPCError({ throw new trpc.TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'Invalid token.', message: 'Invalid token.',

@ -0,0 +1,131 @@
import { z } from 'zod';
import * as trpc from '@trpc/server';
import { TRPCError } from '@trpc/server';
import {
addToProfileResponseMapper, getUserProfileResponseMapper,
} from '~/mappers/offers-mappers';
import { createProtectedRouter } from '../context';
export const offersUserProfileRouter = createProtectedRouter()
.mutation('addToUserProfile', {
input: z.object({
profileId: z.string(),
token: z.string(),
}),
async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
id: input.profileId,
},
});
const profileEditToken = profile?.editToken;
if (profileEditToken === input.token) {
const userId = ctx.session.user.id
const updated = await ctx.prisma.offersProfile.update({
data: {
user: {
connect: {
id: userId,
},
},
},
where: {
id: input.profileId,
},
});
return addToProfileResponseMapper(updated);
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid token.',
});
},
})
.mutation('getUserProfiles', {
async resolve({ ctx }) {
const userId = ctx.session.user.id
const result = await ctx.prisma.user.findFirst({
include: {
OffersProfile: {
include: {
offers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true
}
},
offersIntern: {
include: {
monthlySalary: true
}
}
}
}
}
}
},
where: {
id: userId
}
})
return getUserProfileResponseMapper(result)
}
})
.mutation('removeFromUserProfile', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id
const profiles = await ctx.prisma.user.findFirst({
include: {
OffersProfile: true
},
where: {
id: userId
}
})
// Validation
let doesProfileExist = false;
if (profiles?.OffersProfile) {
for (let i = 0; i < profiles.OffersProfile.length; i++) {
if (profiles.OffersProfile[i].id === input.profileId) {
doesProfileExist = true
}
}
}
if (!doesProfileExist) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No such profile id saved.'
})
}
await ctx.prisma.user.update({
data: {
OffersProfile: {
disconnect: [{
id: input.profileId
}]
}
},
where: {
id: userId
}
})
}
})

@ -1,53 +1,129 @@
import { z } from 'zod'; import { z } from 'zod';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from './context';
export const questionListRouter = createProtectedRouter() export const questionsListRouter = createProtectedRouter()
.query('getListsByUser', { .query('getListsByUser', {
async resolve({ ctx }) { async resolve({ ctx }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsList.findMany({ // TODO: Optimize by not returning question entries
const questionsLists = await ctx.prisma.questionsList.findMany({
include: { include: {
questionEntries: { questionEntries: {
include: { include: {
question: true, question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
}, },
}
}, },
orderBy: { orderBy: {
createdAt: 'asc', createdAt: 'asc',
}, },
where: { where: {
id: userId, userId,
}, },
}); });
}
const lists = questionsLists.map((list) => ({
...list,
questionEntries: list.questionEntries.map((entry) => ({
...entry,
question: createQuestionWithAggregateData(entry.question),
})),
}));
return lists;
},
}) })
.query('getListById', { .query('getListById', {
input: z.object({ input: z.object({
listId: z.string(), listId: z.string(),
}), }),
async resolve({ ctx }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { listId } = input;
return await ctx.prisma.questionsList.findMany({ const questionList = await ctx.prisma.questionsList.findFirst({
include: { include: {
questionEntries: { questionEntries: {
include: { include: {
question: true, question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
}, },
}
}, },
orderBy: { orderBy: {
createdAt: 'asc', createdAt: 'asc',
}, },
where: { where: {
id: userId, id: listId,
userId,
}, },
}); });
if (!questionList) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Question list not found',
});
} }
return {
...questionList,
questionEntries: questionList.questionEntries.map((questionEntry) => ({
...questionEntry,
question: createQuestionWithAggregateData(questionEntry.question),
})),
};
},
}) })
.mutation('create', { .mutation('create', {
input: z.object({ input: z.object({
@ -111,7 +187,7 @@ export const questionListRouter = createProtectedRouter()
}, },
}); });
if (listToDelete?.id !== userId) { if (listToDelete?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -139,7 +215,7 @@ export const questionListRouter = createProtectedRouter()
}, },
}); });
if (listToAugment?.id !== userId) { if (listToAugment?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',
@ -163,27 +239,27 @@ export const questionListRouter = createProtectedRouter()
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const entryToDelete = await ctx.prisma.questionsListQuestionEntry.findUnique({ const entryToDelete =
await ctx.prisma.questionsListQuestionEntry.findUnique({
where: { where: {
id: input.id, id: input.id,
}, },
}); });
if (entryToDelete?.id !== userId) { if (entryToDelete === null) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'NOT_FOUND',
message: 'User have no authorization to record.', message: 'Entry not found.',
}); });
} }
const listToAugment = await ctx.prisma.questionsList.findUnique({ const listToAugment = await ctx.prisma.questionsList.findUnique({
where: { where: {
id: entryToDelete.listId, id: entryToDelete.listId,
}, },
}); });
if (listToAugment?.id !== userId) { if (listToAugment?.userId !== userId) {
throw new TRPCError({ throw new TRPCError({
code: 'UNAUTHORIZED', code: 'UNAUTHORIZED',
message: 'User have no authorization to record.', message: 'User have no authorization to record.',

@ -1,141 +0,0 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import type { AggregatedQuestionEncounter } from '~/types/questions';
export const questionsQuestionEncounterRouter = createProtectedRouter()
.query('getAggregatedEncounters', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company: true,
},
where: {
...input,
},
});
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionEncountersData[0].seenAt;
for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i];
latestSeenAt = latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const questionEncounter: AggregatedQuestionEncounter = {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
};
return questionEncounter;
},
})
.mutation('create', {
input: z.object({
companyId: z.string(),
location: z.string(),
questionId: z.string(),
role: z.string(),
seenAt: z.date(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestionEncounter.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
companyId: z.string().optional(),
id: z.string(),
location: z.string().optional(),
role: z.string().optional(),
seenAt: z.date().optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToUpdate =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionEncounter.update({
data: {
...input,
},
where: {
id: input.id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToDelete =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionEncounter.delete({
where: {
id: input.id,
},
});
},
});

@ -2,9 +2,10 @@ import { z } from 'zod';
import { QuestionsQuestionType, Vote } from '@prisma/client'; import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d'; import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createProtectedRouter() export const questionsQuestionRouter = createProtectedRouter()
@ -122,73 +123,10 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
}); });
const processedQuestionsData = questionsData.map((data) => { const processedQuestionsData = questionsData.map(
const votes: number = data.votes.reduce( createQuestionWithAggregateData,
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
); );
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = data.encounters[0].seenAt;
for (let i = 0; i < data.encounters.length; i++) {
const encounter = data.encounters[i];
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = {
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: data.content,
id: data.id,
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
receivedCount: data.encounters.length,
seenAt: latestSeenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
};
return question;
});
let nextCursor: typeof cursor | undefined = undefined; let nextCursor: typeof cursor | undefined = undefined;
if (questionsData.length > input.limit) { if (questionsData.length > input.limit) {
@ -252,68 +190,8 @@ export const questionsQuestionRouter = createProtectedRouter()
message: 'Question not found', message: 'Question not found',
}); });
} }
const votes: number = questionData.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const companyCounts: Record<string, number> = {}; return createQuestionWithAggregateData(questionData);
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionData.encounters[0].seenAt;
for (const encounter of questionData.encounters) {
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = {
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: questionData.content,
id: questionData.id,
numAnswers: questionData._count.answers,
numComments: questionData._count.comments,
numVotes: votes,
receivedCount: questionData.encounters.length,
seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType,
updatedAt: questionData.updatedAt,
user: questionData.user?.name ?? '',
};
return question;
}, },
}) })
.mutation('create', { .mutation('create', {

@ -0,0 +1,64 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { createRouter } from '../context';
import type { AnswerComment } from '~/types/questions';
export const questionsAnswerCommentRouter = createRouter().query(
'getAnswerComments',
{
input: z.object({
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const questionAnswerCommentsData =
await ctx.prisma.questionsAnswerComment.findMany({
include: {
user: {
select: {
image: true,
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
answerId: input.answerId,
},
});
return questionAnswerCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answerComment: AnswerComment = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numVotes: votes,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
};
return answerComment;
});
},
},
);

@ -2,65 +2,9 @@ import { z } from 'zod';
import { Vote } from '@prisma/client'; import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from '../context';
import type { AnswerComment } from '~/types/questions'; export const questionsAnswerCommentUserRouter = createProtectedRouter()
export const questionsAnswerCommentRouter = createProtectedRouter()
.query('getAnswerComments', {
input: z.object({
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const questionAnswerCommentsData =
await ctx.prisma.questionsAnswerComment.findMany({
include: {
user: {
select: {
image: true,
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
answerId: input.answerId,
},
});
return questionAnswerCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answerComment: AnswerComment = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numVotes: votes,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
};
return answerComment;
});
},
})
.mutation('create', { .mutation('create', {
input: z.object({ input: z.object({
answerId: z.string(), answerId: z.string(),
@ -281,6 +225,5 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}), }),
]); ]);
return answerCommentVote; return answerCommentVote;
}, },
}); });

@ -0,0 +1,128 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createRouter } from '../context';
import type { Answer } from '~/types/questions';
export const questionsAnswerRouter = createRouter()
.query('getAnswers', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const { questionId } = input;
const answersData = await ctx.prisma.questionsAnswer.findMany({
include: {
_count: {
select: {
comments: true,
},
},
user: {
select: {
image: true,
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
questionId,
},
});
return answersData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answer: Answer = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numComments: data._count.comments,
numVotes: votes,
user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
};
return answer;
});
},
})
.query('getAnswerById', {
input: z.object({
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const answerData = await ctx.prisma.questionsAnswer.findUnique({
include: {
_count: {
select: {
comments: true,
},
},
user: {
select: {
image: true,
name: true,
},
},
votes: true,
},
where: {
id: input.answerId,
},
});
if (!answerData) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Answer not found',
});
}
const votes: number = answerData.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answer: Answer = {
content: answerData.content,
createdAt: answerData.createdAt,
id: answerData.id,
numComments: answerData._count.comments,
numVotes: votes,
user: answerData.user?.name ?? '',
userImage: answerData.user?.image ?? '',
};
return answer;
},
});

@ -2,130 +2,9 @@ import { z } from 'zod';
import { Vote } from '@prisma/client'; import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from '../context';
import type { Answer } from '~/types/questions'; export const questionsAnswerUserRouter = createProtectedRouter()
export const questionsAnswerRouter = createProtectedRouter()
.query('getAnswers', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const { questionId } = input;
const answersData = await ctx.prisma.questionsAnswer.findMany({
include: {
_count: {
select: {
comments: true,
},
},
user: {
select: {
image: true,
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
questionId,
},
});
return answersData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answer: Answer = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numComments: data._count.comments,
numVotes: votes,
user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
};
return answer;
});
},
})
.query('getAnswerById', {
input: z.object({
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const answerData = await ctx.prisma.questionsAnswer.findUnique({
include: {
_count: {
select: {
comments: true,
},
},
user: {
select: {
image: true,
name: true,
},
},
votes: true,
},
where: {
id: input.answerId,
},
});
if (!answerData) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Answer not found',
});
}
const votes: number = answerData.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answer: Answer = {
content: answerData.content,
createdAt: answerData.createdAt,
id: answerData.id,
numComments: answerData._count.comments,
numVotes: votes,
user: answerData.user?.name ?? '',
userImage: answerData.user?.image ?? '',
};
return answer;
},
})
.mutation('create', { .mutation('create', {
input: z.object({ input: z.object({
content: z.string(), content: z.string(),
@ -341,6 +220,5 @@ export const questionsAnswerRouter = createProtectedRouter()
}), }),
]); ]);
return questionsAnswerVote; return questionsAnswerVote;
}, },
}); });

@ -0,0 +1,275 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from '../context';
export const questionsListRouter = createProtectedRouter()
.query('getListsByUser', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
// TODO: Optimize by not returning question entries
const questionsLists = await ctx.prisma.questionsList.findMany({
include: {
questionEntries: {
include: {
question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
},
},
orderBy: {
createdAt: 'asc',
},
where: {
userId,
},
});
const lists = questionsLists.map((list) => ({
...list,
questionEntries: list.questionEntries.map((entry) => ({
...entry,
question: createQuestionWithAggregateData(entry.question),
})),
}));
return lists;
},
})
.query('getListById', {
input: z.object({
listId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { listId } = input;
const questionList = await ctx.prisma.questionsList.findFirst({
include: {
questionEntries: {
include: {
question: {
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
},
},
},
},
orderBy: {
createdAt: 'asc',
},
where: {
id: listId,
userId,
},
});
if (!questionList) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Question list not found',
});
}
return {
...questionList,
questionEntries: questionList.questionEntries.map((questionEntry) => ({
...questionEntry,
question: createQuestionWithAggregateData(questionEntry.question),
})),
};
},
})
.mutation('create', {
input: z.object({
name: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { name } = input;
return await ctx.prisma.questionsList.create({
data: {
name,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
id: z.string(),
name: z.string().optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { name, id } = input;
const listToUpdate = await ctx.prisma.questionsList.findUnique({
where: {
id: input.id,
},
});
if (listToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsList.update({
data: {
name,
},
where: {
id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const listToDelete = await ctx.prisma.questionsList.findUnique({
where: {
id: input.id,
},
});
if (listToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsList.delete({
where: {
id: input.id,
},
});
},
})
.mutation('createQuestionEntry', {
input: z.object({
listId: z.string(),
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const listToAugment = await ctx.prisma.questionsList.findUnique({
where: {
id: input.listId,
},
});
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const { questionId, listId } = input;
return await ctx.prisma.questionsListQuestionEntry.create({
data: {
listId,
questionId,
},
});
},
})
.mutation('deleteQuestionEntry', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const entryToDelete =
await ctx.prisma.questionsListQuestionEntry.findUnique({
where: {
id: input.id,
},
});
if (entryToDelete === null) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Entry not found.',
});
}
const listToAugment = await ctx.prisma.questionsList.findUnique({
where: {
id: entryToDelete.listId,
},
});
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsListQuestionEntry.delete({
where: {
id: input.id,
},
});
},
});

@ -0,0 +1,64 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { createRouter } from '../context';
import type { QuestionComment } from '~/types/questions';
export const questionsQuestionCommentRouter = createRouter().query(
'getQuestionComments',
{
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const { questionId } = input;
const questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({
include: {
user: {
select: {
image: true,
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
questionId,
},
});
return questionCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const questionComment: QuestionComment = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numVotes: votes,
user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
};
return questionComment;
});
},
},
);

@ -2,65 +2,9 @@ import { z } from 'zod';
import { Vote } from '@prisma/client'; import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context'; import { createProtectedRouter } from '../context';
import type { QuestionComment } from '~/types/questions'; export const questionsQuestionCommentUserRouter = createProtectedRouter()
export const questionsQuestionCommentRouter = createProtectedRouter()
.query('getQuestionComments', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const { questionId } = input;
const questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({
include: {
user: {
select: {
image: true,
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
questionId,
},
});
return questionCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const questionComment: QuestionComment = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numVotes: votes,
user: data.user?.name ?? '',
userImage: data.user?.image ?? '',
};
return questionComment;
});
},
})
.mutation('create', { .mutation('create', {
input: z.object({ input: z.object({
content: z.string(), content: z.string(),

@ -0,0 +1,61 @@
import { z } from 'zod';
import { createRouter } from '../context';
import type { AggregatedQuestionEncounter } from '~/types/questions';
export const questionsQuestionEncounterRouter = createRouter().query(
'getAggregatedEncounters',
{
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company: true,
},
where: {
...input,
},
});
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionEncountersData[0].seenAt;
for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i];
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const questionEncounter: AggregatedQuestionEncounter = {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
};
return questionEncounter;
},
},
);

@ -0,0 +1,207 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createAggregatedQuestionEncounter } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from '../context';
import { SortOrder } from '~/types/questions.d';
export const questionsQuestionEncounterUserRouter = createProtectedRouter()
.query('getAggregatedEncounters', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const questionEncountersData =
await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company: true,
},
where: {
...input,
},
});
return createAggregatedQuestionEncounter(questionEncountersData);
},
})
.mutation('create', {
input: z.object({
companyId: z.string(),
location: z.string(),
questionId: z.string(),
role: z.string(),
seenAt: z.date(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.$transaction(async (tx) => {
const [questionToUpdate, questionEncounterCreated] = await Promise.all([
tx.questionsQuestion.findUnique({
where: {
id: input.questionId,
},
}),
tx.questionsQuestionEncounter.create({
data: {
...input,
userId,
},
}),
]);
if (questionToUpdate === null) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Question does not exist',
});
}
if (
questionToUpdate.lastSeenAt === null ||
questionToUpdate.lastSeenAt < input.seenAt
) {
await tx.questionsQuestion.update({
data: {
lastSeenAt: input.seenAt,
},
where: {
id: input.questionId,
},
});
}
return questionEncounterCreated;
});
},
})
.mutation('update', {
input: z.object({
companyId: z.string().optional(),
id: z.string(),
location: z.string().optional(),
role: z.string().optional(),
seenAt: z.date().optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToUpdate =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.$transaction(async (tx) => {
const [questionToUpdate, questionEncounterUpdated] = await Promise.all([
tx.questionsQuestion.findUnique({
where: {
id: questionEncounterToUpdate.questionId,
},
}),
tx.questionsQuestionEncounter.update({
data: {
...input,
},
where: {
id: input.id,
},
}),
]);
if (questionToUpdate!.lastSeenAt === questionEncounterToUpdate.seenAt) {
const latestEncounter =
await ctx.prisma.questionsQuestionEncounter.findFirst({
orderBy: {
seenAt: SortOrder.DESC,
},
where: {
questionId: questionToUpdate!.id,
},
});
await tx.questionsQuestion.update({
data: {
lastSeenAt: latestEncounter!.seenAt,
},
where: {
id: questionToUpdate!.id,
},
});
}
return questionEncounterUpdated;
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToDelete =
await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.$transaction(async (tx) => {
const [questionToUpdate, questionEncounterDeleted] = await Promise.all([
tx.questionsQuestion.findUnique({
where: {
id: questionEncounterToDelete.questionId,
},
}),
tx.questionsQuestionEncounter.delete({
where: {
id: input.id,
},
}),
]);
if (questionToUpdate!.lastSeenAt === questionEncounterToDelete.seenAt) {
const latestEncounter =
await ctx.prisma.questionsQuestionEncounter.findFirst({
orderBy: {
seenAt: SortOrder.DESC,
},
where: {
questionId: questionToUpdate!.id,
},
});
const lastSeenVal = latestEncounter ? latestEncounter!.seenAt : null;
await tx.questionsQuestion.update({
data: {
lastSeenAt: lastSeenVal,
},
where: {
id: questionToUpdate!.id,
},
});
}
return questionEncounterDeleted;
});
},
});

@ -0,0 +1,196 @@
import { z } from 'zod';
import { QuestionsQuestionType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
import { createRouter } from '../context';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createRouter()
.query('getQuestionsByFilter', {
input: z.object({
companyNames: z.string().array(),
cursor: z
.object({
idCursor: z.string().optional(),
lastSeenCursor: z.date().nullish().optional(),
upvoteCursor: z.number().optional(),
})
.nullish(),
endDate: z.date().default(new Date()),
limit: z.number().min(1).default(50),
locations: z.string().array(),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
startDate: z.date().optional(),
}),
async resolve({ ctx, input }) {
const { cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
lastSeenAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor:
cursor !== undefined
? {
id: cursor ? cursor!.idCursor : undefined,
}
: undefined,
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
...(input.questionTypes.length > 0
? {
questionType: {
in: input.questionTypes,
},
}
: {}),
encounters: {
some: {
seenAt: {
gte: input.startDate,
lte: input.endDate,
},
...(input.companyNames.length > 0
? {
company: {
name: {
in: input.companyNames,
},
},
}
: {}),
...(input.locations.length > 0
? {
location: {
in: input.locations,
},
}
: {}),
...(input.roles.length > 0
? {
role: {
in: input.roles,
},
}
: {}),
},
},
},
});
const processedQuestionsData = questionsData.map(
createQuestionWithAggregateData,
);
let nextCursor: typeof cursor | undefined = undefined;
if (questionsData.length > input.limit) {
const nextItem = questionsData.pop()!;
processedQuestionsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
const nextLastSeenCursor =
input.sortType === SortType.NEW ? nextItem.lastSeenAt : undefined;
const nextUpvoteCursor =
input.sortType === SortType.TOP ? nextItem.upvotes : undefined;
nextCursor = {
idCursor: nextIdCursor,
lastSeenCursor: nextLastSeenCursor,
upvoteCursor: nextUpvoteCursor,
};
}
return {
data: processedQuestionsData,
nextCursor,
};
},
})
.query('getQuestionById', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const questionData = await ctx.prisma.questionsQuestion.findUnique({
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
where: {
id: input.id,
},
});
if (!questionData) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Question not found',
});
}
return createQuestionWithAggregateData(questionData);
},
});

@ -0,0 +1,248 @@
import { z } from 'zod';
import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from '../context';
export const questionsQuestionUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
companyId: z.string(),
content: z.string(),
location: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType),
role: z.string(),
seenAt: z.date(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestion.create({
data: {
content: input.content,
encounters: {
create: {
company: {
connect: {
id: input.companyId,
},
},
location: input.location,
role: input.role,
seenAt: input.seenAt,
user: {
connect: {
id: userId,
},
},
},
},
lastSeenAt: input.seenAt,
questionType: input.questionType,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType).optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionToUpdate = await ctx.prisma.questionsQuestion.findUnique({
where: {
id: input.id,
},
});
if (questionToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
// Optional: pass the original error to retain stack trace
});
}
const { content, questionType } = input;
return await ctx.prisma.questionsQuestion.update({
data: {
content,
questionType,
},
where: {
id: input.id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionToDelete = await ctx.prisma.questionsQuestion.findUnique({
where: {
id: input.id,
},
});
if (questionToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
// Optional: pass the original error to retain stack trace
});
}
return await ctx.prisma.questionsQuestion.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionId } = input;
return await ctx.prisma.questionsQuestionVote.findUnique({
where: {
questionId_userId: { questionId, userId },
},
});
},
})
.mutation('createVote', {
input: z.object({
questionId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionId, vote } = input;
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.create({
data: {
questionId,
userId,
vote,
},
}),
ctx.prisma.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: questionId,
},
}),
]);
return questionVote;
},
})
.mutation('updateVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.update({
data: {
vote,
},
where: {
id,
},
}),
ctx.prisma.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.questionId,
},
}),
]);
return questionVote;
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
},
});
if (voteToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [questionVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionVote.delete({
where: {
id: input.id,
},
}),
ctx.prisma.questionsQuestion.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.questionId,
},
}),
]);
return questionVote;
},
});

@ -45,9 +45,19 @@ export const resumesCommentsUserRouter = createProtectedRouter()
}; };
}); });
return await ctx.prisma.resumesComment.createMany({ const prevCommentCount = await ctx.prisma.resumesComment.count({
where: {
resumeId,
},
});
const result = await ctx.prisma.resumesComment.createMany({
data: comments, data: comments,
}); });
return {
newCount: Number(prevCommentCount) + result.count,
prevCount: prevCommentCount,
};
}, },
}) })
.mutation('update', { .mutation('update', {

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

@ -1,6 +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 { createRouter } from '../context'; import { createRouter } from '../context';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
@ -96,6 +98,7 @@ export const resumesRouter = createRouter()
createdAt: r.createdAt, createdAt: r.createdAt,
experience: r.experience, experience: r.experience,
id: r.id, id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0, isStarredByUser: r.stars.length > 0,
location: r.location, location: r.location,
numComments: r._count.comments, numComments: r._count.comments,
@ -250,4 +253,72 @@ export const resumesRouter = createRouter()
return topUpvotedCommentCount; return topUpvotedCommentCount;
}, },
})
.query('getTotalFilterCounts', {
async resolve({ ctx }) {
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['role'],
});
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['experience'],
});
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({
_count: {
_all: true,
},
by: ['location'],
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
return {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
};
},
}); });

@ -44,6 +44,23 @@ export const resumesResumeUserRouter = createProtectedRouter()
}); });
}, },
}) })
.mutation('resolve', {
input: z.object({
id: z.string(),
val: z.boolean(),
}),
async resolve({ ctx, input }) {
const resume = await ctx.prisma.resumesResume.update({
data: {
isResolved: input.val,
},
where: {
id: input.id,
},
});
return resume.isResolved;
},
})
.query('findUserStarred', { .query('findUserStarred', {
input: z.object({ input: z.object({
experienceFilters: z.string().array(), experienceFilters: z.string().array(),
@ -147,6 +164,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
createdAt: rs.resume.createdAt, createdAt: rs.resume.createdAt,
experience: rs.resume.experience, experience: rs.resume.experience,
id: rs.resume.id, id: rs.resume.id,
isResolved: rs.resume.isResolved,
isStarredByUser: true, isStarredByUser: true,
location: rs.resume.location, location: rs.resume.location,
numComments: rs.resume._count.comments, numComments: rs.resume._count.comments,
@ -250,6 +268,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
createdAt: r.createdAt, createdAt: r.createdAt,
experience: r.experience, experience: r.experience,
id: r.id, id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0, isStarredByUser: r.stars.length > 0,
location: r.location, location: r.location,
numComments: r._count.comments, numComments: r._count.comments,

@ -0,0 +1,9 @@
export {};
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
gtag: any;
}
}

@ -6,6 +6,7 @@ export type Profile = {
editToken: string?; editToken: string?;
id: string; id: string;
isEditable: boolean; isEditable: boolean;
isSaved: boolean;
offers: Array<ProfileOffer>; offers: Array<ProfileOffer>;
profileName: string; profileName: string;
}; };
@ -183,3 +184,22 @@ export type AddToProfileResponse = {
profileName: string; profileName: string;
userId: string; userId: string;
}; };
export type UserProfile = {
createdAt: Date;
id: string;
offers: Array<UserProfileOffers>;
profileName: string;
token: string;
}
export type UserProfileOffer = {
company: OffersCompany;
id: string;
income: Valuation;
jobType: JobType;
level: string;
location: string;
monthYearReceived: Date;
title: string;
}

@ -3,6 +3,7 @@ export type Resume = {
createdAt: Date; createdAt: Date;
experience: string; experience: string;
id: string; id: string;
isResolved: boolean;
isStarredByUser: boolean; isStarredByUser: boolean;
location: string; location: string;
numComments: number; numComments: number;

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

@ -0,0 +1,102 @@
import type {
Company,
QuestionsQuestion,
QuestionsQuestionVote,
} from '@prisma/client';
import { Vote } from '@prisma/client';
import type { AggregatedQuestionEncounter, Question } from '~/types/questions';
type AggregatableEncounters = Array<{
company: Company | null;
location: string;
role: string;
seenAt: Date;
}>;
type QuestionWithAggregatableData = QuestionsQuestion & {
_count: {
answers: number;
comments: number;
};
encounters: AggregatableEncounters;
user: {
name: string | null;
} | null;
votes: Array<QuestionsQuestionVote>;
};
export function createQuestionWithAggregateData(
data: QuestionWithAggregatableData,
): Question {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const question: Question = {
aggregatedQuestionEncounters: createAggregatedQuestionEncounter(
data.encounters,
),
content: data.content,
id: data.id,
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
receivedCount: data.encounters.length,
seenAt: data.encounters[0].seenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
};
return question;
}
export function createAggregatedQuestionEncounter(
encounters: AggregatableEncounters,
): AggregatedQuestionEncounter {
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = encounters[0].seenAt;
for (const encounter of encounters) {
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 0;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 0;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 0;
}
roleCounts[encounter.role] += 1;
}
return {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
};
}

@ -25,7 +25,7 @@ export const useSearchParam = <Value = string>(
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter(); const router = useRouter();
const [filters, setFilters] = useState<Array<Value>>(defaultValues || []); const [params, setParams] = useState<Array<Value>>(defaultValues || []);
useEffect(() => { useEffect(() => {
if (router.isReady && !isInitialized) { if (router.isReady && !isInitialized) {
@ -33,7 +33,7 @@ export const useSearchParam = <Value = string>(
const query = router.query[name]; const query = router.query[name];
if (query) { if (query) {
const queryValues = Array.isArray(query) ? query : [query]; const queryValues = Array.isArray(query) ? query : [query];
setFilters( setParams(
queryValues queryValues
.map(stringToParam) .map(stringToParam)
.filter((value) => value !== null) as Array<Value>, .filter((value) => value !== null) as Array<Value>,
@ -42,28 +42,32 @@ export const useSearchParam = <Value = string>(
// Try to load from local storage // Try to load from local storage
const localStorageValue = localStorage.getItem(name); const localStorageValue = localStorage.getItem(name);
if (localStorageValue !== null) { if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue); const loadedFilters = JSON.parse(localStorageValue) as Array<string>;
setFilters(loadedFilters); setParams(
loadedFilters
.map(stringToParam)
.filter((value) => value !== null) as Array<Value>,
);
} }
} }
setIsInitialized(true); setIsInitialized(true);
} }
}, [isInitialized, name, stringToParam, router]); }, [isInitialized, name, stringToParam, router]);
const setFiltersCallback = useCallback( const setParamsCallback = useCallback(
(newFilters: Array<Value>) => { (newParams: Array<Value>) => {
setFilters(newFilters); setParams(newParams);
localStorage.setItem( localStorage.setItem(
name, name,
JSON.stringify( JSON.stringify(
newFilters.map(valueToQueryParam).filter((param) => param !== null), newParams.map(valueToQueryParam).filter((param) => param !== null),
), ),
); );
}, },
[name, valueToQueryParam], [name, valueToQueryParam],
); );
return [filters, setFiltersCallback, isInitialized] as const; return [params, setParamsCallback, isInitialized] as const;
}; };
export const useSearchParamSingle = <Value = string>( export const useSearchParamSingle = <Value = string>(
@ -73,14 +77,14 @@ export const useSearchParamSingle = <Value = string>(
}, },
) => { ) => {
const { defaultValue, ...restOpts } = opts ?? {}; const { defaultValue, ...restOpts } = opts ?? {};
const [filters, setFilters, isInitialized] = useSearchParam<Value>(name, { const [params, setParams, isInitialized] = useSearchParam<Value>(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined, defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
...restOpts, ...restOpts,
} as SearchParamOptions<Value>); } as SearchParamOptions<Value>);
return [ return [
filters[0], params[0],
(value: Value) => setFilters([value]), (value: Value) => setParams([value]),
isInitialized, isInitialized,
] as const; ] as const;
}; };

@ -71,51 +71,51 @@ type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
export const useQuestionVote = (id: string) => { export const useQuestionVote = (id: string) => {
return useVote(id, { return useVote(id, {
create: 'questions.questions.createVote', create: 'questions.questions.user.createVote',
deleteKey: 'questions.questions.deleteVote', deleteKey: 'questions.questions.user.deleteVote',
idKey: 'questionId', idKey: 'questionId',
invalidateKeys: [ invalidateKeys: [
'questions.questions.getQuestionsByFilter', 'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionById', 'questions.questions.getQuestionById',
], ],
query: 'questions.questions.getVote', query: 'questions.questions.user.getVote',
update: 'questions.questions.updateVote', update: 'questions.questions.user.updateVote',
}); });
}; };
export const useAnswerVote = (id: string) => { export const useAnswerVote = (id: string) => {
return useVote(id, { return useVote(id, {
create: 'questions.answers.createVote', create: 'questions.answers.user.createVote',
deleteKey: 'questions.answers.deleteVote', deleteKey: 'questions.answers.user.deleteVote',
idKey: 'answerId', idKey: 'answerId',
invalidateKeys: [ invalidateKeys: [
'questions.answers.getAnswers', 'questions.answers.getAnswers',
'questions.answers.getAnswerById', 'questions.answers.getAnswerById',
], ],
query: 'questions.answers.getVote', query: 'questions.answers.user.getVote',
update: 'questions.answers.updateVote', update: 'questions.answers.user.updateVote',
}); });
}; };
export const useQuestionCommentVote = (id: string) => { export const useQuestionCommentVote = (id: string) => {
return useVote(id, { return useVote(id, {
create: 'questions.questions.comments.createVote', create: 'questions.questions.comments.user.createVote',
deleteKey: 'questions.questions.comments.deleteVote', deleteKey: 'questions.questions.comments.user.deleteVote',
idKey: 'questionCommentId', idKey: 'questionCommentId',
invalidateKeys: ['questions.questions.comments.getQuestionComments'], invalidateKeys: ['questions.questions.comments.getQuestionComments'],
query: 'questions.questions.comments.getVote', query: 'questions.questions.comments.user.getVote',
update: 'questions.questions.comments.updateVote', update: 'questions.questions.comments.user.updateVote',
}); });
}; };
export const useAnswerCommentVote = (id: string) => { export const useAnswerCommentVote = (id: string) => {
return useVote(id, { return useVote(id, {
create: 'questions.answers.comments.createVote', create: 'questions.answers.comments.user.createVote',
deleteKey: 'questions.answers.comments.deleteVote', deleteKey: 'questions.answers.comments.user.deleteVote',
idKey: 'answerCommentId', idKey: 'answerCommentId',
invalidateKeys: ['questions.answers.comments.getAnswerComments'], invalidateKeys: ['questions.answers.comments.getAnswerComments'],
query: 'questions.answers.comments.getVote', query: 'questions.answers.comments.user.getVote',
update: 'questions.answers.comments.updateVote', update: 'questions.answers.comments.user.updateVote',
}); });
}; };

@ -1,10 +1,11 @@
export type FilterId = 'experience' | 'location' | 'role'; export type FilterId = 'experience' | 'location' | 'role';
export type FilterLabel = 'Experience' | 'Location' | 'Role';
export type CustomFilter = { export type CustomFilter = {
numComments: number; numComments: number;
}; };
type RoleFilter = export type RoleFilter =
| 'Android Engineer' | 'Android Engineer'
| 'Backend Engineer' | 'Backend Engineer'
| 'DevOps Engineer' | 'DevOps Engineer'
@ -12,16 +13,13 @@ type RoleFilter =
| 'Full-Stack Engineer' | 'Full-Stack Engineer'
| 'iOS Engineer'; | 'iOS Engineer';
type ExperienceFilter = export type ExperienceFilter =
| 'Entry Level (0 - 2 years)' | 'Entry Level (0 - 2 years)'
| 'Freshman' | 'Internship'
| 'Junior'
| 'Mid Level (3 - 5 years)' | 'Mid Level (3 - 5 years)'
| 'Senior Level (5+ years)' | 'Senior Level (5+ years)';
| 'Senior'
| 'Sophomore';
type LocationFilter = 'India' | 'Singapore' | 'United States'; export type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter; export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
@ -32,14 +30,14 @@ export type FilterOption<T> = {
export type Filter = { export type Filter = {
id: FilterId; id: FilterId;
label: string; label: FilterLabel;
options: Array<FilterOption<FilterValue>>; options: Array<FilterOption<FilterValue>>;
}; };
export type FilterState = Partial<CustomFilter> & export type FilterState = Partial<CustomFilter> &
Record<FilterId, Array<FilterValue>>; Record<FilterId, Array<FilterValue>>;
export type SortOrder = 'latest' | 'popular' | 'topComments'; export type SortOrder = 'latest' | 'mostComments' | 'popular';
export type Shortcut = { export type Shortcut = {
customFilters?: CustomFilter; customFilters?: CustomFilter;
@ -54,11 +52,11 @@ export const BROWSE_TABS_VALUES = {
STARRED: 'starred', STARRED: 'starred',
}; };
export const SORT_OPTIONS: Record<string, string> = { export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
latest: 'Latest', { label: 'Latest', value: 'latest' },
popular: 'Popular', { label: 'Popular', value: 'popular' },
topComments: 'Most Comments', { label: 'Most Comments', value: 'mostComments' },
}; ];
export const ROLES: Array<FilterOption<RoleFilter>> = [ export const ROLES: Array<FilterOption<RoleFilter>> = [
{ {
@ -73,10 +71,7 @@ export const ROLES: Array<FilterOption<RoleFilter>> = [
]; ];
export const EXPERIENCES: Array<FilterOption<ExperienceFilter>> = [ export const EXPERIENCES: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Freshman', value: 'Freshman' }, { label: 'Internship', value: 'Internship' },
{ label: 'Sophomore', value: 'Sophomore' },
{ label: 'Junior', value: 'Junior' },
{ label: 'Senior', value: 'Senior' },
{ {
label: 'Entry Level (0 - 2 years)', label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)', value: 'Entry Level (0 - 2 years)',
@ -127,7 +122,7 @@ export const SHORTCUTS: Array<Shortcut> = [
}, },
{ {
filters: INITIAL_FILTER_STATE, filters: INITIAL_FILTER_STATE,
name: 'GOATs', name: 'Top 10',
sortOrder: 'popular', sortOrder: 'popular',
}, },
{ {
@ -149,3 +144,10 @@ export const isInitialFilterState = (filters: FilterState) =>
filters[filter as FilterId].includes(value), filters[filter as FilterId].includes(value),
); );
}); });
export const getFilterLabel = (
filters: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter | SortOrder>
>,
filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder,
) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue;

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

@ -0,0 +1,53 @@
import React, { useState } from 'react';
import type { ComponentMeta } from '@storybook/react';
import type { BannerSize } from '@tih/ui';
import { Banner } from '@tih/ui';
const bannerSizes: ReadonlyArray<BannerSize> = ['xs', 'sm', 'md'];
export default {
argTypes: {
children: {
control: 'text',
},
size: {
control: { type: 'select' },
options: bannerSizes,
},
},
component: Banner,
title: 'Banner',
} as ComponentMeta<typeof Banner>;
export const Basic = {
args: {
children: 'This notice is going to change your life',
size: 'md',
},
};
export function Sizes() {
const [isShown, setIsShown] = useState(true);
const [isShown2, setIsShown2] = useState(true);
const [isShown3, setIsShown3] = useState(true);
return (
<div className="space-y-4">
{isShown && (
<Banner onHide={() => setIsShown(false)}>
This notice is going to change your life unless you close it.
</Banner>
)}
{isShown2 && (
<Banner size="sm" onHide={() => setIsShown2(false)}>
This smaller notice is going to change your life unless you close it.
</Banner>
)}
{isShown3 && (
<Banner size="xs" onHide={() => setIsShown3(false)}>
This even smaller notice is going to change your life unless you close
it.
</Banner>
)}
</div>
);
}

@ -1,8 +1,13 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { ComponentMeta } from '@storybook/react'; import type { ComponentMeta } from '@storybook/react';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption, TypeaheadTextSize } from '@tih/ui';
import { Typeahead } from '@tih/ui'; import { Typeahead } from '@tih/ui';
const typeaheadTextSizes: ReadonlyArray<TypeaheadTextSize> = [
'default',
'inherit',
];
export default { export default {
argTypes: { argTypes: {
disabled: { disabled: {
@ -23,6 +28,10 @@ export default {
required: { required: {
control: 'boolean', control: 'boolean',
}, },
textSize: {
control: { type: 'select' },
options: typeaheadTextSizes,
},
}, },
component: Typeahead, component: Typeahead,
parameters: { parameters: {

@ -0,0 +1,50 @@
import clsx from 'clsx';
import React from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
export type BannerSize = 'md' | 'sm' | 'xs';
type Props = Readonly<{
children: React.ReactNode;
onHide?: () => void;
size?: BannerSize;
}>;
export default function Banner({ children, size = 'md', onHide }: Props) {
return (
<div
className={clsx(
'bg-primary-600 relative',
size === 'sm' && 'text-sm',
size === 'xs' && 'text-xs',
)}>
<div className="mx-auto max-w-7xl py-3 px-3 sm:px-6 lg:px-8">
<div className="pr-16 sm:px-16 sm:text-center">
<p className="font-medium text-white">{children}</p>
</div>
{onHide != null && (
<div
className={clsx(
'absolute inset-y-0 right-0 flex items-start sm:items-start',
size === 'md' && 'pt-2 pr-2',
size === 'sm' && 'pt-2 pr-2',
size === 'xs' && 'pt-1.5 pr-2',
)}>
<button
className={clsx(
'hover:bg-primary-400 flex rounded-md focus:outline-none focus:ring-2 focus:ring-white',
size === 'md' && 'p-1',
size === 'sm' && 'p-0.5',
size === 'xs' && 'p-0.5',
)}
type="button"
onClick={onHide}>
<span className="sr-only">Dismiss</span>
<XMarkIcon aria-hidden="true" className="h-6 w-6 text-white" />
</button>
</div>
)}
</div>
</div>
);
}

@ -85,7 +85,7 @@ export default function Pagination({
} }
if (lastAddedPage < current - pagePadding - 1) { if (lastAddedPage < current - pagePadding - 1) {
elements.push(<PaginationEllipsis />); elements.push(<PaginationEllipsis key="ellipse-1" />);
} }
for (let i = current - pagePadding; i <= current + pagePadding; i++) { for (let i = current - pagePadding; i <= current + pagePadding; i++) {
@ -93,7 +93,7 @@ export default function Pagination({
} }
if (lastAddedPage < end - pagePadding - 1) { if (lastAddedPage < end - pagePadding - 1) {
elements.push(<PaginationEllipsis />); elements.push(<PaginationEllipsis key="ellipse-2" />);
} }
for (let i = end - pagePadding; i <= end; i++) { for (let i = end - pagePadding; i <= end; i++) {

@ -10,6 +10,7 @@ export type TypeaheadOption = Readonly<{
label: string; label: string;
value: string; value: string;
}>; }>;
export type TypeaheadTextSize = 'default' | 'inherit';
type Attributes = Pick< type Attributes = Pick<
InputHTMLAttributes<HTMLInputElement>, InputHTMLAttributes<HTMLInputElement>,
@ -33,10 +34,16 @@ type Props = Readonly<{
) => void; ) => void;
onSelect: (option: TypeaheadOption) => void; onSelect: (option: TypeaheadOption) => void;
options: ReadonlyArray<TypeaheadOption>; options: ReadonlyArray<TypeaheadOption>;
textSize?: TypeaheadTextSize;
value?: TypeaheadOption; value?: TypeaheadOption;
}> & }> &
Readonly<Attributes>; Readonly<Attributes>;
const textSizes: Record<TypeaheadTextSize, string> = {
default: 'text-sm',
inherit: '',
};
export default function Typeahead({ export default function Typeahead({
disabled = false, disabled = false,
isLabelHidden, isLabelHidden,
@ -46,12 +53,14 @@ export default function Typeahead({
options, options,
onQueryChange, onQueryChange,
required, required,
textSize = 'default',
value, value,
onSelect, onSelect,
...props ...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
return ( return (
<div>
<Combobox <Combobox
by="id" by="id"
disabled={disabled} disabled={disabled}
@ -77,7 +86,10 @@ export default function Typeahead({
className={clsx( className={clsx(
isLabelHidden isLabelHidden
? 'sr-only' ? 'sr-only'
: 'mb-1 block text-sm font-medium text-slate-700', : clsx(
'mb-1 block font-medium text-slate-700',
textSizes[textSize],
),
)}> )}>
{label} {label}
{required && ( {required && (
@ -88,10 +100,15 @@ export default function Typeahead({
)} )}
</Combobox.Label> </Combobox.Label>
<div className="relative"> <div className="relative">
<div className="focus-visible:ring-offset-primary-300 relative w-full cursor-default overflow-hidden rounded-lg border border-slate-300 bg-white text-left text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2"> <div
className={clsx(
'focus-visible:ring-offset-primary-300 relative w-full cursor-default overflow-hidden rounded-lg border border-slate-300 bg-white text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2',
textSizes[textSize],
)}>
<Combobox.Input <Combobox.Input
className={clsx( className={clsx(
'w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-slate-900 focus:ring-0', 'w-full border-none py-2 pl-3 pr-10 leading-5 text-slate-900 focus:ring-0',
textSizes[textSize],
disabled && 'pointer-events-none select-none bg-slate-100', disabled && 'pointer-events-none select-none bg-slate-100',
)} )}
displayValue={(option) => displayValue={(option) =>
@ -117,7 +134,11 @@ export default function Typeahead({
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0">
<Combobox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> <Combobox.Options
className={clsx(
'absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none',
textSizes[textSize],
)}>
{options.length === 0 && query !== '' ? ( {options.length === 0 && query !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700"> <div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage} {noResultsMessage}
@ -149,5 +170,6 @@ export default function Typeahead({
</Transition> </Transition>
</div> </div>
</Combobox> </Combobox>
</div>
); );
} }

@ -4,6 +4,9 @@ export { default as Alert } from './Alert/Alert';
// Badge // Badge
export * from './Badge/Badge'; export * from './Badge/Badge';
export { default as Badge } from './Badge/Badge'; export { default as Badge } from './Badge/Badge';
// Banner
export * from './Banner/Banner';
export { default as Banner } from './Banner/Banner';
// Button // Button
export * from './Button/Button'; export * from './Button/Button';
export { default as Button } from './Button/Button'; export { default as Button } from './Button/Button';

6907
tatus

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save