Merge branch 'main' into stuart/seed-db

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

@ -15,6 +15,7 @@
"@headlessui/react": "^1.7.3", "@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.11", "@heroicons/react": "^2.0.11",
"@next-auth/prisma-adapter": "^1.0.4", "@next-auth/prisma-adapter": "^1.0.4",
"@popperjs/core": "^2.11.6",
"@prisma/client": "^4.4.0", "@prisma/client": "^4.4.0",
"@supabase/supabase-js": "^1.35.7", "@supabase/supabase-js": "^1.35.7",
"@tih/ui": "*", "@tih/ui": "*",
@ -33,6 +34,8 @@
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-hook-form": "^7.36.1", "react-hook-form": "^7.36.1",
"react-pdf": "^5.7.2", "react-pdf": "^5.7.2",
"react-popper": "^2.3.0",
"react-popper-tooltip": "^4.4.2",
"react-query": "^3.39.2", "react-query": "^3.39.2",
"read-excel-file": "^5.5.3", "read-excel-file": "^5.5.3",
"superjson": "^1.10.0", "superjson": "^1.10.0",

@ -84,7 +84,7 @@ export default function OfferProfileSave({
onClick={saveProfile} onClick={saveProfile}
/> />
</div> </div>
<div className="mb-10"> <div>
<Button <Button
icon={EyeIcon} icon={EyeIcon}
label="View your profile" label="View your profile"

@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form'; import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid'; import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb'; import { Breadcrumbs } from '~/components/offers/Breadcrumb';
@ -13,7 +14,6 @@ import type {
OfferFormData, OfferFormData,
OffersProfileFormData, OffersProfileFormData,
} from '~/components/offers/types'; } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker'; import type { Month } from '~/components/shared/MonthYearPicker';
import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form'; import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
@ -25,7 +25,7 @@ import type { CreateOfferProfileResponse } from '~/types/offers';
const defaultOfferValues = { const defaultOfferValues = {
comments: '', comments: '',
companyId: '', companyId: '',
jobType: JobType.FullTime, jobType: JobType.FULLTIME,
location: '', location: '',
monthYearReceived: { monthYearReceived: {
month: getCurrentMonth() as Month, month: getCurrentMonth() as Month,
@ -36,18 +36,18 @@ const defaultOfferValues = {
export const defaultFullTimeOfferValues = { export const defaultFullTimeOfferValues = {
...defaultOfferValues, ...defaultOfferValues,
jobType: JobType.FullTime, jobType: JobType.FULLTIME,
}; };
export const defaultInternshipOfferValues = { export const defaultInternshipOfferValues = {
...defaultOfferValues, ...defaultOfferValues,
jobType: JobType.Intern, jobType: JobType.INTERN,
}; };
const defaultOfferProfileValues = { const defaultOfferProfileValues = {
background: { background: {
educations: [], educations: [],
experiences: [{ jobType: JobType.FullTime }], experiences: [{ jobType: JobType.FULLTIME }],
specificYoes: [], specificYoes: [],
totalYoe: 0, totalYoe: 0,
}, },
@ -90,7 +90,12 @@ export default function OffersSubmissionForm({
const formSteps: Array<FormStep> = [ const formSteps: Array<FormStep> = [
{ {
component: <OfferDetailsForm key={0} />, component: (
<OfferDetailsForm
key={0}
defaultJobType={initialOfferProfileValues.offers[0].jobType}
/>
),
hasNext: true, hasNext: true,
hasPrevious: false, hasPrevious: false,
label: 'Offer details', label: 'Offer details',

@ -4,7 +4,7 @@ import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import OfferPercentileAnalysis from './OfferPercentileAnalysis'; import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferProfileCard from './OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../../constants'; import { OVERALL_TAB } from '../../constants';
@ -38,11 +38,12 @@ function OfferAnalysisContent({
} }
return ( return (
<> <>
<OfferPercentileAnalysis <OfferPercentileAnalysisText
companyName={offer.company.name} companyName={offer.company.name}
offerAnalysis={offerAnalysis} offerAnalysis={offerAnalysis}
tab={tab} tab={tab}
/> />
<p className="mt-5">Here are some of the top offers relevant to you:</p>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => ( {offerAnalysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard <OfferProfileCard
key={topPercentileOffer.id} key={topPercentileOffer.id}

@ -1,27 +0,0 @@
import type { Analysis } from '~/types/offers';
type OfferPercentileAnalysisProps = Readonly<{
companyName: string;
offerAnalysis: Analysis;
tab: string;
}>;
export default function OfferPercentileAnalysis({
tab,
companyName,
offerAnalysis: { noOfOffers, percentile },
}: OfferPercentileAnalysisProps) {
return tab === 'Overall' ? (
<p>
Your highest offer is from {companyName}, which is {percentile} percentile
out of {noOfOffers} offers received for the same job type, same level, and
same YOE(+/-1) in the last year.
</p>
) : (
<p>
Your offer from {companyName} is {percentile} percentile out of{' '}
{noOfOffers} offers received in {companyName} for the same job type, same
level, and same YOE(+/-1) in the last year.
</p>
);
}

@ -0,0 +1,27 @@
import type { Analysis } from '~/types/offers';
type OfferPercentileAnalysisTextProps = Readonly<{
companyName: string;
offerAnalysis: Analysis;
tab: string;
}>;
export default function OfferPercentileAnalysisText({
tab,
companyName,
offerAnalysis: { noOfOffers, percentile },
}: OfferPercentileAnalysisTextProps) {
return tab === 'Overall' ? (
<p>
Your highest offer is from <b>{companyName}</b>, which is{' '}
<b>{percentile}</b> percentile out of <b>{noOfOffers}</b> offers received
for the same job title and YOE(+/-1) in the last year.
</p>
) : (
<p>
Your offer from <b>{companyName}</b> is <b>{percentile}</b> percentile out
of <b>{noOfOffers}</b> offers received in {companyName} for the same job
title and YOE(+/-1) in the last year.
</p>
);
}

@ -1,9 +1,10 @@
import { UserCircleIcon } from '@heroicons/react/24/outline'; import { JobType } from '@prisma/client';
import { HorizontalDivider } from '~/../../../packages/ui/dist'; import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import { JobType } from '../../types'; import ProfilePhotoHolder from '../../profile/ProfilePhotoHolder';
import type { AnalysisOffer } from '~/types/offers'; import type { AnalysisOffer } from '~/types/offers';
@ -29,7 +30,7 @@ export default function OfferProfileCard({
<div className="my-5 block rounded-lg border p-4"> <div className="my-5 block rounded-lg border p-4">
<div className="grid grid-flow-col grid-cols-12 gap-x-10"> <div className="grid grid-flow-col grid-cols-12 gap-x-10">
<div className="col-span-1"> <div className="col-span-1">
<UserCircleIcon width={50} /> <ProfilePhotoHolder size="sm" />
</div> </div>
<div className="col-span-10"> <div className="col-span-10">
<p className="text-sm font-semibold">{profileName}</p> <p className="text-sm font-semibold">{profileName}</p>
@ -50,9 +51,9 @@ export default function OfferProfileCard({
<div className="col-span-1 row-span-3"> <div className="col-span-1 row-span-3">
<p className="text-end text-sm">{formatDate(monthYearReceived)}</p> <p className="text-end text-sm">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl"> <p className="text-end text-xl">
{jobType === JobType.FullTime {jobType === JobType.FULLTIME
? `$${income} / year` ? `${convertMoneyToString(income)} / year`
: `$${income} / month`} : `${convertMoneyToString(income)} / month`}
</p> </p>
</div> </div>
</div> </div>

@ -1,4 +1,5 @@
import { useFormContext, useWatch } from 'react-hook-form'; import { useFormContext, useWatch } from 'react-hook-form';
import { JobType } from '@prisma/client';
import { Collapsible, RadioList } from '@tih/ui'; import { Collapsible, RadioList } from '@tih/ui';
import { import {
@ -10,7 +11,6 @@ import {
titleOptions, titleOptions,
} from '~/components/offers/constants'; } from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types'; import type { BackgroundPostData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
@ -239,7 +239,7 @@ function InternshipJobFields() {
function CurrentJobSection() { function CurrentJobSection() {
const { register } = useFormContext(); const { register } = useFormContext();
const watchJobType = useWatch({ const watchJobType = useWatch({
defaultValue: JobType.FullTime, defaultValue: JobType.FULLTIME,
name: 'background.experiences.0.jobType', name: 'background.experiences.0.jobType',
}); });
@ -251,7 +251,7 @@ function CurrentJobSection() {
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5"> <div className="mb-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5"> <div className="mb-5">
<FormRadioList <FormRadioList
defaultValue={JobType.FullTime} defaultValue={JobType.FULLTIME}
isLabelHidden={true} isLabelHidden={true}
label="Job Type" label="Job Type"
orientation="horizontal" orientation="horizontal"
@ -259,16 +259,16 @@ function CurrentJobSection() {
<RadioList.Item <RadioList.Item
key="Full-time" key="Full-time"
label="Full-time" label="Full-time"
value={JobType.FullTime} value={JobType.FULLTIME}
/> />
<RadioList.Item <RadioList.Item
key="Internship" key="Internship"
label="Internship" label="Internship"
value={JobType.Intern} value={JobType.INTERN}
/> />
</FormRadioList> </FormRadioList>
</div> </div>
{watchJobType === JobType.FullTime ? ( {watchJobType === JobType.FULLTIME ? (
<FullTimeJobFields /> <FullTimeJobFields />
) : ( ) : (
<InternshipJobFields /> <InternshipJobFields />

@ -9,6 +9,7 @@ import { useFormContext } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form'; import { useFieldArray } from 'react-hook-form';
import { PlusIcon } from '@heroicons/react/20/solid'; import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline'; import { TrashIcon } from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client';
import { Button, Dialog } from '@tih/ui'; import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -31,7 +32,6 @@ import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
import type { OfferFormData } from '../../types'; import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../../types'; import { JobTypeLabel } from '../../types';
import { JobType } from '../../types';
import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{ type FullTimeOfferDetailsFormProps = Readonly<{
@ -448,7 +448,7 @@ function OfferDetailsFormArray({
{fields.map((item, index) => { {fields.map((item, index) => {
return ( return (
<div key={item.id}> <div key={item.id}>
{jobType === JobType.FullTime ? ( {jobType === JobType.FULLTIME ? (
<FullTimeOfferDetailsForm index={index} remove={remove} /> <FullTimeOfferDetailsForm index={index} remove={remove} />
) : ( ) : (
<InternshipOfferDetailsForm index={index} remove={remove} /> <InternshipOfferDetailsForm index={index} remove={remove} />
@ -464,7 +464,7 @@ function OfferDetailsFormArray({
variant="tertiary" variant="tertiary"
onClick={() => onClick={() =>
append( append(
jobType === JobType.FullTime jobType === JobType.FULLTIME
? defaultFullTimeOfferValues ? defaultFullTimeOfferValues
: defaultInternshipOfferValues, : defaultInternshipOfferValues,
) )
@ -474,8 +474,14 @@ function OfferDetailsFormArray({
); );
} }
export default function OfferDetailsForm() { type OfferDetailsFormProps = Readonly<{
const [jobType, setJobType] = useState(JobType.FullTime); defaultJobType?: JobType;
}>;
export default function OfferDetailsForm({
defaultJobType = JobType.FULLTIME,
}: OfferDetailsFormProps) {
const [jobType, setJobType] = useState(defaultJobType);
const [isDialogOpen, setDialogOpen] = useState(false); const [isDialogOpen, setDialogOpen] = useState(false);
const { control } = useFormContext(); const { control } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' }); const fieldArrayValues = useFieldArray({ control, name: 'offers' });
@ -483,17 +489,17 @@ export default function OfferDetailsForm() {
const toggleJobType = () => { const toggleJobType = () => {
remove(); remove();
if (jobType === JobType.FullTime) { if (jobType === JobType.FULLTIME) {
setJobType(JobType.Intern); setJobType(JobType.INTERN);
append(defaultInternshipOfferValues); append(defaultInternshipOfferValues);
} else { } else {
setJobType(JobType.FullTime); setJobType(JobType.FULLTIME);
append(defaultFullTimeOfferValues); append(defaultFullTimeOfferValues);
} }
}; };
const switchJobTypeLabel = () => const switchJobTypeLabel = () =>
jobType === JobType.FullTime ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME; jobType === JobType.FULLTIME ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
return ( return (
<div className="mb-5"> <div className="mb-5">
@ -506,9 +512,9 @@ export default function OfferDetailsForm() {
display="block" display="block"
label={JobTypeLabel.FULLTIME} label={JobTypeLabel.FULLTIME}
size="md" size="md"
variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'} variant={jobType === JobType.FULLTIME ? 'secondary' : 'tertiary'}
onClick={() => { onClick={() => {
if (jobType === JobType.FullTime) { if (jobType === JobType.FULLTIME) {
return; return;
} }
setDialogOpen(true); setDialogOpen(true);
@ -520,9 +526,9 @@ export default function OfferDetailsForm() {
display="block" display="block"
label={JobTypeLabel.INTERN} label={JobTypeLabel.INTERN}
size="md" size="md"
variant={jobType === JobType.Intern ? 'secondary' : 'tertiary'} variant={jobType === JobType.INTERN ? 'secondary' : 'tertiary'}
onClick={() => { onClick={() => {
if (jobType === JobType.Intern) { if (jobType === JobType.INTERN) {
return; return;
} }
setDialogOpen(true); setDialogOpen(true);

@ -3,18 +3,10 @@ import {
LightBulbIcon, LightBulbIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import type { EducationBackgroundType } from '~/components/offers/types'; import type { EducationDisplayData } from '~/components/offers/types';
type EducationEntity = {
endDate?: string;
field?: string;
school?: string;
startDate?: string;
type?: EducationBackgroundType;
};
type Props = Readonly<{ type Props = Readonly<{
education: EducationEntity; education: EducationDisplayData;
}>; }>;
export default function EducationCard({ export default function EducationCard({
@ -39,9 +31,7 @@ export default function EducationCard({
</div> </div>
{(startDate || endDate) && ( {(startDate || endDate) && (
<div className="font-light text-gray-400"> <div className="font-light text-gray-400">
<p>{`${startDate ? startDate : 'N/A'} - ${ <p>{`${startDate || 'N/A'} - ${endDate || 'N/A'}`}</p>
endDate ? endDate : 'N/A'
}`}</p>
</div> </div>
)} )}
</div> </div>

@ -6,10 +6,10 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { HorizontalDivider } from '@tih/ui'; import { HorizontalDivider } from '@tih/ui';
import type { OfferEntity } from '~/components/offers/types'; import type { OfferDisplayData } from '~/components/offers/types';
type Props = Readonly<{ type Props = Readonly<{
offer: OfferEntity; offer: OfferDisplayData;
}>; }>;
export default function OfferCard({ export default function OfferCard({

@ -3,13 +3,18 @@ import { Spinner } from '@tih/ui';
import EducationCard from '~/components/offers/profile/EducationCard'; import EducationCard from '~/components/offers/profile/EducationCard';
import OfferCard from '~/components/offers/profile/OfferCard'; import OfferCard from '~/components/offers/profile/OfferCard';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types'; import type {
import { EducationBackgroundType } from '~/components/offers/types'; BackgroundDisplayData,
OfferDisplayData,
} from '~/components/offers/types';
import type { ProfileAnalysis } from '~/types/offers';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
background?: BackgroundCard; analysis?: ProfileAnalysis;
background?: BackgroundDisplayData;
isLoading: boolean; isLoading: boolean;
offers: Array<OfferEntity>; offers: Array<OfferDisplayData>;
selectedTab: string; selectedTab: string;
}>; }>;
@ -52,7 +57,7 @@ export default function ProfileDetails({
<BriefcaseIcon className="mr-1 h-5" /> <BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">Work Experience</span> <span className="font-bold">Work Experience</span>
</div> </div>
<OfferCard offer={background?.experiences[0]} /> <OfferCard offer={background.experiences[0]} />
</> </>
)} )}
{background?.educations && background?.educations.length > 0 && ( {background?.educations && background?.educations.length > 0 && (
@ -61,15 +66,7 @@ export default function ProfileDetails({
<AcademicCapIcon className="mr-1 h-5" /> <AcademicCapIcon className="mr-1 h-5" />
<span className="font-bold">Education</span> <span className="font-bold">Education</span>
</div> </div>
<EducationCard <EducationCard education={background.educations[0]} />
education={{
endDate: background.educations[0].endDate,
field: background.educations[0].field,
school: background.educations[0].school,
startDate: background.educations[0].startDate,
type: EducationBackgroundType.Bachelor,
}}
/>
</> </>
)} )}
</> </>

@ -1,7 +1,6 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
BookmarkSquareIcon,
BuildingOffice2Icon, BuildingOffice2Icon,
CalendarDaysIcon, CalendarDaysIcon,
PencilSquareIcon, PencilSquareIcon,
@ -10,12 +9,12 @@ import {
import { Button, Dialog, Spinner, Tabs } from '@tih/ui'; import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder'; import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundCard } from '~/components/offers/types'; import type { BackgroundDisplayData } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link'; import { getProfileEditPath } from '~/utils/offers/link';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
background?: BackgroundCard; background?: BackgroundDisplayData;
handleDelete: () => void; handleDelete: () => void;
isEditable: boolean; isEditable: boolean;
isLoading: boolean; isLoading: boolean;
@ -42,14 +41,14 @@ export default function ProfileHeader({
function renderActionList() { function renderActionList() {
return ( return (
<div className="space-x-2"> <div className="space-x-2">
<Button {/* <Button
disabled={isLoading} disabled={isLoading}
icon={BookmarkSquareIcon} icon={BookmarkSquareIcon}
isLabelHidden={true} isLabelHidden={true}
label="Save to user account" label="Save to user account"
size="md" size="md"
variant="tertiary" variant="tertiary"
/> /> */}
<Button <Button
disabled={isLoading} disabled={isLoading}
icon={PencilSquareIcon} icon={PencilSquareIcon}
@ -109,6 +108,13 @@ export default function ProfileHeader({
</div> </div>
); );
} }
if (!background) {
return null;
}
const { experiences, totalYoe, specificYoes, profileName } = background;
return ( return (
<div className="h-40 bg-white p-4"> <div className="h-40 bg-white p-4">
<div className="justify-left flex h-1/2"> <div className="justify-left flex h-1/2">
@ -118,7 +124,7 @@ export default function ProfileHeader({
<div className="w-full"> <div className="w-full">
<div className="justify-left flex flex-1"> <div className="justify-left flex flex-1">
<h2 className="flex w-4/5 text-2xl font-bold"> <h2 className="flex w-4/5 text-2xl font-bold">
{background?.profileName ?? 'anonymous'} {profileName ?? 'anonymous'}
</h2> </h2>
{isEditable && ( {isEditable && (
<div className="flex h-8 w-1/5 justify-end"> <div className="flex h-8 w-1/5 justify-end">
@ -126,22 +132,26 @@ export default function ProfileHeader({
</div> </div>
)} )}
</div> </div>
<div className="flex flex-row"> {(experiences[0]?.companyName ||
<BuildingOffice2Icon className="mr-2.5 h-5" /> experiences[0]?.jobLevel ||
<span className="mr-2 font-bold">Current:</span> experiences[0]?.jobTitle) && (
<span> <div className="flex flex-row">
{`${background?.experiences[0]?.companyName ?? '-'} ${ <BuildingOffice2Icon className="mr-2.5 h-5" />
background?.experiences[0]?.jobLevel || '' <span className="mr-2 font-bold">Current:</span>
} ${background?.experiences[0]?.jobTitle || ''}`} <span>
</span> {`${experiences[0]?.companyName || ''} ${
</div> experiences[0]?.jobLevel || ''
} ${experiences[0]?.jobTitle || ''}`}
</span>
</div>
)}
<div className="flex flex-row"> <div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" /> <CalendarDaysIcon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">YOE:</span> <span className="mr-2 font-bold">YOE:</span>
<span className="mr-4">{background?.totalYoe}</span> <span className="mr-4">{totalYoe}</span>
{background?.specificYoes && {specificYoes &&
background?.specificYoes.length > 0 && specificYoes.length > 0 &&
background?.specificYoes.map(({ domain, yoe }) => { specificYoes.map(({ domain, yoe }) => {
return ( return (
<span <span
key={domain} key={domain}

@ -1,6 +1,14 @@
export default function ProfilePhotoHolder() { type ProfilePhotoHolderProps = {
size?: 'lg' | 'sm';
};
export default function ProfilePhotoHolder({
size = 'lg',
}: ProfilePhotoHolderProps) {
const sizeMap = { lg: '16', sm: '12' };
return ( return (
<span className="inline-block h-16 w-16 overflow-hidden rounded-full bg-gray-100"> <span
className={`inline-block h-${sizeMap[size]} w-${sizeMap[size]} overflow-hidden rounded-full bg-gray-100`}>
<svg <svg
className="h-full w-full text-gray-300" className="h-full w-full text-gray-300"
fill="currentColor" fill="currentColor"

@ -1,14 +1,11 @@
import type { JobType } from '@prisma/client';
import type { MonthYear } from '~/components/shared/MonthYearPicker'; import type { MonthYear } from '~/components/shared/MonthYearPicker';
/* /*
* Offer Profile * Offer Profile
*/ */
export enum JobType {
FullTime = 'FULLTIME',
Intern = 'INTERN',
}
export const JobTypeLabel = { export const JobTypeLabel = {
FULLTIME: 'Full-time', FULLTIME: 'Full-time',
INTERN: 'Internship', INTERN: 'Internship',
@ -26,17 +23,20 @@ export enum EducationBackgroundType {
export type OffersProfilePostData = { export type OffersProfilePostData = {
background: BackgroundPostData; background: BackgroundPostData;
id?: string;
offers: Array<OfferPostData>; offers: Array<OfferPostData>;
}; };
export type OffersProfileFormData = { export type OffersProfileFormData = {
background: BackgroundPostData; background: BackgroundPostData;
id?: string;
offers: Array<OfferFormData>; offers: Array<OfferFormData>;
}; };
export type BackgroundPostData = { export type BackgroundPostData = {
educations: Array<EducationPostData>; educations: Array<EducationPostData>;
experiences: Array<ExperiencePostData>; experiences: Array<ExperiencePostData>;
id?: string;
specificYoes: Array<SpecificYoePostData>; specificYoes: Array<SpecificYoePostData>;
totalYoe: number; totalYoe: number;
}; };
@ -44,6 +44,7 @@ export type BackgroundPostData = {
type ExperiencePostData = { type ExperiencePostData = {
companyId?: string | null; companyId?: string | null;
durationInMonths?: number | null; durationInMonths?: number | null;
id?: string;
jobType?: string | null; jobType?: string | null;
level?: string | null; level?: string | null;
location?: string | null; location?: string | null;
@ -57,6 +58,7 @@ type ExperiencePostData = {
type EducationPostData = { type EducationPostData = {
endDate?: Date | null; endDate?: Date | null;
field?: string | null; field?: string | null;
id?: string;
school?: string | null; school?: string | null;
startDate?: Date | null; startDate?: Date | null;
type?: string | null; type?: string | null;
@ -64,6 +66,7 @@ type EducationPostData = {
type SpecificYoePostData = { type SpecificYoePostData = {
domain: string; domain: string;
id?: string;
yoe: number; yoe: number;
}; };
@ -72,7 +75,8 @@ type SpecificYoe = SpecificYoePostData;
export type OfferPostData = { export type OfferPostData = {
comments: string; comments: string;
companyId: string; companyId: string;
jobType: string; id?: string;
jobType: JobType;
location: string; location: string;
monthYearReceived: Date; monthYearReceived: Date;
negotiationStrategy: string; negotiationStrategy: string;
@ -87,6 +91,7 @@ export type OfferFormData = Omit<OfferPostData, 'monthYearReceived'> & {
export type OfferFullTimePostData = { export type OfferFullTimePostData = {
baseSalary: Money; baseSalary: Money;
bonus: Money; bonus: Money;
id?: string;
level: string; level: string;
specialization: string; specialization: string;
stocks: Money; stocks: Money;
@ -95,6 +100,7 @@ export type OfferFullTimePostData = {
}; };
export type OfferInternPostData = { export type OfferInternPostData = {
id?: string;
internshipCycle: string; internshipCycle: string;
monthlySalary: Money; monthlySalary: Money;
specialization: string; specialization: string;
@ -104,40 +110,41 @@ export type OfferInternPostData = {
export type Money = { export type Money = {
currency: string; currency: string;
id?: string;
value: number; value: number;
}; };
type EducationDisplay = { export type EducationDisplayData = {
endDate?: string; endDate?: string | null;
field: string; field?: string | null;
school: string; school?: string | null;
startDate?: string; startDate?: string | null;
type: string; type?: string | null;
}; };
export type OfferEntity = { export type OfferDisplayData = {
base?: string; base?: string | null;
bonus?: string; bonus?: string | null;
companyName?: string; companyName?: string | null;
duration?: string; duration?: number | null;
id?: string; id?: string;
jobLevel?: string; jobLevel?: string | null;
jobTitle?: string; jobTitle?: string | null;
location?: string; location?: string | null;
monthlySalary?: string; monthlySalary?: string | null;
negotiationStrategy?: string; negotiationStrategy?: string | null;
otherComment?: string; otherComment?: string | null;
receivedMonth?: string; receivedMonth?: string | null;
stocks?: string; stocks?: string | null;
totalCompensation?: string; totalCompensation?: string | null;
}; };
export type BackgroundCard = { export type BackgroundDisplayData = {
educations: Array<EducationDisplay>; educations: Array<EducationDisplayData>;
experiences: Array<OfferEntity>; experiences: Array<OfferDisplayData>;
profileName: string; profileName: string;
specificYoes: Array<SpecificYoe>; specificYoes: Array<SpecificYoe>;
totalYoe: string; totalYoe: number;
}; };
export type CommentEntity = { export type CommentEntity = {

@ -7,7 +7,7 @@ import {
import { TextInput } from '@tih/ui'; import { TextInput } from '@tih/ui';
import ContributeQuestionDialog from './ContributeQuestionDialog'; import ContributeQuestionDialog from './ContributeQuestionDialog';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm'; import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
export type ContributeQuestionCardProps = Pick< export type ContributeQuestionCardProps = Pick<
ContributeQuestionFormProps, ContributeQuestionFormProps,

@ -2,9 +2,9 @@ import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react'; import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '@tih/ui'; import { HorizontalDivider } from '@tih/ui';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import ContributeQuestionForm from './ContributeQuestionForm';
import DiscardDraftDialog from './DiscardDraftDialog'; import DiscardDraftDialog from './DiscardDraftDialog';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
import ContributeQuestionForm from './forms/ContributeQuestionForm';
export type ContributeQuestionDialogProps = Pick< export type ContributeQuestionDialogProps = Pick<
ContributeQuestionFormProps, ContributeQuestionFormProps,
@ -60,14 +60,14 @@ export default function ContributeQuestionDialog({
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"> leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full"> <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 p-6 pt-5 sm:pb-4">
<div className="flex flex-1 items-stretch"> <div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:text-left"> <div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-medium leading-6 text-gray-900"> className="text-lg font-medium leading-6 text-gray-900">
Question Draft Contribute question
</Dialog.Title> </Dialog.Title>
<div className="w-full"> <div className="w-full">
<HorizontalDivider /> <HorizontalDivider />

@ -1,13 +1,15 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { ArrowSmallRightIcon } from '@heroicons/react/24/outline'; import { ArrowSmallRightIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Select } from '@tih/ui'; import { Button, Select } from '@tih/ui';
import { import { QUESTION_TYPES } from '~/utils/questions/constants';
COMPANIES, import useDefaultCompany from '~/utils/questions/useDefaultCompany';
LOCATIONS, import useDefaultLocation from '~/utils/questions/useDefaultLocation';
QUESTION_TYPES,
} from '~/utils/questions/constants'; import type { FilterChoice } from './filter/FilterSection';
import CompanyTypeahead from './typeahead/CompanyTypeahead';
import LocationTypeahead from './typeahead/LocationTypeahead';
export type LandingQueryData = { export type LandingQueryData = {
company: string; company: string;
@ -22,76 +24,109 @@ export type LandingComponentProps = {
export default function LandingComponent({ export default function LandingComponent({
onLanded: handleLandingQuery, onLanded: handleLandingQuery,
}: LandingComponentProps) { }: LandingComponentProps) {
const [landingQueryData, setLandingQueryData] = useState<LandingQueryData>({ const defaultCompany = useDefaultCompany();
company: 'Google', const defaultLocation = useDefaultLocation();
location: 'Singapore',
questionType: 'CODING', const [company, setCompany] = useState<FilterChoice | undefined>(
}); defaultCompany,
);
const [location, setLocation] = useState<FilterChoice | undefined>(
defaultLocation,
);
const [questionType, setQuestionType] =
useState<QuestionsQuestionType>('CODING');
const handleChangeCompany = (company: string) => { const handleChangeCompany = (newCompany: FilterChoice) => {
setLandingQueryData((prev) => ({ ...prev, company })); setCompany(newCompany);
}; };
const handleChangeLocation = (location: string) => { const handleChangeLocation = (newLocation: FilterChoice) => {
setLandingQueryData((prev) => ({ ...prev, location })); setLocation(newLocation);
}; };
const handleChangeType = (questionType: QuestionsQuestionType) => { const handleChangeType = (newQuestionType: QuestionsQuestionType) => {
setLandingQueryData((prev) => ({ ...prev, questionType })); setQuestionType(newQuestionType);
}; };
return ( useEffect(() => {
<main className="flex flex-1 flex-col items-stretch overflow-y-auto bg-white"> if (company === undefined) {
<div className="pb-4"></div> setCompany(defaultCompany);
<div className="flex flex-1 flex-col justify-center gap-3"> }
<div className="flex items-center justify-center"> }, [defaultCompany, company]);
<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"> useEffect(() => {
Tech Interview Question Bank if (location === undefined) {
</h1> setLocation(defaultLocation);
</div> }
<p className="mx-auto max-w-lg p-6 text-center text-xl text-black sm:max-w-3xl"> }, [defaultLocation, location]);
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"> return (
<p>Find</p> <main className="flex flex-1 flex-col items-center overflow-y-auto bg-white">
<div className=" space-x-2"> <div className="flex flex-1 flex-col items-start justify-center gap-12 px-4">
<header className="flex flex-col items-start gap-4">
<div className="flex items-center justify-center">
<h1 className="text-3xl font-semibold text-slate-900">
Tech Interview Question Bank
</h1>
<img alt="app logo" className="h-20 w-20" src="/logo.svg"></img>
</div>
<p className="mb-2 max-w-lg text-5xl font-semibold text-slate-900 sm:max-w-3xl">
Know the{' '}
<span className="text-primary-700">
latest SWE interview questions
</span>{' '}
asked by top companies.
</p>
</header>
<div className="flex flex-col items-start gap-3 text-xl font-semibold text-slate-900">
<p className="text-3xl">Find questions</p>
<div className="grid grid-cols-[auto_auto] items-baseline gap-x-4 gap-y-2">
<p className="text-slate-600">about</p>
<Select <Select
isLabelHidden={true} isLabelHidden={true}
label="Type" label="Type"
options={QUESTION_TYPES} options={QUESTION_TYPES}
value={landingQueryData.questionType} value={questionType}
onChange={(value) => { onChange={(value) => {
handleChangeType(value.toUpperCase() as QuestionsQuestionType); handleChangeType(value.toUpperCase() as QuestionsQuestionType);
}} }}
/> />
<p className="text-slate-600">from</p>
<CompanyTypeahead
isLabelHidden={true}
value={company}
onSelect={(value) => {
handleChangeCompany(value);
}}
/>
<p className="text-slate-600">in</p>
<LocationTypeahead
isLabelHidden={true}
value={location}
onSelect={(value) => {
handleChangeLocation(value);
}}
/>
</div> </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 <Button
addonPosition="end" addonPosition="end"
icon={ArrowSmallRightIcon} icon={ArrowSmallRightIcon}
label="Go" label="Go"
size="md" size="md"
variant="primary" variant="primary"
onClick={() => handleLandingQuery(landingQueryData)}></Button> onClick={() => {
if (company !== undefined && location !== undefined) {
return handleLandingQuery({
company: company.value,
location: location.value,
questionType,
});
}
}}
/>
</div> </div>
<div className="flex justify-center p-4"> <div className="flex justify-center">
<iframe <iframe
height={30} 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" src="https://ghbtns.com/github-btn.html?user=yangshun&amp;repo=tech-interview-handbook&amp;type=star&amp;count=true&amp;size=large"

@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { Badge } from '@tih/ui';
import 'react-popper-tooltip/dist/styles.css';
type BadgeProps = ComponentProps<typeof Badge>;
export type QuestionAggregateBadgeProps = Omit<BadgeProps, 'label'> & {
statistics: Record<string, number>;
};
export default function QuestionAggregateBadge({
statistics,
...badgeProps
}: QuestionAggregateBadgeProps) {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({
interactive: true,
placement: 'bottom-start',
trigger: ['focus', 'hover'],
});
const mostCommonStatistic = useMemo(
() =>
Object.entries(statistics).reduce(
(mostCommon, [key, value]) => {
if (value > mostCommon.value) {
return { key, value };
}
return mostCommon;
},
{ key: '', value: 0 },
),
[statistics],
);
const sortedStatistics = useMemo(
() =>
Object.entries(statistics)
.sort((a, b) => b[1] - a[1])
.map(([key, value]) => ({ key, value })),
[statistics],
);
const additionalStatisticCount = Object.keys(statistics).length - 1;
const label = useMemo(() => {
if (additionalStatisticCount === 0) {
return mostCommonStatistic.key;
}
return `${mostCommonStatistic.key} (+${additionalStatisticCount})`;
}, [mostCommonStatistic, additionalStatisticCount]);
return (
<>
<button ref={setTriggerRef} className="rounded-full" type="button">
<Badge label={label} {...badgeProps} />
</button>
{visible && (
<div ref={setTooltipRef} {...getTooltipProps()}>
<div className="flex flex-col gap-2 rounded-md bg-white p-2 shadow-md">
<ul>
{sortedStatistics.map(({ key, value }) => (
<li
key={key}
className="flex justify-between gap-x-4 rtl:flex-row-reverse">
<span className="flex text-start font-semibold">{key}</span>
<span className="float-end">{value}</span>
</li>
))}
</ul>
</div>
</div>
)}
</>
);
}

@ -4,29 +4,41 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui'; import { Button, Select, TextInput } from '@tih/ui';
export type SortOption = { export type SortOption<Value> = {
label: string; label: string;
value: string; value: Value;
}; };
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = { type SortOrderProps<SortOrder> = {
onFilterOptionsToggle: () => void; onSortOrderChange?: (sortValue: SortOrder) => void;
onSortChange?: (sortValue: SortOptions[number]['value']) => void; sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOptions: SortOptions; sortOrderValue: SortOrder;
sortValue: SortOptions[number]['value'];
}; };
export default function QuestionSearchBar< type SortTypeProps<SortType> = {
SortOptions extends Array<SortOption>, onSortTypeChange?: (sortType: SortType) => void;
>({ sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
onSortChange, sortTypeValue: SortType;
sortOptions, };
sortValue,
export type QuestionSearchBarProps<SortType, SortOrder> =
SortOrderProps<SortOrder> &
SortTypeProps<SortType> & {
onFilterOptionsToggle: () => void;
};
export default function QuestionSearchBar<SortType, SortOrder>({
onSortOrderChange,
sortOrderOptions,
sortOrderValue,
onSortTypeChange,
sortTypeOptions,
sortTypeValue,
onFilterOptionsToggle, onFilterOptionsToggle,
}: QuestionSearchBarProps<SortOptions>) { }: QuestionSearchBarProps<SortType, SortOrder>) {
return ( return (
<div className="flex items-center gap-4"> <div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1"> <div className="flex-1 ">
<TextInput <TextInput
isLabelHidden={true} isLabelHidden={true}
label="Search by content" label="Search by content"
@ -35,27 +47,48 @@ export default function QuestionSearchBar<
startAddOnType="icon" startAddOnType="icon"
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-end justify-end gap-4">
<span aria-hidden={true} className="align-middle text-sm font-medium"> <div className="flex items-center gap-2">
Sort by: <Select
</span> display="inline"
<Select label="Sort by"
display="inline" options={sortTypeOptions}
isLabelHidden={true} value={sortTypeValue}
label="Sort by" onChange={(value) => {
options={sortOptions} const chosenOption = sortTypeOptions.find(
value={sortValue} (option) => String(option.value) === value,
onChange={onSortChange} );
/> if (chosenOption) {
</div> onSortTypeChange?.(chosenOption.value);
<div className="lg:hidden"> }
<Button }}
addonPosition="start" />
icon={AdjustmentsHorizontalIcon} </div>
label="Filter options" <div className="flex items-center gap-2">
variant="tertiary" <Select
onClick={onFilterOptionsToggle} display="inline"
/> label="Order by"
options={sortOrderOptions}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortOrderChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="lg:hidden">
<Button
addonPosition="start"
icon={AdjustmentsHorizontalIcon}
label="Filter options"
variant="tertiary"
onClick={onFilterOptionsToggle}
/>
</div>
</div> </div>
</div> </div>
); );

@ -1,9 +1,10 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [ const navigation: ProductNavigationItems = [
{ href: '/questions', name: 'My Lists' }, { href: '/questions/browse', name: 'Browse' },
{ href: '/questions', name: 'My Questions' }, { href: '/questions/lists', name: 'My Lists' },
{ href: '/questions', name: 'History' }, { href: '/questions/my-questions', name: 'My Questions' },
{ href: '/questions/history', name: 'History' },
]; ];
const config = { const config = {

@ -13,6 +13,7 @@ export type AnswerCardProps = {
commentCount?: number; commentCount?: number;
content: string; content: string;
createdAt: Date; createdAt: Date;
showHover?: boolean;
upvoteCount: number; upvoteCount: number;
votingButtonsSize: VotingButtonsProps['size']; votingButtonsSize: VotingButtonsProps['size'];
}; };
@ -26,10 +27,14 @@ export default function AnswerCard({
commentCount, commentCount,
votingButtonsSize, votingButtonsSize,
upvoteCount, upvoteCount,
showHover,
}: AnswerCardProps) { }: AnswerCardProps) {
const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId); const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
return ( return (
<article className="flex gap-4 rounded-md border bg-white p-2"> <article
className={`flex gap-4 rounded-md border bg-white p-2 ${hoverClass}`}>
<VotingButtons <VotingButtons
size={votingButtonsSize} size={votingButtonsSize}
upvoteCount={upvoteCount} upvoteCount={upvoteCount}

@ -1,26 +0,0 @@
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: false;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
return (
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={false}
showVoteButtons={true}
/>
);
}

@ -4,11 +4,11 @@ import type { AnswerCardProps } from './AnswerCard';
import AnswerCard from './AnswerCard'; import AnswerCard from './AnswerCard';
export type QuestionAnswerCardProps = Required< export type QuestionAnswerCardProps = Required<
Omit<AnswerCardProps, 'votingButtonsSize'> Omit<AnswerCardProps, 'showHover' | 'votingButtonsSize'>
>; >;
function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) { function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) {
return <AnswerCard {...props} votingButtonsSize="sm" />; return <AnswerCard {...props} showHover={true} votingButtonsSize="sm" />;
} }
const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref); const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref);

@ -1,126 +0,0 @@
import { ChatBubbleBottomCenterTextIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Badge, Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote';
import QuestionTypeBadge from '../QuestionTypeBadge';
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 & {
company: string;
content: string;
location: string;
questionId: string;
receivedCount: number;
role: string;
timestamp: string;
type: QuestionsQuestionType;
};
export default function QuestionCard({
questionId,
company,
answerCount,
content,
// ReceivedCount,
type,
showVoteButtons,
showUserStatistics,
showActionButton,
actionButtonLabel,
onActionButtonClick,
upvoteCount,
timestamp,
role,
location,
}: QuestionCardProps) {
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
)}
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-2 text-slate-500">
<Badge label={company} variant="primary" />
<QuestionTypeBadge type={type} />
<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>
);
}

@ -1,31 +0,0 @@
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;

@ -1,31 +0,0 @@
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}
/>
);
}

@ -0,0 +1,232 @@
import clsx from 'clsx';
import { useState } from 'react';
import {
ChatBubbleBottomCenterTextIcon,
CheckIcon,
EyeIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote';
import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm';
import CreateQuestionEncounterForm from '../../forms/CreateQuestionEncounterForm';
import QuestionAggregateBadge from '../../QuestionAggregateBadge';
import QuestionTypeBadge from '../../QuestionTypeBadge';
import VotingButtons from '../../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
type DeleteProps =
| {
onDelete: () => void;
showDeleteButton: true;
}
| {
onDelete?: never;
showDeleteButton?: false;
};
type AnswerStatisticsProps =
| {
answerCount: number;
showAnswerStatistics: true;
}
| {
answerCount?: never;
showAnswerStatistics?: false;
};
type ActionButtonProps =
| {
actionButtonLabel: string;
onActionButtonClick: () => void;
showActionButton: true;
}
| {
actionButtonLabel?: never;
onActionButtonClick?: never;
showActionButton?: false;
};
type ReceivedStatisticsProps =
| {
receivedCount: number;
showReceivedStatistics: true;
}
| {
receivedCount?: never;
showReceivedStatistics?: false;
};
type CreateEncounterProps =
| {
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
showCreateEncounterButton: true;
}
| {
onReceivedSubmit?: never;
showCreateEncounterButton?: false;
};
export type BaseQuestionCardProps = ActionButtonProps &
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;
truncateContent?: boolean;
type: QuestionsQuestionType;
};
export default function BaseQuestionCard({
questionId,
companies,
answerCount,
content,
receivedCount,
type,
showVoteButtons,
showAnswerStatistics,
showReceivedStatistics,
showCreateEncounterButton,
showActionButton,
actionButtonLabel,
onActionButtonClick,
upvoteCount,
timestamp,
roles,
locations,
showHover,
onReceivedSubmit,
showDeleteButton,
onDelete,
truncateContent = true,
}: BaseQuestionCardProps) {
const [showReceivedForm, setShowReceivedForm] = useState(false);
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
const cardContent = (
<>
{showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
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">
<QuestionTypeBadge type={type} />
<QuestionAggregateBadge statistics={companies} variant="primary" />
<QuestionAggregateBadge statistics={locations} variant="success" />
<QuestionAggregateBadge statistics={roles} variant="danger" />
<p className="text-xs">{timestamp}</p>
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
onClick={onActionButtonClick}
/>
)}
</div>
<p className={clsx(truncateContent && 'line-clamp-2 text-ellipsis')}>
{content}
</p>
{!showReceivedForm &&
(showAnswerStatistics ||
showReceivedStatistics ||
showCreateEncounterButton) && (
<div className="flex gap-2">
{showAnswerStatistics && (
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
)}
{showReceivedStatistics && (
<Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/>
)}
{showCreateEncounterButton && (
<Button
addonPosition="start"
icon={CheckIcon}
label="I received this too"
size="sm"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
setShowReceivedForm(true);
}}
/>
)}
</div>
)}
{showReceivedForm && (
<CreateQuestionEncounterForm
onCancel={() => {
setShowReceivedForm(false);
}}
onSubmit={(data) => {
onReceivedSubmit?.(data);
setShowReceivedForm(false);
}}
/>
)}
</div>
</>
);
return (
<article
className={`group flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}>
{cardContent}
{showDeleteButton && (
<div className="invisible self-center fill-red-700 group-hover:visible">
<Button
icon={TrashIcon}
isLabelHidden={true}
label="Delete"
size="md"
variant="tertiary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
/>
</div>
)}
</article>
);
}

@ -0,0 +1,35 @@
import type { BaseQuestionCardProps } from './BaseQuestionCard';
import BaseQuestionCard from './BaseQuestionCard';
export type QuestionOverviewCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAnswerStatistics: false;
showCreateEncounterButton: true;
showDeleteButton: false;
showReceivedStatistics: false;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
| 'showReceivedStatistics'
| 'showVoteButtons'
>;
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
return (
<BaseQuestionCard
{...props}
showActionButton={false}
showAnswerStatistics={false}
showCreateEncounterButton={true}
showReceivedStatistics={false}
showVoteButtons={true}
truncateContent={false}
/>
);
}

@ -0,0 +1,36 @@
import withHref from '~/utils/questions/withHref';
import type { BaseQuestionCardProps } from './BaseQuestionCard';
import BaseQuestionCard from './BaseQuestionCard';
export type QuestionListCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAnswerStatistics: false;
showDeleteButton: true;
showVoteButtons: false;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAnswerStatistics'
| 'showDeleteButton'
| 'showVoteButtons'
>;
function QuestionListCardWithoutHref(props: QuestionListCardProps) {
return (
<BaseQuestionCard
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
showActionButton={false}
showAnswerStatistics={false}
showDeleteButton={true}
showHover={true}
showVoteButtons={false}
/>
);
}
const QuestionListCard = withHref(QuestionListCardWithoutHref);
export default QuestionListCard;

@ -0,0 +1,42 @@
import withHref from '~/utils/questions/withHref';
import type { BaseQuestionCardProps } from './BaseQuestionCard';
import BaseQuestionCard from './BaseQuestionCard';
export type QuestionOverviewCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAnswerStatistics: true;
showCreateEncounterButton: false;
showDeleteButton: false;
showReceivedStatistics: true;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'onDelete'
| 'showActionButton'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
| 'showReceivedStatistics'
| 'showVoteButtons'
>;
function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
return (
<BaseQuestionCard
{...props}
showActionButton={false}
showAnswerStatistics={true}
showCreateEncounterButton={false}
showDeleteButton={false}
showHover={true}
showReceivedStatistics={true}
showVoteButtons={true}
/>
);
}
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
export default QuestionOverviewCard;

@ -0,0 +1,44 @@
import type { BaseQuestionCardProps } from './BaseQuestionCard';
import BaseQuestionCard from './BaseQuestionCard';
export type SimilarQuestionCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: true;
showAnswerStatistics: true;
showCreateEncounterButton: false;
showDeleteButton: false;
showHover: true;
showReceivedStatistics: false;
showVoteButtons: false;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
| 'showHover'
| 'showReceivedStatistics'
| 'showVoteButtons'
> & {
onSimilarQuestionClick: () => void;
};
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
const { onSimilarQuestionClick, ...rest } = props;
return (
<BaseQuestionCard
actionButtonLabel="Yes, this is my question"
showActionButton={true}
showAnswerStatistics={true}
showCreateEncounterButton={false}
showDeleteButton={false}
showHover={true}
showReceivedStatistics={true}
showVoteButtons={true}
onActionButtonClick={onSimilarQuestionClick}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(rest as any)}
/>
);
}

@ -1,14 +1,20 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import { useMemo } from 'react';
import { CheckboxInput, Collapsible, RadioList, TextInput } from '@tih/ui'; import type { UseFormRegisterReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { CheckboxInput, Collapsible, RadioList } from '@tih/ui';
export type FilterOption<V extends string = string> = { export type FilterChoice<V extends string = string> = {
checked: boolean; id: string;
label: string; label: string;
value: V; value: V;
}; };
export type FilterOption<V extends string = string> = FilterChoice<V> & {
checked: boolean;
};
export type FilterChoices<V extends string = string> = ReadonlyArray< export type FilterChoices<V extends string = string> = ReadonlyArray<
Omit<FilterOption<V>, 'checked'> FilterChoice<V>
>; >;
type FilterSectionType<FilterOptions extends Array<FilterOption>> = type FilterSectionType<FilterOptions extends Array<FilterOption>> =
@ -30,42 +36,87 @@ export type FilterSectionProps<FilterOptions extends Array<FilterOption>> =
options: FilterOptions; options: FilterOptions;
} & ( } & (
| { | {
searchPlaceholder: string; renderInput: (props: {
field: UseFormRegisterReturn<'search'>;
onOptionChange: FilterSectionType<FilterOptions>['onOptionChange'];
options: FilterOptions;
}) => React.ReactNode;
showAll?: never; showAll?: never;
} }
| { | {
searchPlaceholder?: never; renderInput?: never;
showAll: true; showAll: true;
} }
); );
export type FilterSectionFormData = {
search: string;
};
export default function FilterSection< export default function FilterSection<
FilterOptions extends Array<FilterOption>, FilterOptions extends Array<FilterOption>,
>({ >({
label, label,
options, options,
searchPlaceholder,
showAll, showAll,
onOptionChange, onOptionChange,
isSingleSelect, isSingleSelect,
renderInput,
}: FilterSectionProps<FilterOptions>) { }: FilterSectionProps<FilterOptions>) {
const { register, reset } = useForm<FilterSectionFormData>();
const registerSearch = register('search');
const field: UseFormRegisterReturn<'search'> = {
...registerSearch,
onChange: async (event) => {
await registerSearch.onChange(event);
reset();
},
};
const autocompleteOptions = useMemo(() => {
return options.filter((option) => !option.checked) as FilterOptions;
}, [options]);
const selectedCount = useMemo(() => {
return options.filter((option) => option.checked).length;
}, [options]);
const collapsibleLabel = useMemo(() => {
if (isSingleSelect) {
return label;
}
if (selectedCount === 0) {
return `${label} (all)`;
}
return `${label} (${selectedCount})`;
}, [label, selectedCount, isSingleSelect]);
return ( return (
<div className="mx-2"> <div className="mx-2 py-2">
<Collapsible defaultOpen={true} label={label}> <Collapsible defaultOpen={true} label={collapsibleLabel}>
<div className="-mx-2 flex flex-col items-stretch gap-2"> <div className="-mx-2 flex flex-col items-stretch gap-2">
{!showAll && ( {!showAll && (
<TextInput <div className="z-10">
isLabelHidden={true} {renderInput({
label={label} field,
placeholder={searchPlaceholder} onOptionChange: async (
startAddOn={MagnifyingGlassIcon} optionValue: FilterOptions[number]['value'],
startAddOnType="icon" ) => {
/> reset();
return onOptionChange(optionValue, true);
},
options: autocompleteOptions,
})}
</div>
)} )}
{isSingleSelect ? ( {isSingleSelect ? (
<div className="px-1.5"> <div className="px-1.5">
<RadioList <RadioList
label="" isLabelHidden={true}
label={label}
value={options.find((option) => option.checked)?.value} value={options.find((option) => option.checked)?.value}
onChange={(value) => { onChange={(value) => {
onOptionChange(value); onOptionChange(value);
@ -81,16 +132,18 @@ export default function FilterSection<
</div> </div>
) : ( ) : (
<div className="px-1.5"> <div className="px-1.5">
{options.map((option) => ( {options
<CheckboxInput .filter((option) => showAll || option.checked)
key={option.value} .map((option) => (
label={option.label} <CheckboxInput
value={option.checked} key={option.value}
onChange={(checked) => { label={option.label}
onOptionChange(option.value, checked); value={option.checked}
}} onChange={(checked) => {
/> onOptionChange(option.value, checked);
))} }}
/>
))}
</div> </div>
)} )}
</div> </div>

@ -1,26 +1,26 @@
import { startOfMonth } from 'date-fns'; import { startOfMonth } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { CalendarDaysIcon, UserIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { import {
Button, Button,
CheckboxInput, CheckboxInput,
Collapsible, HorizontalDivider,
Select, Select,
TextArea, TextArea,
TextInput,
} from '@tih/ui'; } from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants'; import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import { import {
useFormRegister, useFormRegister,
useSelectRegister, useSelectRegister,
} from '~/utils/questions/useFormRegister'; } from '~/utils/questions/useFormRegister';
import CompaniesTypeahead from '../shared/CompaniesTypeahead'; import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import type { Month } from '../shared/MonthYearPicker'; import LocationTypeahead from '../typeahead/LocationTypeahead';
import MonthYearPicker from '../shared/MonthYearPicker'; import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker';
export type ContributeQuestionData = { export type ContributeQuestionData = {
company: string; company: string;
@ -59,8 +59,17 @@ export default function ContributeQuestionForm({
}; };
return ( return (
<form <form
className=" flex flex-1 flex-col items-stretch justify-center pb-[50px]" className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
onSubmit={handleSubmit(onSubmit)}> onSubmit={handleSubmit(onSubmit)}>
<div className="min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<TextArea <TextArea
label="Question Prompt" label="Question Prompt"
placeholder="Contribute a question" placeholder="Contribute a question"
@ -68,40 +77,41 @@ export default function ContributeQuestionForm({
rows={5} rows={5}
{...register('questionContent')} {...register('questionContent')}
/> />
<div className="mt-3 mb-1 flex flex-wrap items-end gap-2"> <HorizontalDivider />
<div className="mr-2 min-w-[113px] max-w-[113px] flex-1"> <h2 className="text-md text-primary-800 font-semibold">
<Select Additional information
defaultValue="coding" </h2>
label="Type" <div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
options={QUESTION_TYPES} <div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
required={true}
{...selectRegister('questionType')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<Controller <Controller
control={control} control={control}
name="company" name="location"
render={({ field }) => ( render={({ field }) => (
<CompaniesTypeahead <LocationTypeahead
onSelect={({ id }) => { required={true}
field.onChange(id); onSelect={(option) => {
field.onChange(option.value);
}} }}
{...field}
value={LOCATIONS.find(
(location) => location.value === field.value,
)}
/> />
)} )}
/> />
</div> </div>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<div className="min-w-[150px] max-w-[300px] flex-1">
<Controller <Controller
control={control} control={control}
name="date" name="date"
render={({ field }) => ( render={({ field }) => (
<MonthYearPicker <MonthYearPicker
monthRequired={true}
value={{ value={{
month: (field.value.getMonth() + 1) as Month, month: ((field.value.getMonth() as number) + 1) as Month,
year: field.value.getFullYear(), year: field.value.getFullYear(),
}} }}
yearRequired={true}
onChange={({ month, year }) => onChange={({ month, year }) =>
field.onChange(startOfMonth(new Date(year, month - 1))) field.onChange(startOfMonth(new Date(year, month - 1)))
} }
@ -110,28 +120,38 @@ export default function ContributeQuestionForm({
/> />
</div> </div>
</div> </div>
<Collapsible defaultOpen={true} label="Additional info"> <div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="justify-left flex flex-wrap items-end gap-2"> <div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<div className="min-w-[150px] max-w-[300px] flex-1"> <Controller
<TextInput control={control}
label="Location" name="company"
required={true} render={({ field }) => (
startAddOn={CalendarDaysIcon} <CompanyTypeahead
startAddOnType="icon" required={true}
{...register('location')} onSelect={({ id }) => {
/> field.onChange(id);
</div> }}
<div className="min-w-[150px] max-w-[200px] flex-1"> />
<TextInput )}
label="Role" />
required={true}
startAddOn={UserIcon}
startAddOnType="icon"
{...register('role')}
/>
</div>
</div> </div>
</Collapsible> <div className="flex-1 sm:min-w-[150px] sm:max-w-[200px]">
<Controller
control={control}
name="role"
render={({ field }) => (
<RoleTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/>
</div>
</div>
{/* <div className="w-full"> {/* <div className="w-full">
<HorizontalDivider /> <HorizontalDivider />
</div> </div>
@ -151,15 +171,20 @@ export default function ContributeQuestionForm({
}} }}
/> />
</div> */} </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
<div className="mb-1 flex"> 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"
style={{
// Hack to make the background bleed outside the container
clipPath: 'inset(0 -100vmax)',
}}>
<div className="my-2 flex sm:my-0">
<CheckboxInput <CheckboxInput
label="I have checked that my question is new" label="I have checked that my question is new"
value={canSubmit} value={canSubmit}
onChange={handleCheckSimilarQuestions} onChange={handleCheckSimilarQuestions}
/> />
</div> </div>
<div className=" flex gap-x-2"> <div className="flex gap-x-2">
<button <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" 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" type="button"

@ -0,0 +1,148 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Button } from '@tih/ui';
import type { Month } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
export type CreateQuestionEncounterData = {
company: string;
location: string;
role: string;
seenAt: Date;
};
export type CreateQuestionEncounterFormProps = {
onCancel: () => void;
onSubmit: (data: CreateQuestionEncounterData) => void;
};
export default function CreateQuestionEncounterForm({
onCancel,
onSubmit,
}: CreateQuestionEncounterFormProps) {
const [step, setStep] = useState(0);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(
startOfMonth(new Date()),
);
return (
<div className="flex items-center gap-2">
<p className="font-md text-md text-slate-600">I saw this question at</p>
{step === 0 && (
<div>
<CompanyTypeahead
isLabelHidden={true}
placeholder="Other company"
suggestedCount={3}
onSelect={({ value: company }) => {
setSelectedCompany(company);
}}
onSuggestionClick={({ value: company }) => {
setSelectedCompany(company);
setStep(step + 1);
}}
/>
</div>
)}
{step === 1 && (
<div>
<LocationTypeahead
isLabelHidden={true}
placeholder="Other location"
suggestedCount={3}
onSelect={({ value: location }) => {
setSelectedLocation(location);
}}
onSuggestionClick={({ value: location }) => {
setSelectedLocation(location);
setStep(step + 1);
}}
/>
</div>
)}
{step === 2 && (
<div>
<RoleTypeahead
isLabelHidden={true}
placeholder="Other role"
suggestedCount={3}
onSelect={({ value: role }) => {
setSelectedRole(role);
}}
onSuggestionClick={({ value: role }) => {
setSelectedRole(role);
setStep(step + 1);
}}
/>
</div>
)}
{step === 3 && (
<MonthYearPicker
monthLabel=""
value={{
month: ((selectedDate?.getMonth() ?? 0) + 1) as Month,
year: selectedDate?.getFullYear() as number,
}}
yearLabel=""
onChange={(value) => {
setSelectedDate(
startOfMonth(new Date(value.year, value.month - 1)),
);
}}
/>
)}
{step < 3 && (
<Button
disabled={
(step === 0 && selectedCompany === null) ||
(step === 1 && selectedLocation === null) ||
(step === 2 && selectedRole === null)
}
label="Next"
variant="primary"
onClick={() => {
setStep(step + 1);
}}
/>
)}
{step === 3 && (
<Button
label="Submit"
variant="primary"
onClick={() => {
if (
selectedCompany &&
selectedLocation &&
selectedRole &&
selectedDate
) {
onSubmit({
company: selectedCompany,
location: selectedLocation,
role: selectedRole,
seenAt: selectedDate,
});
}
}}
/>
)}
<Button
label="Cancel"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
onCancel();
}}
/>
</div>
);
}

@ -0,0 +1,41 @@
import { useMemo, useState } from 'react';
import { trpc } from '~/utils/trpc';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type CompanyTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: companies } = trpc.useQuery([
'companies.list',
{
name: query,
},
]);
const companyOptions = useMemo(() => {
return (
companies?.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
);
}, [companies]);
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Company"
options={companyOptions}
onQueryChange={setQuery}
/>
);
}

@ -0,0 +1,39 @@
import type { ComponentProps } from 'react';
import { Button, Typeahead } from '@tih/ui';
import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{
onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number;
}> &
TypeaheadProps;
export default function ExpandedTypeahead({
suggestedCount = 0,
onSuggestionClick,
...typeaheadProps
}: ExpandedTypeaheadProps) {
const suggestions = typeaheadProps.options.slice(0, suggestedCount);
return (
<div className="flex flex-wrap gap-x-2">
{suggestions.map((suggestion) => (
<Button
key={suggestion.id}
label={suggestion.label}
variant="tertiary"
onClick={() => {
onSuggestionClick?.(suggestion);
}}
/>
))}
<div className="flex-1">
<Typeahead {...typeaheadProps} />
</div>
</div>
);
}

@ -0,0 +1,21 @@
import { LOCATIONS } from '~/utils/questions/constants';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type LocationTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function LocationTypeahead(props: LocationTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Location"
options={LOCATIONS}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
/>
);
}

@ -0,0 +1,21 @@
import { ROLES } from '~/utils/questions/constants';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type RoleTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function RoleTypeahead(props: RoleTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Role"
options={ROLES}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
/>
);
}

@ -3,12 +3,10 @@ import { useEffect, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf'; import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist'; import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import { import {
ArrowLeftIcon,
ArrowRightIcon,
MagnifyingGlassMinusIcon, MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon, MagnifyingGlassPlusIcon,
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui'; import { Button, Pagination, Spinner } from '@tih/ui';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`; pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
@ -86,28 +84,17 @@ export default function ResumePdf({ url }: Props) {
</div> </div>
</Document> </Document>
</div> </div>
{numPages > 1 && (
<div className="flex flex-row items-center justify-between p-4"> <div className="flex justify-center p-4">
<Button <Pagination
disabled={pageNumber === 1} current={pageNumber}
icon={ArrowLeftIcon} end={numPages}
isLabelHidden={true} label="pagination"
label="Previous" start={1}
variant="tertiary" onSelect={(page) => setPageNumber(page)}
onClick={() => setPageNumber(pageNumber - 1)} />
/> </div>
<p className="text-md text-gray-600"> )}
Page {pageNumber} of {numPages}
</p>
<Button
disabled={pageNumber >= numPages}
icon={ArrowRightIcon}
isLabelHidden={true}
label="Next"
variant="tertiary"
onClick={() => setPageNumber(pageNumber + 1)}
/>
</div>
</div> </div>
); );
} }

@ -3,7 +3,7 @@ import { Badge } from '@tih/ui';
export default function ResumeReviewsTitle() { export default function ResumeReviewsTitle() {
return ( return (
<div> <div>
<h1 className="text-2xl font-bold">Resume Reviews</h1> <h1 className="mb-1 text-2xl font-bold">Resume Reviews</h1>
<Badge <Badge
label="Check out reviewed resumes or look for resumes to review" label="Check out reviewed resumes or look for resumes to review"
variant="info" variant="info"

@ -1,23 +1,12 @@
import { Spinner } from '@tih/ui';
import ResumeListItem from './ResumeListItem'; import ResumeListItem from './ResumeListItem';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
type Props = Readonly<{ type Props = Readonly<{
isLoading: boolean;
resumes: Array<Resume>; resumes: Array<Resume>;
}>; }>;
export default function ResumeListItems({ isLoading, resumes }: Props) { export default function ResumeListItems({ resumes }: Props) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return ( return (
<ul role="list"> <ul role="list">
{resumes.map((resumeObj: Resume) => ( {resumes.map((resumeObj: Resume) => (

@ -57,7 +57,7 @@ export const BROWSE_TABS_VALUES = {
export const SORT_OPTIONS: Record<string, string> = { export const SORT_OPTIONS: Record<string, string> = {
latest: 'Latest', latest: 'Latest',
popular: 'Popular', popular: 'Popular',
topComments: 'Top Comments', topComments: 'Most Comments',
}; };
export const ROLE: Array<FilterOption<RoleFilter>> = [ export const ROLE: Array<FilterOption<RoleFilter>> = [

@ -9,7 +9,7 @@ type ContainerProps = {
export const Container: FC<ContainerProps> = ({ className, ...props }) => { export const Container: FC<ContainerProps> = ({ className, ...props }) => {
return ( return (
<div <div
className={clsx('mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', className)} className={clsx('mx-auto max-w-7xl px-4 lg:px-2', className)}
{...props} {...props}
/> />
); );

@ -4,27 +4,27 @@ import { useEffect, useState } from 'react';
import { Tab } from '@headlessui/react'; import { Tab } from '@headlessui/react';
import { Container } from './Container'; import { Container } from './Container';
import screenshotExpenses from './images/screenshots/expenses.png'; import resumeBrowse from './images/screenshots/resumes-browse.png';
import screenshotPayroll from './images/screenshots/payroll.png'; import resumeReview from './images/screenshots/resumes-review.png';
import screenshotVatReturns from './images/screenshots/vat-returns.png'; import resumeSubmit from './images/screenshots/resumes-submit.png';
const features = [ const features = [
{ {
description: description:
'Browse the most popular reviewed resumes out there and see what you can learn', 'Browse the most popular reviewed resumes out there and see what you can learn',
image: screenshotPayroll, image: resumeBrowse,
title: 'Browse', title: 'Browse',
}, },
{ {
description: description:
'Upload your own resume easily to get feedback from people in industry.', 'Upload your own resume easily to get feedback from people in industry.',
image: screenshotExpenses, image: resumeSubmit,
title: 'Submit', title: 'Submit',
}, },
{ {
description: description:
'Pass the baton forward by reviewing resumes and bounce off ideas with other engineers out there.', 'Pass the baton forward by reviewing resumes and bounce off ideas with other engineers out there.',
image: screenshotVatReturns, image: resumeReview,
title: 'Review', title: 'Review',
}, },
]; ];
@ -49,7 +49,6 @@ export function PrimaryFeatures() {
return ( return (
<section <section
aria-label="Features for running your books"
className="relative overflow-hidden bg-gradient-to-r from-indigo-400 to-indigo-700 pt-20 pb-28 sm:py-32" className="relative overflow-hidden bg-gradient-to-r from-indigo-400 to-indigo-700 pt-20 pb-28 sm:py-32"
id="features"> id="features">
<Container className="relative"> <Container className="relative">
@ -64,7 +63,7 @@ export function PrimaryFeatures() {
vertical={tabOrientation === 'vertical'}> vertical={tabOrientation === 'vertical'}>
{({ selectedIndex }) => ( {({ selectedIndex }) => (
<> <>
<div className="-mx-4 flex overflow-x-auto pb-4 sm:mx-0 sm:overflow-visible sm:pb-0 lg:col-span-5"> <div className="-mx-4 flex overflow-x-auto pb-4 sm:mx-0 sm:overflow-visible sm:pb-0 lg:col-span-4">
<Tab.List className="relative z-10 flex gap-x-4 whitespace-nowrap px-4 sm:mx-auto sm:px-0 lg:mx-0 lg:block lg:gap-x-0 lg:gap-y-1 lg:whitespace-normal"> <Tab.List className="relative z-10 flex gap-x-4 whitespace-nowrap px-4 sm:mx-auto sm:px-0 lg:mx-0 lg:block lg:gap-x-0 lg:gap-y-1 lg:whitespace-normal">
{features.map((feature, featureIndex) => ( {features.map((feature, featureIndex) => (
<div <div
@ -100,7 +99,7 @@ export function PrimaryFeatures() {
))} ))}
</Tab.List> </Tab.List>
</div> </div>
<Tab.Panels className="lg:col-span-7"> <Tab.Panels className="lg:col-span-8">
{features.map((feature) => ( {features.map((feature) => (
<Tab.Panel key={feature.title} unmount={false}> <Tab.Panel key={feature.title} unmount={false}>
<div className="relative sm:px-6 lg:hidden"> <div className="relative sm:px-6 lg:hidden">

@ -94,7 +94,6 @@ function QuoteIcon(props: QuoteProps) {
export function Testimonials() { export function Testimonials() {
return ( return (
<section <section
aria-label="What our customers are saying"
className="bg-gradient-to-r from-indigo-700 to-indigo-400 py-20 sm:py-32" className="bg-gradient-to-r from-indigo-700 to-indigo-400 py-20 sm:py-32"
id="testimonials"> id="testimonials">
<Container> <Container>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

@ -56,7 +56,13 @@ const analysisOfferDtoMapper = (
const analysisOfferDto: AnalysisOffer = { const analysisOfferDto: AnalysisOffer = {
company: offersCompanyDtoMapper(offer.company), company: offersCompanyDtoMapper(offer.company),
id: offer.id, id: offer.id,
income: { baseCurrency: '', baseValue: -1, currency: '', value: -1 }, income: {
baseCurrency: '',
baseValue: -1,
currency: '',
id: '',
value: -1,
},
jobType: offer.jobType, jobType: offer.jobType,
level: offer.offersFullTime?.level ?? '', level: offer.offersFullTime?.level ?? '',
location: offer.location, location: offer.location,
@ -83,6 +89,7 @@ const analysisOfferDtoMapper = (
offer.offersFullTime.totalCompensation.value; offer.offersFullTime.totalCompensation.value;
analysisOfferDto.income.currency = analysisOfferDto.income.currency =
offer.offersFullTime.totalCompensation.currency; offer.offersFullTime.totalCompensation.currency;
analysisOfferDto.income.id = offer.offersFullTime.totalCompensation.id;
analysisOfferDto.income.baseValue = analysisOfferDto.income.baseValue =
offer.offersFullTime.totalCompensation.baseValue; offer.offersFullTime.totalCompensation.baseValue;
analysisOfferDto.income.baseCurrency = analysisOfferDto.income.baseCurrency =
@ -91,6 +98,7 @@ const analysisOfferDtoMapper = (
analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value; analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value;
analysisOfferDto.income.currency = analysisOfferDto.income.currency =
offer.offersIntern.monthlySalary.currency; offer.offersIntern.monthlySalary.currency;
analysisOfferDto.income.id = offer.offersIntern.monthlySalary.id;
analysisOfferDto.income.baseValue = analysisOfferDto.income.baseValue =
offer.offersIntern.monthlySalary.baseValue; offer.offersIntern.monthlySalary.baseValue;
analysisOfferDto.income.baseCurrency = analysisOfferDto.income.baseCurrency =
@ -255,13 +263,14 @@ export const valuationDtoMapper = (currency: {
baseCurrency: string; baseCurrency: string;
baseValue: number; baseValue: number;
currency: string; currency: string;
id?: string; id: string;
value: number; value: number;
}) => { }) => {
const valuationDto: Valuation = { const valuationDto: Valuation = {
baseCurrency: currency.baseCurrency, baseCurrency: currency.baseCurrency,
baseValue: currency.baseValue, baseValue: currency.baseValue,
currency: currency.currency, currency: currency.currency,
id: currency.id,
value: currency.value, value: currency.value,
}; };
return valuationDto; return valuationDto;
@ -595,11 +604,12 @@ export const dashboardOfferDtoMapper = (
baseCurrency: '', baseCurrency: '',
baseValue: -1, baseValue: -1,
currency: '', currency: '',
id: '',
value: -1, value: -1,
}), }),
monthYearReceived: offer.monthYearReceived, monthYearReceived: offer.monthYearReceived,
profileId: offer.profileId, profileId: offer.profileId,
title: offer.offersFullTime?.title ?? '', title: offer.offersFullTime?.title || offer.offersIntern?.title || '',
totalYoe: offer.profile.background?.totalYoe ?? -1, totalYoe: offer.profile.background?.totalYoe ?? -1,
}; };

@ -3,6 +3,7 @@ import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react'; import { SessionProvider } from 'next-auth/react';
import React from 'react'; import React from 'react';
import superjson from 'superjson'; import superjson from 'superjson';
import { ToastsProvider } from '@tih/ui';
import { httpBatchLink } from '@trpc/client/links/httpBatchLink'; import { httpBatchLink } from '@trpc/client/links/httpBatchLink';
import { loggerLink } from '@trpc/client/links/loggerLink'; import { loggerLink } from '@trpc/client/links/loggerLink';
import { withTRPC } from '@trpc/next'; import { withTRPC } from '@trpc/next';
@ -19,9 +20,11 @@ const MyApp: AppType<{ session: Session | null }> = ({
}) => { }) => {
return ( return (
<SessionProvider session={session}> <SessionProvider session={session}>
<AppShell> <ToastsProvider>
<Component {...pageProps} /> <AppShell>
</AppShell> <Component {...pageProps} />
</AppShell>
</ToastsProvider>
</SessionProvider> </SessionProvider>
); );
}; };

@ -1,32 +1,11 @@
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { Month, MonthYear } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
export default function HomePage() { export default function HomePage() {
const [selectedCompany, setSelectedCompany] =
useState<TypeaheadOption | null>(null);
const [monthYear, setMonthYear] = useState<MonthYear>({
month: (new Date().getMonth() + 1) as Month,
year: new Date().getFullYear(),
});
return ( return (
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-primary-600 text-center text-4xl font-bold"> <h1 className="text-primary-600 text-center text-4xl font-bold">
Homepage Tech Interview Handbook Portal
</h1> </h1>
<CompaniesTypeahead
onSelect={(option) => setSelectedCompany(option)}
/>
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} />
</div> </div>
</div> </div>
</main> </main>

@ -5,14 +5,17 @@ import { useState } from 'react';
import ProfileComments from '~/components/offers/profile/ProfileComments'; import ProfileComments from '~/components/offers/profile/ProfileComments';
import ProfileDetails from '~/components/offers/profile/ProfileDetails'; import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader'; import ProfileHeader from '~/components/offers/profile/ProfileHeader';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types'; import type {
BackgroundDisplayData,
OfferDisplayData,
} from '~/components/offers/types';
import { convertMoneyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { getProfilePath } from '~/utils/offers/link'; import { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { Profile, ProfileOffer } from '~/types/offers'; import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers';
export default function OfferProfile() { export default function OfferProfile() {
const ErrorPage = ( const ErrorPage = (
@ -21,10 +24,11 @@ export default function OfferProfile() {
const router = useRouter(); const router = useRouter();
const { offerProfileId, token = '' } = router.query; const { offerProfileId, token = '' } = router.query;
const [isEditable, setIsEditable] = useState(false); const [isEditable, setIsEditable] = useState(false);
const [background, setBackground] = useState<BackgroundCard>(); const [background, setBackground] = useState<BackgroundDisplayData>();
const [offers, setOffers] = useState<Array<OfferEntity>>([]); const [offers, setOffers] = useState<Array<OfferDisplayData>>([]);
const [selectedTab, setSelectedTab] = useState('offers'); const [selectedTab, setSelectedTab] = useState('offers');
const [analysis, setAnalysis] = useState<ProfileAnalysis>();
const getProfileQuery = trpc.useQuery( const getProfileQuery = trpc.useQuery(
[ [
@ -44,75 +48,79 @@ export default function OfferProfile() {
setIsEditable(data?.isEditable ?? false); setIsEditable(data?.isEditable ?? false);
if (data?.offers) { const filteredOffers: Array<OfferDisplayData> = data
const filteredOffers: Array<OfferEntity> = data ? data?.offers.map((res: ProfileOffer) => {
? data?.offers.map((res: ProfileOffer) => { if (res.offersFullTime) {
if (res.offersFullTime) { const filteredOffer: OfferDisplayData = {
const filteredOffer: OfferEntity = { base: convertMoneyToString(res.offersFullTime.baseSalary),
base: convertMoneyToString(res.offersFullTime.baseSalary), bonus: convertMoneyToString(res.offersFullTime.bonus),
bonus: convertMoneyToString(res.offersFullTime.bonus),
companyName: res.company.name,
id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title,
location: res.location,
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived),
stocks: convertMoneyToString(res.offersFullTime.stocks),
totalCompensation: convertMoneyToString(
res.offersFullTime.totalCompensation,
),
};
return filteredOffer;
}
const filteredOffer: OfferEntity = {
companyName: res.company.name, companyName: res.company.name,
id: res.offersIntern!.id, id: res.offersFullTime.id,
jobTitle: res.offersIntern!.title, jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title,
location: res.location, location: res.location,
monthlySalary: convertMoneyToString( negotiationStrategy: res.negotiationStrategy,
res.offersIntern!.monthlySalary, otherComment: res.comments,
),
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived), receivedMonth: formatDate(res.monthYearReceived),
stocks: convertMoneyToString(res.offersFullTime.stocks),
totalCompensation: convertMoneyToString(
res.offersFullTime.totalCompensation,
),
}; };
return filteredOffer; return filteredOffer;
}) }
: []; const filteredOffer: OfferDisplayData = {
setOffers(filteredOffers); companyName: res.company.name,
} id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title,
location: res.location,
monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary,
),
negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments,
receivedMonth: formatDate(res.monthYearReceived),
};
return filteredOffer;
})
: [];
setOffers(filteredOffers);
if (data?.background) { if (data?.background) {
const transformedBackground = { const transformedBackground = {
educations: data.background.educations.map((education) => ({ educations: data.background.educations.map((education) => ({
endDate: education.endDate ? formatDate(education.endDate) : '-', endDate: education.endDate ? formatDate(education.endDate) : null,
field: education.field || '-', field: education.field,
school: education.school || '-', school: education.school,
startDate: education.startDate startDate: education.startDate
? formatDate(education.startDate) ? formatDate(education.startDate)
: '-', : null,
type: education.type || '-', type: education.type,
})),
experiences: data.background.experiences.map((experience) => ({
companyName: experience.company?.name ?? '-',
duration: String(experience.durationInMonths) ?? '-',
jobLevel: experience.level ?? '',
jobTitle: experience.title ?? '-',
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: '-',
totalCompensation: experience.totalCompensation
? convertMoneyToString(experience.totalCompensation)
: '-',
})), })),
experiences: data.background.experiences.map(
(experience): OfferDisplayData => ({
companyName: experience.company?.name,
duration: experience.durationInMonths,
jobLevel: experience.level,
jobTitle: experience.title,
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: null,
totalCompensation: experience.totalCompensation
? convertMoneyToString(experience.totalCompensation)
: null,
}),
),
profileName: data.profileName, profileName: data.profileName,
specificYoes: data.background.specificYoes, specificYoes: data.background.specificYoes,
totalYoe: String(data.background.totalYoe) || '-', totalYoe: data.background.totalYoe,
}; };
setBackground(transformedBackground); setBackground(transformedBackground);
} }
if (data.analysis) {
setAnalysis(data.analysis);
}
}, },
}, },
); );
@ -153,6 +161,7 @@ export default function OfferProfile() {
/> />
<div className="h-4/5 w-full overflow-y-scroll pb-32"> <div className="h-4/5 w-full overflow-y-scroll pb-32">
<ProfileDetails <ProfileDetails
analysis={analysis}
background={background} background={background}
isLoading={getProfileQuery.isLoading} isLoading={getProfileQuery.isLoading}
offers={offers} offers={offers}

@ -1,9 +1,9 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { JobType } from '@prisma/client';
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm'; import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
import type { OffersProfileFormData } from '~/components/offers/types'; import type { OffersProfileFormData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import { Spinner } from '~/../../../packages/ui/dist'; import { Spinner } from '~/../../../packages/ui/dist';
import { getProfilePath } from '~/utils/offers/link'; import { getProfilePath } from '~/utils/offers/link';
@ -25,7 +25,7 @@ export default function OffersEditPage() {
console.error(error.message); console.error(error.message);
}, },
onSuccess(data) { onSuccess(data) {
const { educations, experiences, specificYoes, totalYoe } = const { educations, experiences, specificYoes, totalYoe, id } =
data.background!; data.background!;
setInitialData({ setInitialData({
@ -33,11 +33,13 @@ export default function OffersEditPage() {
educations, educations,
experiences: experiences:
experiences.length === 0 experiences.length === 0
? [{ jobType: JobType.FullTime }] ? [{ jobType: JobType.FULLTIME }]
: experiences, : experiences,
id,
specificYoes, specificYoes,
totalYoe, totalYoe,
}, },
id: data.id,
offers: data.offers.map((offer) => ({ offers: data.offers.map((offer) => ({
comments: offer.comments, comments: offer.comments,
companyId: offer.company.id, companyId: offer.company.id,
@ -67,7 +69,7 @@ export default function OffersEditPage() {
<Spinner className="m-10" display="block" size="lg" /> <Spinner className="m-10" display="block" size="lg" />
</div> </div>
)} )}
{!getProfileResult.isLoading && ( {!getProfileResult.isLoading && initialData && (
<OffersSubmissionForm <OffersSubmissionForm
initialOfferProfileValues={initialData} initialOfferProfileValues={initialData}
profileId={profile?.id} profileId={profile?.id}

@ -8,7 +8,7 @@ function GenerateAnalysis() {
return ( return (
<div> <div>
{JSON.stringify( {JSON.stringify(
analysisMutation.mutate({ profileId: 'cl9j50xzk008vutfqg6mta2ey' }), analysisMutation.mutate({ profileId: 'cl9jj2ks1001li9fn9np47wjr' }),
)} )}
</div> </div>
); );

@ -5,7 +5,7 @@ import { trpc } from '~/utils/trpc';
function GetAnalysis() { function GetAnalysis() {
const analysis = trpc.useQuery([ const analysis = trpc.useQuery([
'offers.analysis.get', 'offers.analysis.get',
{ profileId: 'cl9j50xzk008vutfqg6mta2ey' }, { profileId: 'cl9jj2ks1001li9fn9np47wjr' },
]); ]);
return <div>{JSON.stringify(analysis.data)}</div>; return <div>{JSON.stringify(analysis.data)}</div>;

@ -1,3 +1,4 @@
import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
@ -7,6 +8,7 @@ import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem'
import FullAnswerCard from '~/components/questions/card/FullAnswerCard'; import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import { APP_TITLE } from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -51,10 +53,6 @@ export default function QuestionPage() {
}, },
); );
const handleBackNavigation = () => {
router.back();
};
const handleSubmitComment = (data: AnswerCommentData) => { const handleSubmitComment = (data: AnswerCommentData) => {
resetComment(); resetComment();
addComment({ addComment({
@ -68,90 +66,98 @@ export default function QuestionPage() {
} }
return ( return (
<div className="flex w-full flex-1 items-stretch pb-4"> <>
<div className="flex items-baseline gap-2 py-4 pl-4"> <Head>
<Button <title>
addonPosition="start" {answer.content} - {APP_TITLE}
display="inline" </title>
icon={ArrowSmallLeftIcon} </Head>
label="Back" <div className="flex w-full flex-1 items-stretch pb-4">
variant="secondary" <div className="flex items-baseline gap-2 py-4 pl-4">
onClick={handleBackNavigation}></Button> <Button
</div> addonPosition="start"
<div className="flex w-full justify-center overflow-y-auto py-4 px-5"> display="inline"
<div className="flex max-w-7xl flex-1 flex-col gap-2"> href={`/questions/${router.query.questionId}/${router.query.questionSlug}`}
<FullAnswerCard icon={ArrowSmallLeftIcon}
answerId={answer.id} label="Back"
authorImageUrl={answer.userImage} variant="secondary"
authorName={answer.user}
content={answer.content}
createdAt={answer.createdAt}
upvoteCount={answer.numVotes}
/> />
<div className="mx-2"> </div>
<form <div className="flex w-full justify-center overflow-y-auto py-4 px-5">
className="mb-2" <div className="flex max-w-7xl flex-1 flex-col gap-2">
onSubmit={handleCommentSubmit(handleSubmitComment)}> <FullAnswerCard
<TextArea answerId={answer.id}
{...commentRegister('commentContent', { authorImageUrl={answer.userImage}
minLength: 1, authorName={answer.user}
required: true, content={answer.content}
})} createdAt={answer.createdAt}
label="Post a comment" upvoteCount={answer.numVotes}
required={true} />
resize="vertical" <div className="mx-2">
rows={2} <form
/> className="mb-2"
<div className="my-3 flex justify-between"> onSubmit={handleCommentSubmit(handleSubmitComment)}>
<div className="flex items-baseline gap-2"> <TextArea
<span aria-hidden={true} className="text-sm"> {...commentRegister('commentContent', {
Sort by: minLength: 1,
</span> required: true,
<Select })}
display="inline" label="Post a comment"
isLabelHidden={true} required={true}
label="Sort by" resize="vertical"
options={[ rows={2}
{ />
label: 'Most recent', <div className="my-3 flex justify-between">
value: 'most-recent', <div className="flex items-baseline gap-2">
}, <span aria-hidden={true} className="text-sm">
{ Sort by:
label: 'Most upvotes', </span>
value: 'most-upvotes', <Select
}, display="inline"
]} isLabelHidden={true}
value="most-recent" label="Sort by"
onChange={(value) => { options={[
// eslint-disable-next-line no-console {
console.log(value); 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> </div>
</form>
<Button
disabled={!isCommentDirty || !isCommentValid} {(comments ?? []).map((comment) => (
label="Post" <AnswerCommentListItem
type="submit" key={comment.id}
variant="primary" answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/> />
</div> ))}
</form> </div>
{(comments ?? []).map((comment) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
</div> </div>
</div> </div>
</div> </div>
</div> </>
); );
} }

@ -1,13 +1,15 @@
import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline'; import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui'; import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem'; import AnswerCommentListItem from '~/components/questions/AnswerCommentListItem';
import FullQuestionCard from '~/components/questions/card/FullQuestionCard'; import FullQuestionCard from '~/components/questions/card/question/FullQuestionCard';
import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard'; import QuestionAnswerCard from '~/components/questions/card/QuestionAnswerCard';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner'; import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import { useFormRegister } from '~/utils/questions/useFormRegister'; import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -45,6 +47,11 @@ export default function QuestionPage() {
{ id: questionId as string }, { id: questionId as string },
]); ]);
const { data: aggregatedEncounters } = trpc.useQuery([
'questions.questions.encounters.getAggregatedEncounters',
{ questionId: questionId as string },
]);
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([ const { data: comments } = trpc.useQuery([
@ -74,9 +81,17 @@ export default function QuestionPage() {
}, },
}); });
const handleBackNavigation = () => { const { mutate: addEncounter } = trpc.useMutation(
router.back(); 'questions.questions.encounters.create',
}; {
onSuccess: () => {
utils.invalidateQueries(
'questions.questions.encounters.getAggregatedEncounters',
);
utils.invalidateQueries('questions.questions.getQuestionById');
},
},
);
const handleSubmitAnswer = (data: AnswerQuestionData) => { const handleSubmitAnswer = (data: AnswerQuestionData) => {
addAnswer({ addAnswer({
@ -99,44 +114,125 @@ export default function QuestionPage() {
} }
return ( return (
<div className="flex w-full flex-1 items-stretch pb-4"> <>
<div className="flex items-baseline gap-2 py-4 pl-4"> <Head>
<Button <title>
addonPosition="start" {question.content} - {APP_TITLE}
display="inline" </title>
icon={ArrowSmallLeftIcon} </Head>
label="Back" <div className="flex w-full flex-1 items-stretch pb-4">
variant="secondary" <div className="flex items-baseline gap-2 py-4 pl-4">
onClick={handleBackNavigation}></Button> <Button
</div> addonPosition="start"
<div className="flex w-full justify-center overflow-y-auto py-4 px-5"> display="inline"
<div className="flex max-w-7xl flex-1 flex-col gap-2"> href="/questions/browse"
<FullQuestionCard icon={ArrowSmallLeftIcon}
{...question} label="Back"
questionId={question.id} variant="secondary"
receivedCount={0}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
upvoteCount={question.numVotes}
/> />
<div className="mx-2"> </div>
<Collapsible label={`${(comments ?? []).length} comment(s)`}> <div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<form <div className="flex max-w-7xl flex-1 flex-col gap-2">
className="mb-2" <FullQuestionCard
onSubmit={handleCommentSubmit(handleSubmitComment)}> {...question}
<TextArea companies={aggregatedEncounters?.companyCounts ?? {}}
{...commentRegister('commentContent', { locations={aggregatedEncounters?.locationCounts ?? {}}
minLength: 1, questionId={question.id}
required: true, receivedCount={undefined}
})} roles={aggregatedEncounters?.roleCounts ?? {}}
label="Post a comment" timestamp={question.seenAt.toLocaleDateString(undefined, {
required={true} month: 'short',
resize="vertical" year: 'numeric',
rows={2} })}
/> upvoteCount={question.numVotes}
<div className="my-3 flex justify-between"> onReceivedSubmit={(data) => {
addEncounter({
companyId: data.company,
location: data.location,
questionId: questionId as string,
role: data.role,
seenAt: data.seenAt,
});
}}
/>
<div className="mx-2">
<Collapsible label={`${(comments ?? []).length} 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) => (
<AnswerCommentListItem
key={comment.id}
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
</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>{(answers ?? []).length} answers</p>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm"> <span aria-hidden={true} className="text-sm">
Sort by: Sort by:
@ -162,94 +258,33 @@ export default function QuestionPage() {
}} }}
/> />
</div> </div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div> </div>
</form> <Button
disabled={!isDirty || !isValid}
{(comments ?? []).map((comment) => ( label="Contribute"
<AnswerCommentListItem type="submit"
key={comment.id} variant="primary"
answerCommentId={comment.id}
authorImageUrl={comment.userImage}
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>{(answers ?? []).length} 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> </div>
<Button </form>
disabled={!isDirty || !isValid} {(answers ?? []).map((answer) => (
label="Contribute" <QuestionAnswerCard
type="submit" key={answer.id}
variant="primary" answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/> />
</div> ))}
</form> </div>
{(answers ?? []).map((answer) => (
<QuestionAnswerCard
key={answer.id}
answerId={answer.id}
authorImageUrl={answer.userImage}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
))}
</div> </div>
</div> </div>
</div> </>
); );
} }

@ -0,0 +1,498 @@
import { subMonths, subYears } from 'date-fns';
import Head from 'next/head';
import Router, { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button, SlideOut, Typeahead } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import type { QuestionAge } from '~/utils/questions/constants';
import { SORT_TYPES } from '~/utils/questions/constants';
import { SORT_ORDERS } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants';
import { ROLES } from '~/utils/questions/constants';
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useSearchParam,
useSearchParamSingle,
} from '~/utils/questions/useSearchParam';
import { trpc } from '~/utils/trpc';
import { SortType } from '~/types/questions.d';
import { SortOrder } from '~/types/questions.d';
export default function QuestionsBrowsePage() {
const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchParam('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
areQuestionTypesInitialized,
] = useSearchParam<QuestionsQuestionType>('questionTypes', {
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
return (
QUESTION_TYPES.find(
(questionType) => questionType.value.toUpperCase() === uppercaseParam,
)?.value ?? null
);
},
});
const [
selectedQuestionAge,
setSelectedQuestionAge,
isQuestionAgeInitialized,
] = useSearchParamSingle<QuestionAge>('questionAge', {
defaultValue: 'all',
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
return (
QUESTION_AGES.find(
(questionAge) => questionAge.value.toUpperCase() === uppercaseParam,
)?.value ?? null
);
},
});
const [selectedRoles, setSelectedRoles, areRolesInitialized] =
useSearchParam('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchParam('locations');
const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', {
defaultValue: SortOrder.DESC,
paramToString: (value) => {
if (value === SortOrder.ASC) {
return 'ASC';
}
if (value === SortOrder.DESC) {
return 'DESC';
}
return null;
},
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'ASC') {
return SortOrder.ASC;
}
if (uppercaseParam === 'DESC') {
return SortOrder.DESC;
}
return null;
},
});
const [sortType, setSortType, isSortTypeInitialized] =
useSearchParamSingle<SortType>('sortType', {
defaultValue: SortType.TOP,
paramToString: (value) => {
if (value === SortType.NEW) {
return 'NEW';
}
if (value === SortType.TOP) {
return 'TOP';
}
return null;
},
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'NEW') {
return SortType.NEW;
}
if (uppercaseParam === 'TOP') {
return SortType.TOP;
}
return null;
},
});
const hasFilters = useMemo(
() =>
selectedCompanies.length > 0 ||
selectedQuestionTypes.length > 0 ||
selectedQuestionAge !== 'all' ||
selectedRoles.length > 0 ||
selectedLocations.length > 0,
[
selectedCompanies,
selectedQuestionTypes,
selectedQuestionAge,
selectedRoles,
selectedLocations,
],
);
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',
{
companyNames: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
sortOrder,
sortType,
startDate,
},
],
{
keepPreviousData: true,
},
);
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
},
},
);
const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const companyFilterOptions = useMemo(() => {
return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAge === questionAge.value,
}));
}, [selectedQuestionAge]);
const roleFilterOptions = useMemo(() => {
return ROLES.map((role) => ({
...role,
checked: selectedRoles.includes(role.value),
}));
}, [selectedRoles]);
const locationFilterOptions = useMemo(() => {
return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
const areSearchOptionsInitialized = useMemo(() => {
return (
areCompaniesInitialized &&
areQuestionTypesInitialized &&
isQuestionAgeInitialized &&
areRolesInitialized &&
areLocationsInitialized &&
isSortTypeInitialized &&
isSortOrderInitialized
);
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areRolesInitialized,
areLocationsInitialized,
isSortTypeInitialized,
isSortOrderInitialized,
]);
const { pathname } = router;
useEffect(() => {
if (areSearchOptionsInitialized) {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
Router.replace({
pathname,
query: {
companies: selectedCompanies,
locations: selectedLocations,
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
sortOrder: sortOrder === SortOrder.ASC ? 'ASC' : 'DESC',
sortType: sortType === SortType.TOP ? 'TOP' : 'NEW',
},
});
setLoaded(true);
}
}, [
areSearchOptionsInitialized,
loaded,
pathname,
selectedCompanies,
selectedRoles,
selectedLocations,
selectedQuestionAge,
selectedQuestionTypes,
sortOrder,
sortType,
]);
if (!loaded) {
return null;
}
const filterSidebar = (
<div className="divide-y divide-slate-200 px-4">
<Button
addonPosition="start"
className="my-4"
disabled={!hasFilters}
icon={Bars3BottomLeftIcon}
label="Clear filters"
variant="tertiary"
onClick={() => {
setSelectedCompanies([]);
setSelectedQuestionTypes([]);
setSelectedQuestionAge('all');
setSelectedRoles([]);
setSelectedLocations([]);
}}
/>
<FilterSection
label="Company"
options={companyFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
isLabelHidden={true}
label="Companies"
options={options}
placeholder="Search companies"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== optionValue),
);
}
}}
/>
<FilterSection
label="Question types"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
} else {
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
label="Roles"
options={roleFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
isLabelHidden={true}
label="Roles"
options={options}
placeholder="Search roles"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedRoles([...selectedRoles, optionValue]);
} else {
setSelectedRoles(
selectedRoles.filter((role) => role !== optionValue),
);
}
}}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
isLabelHidden={true}
label="Locations"
options={options}
placeholder="Search locations"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations(
selectedLocations.filter((location) => location !== optionValue),
);
}
}}
/>
</div>
);
return (
<>
<Head>
<title>Home - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<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-8">
<ContributeQuestionCard
onSubmit={(data) => {
createQuestion({
companyId: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
role: data.role,
seenAt: data.date,
});
}}
/>
<QuestionSearchBar
sortOrderOptions={SORT_ORDERS}
sortOrderValue={sortOrder}
sortTypeOptions={SORT_TYPES}
sortTypeValue={sortType}
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
}}
onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType}
/>
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
companies={{ [question.company]: 1 }}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={{ [question.location]: 1 }}
questionId={question.id}
receivedCount={question.receivedCount}
roles={{ [question.role]: 1 }}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
type={question.type}
upvoteCount={question.numVotes}
/>
))}
{questions?.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>Nothing found.</p>
{hasFilters && <p>Try changing your search criteria.</p>}
</div>
)}
</div>
</div>
</div>
</section>
<aside className="hidden w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
<h2 className="px-4 text-xl font-semibold">Filter by</h2>
{filterSidebar}
</aside>
<SlideOut
className="lg:hidden"
enterFrom="end"
isShown={filterDrawerOpen}
size="sm"
title="Filter by"
onClose={() => {
setFilterDrawerOpen(false);
}}>
{filterSidebar}
</SlideOut>
</div>
</main>
</>
);
}

@ -0,0 +1,16 @@
import Head from 'next/head';
import { APP_TITLE } from '~/utils/questions/constants';
export default function HistoryPage() {
return (
<>
<Head>
<title>History - {APP_TITLE}</title>
</Head>
<div className="v-full flex w-full items-center justify-center">
<h1 className="text-center text-4xl font-bold">History</h1>
</div>
</>
);
}

@ -1,337 +1,34 @@
import { subMonths, subYears } from 'date-fns'; import Head from 'next/head';
import Router, { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import FilterSection from '~/components/questions/filter/FilterSection';
import type { LandingQueryData } from '~/components/questions/LandingComponent'; import type { LandingQueryData } from '~/components/questions/LandingComponent';
import LandingComponent from '~/components/questions/LandingComponent'; import LandingComponent from '~/components/questions/LandingComponent';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import type { QuestionAge } from '~/utils/questions/constants'; import { APP_TITLE } 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';
import { SortOrder, SortType } from '~/types/questions.d';
export default function QuestionsHomePage() { export default function QuestionsHomePage() {
const router = useRouter(); 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');
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',
{
companyNames: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: [],
// TODO: Implement sort order and sort type choices
sortOrder: SortOrder.DESC,
sortType: SortType.NEW,
startDate,
},
],
{
keepPreviousData: true,
},
);
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 [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const companyFilterOptions = useMemo(() => {
return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAge === questionAge.value,
}));
}, [selectedQuestionAge]);
const locationFilterOptions = useMemo(() => {
return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
const handleLandingQuery = async (data: LandingQueryData) => { const handleLandingQuery = async (data: LandingQueryData) => {
const { company, location, questionType } = data; const { company, location, questionType } = data;
setSelectedCompanies([company]); // Go to browse page
setSelectedLocations([location]); router.push({
setSelectedQuestionTypes([questionType as QuestionsQuestionType]); pathname: '/questions/browse',
setHasLanded(true); query: {
companies: [company],
locations: [location],
questionTypes: [questionType],
},
});
}; };
const areFiltersInitialized = useMemo(() => { return (
return ( <>
areCompaniesInitialized && <Head>
areQuestionTypesInitialized && <title>Home - {APP_TITLE}</title>
isQuestionAgeInitialized && </Head>
areLocationsInitialized <LandingComponent onLanded={handleLandingQuery}></LandingComponent>
); </>
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areLocationsInitialized,
]);
const { pathname } = router;
useEffect(() => {
if (areFiltersInitialized) {
// Router.replace used instead of router.replace to avoid
// the page reloading itself since the router.replace
// callback changes on every page load
Router.replace({
pathname,
query: {
companies: selectedCompanies,
locations: selectedLocations,
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
},
});
const hasFilter =
selectedCompanies.length > 0 ||
selectedLocations.length > 0 ||
selectedQuestionAge !== 'all' ||
selectedQuestionTypes.length > 0;
if (hasFilter) {
setHasLanded(true);
}
setLoaded(true);
}
}, [
areFiltersInitialized,
hasLanded,
loaded,
pathname,
selectedCompanies,
selectedLocations,
selectedQuestionAge,
selectedQuestionTypes,
]);
if (!loaded) {
return null;
}
const filterSidebar = (
<div className="mt-2 divide-y divide-slate-200 px-4">
<FilterSection
label="Company"
options={companyFilterOptions}
searchPlaceholder="Add company filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies(
selectedCompanies.filter((company) => company !== optionValue),
);
}
}}
/>
<FilterSection
label="Question types"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
} else {
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
searchPlaceholder="Add location filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations(
selectedLocations.filter((location) => location !== optionValue),
);
}
}}
/>
</div>
);
return !hasLanded ? (
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
) : (
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<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">
<ContributeQuestionCard
onSubmit={(data) => {
createQuestion({
companyId: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
role: data.role,
seenAt: data.date,
});
}}
/>
<QuestionSearchBar
sortOptions={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
sortValue="most-recent"
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
}}
onSortChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
{(questions ?? []).map((question) => (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
company={question.company}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
location={question.location}
questionId={question.id}
receivedCount={0}
role={question.role}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
type={question.type} // TODO: Implement received count
upvoteCount={question.numVotes}
/>
))}
{questions?.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>Nothing found. Try changing your search filters.</p>
</div>
)}
</div>
</div>
</section>
<aside className="hidden w-[300px] overflow-y-auto border-l bg-white py-4 lg:block">
<h2 className="px-4 text-xl font-semibold">Filter by</h2>
{filterSidebar}
</aside>
<SlideOut
className="lg:hidden"
enterFrom="end"
isShown={filterDrawerOpen}
size="sm"
title="Filter by"
onClose={() => {
setFilterDrawerOpen(false);
}}>
{filterSidebar}
</SlideOut>
</div>
</main>
); );
} }

@ -0,0 +1,179 @@
import Head from 'next/head';
import { useState } from 'react';
import { Menu } from '@headlessui/react';
import {
EllipsisVerticalIcon,
NoSymbolIcon,
PlusIcon,
} from '@heroicons/react/24/outline';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
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';
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 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 [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) => (
<li
key={list.id}
className={`flex items-center hover:bg-gray-50 ${
selectedList === list.id ? '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);
}}>
<p className="text-primary-700 text-md p-3 font-medium">
{list.name}
</p>
</button>
<div>
<Menu as="div" className="relative inline-block text-left">
<div>
<Menu.Button className="inline-flex w-full justify-center rounded-md p-2 text-sm font-medium text-white">
<EllipsisVerticalIcon
aria-hidden="true"
className="hover:text-primary-700 mr-1 h-5 w-5 text-violet-400"
/>
</Menu.Button>
</div>
<Menu.Items className="w-18 absolute right-0 z-10 mr-2 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="px-1 py-1 ">
<Menu.Item>
{({ active }) => (
<button
className={`${
active
? 'bg-violet-500 text-white'
: 'text-gray-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}
type="button">
Delete
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Menu>
</div>
</li>
))}
</ul>
{lists?.length === 0 && (
<div className="mx-2 flex items-center justify-center gap-2 rounded-md bg-slate-200 p-4 text-slate-600">
<p>You have yet to create a list</p>
</div>
)}
</>
);
return (
<>
<Head>
<title>My Lists - {APP_TITLE}</title>
</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">
<div className="mb-2 flex items-center justify-between">
<h2 className="px-4 text-xl font-semibold">My Lists</h2>
<div className="px-4">
<Button
icon={PlusIcon}
isLabelHidden={true}
label="Create"
size="md"
variant="tertiary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</div>
</div>
{listOptions}
</aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
{selectedList && (
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
<QuestionListCard
key={question.id}
companies={question.companies}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={question.locations}
questionId={question.id}
receivedCount={0}
roles={question.roles}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
},
)}
type={question.type}
onDelete={() => {
// eslint-disable-next-line no-console
console.log('delete');
}}
/>
))}
{questions?.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>
</div>
)}
</div>
)}
</div>
</div>
</section>
</div>
</main>
</>
);
}

@ -0,0 +1,16 @@
import Head from 'next/head';
import { APP_TITLE } from '~/utils/questions/constants';
export default function MyQuestionsPage() {
return (
<>
<Head>
<title>My Questions - {APP_TITLE}</title>
</Head>
<div className="v-full flex w-full items-center justify-center">
<h1 className="text-center text-4xl font-bold">My Questions</h1>
</div>
</>
);
}

@ -44,6 +44,8 @@ import { trpc } from '~/utils/trpc';
import type { FilterState } from '../../components/resumes/browse/resumeFilters'; import type { FilterState } from '../../components/resumes/browse/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
const PAGE_LIMIT = 10; const PAGE_LIMIT = 10;
const filters: Array<Filter> = [ const filters: Array<Filter> = [
{ {
@ -122,14 +124,14 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
}, },
], ],
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.ALL, enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
staleTime: 5 * 60 * 1000, staleTime: STALE_TIME,
}, },
); );
const starredResumesQuery = trpc.useQuery( const starredResumesQuery = trpc.useQuery(
@ -140,7 +142,7 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
}, },
@ -148,7 +150,7 @@ export default function ResumeHomePage() {
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED, enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
retry: false, retry: false,
staleTime: 5 * 60 * 1000, staleTime: STALE_TIME,
}, },
); );
const myResumesQuery = trpc.useQuery( const myResumesQuery = trpc.useQuery(
@ -159,7 +161,7 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
}, },
@ -167,7 +169,7 @@ export default function ResumeHomePage() {
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.MY, enabled: tabsValue === BROWSE_TABS_VALUES.MY,
retry: false, retry: false,
staleTime: 5 * 60 * 1000, staleTime: STALE_TIME,
}, },
); );
@ -238,6 +240,11 @@ export default function ResumeHomePage() {
: Math.floor(numRecords / PAGE_LIMIT) + 1; : Math.floor(numRecords / PAGE_LIMIT) + 1;
}; };
const isFetchingResumes =
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching;
return ( return (
<> <>
<Head> <Head>
@ -271,7 +278,7 @@ export default function ResumeHomePage() {
leave="transition ease-in-out duration-300 transform" leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0" leaveFrom="translate-x-0"
leaveTo="translate-x-full"> leaveTo="translate-x-full">
<Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-auto bg-white py-4 pb-12 shadow-xl"> <Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-scroll bg-white py-4 pb-12 shadow-xl">
<div className="flex items-center justify-between px-4"> <div className="flex items-center justify-between px-4">
<h2 className="text-lg font-medium text-gray-900"> <h2 className="text-lg font-medium text-gray-900">
Shortcuts Shortcuts
@ -362,20 +369,20 @@ export default function ResumeHomePage() {
</Transition.Root> </Transition.Root>
</div> </div>
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-auto"> <main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll">
<div className="ml-4 py-4"> <div className="ml-8 py-4">
<ResumeReviewsTitle /> <ResumeReviewsTitle />
</div> </div>
<div className="mx-8 mt-4 flex justify-start"> <div className="mx-8 mt-4 flex justify-start">
<div className="hidden w-1/6 pt-2 lg:block"> <div className="hidden w-1/6 pt-2 lg:block">
<h3 className="text-md mb-4 font-medium tracking-tight text-gray-900"> <h3 className="text-md font-medium tracking-tight text-gray-900">
Shortcuts: Shortcuts
</h3> </h3>
<div className="w-100 pt-4 sm:pr-0 md:pr-4"> <div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form> <form>
<ul <ul
className="flex flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900" className="flex w-11/12 flex-wrap justify-start gap-2 pb-6 text-sm font-medium text-gray-900"
role="list"> role="list">
{SHORTCUTS.map((shortcut) => ( {SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}> <li key={shortcut.name}>
@ -387,8 +394,8 @@ export default function ResumeHomePage() {
</li> </li>
))} ))}
</ul> </ul>
<h3 className="text-md my-4 font-medium tracking-tight text-gray-900"> <h3 className="text-md font-medium tracking-tight text-gray-900">
Explore these filters: Explore these filters
</h3> </h3>
{filters.map((filter) => ( {filters.map((filter) => (
<Disclosure <Disclosure
@ -529,9 +536,7 @@ export default function ResumeHomePage() {
</div> </div>
</div> </div>
<div className="mb-6"> <div className="mb-6">
{allResumesQuery.isLoading || {isFetchingResumes ? (
starredResumesQuery.isLoading ||
myResumesQuery.isLoading ? (
<div className="w-full pt-4"> <div className="w-full pt-4">
{' '} {' '}
<Spinner display="block" size="lg" />{' '} <Spinner display="block" size="lg" />{' '}
@ -553,23 +558,18 @@ export default function ResumeHomePage() {
</div> </div>
) : ( ) : (
<> <>
<ResumeListItems <ResumeListItems resumes={getTabResumes()} />
isLoading={ {getTabTotalPages() > 1 && (
allResumesQuery.isFetching || <div className="my-4 flex justify-center">
starredResumesQuery.isFetching || <Pagination
myResumesQuery.isFetching current={currentPage}
} end={getTabTotalPages()}
resumes={getTabResumes()} label="pagination"
/> start={1}
<div className="my-4 flex justify-center"> onSelect={(page) => setCurrentPage(page)}
<Pagination />
current={currentPage} </div>
end={getTabTotalPages()} )}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
</div>
</> </>
)} )}
</div> </div>

@ -3,7 +3,6 @@ import Head from 'next/head';
import { CallToAction } from '~/components/resumes/landing/CallToAction'; import { CallToAction } from '~/components/resumes/landing/CallToAction';
import { Hero } from '~/components/resumes/landing/Hero'; import { Hero } from '~/components/resumes/landing/Hero';
import { PrimaryFeatures } from '~/components/resumes/landing/PrimaryFeatures'; import { PrimaryFeatures } from '~/components/resumes/landing/PrimaryFeatures';
import { Testimonials } from '~/components/resumes/landing/Testimonials';
export default function Home() { export default function Home() {
return ( return (
@ -16,7 +15,6 @@ export default function Home() {
<Hero /> <Hero />
<PrimaryFeatures /> <PrimaryFeatures />
<CallToAction /> <CallToAction />
<Testimonials />
</main> </main>
</> </>
); );

@ -0,0 +1,51 @@
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Button } from '@tih/ui';
import { useToast } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
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) as Month,
year: new Date().getFullYear(),
});
const { showToast } = useToast();
return (
<main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center">
<div className="space-y-4">
<h1 className="text-primary-600 text-center text-4xl font-bold">
Test Page
</h1>
<CompaniesTypeahead
onSelect={(option) => setSelectedCompany(option)}
/>
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} />
<HorizontalDivider />
<Button
label="Add toast"
variant="primary"
onClick={() => {
showToast({
// Duration: 10000 (optional)
subtitle: `Some optional subtitle ${Date.now()}`,
title: `Hello World ${Date.now()}`,
variant: 'success',
});
}}
/>
</div>
</div>
</main>
);
}

@ -10,6 +10,7 @@ import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router'; import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router'; import { questionsAnswerRouter } from './questions-answer-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router'; import { questionsQuestionCommentRouter } from './questions-question-comment-router';
import { questionsQuestionEncounterRouter } from './questions-question-encounter-router';
import { questionsQuestionRouter } from './questions-question-router'; import { questionsQuestionRouter } from './questions-question-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-router'; import { resumeCommentsRouter } from './resumes/resumes-comments-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router'; import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
@ -40,6 +41,7 @@ export const appRouter = createRouter()
.merge('questions.answers.comments.', questionsAnswerCommentRouter) .merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter) .merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter) .merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.encounters.', questionsQuestionEncounterRouter)
.merge('questions.questions.', questionsQuestionRouter) .merge('questions.questions.', questionsQuestionRouter)
.merge('offers.', offersRouter) .merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter) .merge('offers.profile.', offersProfileRouter)

@ -285,7 +285,6 @@ export const offersAnalysisRouter = createRouter()
OR: [ OR: [
{ {
offersFullTime: { offersFullTime: {
level: overallHighestOffer.offersFullTime?.level,
title: overallHighestOffer.offersFullTime?.title, title: overallHighestOffer.offersFullTime?.title,
}, },
offersIntern: { offersIntern: {

@ -9,7 +9,7 @@ import {
profileDtoMapper, profileDtoMapper,
} from '~/mappers/offers-mappers'; } from '~/mappers/offers-mappers';
import { baseCurrencyString } from '~/utils/offers/currency'; import { baseCurrencyString } from '~/utils/offers/currency';
import { convert } from '~/utils/offers/currency/currency-exchange'; import { convert } from '~/utils/offers/currency/currencyExchange';
import { createValidationRegex } from '~/utils/offers/zodRegex'; import { createValidationRegex } from '~/utils/offers/zodRegex';
import { createRouter } from '../context'; import { createRouter } from '../context';
@ -385,7 +385,7 @@ export const offersProfileRouter = createRouter()
throw new trpc.TRPCError({ throw new trpc.TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Missing fields.', message: 'Missing fields in background experiences.',
}); });
}), }),
}, },
@ -533,7 +533,7 @@ export const offersProfileRouter = createRouter()
// Throw error // Throw error
throw new trpc.TRPCError({ throw new trpc.TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',
message: 'Missing fields.', message: 'Missing fields in offers.',
}); });
}), }),
), ),
@ -754,12 +754,76 @@ export const offersProfileRouter = createRouter()
} }
} else if (!exp.id) { } else if (!exp.id) {
// Create new experience // Create new experience
if ( if (exp.jobType === JobType.FULLTIME) {
exp.jobType === JobType.FULLTIME && if (exp.totalCompensation?.currency != null &&
exp.totalCompensation?.currency != null && exp.totalCompensation?.value != null) {
exp.totalCompensation?.value != null if (exp.companyId) {
) { await ctx.prisma.offersBackground.update({
if (exp.companyId) { data: {
experiences: {
create: {
company: {
connect: {
id: exp.companyId,
},
},
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
level: exp.level,
location: exp.location,
specialization: exp.specialization,
title: exp.title,
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
},
},
},
},
where: {
id: input.background.id,
},
});
} else {
await ctx.prisma.offersBackground.update({
data: {
experiences: {
create: {
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
level: exp.level,
location: exp.location,
specialization: exp.specialization,
title: exp.title,
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
},
},
},
},
where: {
id: input.background.id,
},
});
}
} else if (exp.companyId) {
await ctx.prisma.offersBackground.update({ await ctx.prisma.offersBackground.update({
data: { data: {
experiences: { experiences: {
@ -775,18 +839,6 @@ export const offersProfileRouter = createRouter()
location: exp.location, location: exp.location,
specialization: exp.specialization, specialization: exp.specialization,
title: exp.title, title: exp.title,
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
},
}, },
}, },
}, },
@ -805,18 +857,6 @@ export const offersProfileRouter = createRouter()
location: exp.location, location: exp.location,
specialization: exp.specialization, specialization: exp.specialization,
title: exp.title, title: exp.title,
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
},
}, },
}, },
}, },
@ -825,12 +865,74 @@ export const offersProfileRouter = createRouter()
}, },
}); });
} }
} else if ( } else if (exp.jobType === JobType.INTERN) {
exp.jobType === JobType.INTERN && if (exp.monthlySalary?.currency != null &&
exp.monthlySalary?.currency != null && exp.monthlySalary?.value != null) {
exp.monthlySalary?.value != null if (exp.companyId) {
) { await ctx.prisma.offersBackground.update({
if (exp.companyId) { data: {
experiences: {
create: {
company: {
connect: {
id: exp.companyId,
},
},
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
location: exp.location,
monthlySalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
},
specialization: exp.specialization,
title: exp.title,
},
},
},
where: {
id: input.background.id,
},
});
} else {
await ctx.prisma.offersBackground.update({
data: {
experiences: {
create: {
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
location: exp.location,
monthlySalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
},
specialization: exp.specialization,
title: exp.title,
},
},
},
where: {
id: input.background.id,
},
});
}
} else if (exp.companyId) {
await ctx.prisma.offersBackground.update({ await ctx.prisma.offersBackground.update({
data: { data: {
experiences: { experiences: {
@ -843,18 +945,6 @@ export const offersProfileRouter = createRouter()
durationInMonths: exp.durationInMonths, durationInMonths: exp.durationInMonths,
jobType: exp.jobType, jobType: exp.jobType,
location: exp.location, location: exp.location,
monthlySalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
},
specialization: exp.specialization, specialization: exp.specialization,
title: exp.title, title: exp.title,
}, },
@ -872,18 +962,6 @@ export const offersProfileRouter = createRouter()
durationInMonths: exp.durationInMonths, durationInMonths: exp.durationInMonths,
jobType: exp.jobType, jobType: exp.jobType,
location: exp.location, location: exp.location,
monthlySalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
},
specialization: exp.specialization, specialization: exp.specialization,
title: exp.title, title: exp.title,
}, },

@ -5,7 +5,7 @@ import {
dashboardOfferDtoMapper, dashboardOfferDtoMapper,
getOffersResponseMapper, getOffersResponseMapper,
} from '~/mappers/offers-mappers'; } from '~/mappers/offers-mappers';
import { convertWithDate } from '~/utils/offers/currency/currency-exchange'; import { convertWithDate } from '~/utils/offers/currency/currencyExchange';
import { Currency } from '~/utils/offers/currency/CurrencyEnum'; import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import { createValidationRegex } from '~/utils/offers/zodRegex'; import { createValidationRegex } from '~/utils/offers/zodRegex';

@ -11,46 +11,46 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
questionId: z.string(), questionId: z.string(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const questionEncountersData = await ctx.prisma.questionsQuestionEncounter.findMany({ const questionEncountersData =
include: { await ctx.prisma.questionsQuestionEncounter.findMany({
company : true, include: {
}, company: true,
where: { },
...input, where: {
}, ...input,
}); },
});
const companyCounts: Record<string, number> = {}; const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {}; const locationCounts: Record<string, number> = {};
const roleCounts:Record<string, number> = {}; const roleCounts: Record<string, number> = {};
for (let i = 0; i < questionEncountersData.length; i++) { for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i]; const encounter = questionEncountersData[i];
if (!(encounter.company!.name in companyCounts)) { if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1; companyCounts[encounter.company!.name] = 1;
} }
companyCounts[encounter.company!.name] += 1; companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) { if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1; locationCounts[encounter.location] = 1;
} }
locationCounts[encounter.location] += 1; locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) { if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1; roleCounts[encounter.role] = 1;
} }
roleCounts[encounter.role] += 1; roleCounts[encounter.role] += 1;
} }
const questionEncounter:AggregatedQuestionEncounter = { const questionEncounter: AggregatedQuestionEncounter = {
companyCounts, companyCounts,
locationCounts, locationCounts,
roleCounts, roleCounts,
} };
return questionEncounter; return questionEncounter;
} },
}) })
.mutation('create', { .mutation('create', {
input: z.object({ input: z.object({
@ -58,7 +58,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
location: z.string(), location: z.string(),
questionId: z.string(), questionId: z.string(),
role: z.string(), role: z.string(),
seenAt: z.date() seenAt: z.date(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
@ -83,11 +83,12 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const questionEncounterToUpdate = await ctx.prisma.questionsQuestionEncounter.findUnique({ const questionEncounterToUpdate =
where: { await ctx.prisma.questionsQuestionEncounter.findUnique({
id: input.id, where: {
}, id: input.id,
}); },
});
if (questionEncounterToUpdate?.id !== userId) { if (questionEncounterToUpdate?.id !== userId) {
throw new TRPCError({ throw new TRPCError({
@ -113,11 +114,12 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const questionEncounterToDelete = await ctx.prisma.questionsQuestionEncounter.findUnique({ const questionEncounterToDelete =
where: { await ctx.prisma.questionsQuestionEncounter.findUnique({
id: input.id, where: {
}, id: input.id,
}); },
});
if (questionEncounterToDelete?.id !== userId) { if (questionEncounterToDelete?.id !== userId) {
throw new TRPCError({ throw new TRPCError({
@ -132,4 +134,4 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
}, },
}); });
}, },
}); });

@ -7,8 +7,6 @@ import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions'; import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions.d'; import { SortOrder, SortType } from '~/types/questions.d';
const TWO_WEEK_IN_MS = 12096e5;
export const questionsQuestionRouter = createProtectedRouter() export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', { .query('getQuestionsByFilter', {
input: z.object({ input: z.object({
@ -20,7 +18,7 @@ export const questionsQuestionRouter = createProtectedRouter()
roles: z.string().array(), roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder), sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType), sortType: z.nativeEnum(SortType),
startDate: z.date().default(new Date(Date.now() - TWO_WEEK_IN_MS)), startDate: z.date().optional(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const sortCondition = const sortCondition =
@ -99,6 +97,7 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
}, },
}); });
return questionsData.map((data) => { return questionsData.map((data) => {
const votes: number = data.votes.reduce( const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => { (previousValue: number, currentValue) => {
@ -125,6 +124,7 @@ export const questionsQuestionRouter = createProtectedRouter()
numAnswers: data._count.answers, numAnswers: data._count.answers,
numComments: data._count.comments, numComments: data._count.comments,
numVotes: votes, numVotes: votes,
receivedCount: data.encounters.length,
role: data.encounters[0].role ?? 'Unknown role', role: data.encounters[0].role ?? 'Unknown role',
seenAt: data.encounters[0].seenAt, seenAt: data.encounters[0].seenAt,
type: data.questionType, type: data.questionType,
@ -198,6 +198,7 @@ export const questionsQuestionRouter = createProtectedRouter()
numAnswers: questionData._count.answers, numAnswers: questionData._count.answers,
numComments: questionData._count.comments, numComments: questionData._count.comments,
numVotes: votes, numVotes: votes,
receivedCount: questionData.encounters.length,
role: questionData.encounters[0].role ?? 'Unknown role', role: questionData.encounters[0].role ?? 'Unknown role',
seenAt: questionData.encounters[0].seenAt, seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType, type: questionData.questionType,

@ -45,6 +45,7 @@ export type Valuation = {
baseCurrency: string; baseCurrency: string;
baseValue: number; baseValue: number;
currency: string; currency: string;
id: string;
value: number; value: number;
}; };

@ -9,6 +9,7 @@ export type Question = {
numAnswers: number; numAnswers: number;
numComments: number; numComments: number;
numVotes: number; numVotes: number;
receivedCount: number;
role: string; role: string;
seenAt: Date; seenAt: Date;
type: QuestionsQuestionType; type: QuestionsQuestionType;

@ -0,0 +1 @@
export type RequireAllOrNone<T> = T | { [K in keyof T]?: never };

@ -1,13 +1,19 @@
import type { QuestionsQuestionType } from '@prisma/client'; import { QuestionsQuestionType } from '@prisma/client';
import type { FilterChoices } from '~/components/questions/filter/FilterSection'; import type { FilterChoices } from '~/components/questions/filter/FilterSection';
import { SortOrder, SortType } from '~/types/questions.d';
export const APP_TITLE = 'Questions Bank';
export const COMPANIES: FilterChoices = [ export const COMPANIES: FilterChoices = [
{ {
id: 'Google',
label: 'Google', label: 'Google',
value: 'Google', value: 'Google',
}, },
{ {
id: 'Meta',
label: 'Meta', label: 'Meta',
value: 'Meta', value: 'Meta',
}, },
@ -16,14 +22,17 @@ export const COMPANIES: FilterChoices = [
// Code, design, behavioral // Code, design, behavioral
export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [ export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
{ {
id: 'CODING',
label: 'Coding', label: 'Coding',
value: 'CODING', value: 'CODING',
}, },
{ {
id: 'SYSTEM_DESIGN',
label: 'Design', label: 'Design',
value: 'SYSTEM_DESIGN', value: 'SYSTEM_DESIGN',
}, },
{ {
id: 'BEHAVIORAL',
label: 'Behavioral', label: 'Behavioral',
value: 'BEHAVIORAL', value: 'BEHAVIORAL',
}, },
@ -33,18 +42,22 @@ export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year';
export const QUESTION_AGES: FilterChoices<QuestionAge> = [ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
{ {
id: 'last-month',
label: 'Last month', label: 'Last month',
value: 'last-month', value: 'last-month',
}, },
{ {
id: 'last-6-months',
label: 'Last 6 months', label: 'Last 6 months',
value: 'last-6-months', value: 'last-6-months',
}, },
{ {
id: 'last-year',
label: 'Last year', label: 'Last year',
value: 'last-year', value: 'last-year',
}, },
{ {
id: 'all',
label: 'All', label: 'All',
value: 'all', value: 'all',
}, },
@ -52,37 +65,82 @@ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
export const LOCATIONS: FilterChoices = [ export const LOCATIONS: FilterChoices = [
{ {
id: 'Singapore',
label: 'Singapore', label: 'Singapore',
value: 'Singapore', value: 'Singapore',
}, },
{ {
id: 'Menlo Park',
label: 'Menlo Park', label: 'Menlo Park',
value: 'Menlo Park', value: 'Menlo Park',
}, },
{ {
id: 'California',
label: 'California', label: 'California',
value: 'california', value: 'California',
}, },
{ {
id: 'Hong Kong',
label: 'Hong Kong', label: 'Hong Kong',
value: 'Hong Kong', value: 'Hong Kong',
}, },
{ {
id: 'Taiwan',
label: 'Taiwan', label: 'Taiwan',
value: 'Taiwan', value: 'Taiwan',
}, },
] as const; ] as const;
export const ROLES: FilterChoices = [
{
id: 'Software Engineer',
label: 'Software Engineer',
value: 'Software Engineer',
},
{
id: 'Software Engineer Intern',
label: 'Software Engineer Intern',
value: 'Software Engineer Intern',
},
] as const;
export const SORT_ORDERS = [
{
label: 'Ascending',
value: SortOrder.ASC,
},
{
label: 'Descending',
value: SortOrder.DESC,
},
];
export const SORT_TYPES = [
{
label: 'New',
value: SortType.NEW,
},
{
label: 'Top',
value: SortType.TOP,
},
];
export const SAMPLE_QUESTION = { export const SAMPLE_QUESTION = {
answerCount: 10, answerCount: 10,
commentCount: 10, commentCount: 10,
company: 'Google', companies: { Google: 1 },
content: 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', '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', createdAt: new Date(2014, 8, 1, 11, 30, 40),
id: '1',
locations: { 'Menlo Park, CA': 1 },
receivedCount: 12, receivedCount: 12,
role: 'Software Engineer', roles: { 'Software Engineer': 1 },
seenAt: new Date(2014, 8, 1, 11, 30, 40),
timestamp: 'Last month', timestamp: 'Last month',
type: QuestionsQuestionType.CODING,
upvoteCount: 5, upvoteCount: 5,
}; };

@ -0,0 +1,22 @@
import type { FilterChoice } from '~/components/questions/filter/FilterSection';
import { trpc } from '../trpc';
export default function useDefaultCompany(): FilterChoice | undefined {
const { data: companies } = trpc.useQuery([
'companies.list',
{
name: '',
},
]);
const company = companies?.[0];
if (company === undefined) {
return company;
}
return {
id: company.id,
label: company.name,
value: company.id,
};
}

@ -0,0 +1,7 @@
import type { FilterChoice } from '~/components/questions/filter/FilterSection';
import { LOCATIONS } from './constants';
export default function useDefaultLocation(): FilterChoice | undefined {
return LOCATIONS[0];
}

@ -1,65 +0,0 @@
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);
}
}
setIsInitialized(true);
}
}, [isInitialized, name, queryParamToValue, router]);
const setFiltersCallback = useCallback(
(newFilters: Array<Value>) => {
setFilters(newFilters);
localStorage.setItem(name, JSON.stringify(newFilters));
},
[name],
);
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,86 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
type SearchParamOptions<Value> = [Value] extends [string]
? {
defaultValues?: Array<Value>;
paramToString?: (value: Value) => string | null;
stringToParam?: (param: string) => Value | null;
}
: {
defaultValues?: Array<Value>;
paramToString: (value: Value) => string | null;
stringToParam: (param: string) => Value | null;
};
export const useSearchParam = <Value = string>(
name: string,
opts?: SearchParamOptions<Value>,
) => {
const {
defaultValues,
stringToParam = (param: string) => param,
paramToString: valueToQueryParam = (value: Value) => String(value),
} = 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(stringToParam)
.filter((value) => value !== null) 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);
}
}
setIsInitialized(true);
}
}, [isInitialized, name, stringToParam, router]);
const setFiltersCallback = useCallback(
(newFilters: Array<Value>) => {
setFilters(newFilters);
localStorage.setItem(
name,
JSON.stringify(
newFilters.map(valueToQueryParam).filter((param) => param !== null),
),
);
},
[name, valueToQueryParam],
);
return [filters, setFiltersCallback, isInitialized] as const;
};
export const useSearchParamSingle = <Value = string>(
name: string,
opts?: Omit<SearchParamOptions<Value>, 'defaultValues'> & {
defaultValue?: Value;
},
) => {
const { defaultValue, ...restOpts } = opts ?? {};
const [filters, setFilters, isInitialized] = useSearchParam<Value>(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
...restOpts,
} as SearchParamOptions<Value>);
return [
filters[0],
(value: Value) => setFilters([value]),
isInitialized,
] as const;
};

@ -74,6 +74,10 @@ export const useQuestionVote = (id: string) => {
create: 'questions.questions.createVote', create: 'questions.questions.createVote',
deleteKey: 'questions.questions.deleteVote', deleteKey: 'questions.questions.deleteVote',
idKey: 'questionId', idKey: 'questionId',
invalidateKeys: [
'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionById',
],
query: 'questions.questions.getVote', query: 'questions.questions.getVote',
update: 'questions.questions.updateVote', update: 'questions.questions.updateVote',
}); });
@ -84,6 +88,10 @@ export const useAnswerVote = (id: string) => {
create: 'questions.answers.createVote', create: 'questions.answers.createVote',
deleteKey: 'questions.answers.deleteVote', deleteKey: 'questions.answers.deleteVote',
idKey: 'answerId', idKey: 'answerId',
invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById',
],
query: 'questions.answers.getVote', query: 'questions.answers.getVote',
update: 'questions.answers.updateVote', update: 'questions.answers.updateVote',
}); });
@ -94,6 +102,7 @@ export const useQuestionCommentVote = (id: string) => {
create: 'questions.questions.comments.createVote', create: 'questions.questions.comments.createVote',
deleteKey: 'questions.questions.comments.deleteVote', deleteKey: 'questions.questions.comments.deleteVote',
idKey: 'questionCommentId', idKey: 'questionCommentId',
invalidateKeys: ['questions.questions.comments.getQuestionComments'],
query: 'questions.questions.comments.getVote', query: 'questions.questions.comments.getVote',
update: 'questions.questions.comments.updateVote', update: 'questions.questions.comments.updateVote',
}); });
@ -104,6 +113,7 @@ export const useAnswerCommentVote = (id: string) => {
create: 'questions.answers.comments.createVote', create: 'questions.answers.comments.createVote',
deleteKey: 'questions.answers.comments.deleteVote', deleteKey: 'questions.answers.comments.deleteVote',
idKey: 'answerCommentId', idKey: 'answerCommentId',
invalidateKeys: ['questions.answers.comments.getAnswerComments'],
query: 'questions.answers.comments.getVote', query: 'questions.answers.comments.getVote',
update: 'questions.answers.comments.updateVote', update: 'questions.answers.comments.updateVote',
}); });
@ -113,29 +123,30 @@ type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
create: MutationKey; create: MutationKey;
deleteKey: MutationKey; deleteKey: MutationKey;
idKey: string; idKey: string;
invalidateKeys: Array<VoteQueryKey>;
query: VoteQueryKey; query: VoteQueryKey;
update: MutationKey; update: MutationKey;
}; };
type UseVoteMutationContext = {
currentData: any;
previousData: any;
};
export const useVote = <VoteQueryKey extends QueryKey = QueryKey>( export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
id: string, id: string,
opts: VoteProps<VoteQueryKey>, opts: VoteProps<VoteQueryKey>,
) => { ) => {
const { create, deleteKey, query, update, idKey } = opts; const { create, deleteKey, query, update, idKey, invalidateKeys } = opts;
const utils = trpc.useContext(); const utils = trpc.useContext();
const onVoteUpdate = useCallback(() => { const onVoteUpdate = useCallback(() => {
// TODO: Optimise query invalidation // TODO: Optimise query invalidation
utils.invalidateQueries([query, { [idKey]: id } as any]); utils.invalidateQueries([query, { [idKey]: id } as any]);
utils.invalidateQueries(['questions.questions.getQuestionsByFilter']); for (const invalidateKey of invalidateKeys) {
utils.invalidateQueries(['questions.questions.getQuestionById']); utils.invalidateQueries([invalidateKey]);
utils.invalidateQueries(['questions.answers.getAnswers']); }
utils.invalidateQueries(['questions.answers.getAnswerById']); }, [id, idKey, utils, query, invalidateKeys]);
utils.invalidateQueries([
'questions.questions.comments.getQuestionComments',
]);
utils.invalidateQueries(['questions.answers.comments.getAnswerComments']);
}, [id, idKey, utils, query]);
const { data } = trpc.useQuery([ const { data } = trpc.useQuery([
query, query,
@ -146,16 +157,87 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const backendVote = data as BackendVote; const backendVote = data as BackendVote;
const { mutate: createVote } = trpc.useMutation(create, { const { mutate: createVote } = trpc.useMutation<any, UseVoteMutationContext>(
onSuccess: onVoteUpdate, create,
}); {
const { mutate: updateVote } = trpc.useMutation(update, { onError: (err, variables, context) => {
onSuccess: onVoteUpdate, if (context !== undefined) {
}); utils.setQueryData([query], context.previousData);
}
},
onMutate: async (vote) => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
[query, { [idKey]: id } as any],
);
const { mutate: deleteVote } = trpc.useMutation(deleteKey, { utils.setQueryData(
onSuccess: onVoteUpdate, [
}); query,
{
[idKey]: id,
} as any,
],
vote as any,
);
return { currentData: vote, previousData };
},
onSettled: onVoteUpdate,
},
);
const { mutate: updateVote } = trpc.useMutation<any, UseVoteMutationContext>(
update,
{
onError: (error, variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
},
onMutate: async (vote) => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
[query, { [idKey]: id } as any],
);
utils.setQueryData(
[
query,
{
[idKey]: id,
} as any,
],
vote,
);
return { currentData: vote, previousData };
},
onSettled: onVoteUpdate,
},
);
const { mutate: deleteVote } = trpc.useMutation<any, UseVoteMutationContext>(
deleteKey,
{
onError: (err, variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
},
onMutate: async (vote) => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
utils.setQueryData(
[
query,
{
[idKey]: id,
} as any,
],
null as any,
);
return { currentData: null, previousData: vote };
},
onSettled: onVoteUpdate,
},
);
const { handleDownvote, handleUpvote } = createVoteCallbacks( const { handleDownvote, handleUpvote } = createVoteCallbacks(
backendVote ?? null, backendVote ?? null,

@ -0,0 +1,108 @@
import { Fragment, useEffect, useRef } from 'react';
import { Transition } from '@headlessui/react';
import { CheckIcon } from '@heroicons/react/24/outline';
import { XMarkIcon } from '@heroicons/react/24/solid';
type ToastVariant = 'failure' | 'success';
export type ToastMessage = {
duration?: number;
subtitle?: string;
title: string;
variant: ToastVariant;
};
type Props = Readonly<{
duration?: number;
onClose: () => void;
subtitle?: string;
title: string;
variant: ToastVariant;
}>;
const DEFAULT_DURATION = 5000;
function ToastIcon({ variant }: Readonly<{ variant: ToastVariant }>) {
switch (variant) {
case 'success':
return (
<CheckIcon aria-hidden="true" className="text-success-500 h-6 w-6" />
);
case 'failure':
return (
<XMarkIcon aria-hidden="true" className="text-error-500 h-6 w-6" />
);
}
}
export default function Toast({
duration = DEFAULT_DURATION,
title,
subtitle,
variant,
onClose,
}: Props) {
const timer = useRef<number | null>(null);
function clearTimer() {
if (timer.current == null) {
return;
}
window.clearTimeout(timer.current);
timer.current = null;
}
function close() {
onClose();
clearTimer();
}
useEffect(() => {
timer.current = window.setTimeout(() => {
close();
}, duration);
return () => {
clearTimer();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Transition
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-2 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={true}>
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<ToastIcon variant={variant} />
</div>
<div className="ml-3 w-0 flex-1 space-y-1 pt-0.5">
<p className="text-sm font-medium text-slate-900">{title}</p>
{subtitle && (
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
)}
</div>
<div className="ml-4 flex flex-shrink-0">
<button
className="focus:ring-brand-500 inline-flex rounded-md bg-white text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
type="button"
onClick={close}>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</Transition>
);
}

@ -0,0 +1,72 @@
import React, { createContext, useContext, useState } from 'react';
import type { ToastMessage } from './Toast';
import Toast from './Toast';
type Context = Readonly<{
showToast: (message: ToastMessage) => void;
}>;
export const ToastContext = createContext<Context>({
// eslint-disable-next-line @typescript-eslint/no-empty-function
showToast: (_: ToastMessage) => {},
});
const getID = (() => {
let id = 0;
return () => {
return id++;
};
})();
type ToastData = ToastMessage & {
id: number;
};
type Props = Readonly<{
children: React.ReactNode;
}>;
export function useToast() {
return useContext(ToastContext);
}
export default function ToastsProvider({ children }: Props) {
const [toasts, setToasts] = useState<Array<ToastData>>([]);
function showToast({ title, subtitle, variant }: ToastMessage) {
setToasts([{ id: getID(), subtitle, title, variant }, ...toasts]);
}
function closeToast(id: number) {
setToasts((oldToasts) => {
const newToasts = oldToasts.filter((toast) => toast.id !== id);
return newToasts;
});
}
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end px-4 py-6 sm:p-6">
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
{/* Notification panel, dynamically insert this into the live region when it needs to be displayed */}
{toasts.map(({ id, title, subtitle, variant }) => (
<Toast
key={id}
subtitle={subtitle}
title={title}
variant={variant}
onClose={() => {
closeToast(id);
}}
/>
))}
</div>
</div>
</ToastContext.Provider>
);
}

@ -49,6 +49,12 @@ export { default as TextArea } from './TextArea/TextArea';
// TextInput // TextInput
export * from './TextInput/TextInput'; export * from './TextInput/TextInput';
export { default as TextInput } from './TextInput/TextInput'; export { default as TextInput } from './TextInput/TextInput';
// Toast
export * from './Toast/Toast';
export { default as Toast } from './Toast/Toast';
// ToastsProvider
export * from './Toast/ToastsProvider';
export { default as ToastsProvider } from './Toast/ToastsProvider';
// Typeahead // Typeahead
export * from './Typeahead/Typeahead'; export * from './Typeahead/Typeahead';
export { default as Typeahead } from './Typeahead/Typeahead'; export { default as Typeahead } from './Typeahead/Typeahead';

@ -1240,6 +1240,13 @@
dependencies: dependencies:
regenerator-runtime "^0.13.4" regenerator-runtime "^0.13.4"
"@babel/runtime@^7.18.3":
version "7.19.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.4.tgz#a42f814502ee467d55b38dd1c256f53a7b885c78"
integrity sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.12.7", "@babel/template@^7.18.10": "@babel/template@^7.12.7", "@babel/template@^7.18.10":
version "7.18.10" version "7.18.10"
resolved "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz" resolved "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz"
@ -2156,6 +2163,11 @@
resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz" resolved "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz"
integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==
"@popperjs/core@^2.11.5", "@popperjs/core@^2.11.6":
version "2.11.6"
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45"
integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
"@prisma/client@^4.4.0": "@prisma/client@^4.4.0":
version "4.4.0" version "4.4.0"
resolved "https://registry.npmjs.org/@prisma/client/-/client-4.4.0.tgz" resolved "https://registry.npmjs.org/@prisma/client/-/client-4.4.0.tgz"
@ -12287,7 +12299,7 @@ react-error-overlay@^6.0.11:
resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz" resolved "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz"
integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==
react-fast-compare@^3.2.0: react-fast-compare@^3.0.1, react-fast-compare@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz" resolved "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz"
integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==
@ -12365,6 +12377,23 @@ react-pdf@^5.7.2:
tiny-invariant "^1.0.0" tiny-invariant "^1.0.0"
tiny-warning "^1.0.0" tiny-warning "^1.0.0"
react-popper-tooltip@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/react-popper-tooltip/-/react-popper-tooltip-4.4.2.tgz#0dc4894b8e00ba731f89bd2d30584f6032ec6163"
integrity sha512-y48r0mpzysRTZAIh8m2kpZ8S1YPNqGtQPDrlXYSGvDS1c1GpG/NUXbsbIdfbhXfmSaRJuTcaT6N1q3CKuHRVbg==
dependencies:
"@babel/runtime" "^7.18.3"
"@popperjs/core" "^2.11.5"
react-popper "^2.3.0"
react-popper@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-2.3.0.tgz#17891c620e1320dce318bad9fede46a5f71c70ba"
integrity sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==
dependencies:
react-fast-compare "^3.0.1"
warning "^4.0.2"
react-query@^3.39.2: react-query@^3.39.2:
version "3.39.2" version "3.39.2"
resolved "https://registry.npmjs.org/react-query/-/react-query-3.39.2.tgz" resolved "https://registry.npmjs.org/react-query/-/react-query-3.39.2.tgz"
@ -14871,6 +14900,13 @@ walker@^1.0.7, walker@~1.0.5:
dependencies: dependencies:
makeerror "1.0.12" makeerror "1.0.12"
warning@^4.0.2:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==
dependencies:
loose-envify "^1.0.0"
watchpack-chokidar2@^2.0.1: watchpack-chokidar2@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz" resolved "https://registry.npmjs.org/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz"

Loading…
Cancel
Save