[questions][feat] add questions frontend filter

pull/339/head
Jeff Sieu 3 years ago
commit 61e28c6f37

@ -175,7 +175,7 @@ export default function AppShell({ children }: Props) {
/>
{/* Content area */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex h-screen flex-1 flex-col overflow-hidden">
<header className="w-full">
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white shadow-sm">
<button

@ -0,0 +1,38 @@
import { format } from 'date-fns';
import VotingButtons from './VotingButtons';
export type CommentListItemProps = {
authorImageUrl: string;
authorName: string;
content: string;
createdAt: Date;
upvoteCount: number;
};
export default function CommentListItem({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: CommentListItemProps) {
return (
<div className="flex gap-4 border bg-white p-2 ">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
</div>
</div>
);
}

@ -1,102 +1,83 @@
import type { ComponentProps, ForwardedRef } from 'react';
import { useState } from 'react';
import { forwardRef } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui';
import { TextInput } from '@tih/ui';
import ContributeQuestionModal from './ContributeQuestionModal';
import ContributeQuestionDialog from './ContributeQuestionDialog';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
export type ContributeQuestionData = {
company: string;
date: Date;
questionContent: string;
questionType: string;
};
type TextInputProps = ComponentProps<typeof TextInput>;
type FormTextInputProps = Omit<TextInputProps, 'onChange'> &
Pick<UseFormRegisterReturn<never>, 'onChange'>;
function FormTextInputWithRef(
props: FormTextInputProps,
ref?: ForwardedRef<HTMLInputElement>,
) {
const { onChange, ...rest } = props;
return (
<TextInput
{...(rest as TextInputProps)}
ref={ref}
onChange={(_, event) => onChange(event)}
/>
);
}
const FormTextInput = forwardRef(FormTextInputWithRef);
export type ContributeQuestionCardProps = {
onSubmit: (data: ContributeQuestionData) => void;
};
export type ContributeQuestionCardProps = Pick<
ContributeQuestionFormProps,
'onSubmit'
>;
export default function ContributeQuestionCard({
onSubmit,
}: ContributeQuestionCardProps) {
const { register, handleSubmit } = useForm<ContributeQuestionData>();
const [isOpen, setOpen] = useState<boolean>(false);
const [showDraftDialog, setShowDraftDialog] = useState(false);
const handleDraftDialogCancel = () => {
setShowDraftDialog(false);
};
const handleOpenContribute = () => {
setShowDraftDialog(true);
};
return (
<>
<form
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 p-4"
onSubmit={handleSubmit(onSubmit)}>
<FormTextInput
<div>
<button
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-gray-100"
type="button"
onClick={handleOpenContribute}>
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
{...register('questionContent')}
onChange={handleOpenContribute}
/>
<div className="flex items-end justify-center gap-x-2">
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Company"
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
{...register('company')}
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Question type"
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
{...register('questionType')}
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Date"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('date')}
onChange={handleOpenContribute}
/>
</div>
<Button
label="Contribute"
type="submit"
variant="primary"
onClick={() => setOpen(true)}
/>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
</div>
</form>
<ContributeQuestionModal
contributeState={isOpen}
setContributeState={setOpen}></ContributeQuestionModal>
</>
</button>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
onSubmit={onSubmit}
/>
</div>
);
}

@ -0,0 +1,100 @@
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import ContributeQuestionForm from './ContributeQuestionForm';
import DiscardDraftDialog from './DiscardDraftDialog';
export type ContributeQuestionDialogProps = Pick<
ContributeQuestionFormProps,
'onSubmit'
> & {
onCancel: () => void;
show: boolean;
};
export default function ContributeQuestionDialog({
show,
onSubmit,
onCancel,
}: ContributeQuestionDialogProps) {
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const handleDraftDiscard = () => {
setShowDiscardDialog(false);
onCancel();
};
const handleDiscardCancel = () => {
setShowDiscardDialog(false);
};
return (
<div>
<Transition.Root as={Fragment} show={show}>
<Dialog
as="div"
className="relative z-10"
onClose={() => {
// Todo: save state
onCancel();
}}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
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 max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full">
<div className="bg-white p-6 pt-5 sm:pb-4">
<div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900">
Question Draft
</Dialog.Title>
<div className="w-full">
<HorizontalDivider />
</div>
<div className="mt-2">
<ContributeQuestionForm
onDiscard={() => setShowDiscardDialog(true)}
onSubmit={(data) => {
onSubmit(data);
onCancel();
}}
/>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
<DiscardDraftDialog
show={showDiscardDialog}
onCancel={handleDiscardCancel}
onDiscard={handleDraftDiscard}></DiscardDraftDialog>
</div>
);
}

@ -0,0 +1,155 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
UserIcon,
} from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Collapsible, Select, TextArea, TextInput } from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import {
useFormRegister,
useSelectRegister,
} from '~/utils/questions/useFormRegister';
import Checkbox from './ui-patch/Checkbox';
export type ContributeQuestionData = {
company: string;
date: Date;
location: string;
position: string;
questionContent: string;
questionType: QuestionsQuestionType;
role: string;
};
export type ContributeQuestionFormProps = {
onDiscard: () => void;
onSubmit: (data: ContributeQuestionData) => void;
};
export default function ContributeQuestionForm({
onSubmit,
onDiscard,
}: ContributeQuestionFormProps) {
const { register: formRegister, handleSubmit } =
useForm<ContributeQuestionData>();
const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return (
<form
className=" flex flex-1 flex-col items-stretch justify-center pb-[50px]"
onSubmit={handleSubmit(onSubmit)}>
<TextArea
label="Question Prompt"
placeholder="Contribute a question"
required={true}
rows={5}
{...register('questionContent')}
/>
<div className="mt-3 mb-1 flex flex-wrap items-end gap-2">
<div className="mr-2 min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Company"
required={true}
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
{...register('company')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Date"
required={true}
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('date', {
valueAsDate: true,
})}
/>
</div>
</div>
<Collapsible defaultOpen={true} label="Additional info">
<div className="justify-left flex flex-wrap items-end gap-2">
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Location"
required={true}
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('location')}
/>
</div>
<div className="min-w-[150px] max-w-[200px] flex-1">
<TextInput
label="Role"
required={true}
startAddOn={UserIcon}
startAddOnType="icon"
{...register('role')}
/>
</div>
</div>
</Collapsible>
{/* <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 fixed bottom-0 left-0 w-full px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6">
<div className="mb-1 flex">
<Checkbox
checked={canSubmit}
label="I have checked that my question is new"
onChange={handleCheckSimilarQuestions}></Checkbox>
</div>
<div className=" flex gap-x-2">
<button
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button"
onClick={onDiscard}>
Discard
</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-gray-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!canSubmit}
label="Contribute"
type="submit"
variant="primary"></Button>
</div>
</div>
</form>
);
}

@ -1,96 +0,0 @@
import type { Dispatch, SetStateAction } from 'react';
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import Checkbox from './ui-patch/Checkbox';
export type ContributeQuestionModalProps = {
contributeState: boolean;
setContributeState: Dispatch<SetStateAction<boolean>>;
};
export default function ContributeQuestionModal({
contributeState,
setContributeState,
}: ContributeQuestionModalProps) {
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return (
<Transition.Root as={Fragment} show={contributeState}>
<Dialog
as="div"
className="relative z-10"
onClose={() => setContributeState(false)}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
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 transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900">
Question Draft
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Question Contribution form
</p>
</div>
</div>
</div>
</div>
<div className="bg-primary-50 px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6">
<div className="mb-1 flex">
<Checkbox
checked={canSubmit}
label="I have checked that my question is new"
onChange={handleCheckSimilarQuestions}></Checkbox>
</div>
<div className=" flex gap-x-2">
<button
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button"
onClick={() => setContributeState(false)}>
Discard
</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-gray-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!canSubmit}
type="button"
onClick={() => setContributeState(false)}>
Contribute
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

@ -0,0 +1,30 @@
import { Button, Dialog } from '@tih/ui';
export type DiscardDraftDialogProps = {
onCancel: () => void;
onDiscard: () => void;
show: boolean;
};
export default function DiscardDraftDialog({
show,
onCancel,
onDiscard,
}: DiscardDraftDialogProps) {
return (
<Dialog
isShown={show}
primaryButton={
<Button label="Discard" variant="primary" onClick={onDiscard} />
}
secondaryButton={
<Button label="Cancel" variant="tertiary" onClick={onCancel} />
}
title="Discard draft"
onClose={onCancel}>
<p>
Are you sure you want to discard the current draft? This action cannot
be undone.
</p>
</Dialog>
);
}

@ -0,0 +1,9 @@
import { Spinner } from '@tih/ui';
export default function FullScreenSpinner() {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner size="lg" />
</div>
);
}

@ -0,0 +1,108 @@
import { useState } from 'react';
import { ArrowSmallRightIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Select } from '@tih/ui';
import {
COMPANIES,
LOCATIONS,
QUESTION_TYPES,
} from '~/utils/questions/constants';
export type LandingQueryData = {
company: string;
location: string;
questionType: QuestionsQuestionType;
};
export type LandingComponentProps = {
onLanded: (data: LandingQueryData) => void;
};
export default function LandingComponent({
onLanded: handleLandingQuery,
}: LandingComponentProps) {
const [landingQueryData, setLandingQueryData] = useState<LandingQueryData>({
company: 'Google',
location: 'Singapore',
questionType: 'CODING',
});
const handleChangeCompany = (company: string) => {
setLandingQueryData((prev) => ({ ...prev, company }));
};
const handleChangeLocation = (location: string) => {
setLandingQueryData((prev) => ({ ...prev, location }));
};
const handleChangeType = (questionType: QuestionsQuestionType) => {
setLandingQueryData((prev) => ({ ...prev, questionType }));
};
return (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto bg-white">
<div className="pb-4"></div>
<div className="flex flex-1 flex-col justify-center gap-3">
<div className="flex items-center justify-center">
<img alt="app logo" className=" h-20 w-20" src="/logo.svg"></img>
<h1 className="text-primary-600 p-4 text-center text-5xl font-bold">
Tech Interview Question Bank
</h1>
</div>
<p className="mx-auto max-w-lg p-6 text-center text-xl text-black sm:max-w-3xl">
Get to know the latest SWE interview questions asked by top companies
</p>
<div className="mx-auto flex max-w-lg items-baseline gap-3 p-4 text-center text-xl text-black sm:max-w-3xl">
<p>Find</p>
<div className=" space-x-2">
<Select
isLabelHidden={true}
label="Type"
options={QUESTION_TYPES}
value={landingQueryData.questionType}
onChange={handleChangeType}
/>
</div>
<p>questions from</p>
<Select
isLabelHidden={true}
label="Company"
options={COMPANIES}
value={landingQueryData.company}
onChange={handleChangeCompany}
/>
<p>in</p>
<Select
isLabelHidden={true}
label="Location"
options={LOCATIONS}
value={landingQueryData.location}
onChange={handleChangeLocation}
/>
<Button
addonPosition="end"
icon={ArrowSmallRightIcon}
label="Go"
size="md"
variant="primary"
onClick={() => handleLandingQuery(landingQueryData)}></Button>
</div>
<div className="flex justify-center p-4">
<iframe
height={30}
src="https://ghbtns.com/github-btn.html?user=yangshun&amp;repo=tech-interview-handbook&amp;type=star&amp;count=true&amp;size=large"
title="GitHub Stars"
width={160}
/>
</div>
<div>
<p className="py-20 text-center text-white ">
TODO questions Carousel
</p>
</div>
</div>
</main>
);
}

@ -1,72 +0,0 @@
import {
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
ChevronUpIcon,
EyeIcon,
} from '@heroicons/react/24/outline';
import { Badge, Button } from '@tih/ui';
export type QuestionOverviewCardProps = {
answerCount: number;
content: string;
location: string;
role: string;
similarCount: number;
timestamp: string;
upvoteCount: number;
};
export default function QuestionOverviewCard({
answerCount,
content,
similarCount,
upvoteCount,
timestamp,
role,
location,
}: QuestionOverviewCardProps) {
return (
<article className="flex gap-2 rounded-md border border-slate-300 p-4">
<div className="flex flex-col items-center">
<Button
icon={ChevronUpIcon}
isLabelHidden={true}
label="Upvote"
variant="tertiary"
/>
<p>{upvoteCount}</p>
<Button
icon={ChevronDownIcon}
isLabelHidden={true}
label="Downvote"
variant="tertiary"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-slate-500">
<Badge label="Technical" variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
<p className="line-clamp-2 text-ellipsis">{content}</p>
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
<Button
addonPosition="start"
icon={EyeIcon}
label={`${similarCount} received this`}
size="sm"
variant="tertiary"
/>
</div>
</div>
</article>
);
}

@ -30,10 +30,13 @@ export default function QuestionSearchBar<
startAddOnType="icon"
/>
</div>
<span className="pl-3 pr-1 pt-1 text-sm">Sort by:</span>
<span aria-hidden={true} className="pl-3 pr-1 pt-1 text-sm">
Sort by:
</span>
<Select
display="inline"
label=""
isLabelHidden={true}
label="Sort by"
options={sortOptions}
value={sortValue}
onChange={onSortChange}></Select>

@ -2,14 +2,14 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [
{ href: '/questions', name: 'Home' },
{ href: '#', name: 'My Lists' },
{ href: '#', name: 'My Questions' },
{ href: '#', name: 'History' },
{ href: '/questions', name: 'My Lists' },
{ href: '/questions', name: 'My Questions' },
{ href: '/questions', name: 'History' },
];
const config = {
navigation,
showGlobalNav: true,
showGlobalNav: false,
title: 'Questions Bank',
};

@ -0,0 +1,33 @@
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import type { ButtonSize } from '@tih/ui';
import { Button } from '@tih/ui';
export type VotingButtonsProps = {
size?: ButtonSize;
upvoteCount: number;
};
export default function VotingButtons({
upvoteCount,
size = 'md',
}: VotingButtonsProps) {
return (
<div className="flex flex-col items-center">
<Button
icon={ChevronUpIcon}
isLabelHidden={true}
label="Upvote"
size={size}
variant="tertiary"
/>
<p>{upvoteCount}</p>
<Button
icon={ChevronDownIcon}
isLabelHidden={true}
label="Downvote"
size={size}
variant="tertiary"
/>
</div>
);
}

@ -0,0 +1,48 @@
import { format } from 'date-fns';
import withHref from '~/utils/questions/withHref';
import VotingButtons from '../VotingButtons';
export type AnswerCardProps = {
authorImageUrl: string;
authorName: string;
commentCount: number;
content: string;
createdAt: Date;
upvoteCount: number;
};
function AnswerCardWithoutHref({
authorName,
authorImageUrl,
upvoteCount,
content,
createdAt,
commentCount,
}: AnswerCardProps) {
return (
<div className="flex gap-4 rounded-md border bg-white p-2 hover:bg-slate-50">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
<p className="py-1 pl-3 text-sm font-light underline underline-offset-4">
{commentCount} comment(s)
</p>
</div>
</div>
);
}
const AnswerCard = withHref(AnswerCardWithoutHref);
export default AnswerCard;

@ -0,0 +1,38 @@
import { format } from 'date-fns';
import VotingButtons from '../VotingButtons';
export type FullAnswerCardProps = {
authorImageUrl: string;
authorName: string;
content: string;
createdAt: Date;
upvoteCount: number;
};
export default function FullAnswerCard({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: FullAnswerCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
<VotingButtons upvoteCount={upvoteCount}></VotingButtons>
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
</div>
</article>
);
}

@ -0,0 +1,58 @@
import { Badge } from '@tih/ui';
import VotingButtons from '../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
export type FullQuestionCardProps = UpvoteProps & {
company: string;
content: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
type: string;
};
export default function FullQuestionCard({
company,
content,
showVoteButtons,
upvoteCount,
timestamp,
role,
location,
type,
}: FullQuestionCardProps) {
const altText = company + ' logo';
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<img alt={altText} src="https://logo.clearbit.com/google.com"></img>
<h2 className="ml-2 text-xl">{company}</h2>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500">
<Badge label={type} variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
</div>
<div className="mx-2 mb-2">
<p>{content}</p>
</div>
</div>
</article>
);
}

@ -0,0 +1,112 @@
import {
ChatBubbleBottomCenterTextIcon,
// EyeIcon,
} from '@heroicons/react/24/outline';
import { Badge, Button } from '@tih/ui';
import VotingButtons from '../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
type StatisticsProps =
| {
answerCount: number;
showUserStatistics: true;
}
| {
answerCount?: never;
showUserStatistics?: false;
};
type ActionButtonProps =
| {
actionButtonLabel: string;
onActionButtonClick: () => void;
showActionButton: true;
}
| {
actionButtonLabel?: never;
onActionButtonClick?: never;
showActionButton?: false;
};
export type QuestionCardProps = ActionButtonProps &
StatisticsProps &
UpvoteProps & {
content: string;
href?: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
type: string;
};
export default function QuestionCard({
answerCount,
content,
// ReceivedCount,
type,
showVoteButtons,
showUserStatistics,
showActionButton,
actionButtonLabel,
onActionButtonClick,
upvoteCount,
timestamp,
role,
location,
}: QuestionCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4 hover:bg-slate-50">
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500">
<Badge label={type} variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
onClick={onActionButtonClick}
/>
)}
</div>
<div className="ml-2">
<p className="line-clamp-2 text-ellipsis ">{content}</p>
</div>
{showUserStatistics && (
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
{/* <Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/> */}
</div>
)}
</div>
</article>
);
}

@ -0,0 +1,31 @@
import withHref from '~/utils/questions/withHref';
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: true;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
return (
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={true}
showVoteButtons={true}
/>
);
}
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
export default QuestionOverviewCard;

@ -0,0 +1,31 @@
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type SimilarQuestionCardProps = Omit<
QuestionCardProps & {
showActionButton: true;
showUserStatistics: false;
showVoteButtons: false;
},
| 'actionButtonLabel'
| 'answerCount'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
| 'upvoteCount'
> & {
onSimilarQuestionClick: () => void;
};
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
const { onSimilarQuestionClick, ...rest } = props;
return (
<QuestionCard
{...rest}
actionButtonLabel="Yes, this is my question"
showActionButton={true}
onActionButtonClick={onSimilarQuestionClick}
/>
);
}

@ -2,35 +2,56 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Collapsible, TextInput } from '@tih/ui';
import Checkbox from '../ui-patch/Checkbox';
import RadioGroup from '../ui-patch/RadioGroup';
export type FilterOptions = {
export type FilterOption<V extends string = string> = {
checked: boolean;
label: string;
value: string;
value: V;
};
export type FilterSectionProps = {
label: string;
onOptionChange: (optionValue: string, checked: boolean) => void;
options: Array<FilterOptions>;
} & (
export type FilterChoices<V extends string = string> = ReadonlyArray<
Omit<FilterOption<V>, 'checked'>
>;
type FilterSectionType<FilterOptions extends Array<FilterOption>> =
| {
searchPlaceholder: string;
showAll?: never;
isSingleSelect: true;
onOptionChange: (optionValue: FilterOptions[number]['value']) => void;
}
| {
searchPlaceholder?: never;
showAll: true;
}
);
isSingleSelect?: false;
onOptionChange: (
optionValue: FilterOptions[number]['value'],
checked: boolean,
) => void;
};
export default function FilterSection({
export type FilterSectionProps<FilterOptions extends Array<FilterOption>> =
FilterSectionType<FilterOptions> & {
label: string;
options: FilterOptions;
} & (
| {
searchPlaceholder: string;
showAll?: never;
}
| {
searchPlaceholder?: never;
showAll: true;
}
);
export default function FilterSection<
FilterOptions extends Array<FilterOption>,
>({
label,
options,
searchPlaceholder,
showAll,
onOptionChange,
}: FilterSectionProps) {
isSingleSelect,
}: FilterSectionProps<FilterOptions>) {
return (
<div className="mx-2">
<Collapsible defaultOpen={true} label={label}>
@ -44,17 +65,25 @@ export default function FilterSection({
startAddOnType="icon"
/>
)}
<div className="mx-1">
{options.map((option) => (
<Checkbox
key={option.value}
{...option}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
{isSingleSelect ? (
<RadioGroup
radioData={options}
onChange={(value) => {
onOptionChange(value);
}}></RadioGroup>
) : (
<div className="mx-1">
{options.map((option) => (
<Checkbox
key={option.value}
{...option}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
)}
</div>
</Collapsible>
</div>

@ -0,0 +1,36 @@
export type RadioProps = {
onChange: (value: string) => void;
radioData: Array<RadioData>;
};
export type RadioData = {
checked: boolean;
label: string;
value: string;
};
export default function RadioGroup({ radioData, onChange }: RadioProps) {
return (
<div className="mx-1 space-y-1">
{radioData.map((radio) => (
<div key={radio.value} className="flex items-center">
<input
checked={radio.checked}
className="text-primary-600 focus:ring-primary-500 h-4 w-4 border-gray-300"
type="radio"
value={radio.value}
onChange={(event) => {
const target = event.target as HTMLInputElement;
onChange(target.value);
}}
/>
<label
className="ml-3 min-w-0 flex-1 text-gray-700"
htmlFor={radio.value}>
{radio.label}
</label>
</div>
))}
</div>
);
}

@ -1,12 +1,9 @@
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
type Props = Readonly<{
@ -16,30 +13,16 @@ type Props = Readonly<{
export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [file, setFile] = useState<File>();
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages);
};
useEffect(() => {
async function fetchData() {
await axios
.get(`/api/file-storage?key=${RESUME_STORAGE_KEY}&url=${url}`, {
responseType: 'blob',
})
.then((res) => {
setFile(res.data);
});
}
fetchData();
}, [url]);
return (
<div>
<Document
className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-scroll"
file={file}
file={url}
loading={<Spinner display="block" label="" size="lg" />}
noData=""
onLoadSuccess={onPdfLoadSuccess}>

@ -2,16 +2,11 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [
{
children: [
{ href: '#', name: 'Technical Support' },
{ href: '#', name: 'Sales' },
{ href: '#', name: 'General' },
],
href: '#',
name: 'Inboxes',
children: [],
href: '/resumes',
name: 'Browse',
},
{ children: [], href: '#', name: 'Reporting' },
{ children: [], href: '#', name: 'Settings' },
{ children: [], href: '/resumes/submit', name: 'Submit for review' },
];
const config = {

@ -89,6 +89,7 @@ export default function CommentsForm({
<div className="mt-4 space-y-4">
<TextArea
{...(register('general'), {})}
disabled={reviewCreateMutation.isLoading}
label="General"
placeholder="General comments about the resume"
onChange={(value) => onValueChange('general', value)}
@ -96,6 +97,7 @@ export default function CommentsForm({
<TextArea
{...(register('education'), {})}
disabled={reviewCreateMutation.isLoading}
label="Education"
placeholder="Comments about the Education section"
onChange={(value) => onValueChange('education', value)}
@ -103,6 +105,7 @@ export default function CommentsForm({
<TextArea
{...(register('experience'), {})}
disabled={reviewCreateMutation.isLoading}
label="Experience"
placeholder="Comments about the Experience section"
onChange={(value) => onValueChange('experience', value)}
@ -110,6 +113,7 @@ export default function CommentsForm({
<TextArea
{...(register('projects'), {})}
disabled={reviewCreateMutation.isLoading}
label="Projects"
placeholder="Comments about the Projects section"
onChange={(value) => onValueChange('projects', value)}
@ -117,6 +121,7 @@ export default function CommentsForm({
<TextArea
{...(register('skills'), {})}
disabled={reviewCreateMutation.isLoading}
label="Skills"
placeholder="Comments about the Skills section"
onChange={(value) => onValueChange('skills', value)}
@ -125,6 +130,7 @@ export default function CommentsForm({
<div className="flex justify-end space-x-2 pt-4">
<Button
disabled={reviewCreateMutation.isLoading}
label="Cancel"
type="button"
variant="tertiary"
@ -132,7 +138,8 @@ export default function CommentsForm({
/>
<Button
disabled={!isDirty}
disabled={!isDirty || reviewCreateMutation.isLoading}
isLoading={reviewCreateMutation.isLoading}
label="Submit"
type="submit"
variant="primary"

@ -1,6 +1,6 @@
import { Select } from '@tih/ui';
type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type MonthYear = Readonly<{
month: Month;

@ -2,6 +2,7 @@ import formidable from 'formidable';
import * as fs from 'fs';
import type { NextApiRequest, NextApiResponse } from 'next';
import { env } from '~/env/server.mjs';
import { supabase } from '~/utils/supabase';
export const config = {
@ -10,6 +11,8 @@ export const config = {
},
};
const BASE_FILE_URL = `${env.SUPABASE_URL}/storage/v1/object/public`;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
@ -38,28 +41,12 @@ export default async function handler(
throw error;
}
return res.status(200).json({
url: filePath,
return res.status(201).json({
url: `${BASE_FILE_URL}/${key}/${filePath}`,
});
});
} catch (error: unknown) {
return Promise.reject(error);
}
}
if (req.method === 'GET') {
const { key, url } = req.query;
const { data, error } = await supabase.storage
.from(`public/${key as string}`)
.download(url as string);
if (error || data == null) {
throw error;
}
const arrayBuffer = await data.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
res.status(200).send(buffer);
}
}

@ -3,14 +3,14 @@ import type { TypeaheadOption } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { MonthYear } from '~/components/shared/MonthYearPicker';
import type { Month, MonthYear } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
export default function HomePage() {
const [selectedCompany, setSelectedCompany] =
useState<TypeaheadOption | null>(null);
const [monthYear, setMonthYear] = useState<MonthYear>({
month: new Date().getMonth() + 1,
month: (new Date().getMonth() + 1) as Month,
year: new Date().getFullYear(),
});

@ -0,0 +1,159 @@
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Select, TextArea } from '@tih/ui';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import CommentListItem from '~/components/questions/CommentListItem';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import {
SAMPLE_ANSWER,
SAMPLE_ANSWER_COMMENT,
} from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
export type AnswerCommentData = {
commentContent: string;
};
export default function QuestionPage() {
const router = useRouter();
const {
register: comRegister,
reset: resetComment,
handleSubmit: handleCommentSubmit,
formState: { isDirty: isCommentDirty, isValid: isCommentValid },
} = useForm<AnswerCommentData>({ mode: 'onChange' });
const commentRegister = useFormRegister(comRegister);
const { answerId } = router.query;
const utils = trpc.useContext();
const { data: answer } = trpc.useQuery([
'questions.answers.getAnswerById',
{ answerId: answerId as string },
]);
const { data: comments } = trpc.useQuery([
'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string },
]);
const { mutate: addComment } = trpc.useMutation(
'questions.answers.comments.create',
{
onSuccess: () => {
utils.invalidateQuery([
'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string },
]);
},
},
);
const handleBackNavigation = () => {
router.back();
};
const handleSubmitComment = (data: AnswerCommentData) => {
resetComment();
addComment({
answerId: answerId as string,
content: data.commentContent,
});
};
if (!answer) {
return <FullScreenSpinner />;
}
return (
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
onClick={handleBackNavigation}></Button>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullAnswerCard
authorImageUrl={SAMPLE_ANSWER.authorImageUrl}
authorName={answer.user}
content={answer.content}
createdAt={answer.createdAt}
upvoteCount={0}
/>
<div className="mx-2">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<CommentListItem
key={comment.id}
authorImageUrl={SAMPLE_ANSWER_COMMENT.authorImageUrl}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
</div>
</div>
</div>
</div>
);
}

@ -0,0 +1,252 @@
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import AnswerCard from '~/components/questions/card/AnswerCard';
import FullQuestionCard from '~/components/questions/card/FullQuestionCard';
import CommentListItem from '~/components/questions/CommentListItem';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import {
SAMPLE_ANSWER,
SAMPLE_QUESTION_COMMENT,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
export type AnswerQuestionData = {
answerContent: string;
};
export type QuestionCommentData = {
commentContent: string;
};
export default function QuestionPage() {
const router = useRouter();
const {
register: ansRegister,
handleSubmit,
formState: { isDirty, isValid },
} = useForm<AnswerQuestionData>({ mode: 'onChange' });
const answerRegister = useFormRegister(ansRegister);
const {
register: comRegister,
handleSubmit: handleCommentSubmit,
reset: resetComment,
formState: { isDirty: isCommentDirty, isValid: isCommentValid },
} = useForm<QuestionCommentData>({ mode: 'onChange' });
const commentRegister = useFormRegister(comRegister);
const { questionId } = router.query;
const { data: question } = trpc.useQuery([
'questions.questions.getQuestionById',
{ id: questionId as string },
]);
const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([
'questions.questions.comments.getQuestionComments',
{ questionId: questionId as string },
]);
const { mutate: addComment } = trpc.useMutation(
'questions.questions.comments.create',
{
onSuccess: () => {
utils.invalidateQueries(
'questions.questions.comments.getQuestionComments',
);
},
},
);
const { data: answers } = trpc.useQuery([
'questions.answers.getAnswers',
{ questionId: questionId as string },
]);
const { mutate: addAnswer } = trpc.useMutation('questions.answers.create', {
onSuccess: () => {
utils.invalidateQueries('questions.answers.getAnswers');
},
});
const handleBackNavigation = () => {
router.back();
};
const handleSubmitAnswer = (data: AnswerQuestionData) => {
addAnswer({
content: data.answerContent,
questionId: questionId as string,
});
};
const handleSubmitComment = (data: QuestionCommentData) => {
addComment({
content: data.commentContent,
questionId: questionId as string,
});
resetComment();
};
if (!question) {
return <FullScreenSpinner />;
}
return (
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
onClick={handleBackNavigation}></Button>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard
{...question}
receivedCount={0} // TODO: Change to actual value
showVoteButtons={true}
timestamp={question.seenAt.toLocaleDateString()}
upvoteCount={question.numVotes}
/>
<div className="mx-2">
<Collapsible label={`${question.numComments} comment(s)`}>
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<CommentListItem
key={comment.id}
authorImageUrl={SAMPLE_QUESTION_COMMENT.authorImageUrl}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={0}
/>
))}
</Collapsible>
</div>
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
<TextArea
{...answerRegister('answerContent', {
minLength: 1,
required: true,
})}
label="Contribute your answer"
required={true}
resize="vertical"
rows={5}
/>
<div className="mt-3 mb-1 flex justify-between">
<div className="flex items-baseline justify-start gap-2">
<p>{question.numAnswers} answers</p>
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
</div>
<Button
disabled={!isDirty || !isValid}
label="Contribute"
type="submit"
variant="primary"
/>
</div>
</form>
{(answers ?? []).map((answer) => (
<AnswerCard
key={answer.id}
authorImageUrl={SAMPLE_ANSWER.authorImageUrl}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
))}
</div>
</div>
</div>
);
}

@ -1,104 +1,172 @@
import { useMemo, useState } from 'react';
import { subMonths, subYears } from 'date-fns';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import type { QuestionsQuestionType } from '@prisma/client';
import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOptions } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionOverviewCard from '~/components/questions/QuestionOverviewCard';
import type { LandingQueryData } from '~/components/questions/LandingComponent';
import LandingComponent from '~/components/questions/LandingComponent';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
type FilterChoices = Array<Omit<FilterOptions, 'checked'>>;
const companies: FilterChoices = [
{
label: 'Google',
value: 'Google',
},
{
label: 'Meta',
value: 'meta',
},
];
// Code, design, behavioral
const questionTypes: FilterChoices = [
{
label: 'Code',
value: 'code',
},
{
label: 'Design',
value: 'design',
},
{
label: 'Behavioral',
value: 'behavioral',
},
];
const questionAges: FilterChoices = [
{
label: 'Last month',
value: 'last-month',
},
{
label: 'Last 6 months',
value: 'last-6-months',
},
{
label: 'Last year',
value: 'last-year',
},
];
const locations: FilterChoices = [
{
label: 'Singapore',
value: 'singapore',
},
];
import type { QuestionAge } from '~/utils/questions/constants';
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useSearchFilter,
useSearchFilterSingle,
} from '~/utils/questions/useSearchFilter';
import { trpc } from '~/utils/trpc';
export default function QuestionsHomePage() {
const [selectedCompanies, setSelectedCompanies] = useState<Array<string>>([]);
const [selectedQuestionTypes, setSelectedQuestionTypes] = useState<
Array<string>
>([]);
const [selectedQuestionAges, setSelectedQuestionAges] = useState<
Array<string>
>([]);
const [selectedLocations, setSelectedLocations] = useState<Array<string>>([]);
const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchFilter('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
areQuestionTypesInitialized,
] = useSearchFilter<QuestionsQuestionType>('questionTypes', {
queryParamToValue: (param) => {
return param.toUpperCase() as QuestionsQuestionType;
},
});
const [
selectedQuestionAge,
setSelectedQuestionAge,
isQuestionAgeInitialized,
] = useSearchFilterSingle<QuestionAge>('questionAge', {
defaultValue: 'all',
});
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchFilter('locations');
// TODO: Implement filtering
// const questions = useMemo(() => {
const today = useMemo(() => new Date(), []);
const startDate = useMemo(() => {
return selectedQuestionAge === 'last-year'
? subYears(new Date(), 1)
: selectedQuestionAge === 'last-6-months'
? subMonths(new Date(), 6)
: selectedQuestionAge === 'last-month'
? subMonths(new Date(), 1)
: undefined;
}, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery([
'questions.questions.getQuestionsByFilter',
{
companies: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: [],
startDate,
},
]);
// Const questions = [];
// Return data;
// }, [selectedQuestionTypes, selectedQuestionAge]);
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
{
onSuccess: () => {
// Utils.invalidateQueries('questions.questions.getQuestionsByFilter');
},
},
);
const [hasLanded, setHasLanded] = useState(false);
const [loaded, setLoaded] = useState(false);
const companyFilterOptions = useMemo(() => {
return companies.map((company) => ({
return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
return questionTypes.map((questionType) => ({
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return questionAges.map((questionAge) => ({
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAges.includes(questionAge.value),
checked: selectedQuestionAge === questionAge.value,
}));
}, [selectedQuestionAges]);
}, [selectedQuestionAge]);
const locationFilterOptions = useMemo(() => {
return locations.map((location) => ({
return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
return (
const handleLandingQuery = (data: LandingQueryData) => {
const { company, location, questionType } = data;
setSelectedCompanies([company]);
setSelectedLocations([location]);
setSelectedQuestionTypes([questionType as QuestionsQuestionType]);
setHasLanded(true);
};
const areFiltersInitialized = useMemo(() => {
return (
areCompaniesInitialized &&
areQuestionTypesInitialized &&
isQuestionAgeInitialized &&
areLocationsInitialized
);
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areLocationsInitialized,
]);
useEffect(() => {
if (areFiltersInitialized) {
const hasFilter =
router.query.companies ||
router.query.questionTypes ||
router.query.questionAge ||
router.query.locations;
if (hasFilter) {
setHasLanded(true);
}
// Console.log('landed', hasLanded);
setLoaded(true);
}
}, [areFiltersInitialized, hasLanded, router.query]);
if (!loaded) {
return null;
}
return !hasLanded ? (
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
) : (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto">
<div className="flex pt-4">
<section className="w-[300px] border-r px-4">
<aside className="w-[300px] border-r px-4">
<h2 className="text-xl font-semibold">Filter by</h2>
<div className="divide-y divide-slate-200">
<FilterSection
@ -107,10 +175,12 @@ export default function QuestionsHomePage() {
searchPlaceholder="Add company filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies((prev) => [...prev, optionValue]);
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies((prev) =>
prev.filter((company) => company !== optionValue),
setSelectedCompanies(
selectedCompanies.filter(
(company) => company !== optionValue,
),
);
}
}}
@ -121,26 +191,26 @@ export default function QuestionsHomePage() {
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes((prev) => [...prev, optionValue]);
setSelectedQuestionTypes([
...selectedQuestionTypes,
optionValue,
]);
} else {
setSelectedQuestionTypes((prev) =>
prev.filter((questionType) => questionType !== optionValue),
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionAges((prev) => [...prev, optionValue]);
} else {
setSelectedQuestionAges((prev) =>
prev.filter((questionAge) => questionAge !== optionValue),
);
}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
@ -149,23 +219,31 @@ export default function QuestionsHomePage() {
searchPlaceholder="Add location filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations((prev) => [...prev, optionValue]);
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations((prev) =>
prev.filter((location) => location !== optionValue),
setSelectedLocations(
selectedLocations.filter(
(location) => location !== optionValue,
),
);
}
}}
/>
</div>
</section>
<div className="flex flex-1 justify-center">
<div className="flex max-w-3xl flex-1 gap-x-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
</aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto pt-4">
<div className="flex min-h-0 max-w-3xl flex-1">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4 pb-4">
<ContributeQuestionCard
onSubmit={(data) => {
// eslint-disable-next-line no-console
console.log(data);
createQuestion({
company: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
role: data.role,
seenAt: data.date,
});
}}
/>
<QuestionSearchBar
@ -180,19 +258,31 @@ export default function QuestionsHomePage() {
},
]}
sortValue="most-recent"
onSortChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
<QuestionOverviewCard
answerCount={0}
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 of the two numbers such that they add up. Given an array of integers nums and"
location="Menlo Park, CA"
role="Senior Engineering Manager"
similarCount={0}
timestamp="Last month"
upvoteCount={0}
/>
{(questions ?? []).map((question) => (
<QuestionOverviewCard
// eslint-disable-next-line react/no-array-index-key
key={question.id}
answerCount={question.numAnswers}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
location={question.location}
receivedCount={0} // TODO: Implement received count
role={question.role}
timestamp={question.seenAt.toLocaleDateString()}
type={question.type}
upvoteCount={question.numVotes}
/>
))}
</div>
</div>
</div>
</section>
</div>
</main>
);

@ -140,7 +140,7 @@ export default function SubmitResumeForm() {
<div className="mb-4">
<TextInput
{...register('title', { required: true })}
errorMessage={errors?.title && 'Title cannot be empty!'}
disabled={isLoading}
label="Title"
placeholder={TITLE_PLACEHOLDER}
required={true}
@ -150,6 +150,7 @@ export default function SubmitResumeForm() {
<div className="mb-4">
<Select
{...register('role', { required: true })}
disabled={isLoading}
label="Role"
options={ROLES}
required={true}
@ -159,6 +160,7 @@ export default function SubmitResumeForm() {
<div className="mb-4">
<Select
{...register('experience', { required: true })}
disabled={isLoading}
label="Experience Level"
options={EXPERIENCE}
required={true}
@ -168,6 +170,7 @@ export default function SubmitResumeForm() {
<div className="mb-4">
<Select
{...register('location', { required: true })}
disabled={isLoading}
label="Location"
name="location"
options={LOCATION}
@ -204,6 +207,7 @@ export default function SubmitResumeForm() {
{...register('file', { required: true })}
accept="application/pdf"
className="sr-only"
disabled={isLoading}
id="file-upload"
name="file-upload"
type="file"
@ -223,6 +227,7 @@ export default function SubmitResumeForm() {
<div className="mb-8">
<TextArea
{...register('additionalInfo')}
disabled={isLoading}
label="Additional Information"
placeholder={ADDITIONAL_INFO_PLACEHOLDER}
onChange={(val) => setValue('additionalInfo', val)}
@ -259,12 +264,14 @@ export default function SubmitResumeForm() {
</div>
<CheckboxInput
{...register('isChecked', { required: true })}
disabled={isLoading}
label="I have read and will follow the guidelines stated."
onChange={(val) => setValue('isChecked', val)}
/>
<div className="mt-4 flex justify-end gap-4">
<Button
addonPosition="start"
disabled={isLoading}
display="inline"
label="Clear"
size="md"
@ -273,6 +280,7 @@ export default function SubmitResumeForm() {
/>
<Button
addonPosition="start"
disabled={isLoading}
display="inline"
isLoading={isLoading}
label="Submit"

@ -3,6 +3,9 @@ import superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context';
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 { questionsQuestionRouter } from './questions-question-router';
import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
@ -26,6 +29,9 @@ export const appRouter = createRouter()
.merge('resumes.star.user.', resumesStarUserRouter)
.merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.', questionsQuestionRouter);
// Export type definition of API

@ -0,0 +1,229 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
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: {
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
...input,
},
});
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 ?? '',
};
return answerComment;
});
},
})
.mutation('create', {
input: z.object({
answerId: z.string(),
content: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsAnswerComment.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const answerCommentToUpdate =
await ctx.prisma.questionsAnswerComment.findUnique({
where: {
id: input.id,
},
});
if (answerCommentToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerComment.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 answerCommentToDelete =
await ctx.prisma.questionsAnswerComment.findUnique({
where: {
id: input.id,
},
});
if (answerCommentToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerComment.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
answerCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerCommentId } = input;
return await ctx.prisma.questionsAnswerCommentVote.findUnique({
where: {
answerCommentId_userId: { answerCommentId, userId },
},
});
},
})
.mutation('createVote', {
input: z.object({
answerCommentId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsAnswerCommentVote.create({
data: {
...input,
userId,
},
});
},
})
.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.questionsAnswerCommentVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerCommentVote.update({
data: {
vote,
},
where: {
id,
},
});
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete =
await ctx.prisma.questionsAnswerCommentVote.findUnique({
where: {
id: input.id,
},
});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerCommentVote.delete({
where: {
id: input.id,
},
});
},
});

@ -0,0 +1,287 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
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 answersData = await ctx.prisma.questionsAnswer.findMany({
include: {
_count: {
select: {
comments: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
...input,
},
});
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 ?? '',
};
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: {
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 ?? '',
};
return answer;
},
})
.mutation('create', {
input: z.object({
content: z.string(),
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsAnswer.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { content, id } = input;
const answerToUpdate = await ctx.prisma.questionsAnswer.findUnique({
where: {
id: input.id,
},
});
if (answerToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswer.update({
data: {
content,
},
where: {
id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const answerToDelete = await ctx.prisma.questionsAnswer.findUnique({
where: {
id: input.id,
},
});
if (answerToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswer.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerId } = input;
return await ctx.prisma.questionsAnswerVote.findUnique({
where: {
answerId_userId: { answerId, userId },
},
});
},
})
.mutation('createVote', {
input: z.object({
answerId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsAnswerVote.create({
data: {
...input,
userId,
},
});
},
})
.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.questionsAnswerVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerVote.update({
data: {
vote,
},
where: {
id,
},
});
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete = await ctx.prisma.questionsAnswerVote.findUnique({
where: {
id: input.id,
},
});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerVote.delete({
where: {
id: input.id,
},
});
},
});

@ -0,0 +1,228 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
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 questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({
include: {
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
...input,
},
});
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 ?? '',
};
return questionComment;
});
},
})
.mutation('create', {
input: z.object({
content: z.string(),
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestionComment.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionCommentToUpdate =
await ctx.prisma.questionsQuestionComment.findUnique({
where: {
id: input.id,
},
});
if (questionCommentToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionComment.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 questionCommentToDelete =
await ctx.prisma.questionsQuestionComment.findUnique({
where: {
id: input.id,
},
});
if (questionCommentToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionComment.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
questionCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionCommentId } = input;
return await ctx.prisma.questionsQuestionCommentVote.findUnique({
where: {
questionCommentId_userId: { questionCommentId, userId },
},
});
},
})
.mutation('createVote', {
input: z.object({
questionCommentId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestionCommentVote.create({
data: {
...input,
userId,
},
});
},
})
.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.questionsQuestionCommentVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionCommentVote.update({
data: {
vote,
},
where: {
id,
},
});
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete =
await ctx.prisma.questionsQuestionCommentVote.findUnique({
where: {
id: input.id,
},
});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionCommentVote.delete({
where: {
id: input.id,
},
});
},
});

@ -1,5 +1,5 @@
import { z } from 'zod';
import {QuestionsQuestionType, Vote } from '@prisma/client';
import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
@ -9,95 +9,178 @@ import type { Question } from '~/types/questions';
export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', {
input: z.object({
company: z.string().array().optional(),
companies: z.string().array(),
endDate: z.date(),
location: z.string().array().optional(),
questionType: z.nativeEnum(QuestionsQuestionType),
role: z.string().array().optional(),
startDate: z.date()
locations: z.string().array(),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
startDate: z.date().optional(),
}),
async resolve({ ctx, input }) {
const questionsData = await ctx.prisma.questionsQuestion.findMany({
include: {
_count: {
select: {
answers: true,
comments: true,
_count: {
select: {
answers: true,
comments: true,
},
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
},
user: {
select: {
name: true,
user: {
select: {
name: true,
},
},
},
votes: true,
votes: true,
},
orderBy: {
createdAt: 'desc',
createdAt: 'desc',
},
where: {
questionType: input.questionType,
...(input.questionTypes.length > 0
? {
questionType: {
in: input.questionTypes,
},
}
: {}),
},
});
return questionsData
.filter((data) => {
for (let i = 0; i < data.encounters.length; i++) {
const encounter = data.encounters[i]
const matchCompany = (!input.company || (input.company.includes(encounter.company)));
const matchLocation = (!input.location || (input.location.includes(encounter.location)));
const matchRole = (!input.role || (input.role.includes(encounter.role)));
const matchDate = encounter.seenAt >= input.startDate && encounter.seenAt <= input.endDate;
if (matchCompany && matchLocation && matchRole && matchDate) {return true};
const encounter = data.encounters[i];
const matchCompany =
input.companies.length === 0 ||
input.companies.includes(encounter.company);
const matchLocation =
input.locations.length === 0 ||
input.locations.includes(encounter.location);
const matchRole =
input.roles.length === 0 || input.roles.includes(encounter.role);
const matchDate =
(!input.startDate || encounter.seenAt >= input.startDate) &&
encounter.seenAt <= input.endDate;
if (matchCompany && matchLocation && matchRole && matchDate) {
return true;
}
}
return false;
})
.map((data) => {
const votes:number = data.votes.reduce(
(previousValue:number, currentValue) => {
let result:number = previousValue;
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;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0
);
let userName = "";
if (data.user) {
userName = data.user.name!;
}
0,
);
const question: Question = {
company: "",
company: data.encounters[0].company,
content: data.content,
id: data.id,
location: "",
location: data.encounters[0].location ?? 'Unknown location',
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
role: "",
role: data.encounters[0].role ?? 'Unknown role',
seenAt: data.encounters[0].seenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: userName,
user: data.user?.name ?? '',
};
return question;
});
},
})
.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',
});
}
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 question: Question = {
company: questionData.encounters[0].company,
content: questionData.content,
id: questionData.id,
location: questionData.encounters[0].location ?? 'Unknown location',
numAnswers: questionData._count.answers,
numComments: questionData._count.comments,
numVotes: votes,
role: questionData.encounters[0].role ?? 'Unknown role',
seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType,
updatedAt: questionData.updatedAt,
user: questionData.user?.name ?? '',
};
return question;
},
})
.mutation('create', {
input: z.object({
@ -111,24 +194,38 @@ export const questionsQuestionRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestion.create({
const question = await ctx.prisma.questionsQuestion.create({
data: {
content: input.content,
encounters: {
create: [
{
company :input.company,
company: input.company,
location: input.location,
role: input.role,
seenAt: input.seenAt,
userId
}
userId,
},
],
},
questionType: input.questionType,
userId
userId,
},
});
// Create question encounter
await ctx.prisma.questionsQuestionEncounter.create({
data: {
company: input.company,
location: input.location,
questionId: question.id,
role: input.role,
seenAt: input.seenAt,
userId,
},
});
return question;
},
})
.mutation('update', {
@ -136,7 +233,6 @@ export const questionsQuestionRouter = createProtectedRouter()
content: z.string().optional(),
id: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType).optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
@ -199,11 +295,11 @@ export const questionsQuestionRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const {questionId} = input
const { questionId } = input;
return await ctx.prisma.questionsQuestionVote.findUnique({
where: {
questionId_userId : {questionId,userId }
questionId_userId: { questionId, userId },
},
});
},
@ -231,7 +327,7 @@ export const questionsQuestionRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const {id, vote} = input
const { id, vote } = input;
const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
@ -266,7 +362,8 @@ export const questionsQuestionRouter = createProtectedRouter()
const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
},});
},
});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
@ -281,4 +378,4 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
},
});
});

@ -8,6 +8,34 @@ export type Question = {
numComments: number;
numVotes: number;
role: string;
seenAt: Date;
type: stringl;
updatedAt: Date;
user: string;
};
};
export type AnswerComment = {
content: string;
createdAt: Date;
id: string;
numVotes: number;
updatedAt: Date;
user: string;
};
export type Answer = {
content: string;
createdAt: Date;
id: string;
numComments: number;
numVotes: number;
user: string;
};
export type QuestionComment = {
content: string;
createdAt: Date;
id: string;
numVotes: number;
user: string;
};

@ -0,0 +1,112 @@
import type { QuestionsQuestionType } from '@prisma/client';
import type { FilterChoices } from '~/components/questions/filter/FilterSection';
export const COMPANIES: FilterChoices = [
{
label: 'Google',
value: 'Google',
},
{
label: 'Meta',
value: 'Meta',
},
] as const;
// Code, design, behavioral
export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
{
label: 'Coding',
value: 'CODING',
},
{
label: 'Design',
value: 'SYSTEM_DESIGN',
},
{
label: 'Behavioral',
value: 'BEHAVIORAL',
},
] as const;
export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year';
export const QUESTION_AGES: FilterChoices<QuestionAge> = [
{
label: 'Last month',
value: 'last-month',
},
{
label: 'Last 6 months',
value: 'last-6-months',
},
{
label: 'Last year',
value: 'last-year',
},
{
label: 'All',
value: 'all',
},
] as const;
export const LOCATIONS: FilterChoices = [
{
label: 'Singapore',
value: 'Singapore',
},
{
label: 'Menlo Park',
value: 'Menlo Park',
},
{
label: 'California',
value: 'california',
},
{
label: 'Hong Kong',
value: 'Hong Kong',
},
{
label: 'Taiwan',
value: 'Taiwan',
},
] as const;
export const SAMPLE_QUESTION = {
answerCount: 10,
commentCount: 10,
company: 'Google',
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 of the two numbers such that they add up. Given an array of integers nums andiven 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 of the two numbers such that they add up. Given an array of integers nums andiven 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 of the two numbers such that they add up. Given an array of integers nums andiven 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 of the two numbers such that they add up. Given an array of integers nums andiven 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 of the two numbers such that they add up. Given an array of integers nums andiven 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 of the two numbers such that they add up. Given an array of integers nums andiven 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 of the two numbers such that they add up. Given an array of integers nums andiven 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 of the two numbers such that they add up. Given an array of integers nums and',
location: 'Menlo Park, CA',
receivedCount: 12,
role: 'Software Engineer',
timestamp: 'Last month',
upvoteCount: 5,
};
export const SAMPLE_ANSWER = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
commentCount: 10,
content: 'This is a sample answer',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};
export const SAMPLE_QUESTION_COMMENT = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
content: 'This is a sample question comment',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};
export const SAMPLE_ANSWER_COMMENT = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
content: 'This is an sample answer comment',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};

@ -0,0 +1,7 @@
export default function createSlug(content: string) {
return content
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '')
.substring(0, 100);
}

@ -0,0 +1,43 @@
import type { ChangeEvent } from 'react';
import { useCallback } from 'react';
import type { FieldValues, UseFormRegister } from 'react-hook-form';
export const useFormRegister = <TFieldValues extends FieldValues>(
register: UseFormRegister<TFieldValues>,
) => {
const formRegister = useCallback(
(...args: Parameters<typeof register>) => {
const { onChange, ...rest } = register(...args);
return {
...rest,
onChange: (value: string, event: ChangeEvent<unknown>) => {
onChange(event);
},
};
},
[register],
);
return formRegister;
};
export const useSelectRegister = <TFieldValues extends FieldValues>(
register: UseFormRegister<TFieldValues>,
) => {
const formRegister = useCallback(
(...args: Parameters<typeof register>) => {
const { onChange, ...rest } = register(...args);
return {
...rest,
onChange: (value: string) => {
onChange({
target: {
value,
},
});
},
};
},
[register],
);
return formRegister;
};

@ -0,0 +1,81 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
export const useSearchFilter = <Value extends string = string>(
name: string,
opts: {
defaultValues?: Array<Value>;
queryParamToValue?: (param: string) => Value;
} = {},
) => {
const { defaultValues, queryParamToValue = (param) => param } = opts;
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
const [filters, setFilters] = useState<Array<Value>>(defaultValues || []);
useEffect(() => {
if (router.isReady && !isInitialized) {
// Initialize from query params
const query = router.query[name];
if (query) {
const queryValues = Array.isArray(query) ? query : [query];
setFilters(queryValues.map(queryParamToValue) as Array<Value>);
} else {
// Try to load from local storage
const localStorageValue = localStorage.getItem(name);
if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue);
setFilters(loadedFilters);
router.replace({
pathname: router.pathname,
query: {
...router.query,
[name]: loadedFilters,
},
});
}
}
setIsInitialized(true);
}
}, [isInitialized, name, queryParamToValue, router]);
const setFiltersCallback = useCallback(
(newFilters: Array<Value>) => {
setFilters(newFilters);
localStorage.setItem(name, JSON.stringify(newFilters));
router.replace({
pathname: router.pathname,
query: {
...router.query,
[name]: newFilters,
},
});
},
[name, router],
);
return [filters, setFiltersCallback, isInitialized] as const;
};
export const useSearchFilterSingle = <Value extends string = string>(
name: string,
opts: {
defaultValue?: Value;
queryParamToValue?: (param: string) => Value;
} = {},
) => {
const { defaultValue, queryParamToValue } = opts;
const [filters, setFilters, isInitialized] = useSearchFilter(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
queryParamToValue,
});
return [
filters[0],
(value: Value) => {
setFilters([value]);
},
isInitialized,
] as const;
};

@ -0,0 +1,21 @@
const withHref = <Props extends Record<string, unknown>>(
Component: React.ComponentType<Props>,
) => {
return (
props: Props & {
href: string;
},
) => {
const { href, ...others } = props;
return (
<a
className="ring-primary-500 rounded-md focus:ring-2 focus-visible:outline-none active:bg-slate-100"
href={href}>
<Component {...(others as unknown as Props)} />
</a>
);
};
};
export default withHref;

@ -23,6 +23,9 @@ const buttonVariants: ReadonlyArray<ButtonVariant> = [
'tertiary',
'special',
'success',
'danger',
'warning',
'info',
];
export default {

@ -9,11 +9,14 @@ export type ButtonDisplay = 'block' | 'inline';
export type ButtonSize = 'lg' | 'md' | 'sm';
export type ButtonType = 'button' | 'reset' | 'submit';
export type ButtonVariant =
| 'danger'
| 'info'
| 'primary'
| 'secondary'
| 'special'
| 'success'
| 'tertiary';
| 'tertiary'
| 'warning';
type Props = Readonly<{
addonPosition?: ButtonAddOnPosition;
@ -69,20 +72,32 @@ const sizeIconClasses: Record<ButtonSize, string> = {
};
const variantClasses: Record<ButtonVariant, string> = {
primary: 'border-transparent text-white bg-primary-600 hover:bg-primary-500',
danger:
'border-transparent text-white bg-danger-600 hover:bg-danger-500 focus:ring-danger-500',
info: 'border-transparent text-white bg-info-600 hover:bg-info-500 focus:ring-info-500',
primary:
'border-transparent text-white bg-primary-600 hover:bg-primary-500 focus:ring-primary-500',
secondary:
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200',
special: 'border-slate-900 text-white bg-slate-900 hover:bg-slate-700',
success: 'border-transparent text-white bg-success-600 hover:bg-success-500',
tertiary: 'border-slate-300 text-slate-700 bg-white hover:bg-slate-50',
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200 focus:ring-primary-500',
special:
'border-slate-900 text-white bg-slate-900 hover:bg-slate-700 focus:ring-slate-900',
success:
'border-transparent text-white bg-success-600 hover:bg-success-500 focus:ring-success-500',
tertiary:
'border-slate-300 text-slate-700 bg-white hover:bg-slate-50 focus:ring-slate-600',
warning:
'border-transparent text-white bg-warning-600 hover:bg-warning-500 focus:ring-warning-500',
};
const variantDisabledClasses: Record<ButtonVariant, string> = {
danger: 'border-transparent text-slate-500 bg-slate-300',
info: 'border-transparent text-slate-500 bg-slate-300',
primary: 'border-transparent text-slate-500 bg-slate-300',
secondary: 'border-transparent text-slate-400 bg-slate-200',
special: 'border-transparent text-slate-500 bg-slate-300',
success: 'border-transparent text-slate-500 bg-slate-300',
tertiary: 'border-slate-300 text-slate-400 bg-slate-100',
warning: 'border-transparent text-slate-500 bg-slate-300',
};
export default function Button({
@ -132,7 +147,7 @@ export default function Button({
children,
className: clsx(
display === 'block' ? 'flex w-full justify-center' : 'inline-flex',
'whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
'whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2',
disabled ? variantDisabledClasses[variant] : variantClasses[variant],
disabled && 'pointer-events-none',
isLabelHidden ? iconOnlySizeClasses[size] : sizeClasses[size],

@ -7,7 +7,8 @@
".next/**",
"build/**",
"api/**",
"public/build/**"
"public/build/**",
"storybook-static/**"
],
"dependsOn": ["^build"]
},

Loading…
Cancel
Save