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 {
provider = "prisma-client-js"
previewFeatures = ["interactiveTransactions"]
}
datasource db {
@ -119,6 +120,7 @@ model ResumesResume {
location String @db.Text
url String
additionalInfo String? @db.Text
isResolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@ -402,7 +404,7 @@ model QuestionsQuestion {
userId String?
content String @db.Text
questionType QuestionsQuestionType
lastSeenAt DateTime
lastSeenAt DateTime?
upvotes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

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

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

@ -1,21 +1,43 @@
export type BreadcrumbStep = {
label: string;
step?: number;
};
type BreadcrumbsProps = Readonly<{
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 (
<div className="flex space-x-1">
{stepLabels.map((label, index) => (
{steps.map(({ label, step }, index) => (
<div key={label} className="flex space-x-1">
{index === currentStep ? (
<p className="text-primary-700 text-sm">{label}</p>
) : (
<p className="text-sm text-slate-400">{label}</p>
)}
{index !== stepLabels.length - 1 && (
<p className="text-sm text-slate-400">{'>'}</p>
)}
{step === currentStep
? getPrimaryText(label)
: step !== undefined
? getTextWithLink(label, () => setStep(step))
: getSlateText(label)}
{index !== steps.length - 1 && getSlateText('>')}
</div>
))}
</div>

@ -6,6 +6,8 @@ const navigation: ProductNavigationItems = [
];
const config = {
// TODO: Change this to your own GA4 measurement ID.
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation,
showGlobalNav: false,
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 type { ProfileAnalysis } from '~/types/offers';
type Props = Readonly<{
analysis?: ProfileAnalysis | null;
isError: boolean;
isLoading: boolean;
profileId?: string;
token?: string;
}>;
export default function OffersSubmissionAnalysis({
analysis,
isError,
isLoading,
profileId = '',
token = '',
}: Props) {
const router = useRouter();
return (
<div>
<div className="mb-8">
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result
</h5>
@ -36,14 +23,6 @@ export default function OffersSubmissionAnalysis({
isError={isError}
isLoading={isLoading}
/>
<div className="mt-8 text-center">
<Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div>
</div>
);
}

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

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

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

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

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

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

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

@ -1,14 +1,7 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import type { QuestionsQuestionType } from '@prisma/client';
import {
Button,
CheckboxInput,
HorizontalDivider,
Select,
TextArea,
} from '@tih/ui';
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import {
@ -50,14 +43,12 @@ export default function ContributeQuestionForm({
date: startOfMonth(new Date()),
},
});
const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return (
<div className="flex flex-col justify-between gap-4">
<form
className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
onSubmit={handleSubmit(onSubmit)}>
@ -152,38 +143,15 @@ export default function ContributeQuestionForm({
/>
</div>
</div>
{/* <div className="w-full">
<div className="w-full">
<HorizontalDivider />
</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
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={{
// Hack to make the background bleed outside the container
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">
<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"
@ -193,12 +161,12 @@ export default function ContributeQuestionForm({
</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"
disabled={!canSubmit}
label="Contribute"
type="submit"
variant="primary"></Button>
</div>
</div>
</form>
</div>
);
}

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

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

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

@ -1,3 +1,4 @@
import clsx from 'clsx';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Link from 'next/link';
import type { UrlObject } from 'url';
@ -9,6 +10,18 @@ import {
} from '@heroicons/react/20/solid';
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';
type Props = Readonly<{
@ -19,23 +32,36 @@ type Props = Readonly<{
export default function ResumeListItem({ href, resumeInfo }: Props) {
return (
<Link href={href}>
<div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100">
<div className="col-span-4">
{resumeInfo.title}
<div
className={clsx(
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="flex">
<BriefcaseIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{resumeInfo.role}
{getFilterLabel(ROLES, resumeInfo.role as RoleFilter)}
</div>
<div className="ml-4 flex">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{resumeInfo.experience}
{getFilterLabel(
EXPERIENCES,
resumeInfo.experience as ExperienceFilter,
)}
</div>
</div>
<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 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>
{`Uploaded ${formatDistanceToNow(resumeInfo.createdAt, {
addSuffix: true,
})} by ${resumeInfo.user}`}
</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>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" />
</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
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
const onSubmit: SubmitHandler<IFormInput> = async (formData) => {
return await commentCreateMutation.mutate(
{
resumeId,
...data,
...formData,
},
{
onSuccess: () => {
onSuccess: (data) => {
// Redirect back to comments section
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 (
<div className="space-y-3">
{commentsQuery.isLoading ? (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
) : (
<div className="mb-8 flow-root h-[calc(100vh-13rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden 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 }) => {
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {
@ -65,13 +67,17 @@ export default function ResumeCommentsList({
const commentCount = comments.length;
return (
<div key={value} className="space-y-6 pr-4">
<div className="text-primary-800 -mb-2 flex flex-row items-center space-x-2">
<div key={value} className="space-y-4 pr-4">
{/* CommentHeader Section */}
<div className="text-primary-800 flex items-center space-x-2">
<hr className="flex-grow border-slate-800" />
{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>
{/* Comment Section */}
<div
className={clsx(
'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>
<hr className="border-gray-300" />
</div>
);
})}
</div>
)}
</div>
);
}

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

@ -1,4 +1,4 @@
import Link from 'next/link';
import { Button } from '@tih/ui';
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
engineers in the field.
</p>
<Link href="/resumes/browse">
<button
className="bg-primary-500 mt-4 rounded-md py-2 px-3 text-sm font-medium text-white"
type="button">
Start browsing now
</button>
</Link>
<Button
className="mt-4"
href="/resumes/browse"
label="Start browsing now"
variant="primary"
/>
</div>
</Container>
</section>

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

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

@ -33,8 +33,9 @@ import type {
ProfileAnalysis,
ProfileOffer,
SpecificYoe,
Valuation,
} from '~/types/offers';
UserProfile,
UserProfileOffer,
Valuation} from '~/types/offers';
const analysisOfferDtoMapper = (
offer: OffersOffer & {
@ -526,8 +527,10 @@ export const profileDtoMapper = (
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
}
>;
user: User | null;
},
inputToken: string | undefined,
inputUserId: string | null | undefined
) => {
const profileDto: Profile = {
analysis: profileAnalysisDtoMapper(profile.analysis),
@ -535,6 +538,7 @@ export const profileDtoMapper = (
editToken: null,
id: profile.id,
isEditable: false,
isSaved: false,
offers: profile.offers.map((offer) => profileOfferDtoMapper(offer)),
profileName: profile.profileName,
};
@ -542,6 +546,20 @@ export const profileDtoMapper = (
if (inputToken === profile.editToken) {
profileDto.editToken = profile.editToken ?? null;
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;
@ -626,3 +644,84 @@ export const getOffersResponseMapper = (
};
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);
}
// 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));
}
setIsEditable(data?.isEditable ?? false);
setIsEditable(data.isEditable);
const filteredOffers: Array<OfferDisplayData> = data
? 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(
['offers.profile.addToUserProfile'],
['offers.user.profile.addToUserProfile'],
{
onError(err) {
alert(err);
@ -85,7 +85,7 @@ function Test() {
addToUserProfileMutation.mutate({
profileId: 'cl9efyn9p004ww3u42mjgl1vn',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: 'cl9ehvpng0000w3ec2mpx0bdd',
// UserId: 'cl9ehvpng0000w3ec2mpx0bdd',
});
};

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

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

@ -9,7 +9,6 @@ import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOption } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
@ -184,7 +183,7 @@ export default function QuestionsBrowsePage() {
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
'questions.questions.user.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
@ -195,18 +194,6 @@ export default function QuestionsBrowsePage() {
const [loaded, setLoaded] = 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(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
@ -275,9 +262,37 @@ export default function QuestionsBrowsePage() {
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) {
return null;
}
const filterSidebar = (
<div className="divide-y divide-slate-200 px-4">
<Button
@ -293,9 +308,6 @@ export default function QuestionsBrowsePage() {
setSelectedQuestionAge('all');
setSelectedRoles([]);
setSelectedLocations([]);
setSelectedCompanyOptions([]);
setSelectedRoleOptions([]);
setSelectedLocationOptions([]);
}}
/>
<FilterSection
@ -306,8 +318,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedCompanyOptions.some((selectedOption) => {
return selectedOption.value === option.value;
return !selectedCompanies.some((company) => {
return company === option.value;
});
}}
isLabelHidden={true}
@ -323,19 +335,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanyOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== option.label),
);
setSelectedCompanyOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.label !== option.label,
),
);
}
}}
/>
@ -347,8 +350,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedRoleOptions.some((selectedOption) => {
return selectedOption.value === option.value;
return !selectedRoles.some((role) => {
return role === option.value;
});
}}
isLabelHidden={true}
@ -364,19 +367,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedRoles([...selectedRoles, option.value]);
setSelectedRoleOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
setSelectedRoles(
selectedCompanies.filter((role) => role !== option.value),
);
setSelectedRoleOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
);
}
}}
/>
@ -413,8 +407,8 @@ export default function QuestionsBrowsePage() {
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedLocationOptions.some((selectedOption) => {
return selectedOption.value === option.value;
return !selectedLocations.some((location) => {
return location === option.value;
});
}}
isLabelHidden={true}
@ -430,19 +424,10 @@ export default function QuestionsBrowsePage() {
onOptionChange={(option) => {
if (option.checked) {
setSelectedLocations([...selectedLocations, option.value]);
setSelectedLocationOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
setSelectedLocations(
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';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
import type { CreateListFormData } from '~/components/questions/CreateListDialog';
import CreateListDialog from '~/components/questions/CreateListDialog';
import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants';
import { SAMPLE_QUESTION } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import { trpc } from '~/utils/trpc';
export default function ListPage() {
const questions = [
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
SAMPLE_QUESTION,
];
const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const { mutateAsync: createList } = trpc.useMutation(
'questions.lists.create',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const { mutateAsync: deleteList } = trpc.useMutation(
'questions.lists.delete',
{
onSuccess: () => {
// 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 = [
{ id: 1, name: 'list 1', questions },
{ id: 2, name: 'list 2', questions },
{ id: 3, name: 'list 3', questions },
{ id: 4, name: 'list 4', questions },
{ id: 5, name: 'list 5', questions },
];
const [selectedListIndex, setSelectedListIndex] = useState(0);
const [showDeleteListDialog, setShowDeleteListDialog] = useState(false);
const [showCreateListDialog, setShowCreateListDialog] = useState(false);
const [listIdToDelete, setListIdToDelete] = useState('');
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 = (
<>
<ul className="flex flex-1 flex-col divide-y divide-solid divide-slate-200">
{lists.map((list) => (
{(lists ?? []).map((list, index) => (
<li
key={list.id}
className={`flex items-center hover:bg-slate-50 ${
selectedList === list.id ? 'bg-primary-100' : ''
selectedListIndex === index ? 'bg-primary-100' : ''
}`}>
<button
className="flex w-full flex-1 justify-between "
type="button"
onClick={() => {
setSelectedList(list.id);
// eslint-disable-next-line no-console
console.log(selectedList);
setSelectedListIndex(index);
}}>
<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}
</p>
</button>
@ -85,7 +115,11 @@ export default function ListPage() {
? 'bg-violet-500 text-white'
: 'text-slate-900'
} 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
</button>
)}
@ -104,6 +138,7 @@ export default function ListPage() {
)}
</>
);
return (
<>
<Head>
@ -111,7 +146,7 @@ export default function ListPage() {
</Head>
<main className="flex flex-1 flex-col items-stretch">
<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">
<h2 className="px-4 text-xl font-semibold">My Lists</h2>
<div className="px-4">
@ -124,6 +159,7 @@ export default function ListPage() {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowCreateListDialog(true);
}}
/>
</div>
@ -133,20 +169,27 @@ export default function ListPage() {
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
{selectedList && (
{lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
{lists[selectedListIndex].questionEntries.map(
({ question, id: entryId }) => (
<QuestionListCard
key={question.id}
companies={question.companies}
companies={
question.aggregatedQuestionEncounters.companyCounts
}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={question.locations}
locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id}
receivedCount={0}
roles={question.roles}
receivedCount={question.receivedCount}
roles={
question.aggregatedQuestionEncounters.roleCounts
}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
@ -156,21 +199,33 @@ export default function ListPage() {
)}
type={question.type}
onDelete={() => {
// eslint-disable-next-line no-console
console.log('delete');
deleteQuestionEntry({ id: entryId });
}}
/>
))}
{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">
<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>
<DeleteListDialog
show={showDeleteListDialog}
onCancel={handleDeleteListCancel}
onDelete={() => {
handleDeleteList(listIdToDelete);
}}></DeleteListDialog>
<CreateListDialog
show={showCreateListDialog}
onCancel={handleCreateListCancel}
onSubmit={handleCreateList}></CreateListDialog>
</section>
</div>
</main>

@ -3,12 +3,13 @@ import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Error from 'next/error';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { signIn, useSession } from 'next-auth/react';
import { useSession } from 'next-auth/react';
import { useState } from 'react';
import {
AcademicCapIcon,
BriefcaseIcon,
CalendarIcon,
CheckCircleIcon,
InformationCircleIcon,
MapPinIcon,
PencilSquareIcon,
@ -21,6 +22,20 @@ import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList
import ResumePdf from '~/components/resumes/ResumePdf';
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 SubmitResumeForm from './submit';
@ -42,22 +57,32 @@ export default function ResumeReviewPage() {
);
const starMutation = trpc.useMutation('resumes.resume.star', {
onSuccess() {
utils.invalidateQueries(['resumes.resume.findOne']);
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
invalidateResumeQueries();
},
});
const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
onSuccess() {
invalidateResumeQueries();
},
});
const resolveMutation = trpc.useMutation('resumes.resume.user.resolve', {
onSuccess() {
invalidateResumeQueries();
},
});
const invalidateResumeQueries = () => {
utils.invalidateQueries(['resumes.resume.findOne']);
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
},
});
};
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 [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 = () => {
setIsEditMode(true);
};
const onResolveButtonClick = () => {
resolveMutation.mutate({
id: resumeId as string,
val: !isResumeResolved,
});
};
const renderReviewButton = () => {
if (session === null) {
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">
<a
<Button
className="h-10 shadow-md"
display="block"
href="/api/auth/signin"
onClick={(event) => {
event.preventDefault();
signIn();
}}>
Sign in to join discussion
</a>
</div>
label="Sign in to join discussion"
variant="primary"
/>
);
}
return (
<Button
className="h-10 py-2 shadow-md"
className="h-10 shadow-md"
display="block"
label="Add your review"
variant="tertiary"
variant="primary"
onClick={() => setShowCommentsForm(true)}
/>
);
@ -122,10 +191,7 @@ export default function ResumeReviewPage() {
url: detailsQuery.data.url,
}}
onClose={() => {
utils.invalidateQueries(['resumes.resume.findOne']);
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
invalidateResumeQueries();
setIsEditMode(false);
}}
/>
@ -146,23 +212,49 @@ export default function ResumeReviewPage() {
<Head>
<title>{detailsQuery.data.title}</title>
</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">
<h1 className="pr-2 text-2xl font-semibold leading-7 text-slate-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title}
</h1>
<div className="flex gap-3 xl:pr-4">
{userIsOwner && (
<>
<Button
addonPosition="start"
className="h-10 shadow-md"
icon={PencilSquareIcon}
label="Edit"
variant="tertiary"
onClick={onEditButtonClick}
/>
<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"
onClick={onEditButtonClick}>
<PencilSquareIcon className="text-primary-600 h-6 w-6" />
onClick={onResolveButtonClick}>
<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
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}
type="button"
onClick={onStarButtonClick}>
@ -183,13 +275,12 @@ export default function ResumeReviewPage() {
/>
)}
</div>
Star
{detailsQuery.data?.stars.length ? 'Starred' : 'Star'}
</span>
<span className="relative -ml-px inline-flex">
{detailsQuery.data?._count.stars}
</span>
</button>
<div className="hidden xl:block">{renderReviewButton()}</div>
</div>
</div>
@ -199,21 +290,54 @@ export default function ResumeReviewPage() {
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{detailsQuery.data.role}
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
roleLabel: detailsQuery.data?.role,
})
}>
{getFilterLabel(ROLES, detailsQuery.data.role as RoleFilter)}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{detailsQuery.data.location}
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
locationLabel: detailsQuery.data?.location,
})
}>
{getFilterLabel(
LOCATIONS,
detailsQuery.data.location as LocationFilter,
)}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{detailsQuery.data.experience}
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
experienceLabel: detailsQuery.data?.experience,
})
}>
{getFilterLabel(
EXPERIENCES,
detailsQuery.data.experience as ExperienceFilter,
)}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<CalendarIcon
@ -243,21 +367,17 @@ export default function ResumeReviewPage() {
<ResumePdf url={detailsQuery.data.url} />
</div>
<div className="grow">
<div className="relative p-2 xl:hidden">
<div
aria-hidden="true"
className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300" />
</div>
<div className="relative flex justify-center">
<div className="mb-6 space-y-4 xl:hidden">
{renderReviewButton()}
<div className="flex items-center space-x-2">
<hr className="flex-grow border-slate-300" />
<span className="bg-slate-50 px-3 text-lg font-medium text-slate-900">
Reviews
</span>
<hr className="flex-grow border-slate-300" />
</div>
</div>
<div className="mb-4 xl:hidden">{renderReviewButton()}</div>
{showCommentsForm ? (
<ResumeCommentsForm
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 { useRouter } from 'next/router';
import Router, { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { Fragment, useEffect, useState } from 'react';
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Dialog, Disclosure, Transition } from '@headlessui/react';
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import {
@ -10,6 +10,7 @@ import {
XMarkIcon,
} from '@heroicons/react/24/outline';
import {
Button,
CheckboxInput,
CheckboxList,
DropdownMenu,
@ -20,28 +21,31 @@ import {
} from '@tih/ui';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import type {
Filter,
FilterId,
FilterLabel,
Shortcut,
} from '~/components/resumes/browse/resumeFilters';
} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATIONS,
ROLES,
SHORTCUTS,
SORT_OPTIONS,
} from '~/components/resumes/browse/resumeFilters';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
} from '~/utils/resumes/resumeFilters';
import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
import type { FilterState } from '../../components/resumes/browse/resumeFilters';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
@ -101,19 +105,89 @@ const getEmptyDataText = (
export default function ResumeHomePage() {
const { data: sessionData } = useSession();
const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
const [sortOrder, setSortOrder] = useState('latest');
const [searchValue, setSearchValue] = useState('');
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
const [shortcutSelected, setShortcutSelected] = useState('All');
const [currentPage, setCurrentPage] = useState(1);
const [tabsValue, setTabsValue, isTabsValueInit] = useSearchParams(
'tabsValue',
BROWSE_TABS_VALUES.ALL,
);
const [sortOrder, setSortOrder, isSortOrderInit] = useSearchParams<SortOrder>(
'sortOrder',
'latest',
);
const [searchValue, setSearchValue, isSearchValueInit] = useSearchParams(
'searchValue',
'',
);
const [shortcutSelected, setShortcutSelected, isShortcutInit] =
useSearchParams('shortcutSelected', 'All');
const [currentPage, setCurrentPage, isCurrentPageInit] = useSearchParams(
'currentPage',
1,
);
const [userFilters, setUserFilters, isUserFiltersInit] = useSearchParams(
'userFilters',
INITIAL_FILTER_STATE,
);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const skip = (currentPage - 1) * PAGE_LIMIT;
const isSearchOptionsInit = useMemo(() => {
return (
isTabsValueInit &&
isSortOrderInit &&
isSearchValueInit &&
isShortcutInit &&
isCurrentPageInit &&
isUserFiltersInit
);
}, [
isTabsValueInit,
isSortOrderInit,
isSearchValueInit,
isShortcutInit,
isCurrentPageInit,
isUserFiltersInit,
]);
useEffect(() => {
setCurrentPage(1);
}, [userFilters, sortOrder, searchValue]);
}, [userFilters, sortOrder, setCurrentPage, searchValue]);
useEffect(() => {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
if (!isSearchOptionsInit) {
return;
}
Router.replace({
pathname: router.pathname,
query: {
currentPage: JSON.stringify(currentPage),
searchValue: JSON.stringify(searchValue),
shortcutSelected: JSON.stringify(shortcutSelected),
sortOrder: JSON.stringify(sortOrder),
tabsValue: JSON.stringify(tabsValue),
userFilters: JSON.stringify(userFilters),
},
});
}, [
tabsValue,
sortOrder,
searchValue,
userFilters,
shortcutSelected,
currentPage,
router.pathname,
isSearchOptionsInit,
]);
const filterCountsQuery = trpc.useQuery(
['resumes.resume.getTotalFilterCounts'],
{
staleTime: STALE_TIME,
},
);
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 = () => {
if (sessionData === null) {
router.push('/api/auth/signin');
@ -203,6 +285,13 @@ export default function ResumeHomePage() {
}
};
const onClearFilterClick = (filterSection: FilterId) => {
setUserFilters({
...userFilters,
[filterSection]: [],
});
};
const onShortcutChange = ({
sortOrder: shortcutSortOrder,
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">
<div className="flex items-center justify-between px-4">
<h2 className="text-lg font-medium text-slate-900">
Shortcuts
Quick access
</h2>
<button
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">
<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">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
@ -313,7 +402,7 @@ export default function ResumeHomePage() {
<Disclosure
key={filter.id}
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 }) => (
<>
<h3 className="-mx-2 -my-3 flow-root">
@ -336,14 +425,17 @@ export default function ResumeHomePage() {
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="pt-6">
<div className="space-y-6">
<Disclosure.Panel className="space-y-4 pt-6">
<div className="space-y-3">
{filter.options.map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={option.label}
label={`${option.label} (${getFilterCount(
filter.label,
option.label,
)})`}
value={userFilters[filter.id].includes(
option.value,
)}
@ -358,6 +450,11 @@ export default function ResumeHomePage() {
</div>
))}
</div>
<p
className="cursor-pointer text-sm text-slate-500 underline"
onClick={() => onClearFilterClick(filter.id)}>
Clear
</p>
</Disclosure.Panel>
</>
)}
@ -371,11 +468,12 @@ export default function ResumeHomePage() {
</Transition.Root>
</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="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">
Shortcuts
Quick access
</h3>
<div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form>
@ -392,6 +490,7 @@ export default function ResumeHomePage() {
</li>
))}
</ul>
{/* Filter Section */}
<h3 className="text-md font-medium tracking-tight text-slate-900">
Explore these filters
</h3>
@ -399,7 +498,7 @@ export default function ResumeHomePage() {
<Disclosure
key={filter.id}
as="div"
className="border-b border-slate-200 py-6">
className="border-b border-slate-200 pt-6 pb-4">
{({ open }) => (
<>
<h3 className="-my-3 flow-root">
@ -422,7 +521,7 @@ export default function ResumeHomePage() {
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="pt-4">
<Disclosure.Panel className="space-y-4 pt-4">
<CheckboxList
description=""
isLabelHidden={true}
@ -431,9 +530,12 @@ export default function ResumeHomePage() {
{filter.options.map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 [&>div>div:nth-child(2)>label]:font-normal">
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 px-1 [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput
label={option.label}
label={`${option.label} (${getFilterCount(
filter.label,
option.label,
)})`}
value={userFilters[filter.id].includes(
option.value,
)}
@ -448,6 +550,11 @@ export default function ResumeHomePage() {
</div>
))}
</CheckboxList>
<p
className="cursor-pointer text-sm text-slate-500 underline"
onClick={() => onClearFilterClick(filter.id)}>
Clear
</p>
</Disclosure.Panel>
</>
)}
@ -457,8 +564,8 @@ export default function ResumeHomePage() {
</div>
</div>
<div className="relative lg:left-64 lg:w-[calc(100%-16rem)]">
<div className="lg:border-grey-200 sticky top-0 z-10 flex flex-wrap items-center justify-between bg-gray-50 pt-6 pb-2 lg:border-b">
<div className="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="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 xl:pb-0">
<div>
<Tabs
label="Resume Browse Tabs"
@ -480,16 +587,8 @@ export default function ResumeHomePage() {
onChange={onTabChange}
/>
</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 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">
<TextInput
isLabelHidden={true}
@ -502,32 +601,31 @@ export default function ResumeHomePage() {
onChange={setSearchValue}
/>
</div>
<div>
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
{Object.entries(SORT_OPTIONS).map(([key, value]) => (
<DropdownMenu
align="end"
label={getFilterLabel(SORT_OPTIONS, sortOrder)}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item
key={key}
isSelected={sortOrder === key}
label={value}
onClick={() => setSortOrder(key)}></DropdownMenu.Item>
key={value}
isSelected={sortOrder === value}
label={label}
onClick={() => setSortOrder(value)}></DropdownMenu.Item>
))}
</DropdownMenu>
</div>
<button
className="-m-2 text-slate-400 hover:text-slate-500 lg:hidden"
type="button"
onClick={() => setMobileFiltersOpen(true)}>
<span className="sr-only">Filters</span>
<FunnelIcon aria-hidden="true" className="h-6 w-6" />
</button>
<div>
<button
className="bg-primary-500 hidden w-36 rounded-md py-2 px-3 text-sm font-medium text-white lg:block"
type="button"
onClick={onSubmitResume}>
Submit Resume
</button>
</div>
<Button
className="lg:hidden"
icon={FunnelIcon}
isLabelHidden={true}
label="Filters"
variant="tertiary"
onClick={() => setMobileFiltersOpen(true)}
/>
<Button
className="whitespace-pre-wrap px-2 lg:block"
label="Submit Resume"
variant="primary"
onClick={onSubmitResume}
/>
</div>
</div>
{isFetchingResumes ? (

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

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

@ -6,12 +6,19 @@ import { offersRouter } from './offers/offers';
import { offersAnalysisRouter } from './offers/offers-analysis-router';
import { offersCommentsRouter } from './offers/offers-comments-router';
import { offersProfileRouter } from './offers/offers-profile-router';
import { offersUserProfileRouter } from './offers/offers-user-profile-router';
import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
import { questionsQuestionEncounterRouter } from './questions-question-encounter-router';
import { questionsQuestionRouter } from './questions-question-router';
import { questionsAnswerCommentRouter } from './questions/questions-answer-comment-router';
import { questionsAnswerCommentUserRouter } from './questions/questions-answer-comment-user-router';
import { questionsAnswerRouter } from './questions/questions-answer-router';
import { questionsAnswerUserRouter } from './questions/questions-answer-user-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 { resumesCommentsUserRouter } from './resumes/resumes-comments-user-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.user.', resumesCommentsVotesUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.comments.user.', questionsAnswerCommentUserRouter)
.merge('questions.answers.', questionsAnswerRouter)
.merge('questions.answers.user.', questionsAnswerUserRouter)
.merge('questions.lists.', questionsListRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge(
'questions.questions.comments.user.',
questionsQuestionCommentUserRouter,
)
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
.merge(
'questions.questions.encounters.user.',
questionsQuestionEncounterUserRouter,
)
.merge('questions.questions.', questionsQuestionRouter)
.merge('questions.questions.user.', questionsQuestionUserRouter)
.merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter)
.merge('offers.analysis.', offersAnalysisRouter)
.merge('offers.comments.', offersCommentsRouter);
.merge('offers.comments.', offersCommentsRouter)
.merge('offers.user.profile.', offersUserProfileRouter);
// Export type definition of API
export type AppRouter = typeof appRouter;

@ -4,7 +4,6 @@ import { JobType } from '@prisma/client';
import * as trpc from '@trpc/server';
import {
addToProfileResponseMapper,
createOfferProfileResponseMapper,
profileDtoMapper,
} from '~/mappers/offers-mappers';
@ -107,6 +106,7 @@ export const offersProfileRouter = createRouter()
input: z.object({
profileId: z.string(),
token: z.string().optional(),
userId: z.string().nullish(),
}),
async resolve({ ctx, input }) {
const result = await ctx.prisma.offersProfile.findFirst({
@ -229,6 +229,7 @@ export const offersProfileRouter = createRouter()
},
},
},
user: true,
},
where: {
id: input.profileId,
@ -236,7 +237,7 @@ export const offersProfileRouter = createRouter()
});
if (result) {
return profileDtoMapper(result, input.token);
return profileDtoMapper(result, input.token, input.userId);
}
throw new trpc.TRPCError({
@ -284,7 +285,8 @@ export const offersProfileRouter = createRouter()
})),
},
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.companyId) {
return {
@ -392,6 +394,7 @@ export const offersProfileRouter = createRouter()
message: 'Missing fields in background experiences.',
});
}),
)
},
specificYoes: {
create: input.background.specificYoes.map((x) => {
@ -546,7 +549,6 @@ export const offersProfileRouter = createRouter()
profileName: uniqueName,
},
});
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({
code: 'UNAUTHORIZED',
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 { TRPCError } from '@trpc/server';
import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from './context';
export const questionListRouter = createProtectedRouter()
export const questionsListRouter = createProtectedRouter()
.query('getListsByUser', {
async resolve({ ctx }) {
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: {
questionEntries: {
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: {
createdAt: 'asc',
},
where: {
id: userId,
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 }) {
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { listId } = input;
return await ctx.prisma.questionsList.findMany({
const questionList = await ctx.prisma.questionsList.findFirst({
include: {
questionEntries: {
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: {
createdAt: 'asc',
},
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', {
input: z.object({
@ -111,7 +187,7 @@ export const questionListRouter = createProtectedRouter()
},
});
if (listToDelete?.id !== userId) {
if (listToDelete?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
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({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
@ -163,27 +239,27 @@ export const questionListRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const entryToDelete = await ctx.prisma.questionsListQuestionEntry.findUnique({
const entryToDelete =
await ctx.prisma.questionsListQuestionEntry.findUnique({
where: {
id: input.id,
},
});
if (entryToDelete?.id !== userId) {
if (entryToDelete === null) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
code: 'NOT_FOUND',
message: 'Entry not found.',
});
}
const listToAugment = await ctx.prisma.questionsList.findUnique({
where: {
id: entryToDelete.listId,
},
});
if (listToAugment?.id !== userId) {
if (listToAugment?.userId !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
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 { TRPCError } from '@trpc/server';
import { createQuestionWithAggregateData } from '~/utils/questions/server/aggregate-encounters';
import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d';
export const questionsQuestionRouter = createProtectedRouter()
@ -122,73 +123,10 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
const processedQuestionsData = questionsData.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 processedQuestionsData = questionsData.map(
createQuestionWithAggregateData,
);
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;
if (questionsData.length > input.limit) {
@ -252,68 +190,8 @@ export const questionsQuestionRouter = createProtectedRouter()
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> = {};
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;
return createQuestionWithAggregateData(questionData);
},
})
.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 { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import { createProtectedRouter } from '../context';
import type { AnswerComment } from '~/types/questions';
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;
});
},
})
export const questionsAnswerCommentUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
answerId: z.string(),
@ -281,6 +225,5 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}),
]);
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 { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import { createProtectedRouter } from '../context';
import type { Answer } from '~/types/questions';
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;
},
})
export const questionsAnswerUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
content: z.string(),
@ -341,6 +220,5 @@ export const questionsAnswerRouter = createProtectedRouter()
}),
]);
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 { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import { createProtectedRouter } from '../context';
import type { QuestionComment } from '~/types/questions';
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;
});
},
})
export const questionsQuestionCommentUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
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,
});
return {
newCount: Number(prevCommentCount) + result.count,
prevCount: prevCommentCount,
};
},
})
.mutation('update', {

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

@ -1,6 +1,8 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { createRouter } from '../context';
import type { Resume } from '~/types/resume';
@ -96,6 +98,7 @@ export const resumesRouter = createRouter()
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
@ -250,4 +253,72 @@ export const resumesRouter = createRouter()
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', {
input: z.object({
experienceFilters: z.string().array(),
@ -147,6 +164,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
createdAt: rs.resume.createdAt,
experience: rs.resume.experience,
id: rs.resume.id,
isResolved: rs.resume.isResolved,
isStarredByUser: true,
location: rs.resume.location,
numComments: rs.resume._count.comments,
@ -250,6 +268,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
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?;
id: string;
isEditable: boolean;
isSaved: boolean;
offers: Array<ProfileOffer>;
profileName: string;
};
@ -183,3 +184,22 @@ export type AddToProfileResponse = {
profileName: 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;
experience: string;
id: string;
isResolved: boolean;
isStarredByUser: boolean;
location: string;
numComments: number;

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

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

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

@ -1,10 +1,11 @@
export type FilterId = 'experience' | 'location' | 'role';
export type FilterLabel = 'Experience' | 'Location' | 'Role';
export type CustomFilter = {
numComments: number;
};
type RoleFilter =
export type RoleFilter =
| 'Android Engineer'
| 'Backend Engineer'
| 'DevOps Engineer'
@ -12,16 +13,13 @@ type RoleFilter =
| 'Full-Stack Engineer'
| 'iOS Engineer';
type ExperienceFilter =
export type ExperienceFilter =
| 'Entry Level (0 - 2 years)'
| 'Freshman'
| 'Junior'
| 'Internship'
| 'Mid Level (3 - 5 years)'
| 'Senior Level (5+ years)'
| 'Senior'
| 'Sophomore';
| 'Senior Level (5+ years)';
type LocationFilter = 'India' | 'Singapore' | 'United States';
export type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
@ -32,14 +30,14 @@ export type FilterOption<T> = {
export type Filter = {
id: FilterId;
label: string;
label: FilterLabel;
options: Array<FilterOption<FilterValue>>;
};
export type FilterState = Partial<CustomFilter> &
Record<FilterId, Array<FilterValue>>;
export type SortOrder = 'latest' | 'popular' | 'topComments';
export type SortOrder = 'latest' | 'mostComments' | 'popular';
export type Shortcut = {
customFilters?: CustomFilter;
@ -54,11 +52,11 @@ export const BROWSE_TABS_VALUES = {
STARRED: 'starred',
};
export const SORT_OPTIONS: Record<string, string> = {
latest: 'Latest',
popular: 'Popular',
topComments: 'Most Comments',
};
export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
{ label: 'Latest', value: 'latest' },
{ label: 'Popular', value: 'popular' },
{ label: 'Most Comments', value: 'mostComments' },
];
export const ROLES: Array<FilterOption<RoleFilter>> = [
{
@ -73,10 +71,7 @@ export const ROLES: Array<FilterOption<RoleFilter>> = [
];
export const EXPERIENCES: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Freshman', value: 'Freshman' },
{ label: 'Sophomore', value: 'Sophomore' },
{ label: 'Junior', value: 'Junior' },
{ label: 'Senior', value: 'Senior' },
{ label: 'Internship', value: 'Internship' },
{
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
@ -127,7 +122,7 @@ export const SHORTCUTS: Array<Shortcut> = [
},
{
filters: INITIAL_FILTER_STATE,
name: 'GOATs',
name: 'Top 10',
sortOrder: 'popular',
},
{
@ -149,3 +144,10 @@ export const isInitialFilterState = (filters: FilterState) =>
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 type { ComponentMeta } from '@storybook/react';
import type { TypeaheadOption } from '@tih/ui';
import type { TypeaheadOption, TypeaheadTextSize } from '@tih/ui';
import { Typeahead } from '@tih/ui';
const typeaheadTextSizes: ReadonlyArray<TypeaheadTextSize> = [
'default',
'inherit',
];
export default {
argTypes: {
disabled: {
@ -23,6 +28,10 @@ export default {
required: {
control: 'boolean',
},
textSize: {
control: { type: 'select' },
options: typeaheadTextSizes,
},
},
component: Typeahead,
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) {
elements.push(<PaginationEllipsis />);
elements.push(<PaginationEllipsis key="ellipse-1" />);
}
for (let i = current - pagePadding; i <= current + pagePadding; i++) {
@ -93,7 +93,7 @@ export default function Pagination({
}
if (lastAddedPage < end - pagePadding - 1) {
elements.push(<PaginationEllipsis />);
elements.push(<PaginationEllipsis key="ellipse-2" />);
}
for (let i = end - pagePadding; i <= end; i++) {

@ -10,6 +10,7 @@ export type TypeaheadOption = Readonly<{
label: string;
value: string;
}>;
export type TypeaheadTextSize = 'default' | 'inherit';
type Attributes = Pick<
InputHTMLAttributes<HTMLInputElement>,
@ -33,10 +34,16 @@ type Props = Readonly<{
) => void;
onSelect: (option: TypeaheadOption) => void;
options: ReadonlyArray<TypeaheadOption>;
textSize?: TypeaheadTextSize;
value?: TypeaheadOption;
}> &
Readonly<Attributes>;
const textSizes: Record<TypeaheadTextSize, string> = {
default: 'text-sm',
inherit: '',
};
export default function Typeahead({
disabled = false,
isLabelHidden,
@ -46,12 +53,14 @@ export default function Typeahead({
options,
onQueryChange,
required,
textSize = 'default',
value,
onSelect,
...props
}: Props) {
const [query, setQuery] = useState('');
return (
<div>
<Combobox
by="id"
disabled={disabled}
@ -77,7 +86,10 @@ export default function Typeahead({
className={clsx(
isLabelHidden
? 'sr-only'
: 'mb-1 block text-sm font-medium text-slate-700',
: clsx(
'mb-1 block font-medium text-slate-700',
textSizes[textSize],
),
)}>
{label}
{required && (
@ -88,10 +100,15 @@ export default function Typeahead({
)}
</Combobox.Label>
<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
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',
)}
displayValue={(option) =>
@ -117,7 +134,11 @@ export default function Typeahead({
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
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 !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage}
@ -149,5 +170,6 @@ export default function Typeahead({
</Transition>
</div>
</Combobox>
</div>
);
}

@ -4,6 +4,9 @@ export { default as Alert } from './Alert/Alert';
// Badge
export * from './Badge/Badge';
export { default as Badge } from './Badge/Badge';
// Banner
export * from './Banner/Banner';
export { default as Banner } from './Banner/Banner';
// Button
export * 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