Merge branch 'main' into hongpo/update-question-filter

pull/409/head
hpkoh 3 years ago
commit 93c2c98c71

@ -38,6 +38,7 @@
"react-popper-tooltip": "^4.4.2", "react-popper-tooltip": "^4.4.2",
"react-query": "^3.39.2", "react-query": "^3.39.2",
"superjson": "^1.10.0", "superjson": "^1.10.0",
"unique-names-generator": "^4.7.1",
"zod": "^3.18.0" "zod": "^3.18.0"
}, },
"devDependencies": { "devDependencies": {

@ -0,0 +1,14 @@
/*
Warnings:
- Added the required column `upvotes` to the `QuestionsAnswerComment` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "QuestionsAnswer" ADD COLUMN "upvotes" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "QuestionsAnswerComment" ADD COLUMN "upvotes" INTEGER NOT NULL;
-- AlterTable
ALTER TABLE "QuestionsQuestionComment" ADD COLUMN "upvotes" INTEGER NOT NULL DEFAULT 0;

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "QuestionsAnswerComment" ALTER COLUMN "upvotes" SET DEFAULT 0;

@ -455,6 +455,7 @@ model QuestionsQuestionComment {
id String @id @default(cuid()) id String @id @default(cuid())
questionId String questionId String
userId String? userId String?
upvotes Int @default(0)
content String @db.Text content String @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -483,6 +484,7 @@ model QuestionsAnswer {
questionId String questionId String
userId String? userId String?
content String @db.Text content String @db.Text
upvotes Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -511,6 +513,7 @@ model QuestionsAnswerComment {
answerId String answerId String
userId String? userId String?
content String @db.Text content String @db.Text
upvotes Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

@ -1,13 +1,14 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [ const navigation: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Benchmark your offer' }, { href: '/offers/home', name: 'Home' },
{ href: '/offers/submit', name: 'Analyse your offers' },
]; ];
const config = { const config = {
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Tech Offers Repo', title: 'Offer Profile Repository',
titleHref: '/offers', titleHref: '/offers',
}; };

@ -3,14 +3,14 @@ export default function OffersTitle() {
<> <>
<div className="flex items-end justify-center"> <div className="flex items-end justify-center">
<h1 className="text-primary-600 mt-16 text-center text-4xl font-bold"> <h1 className="text-primary-600 mt-16 text-center text-4xl font-bold">
Tech Handbook Offers Repo Offer Profile Repository
</h1> </h1>
</div> </div>
<div className="text-primary-500 mt-2 text-center text-2xl font-normal"> <div className="text-primary-500 mt-2 text-center text-2xl font-normal">
Reveal profile stories behind offers Reveal profile stories behind offers
</div> </div>
<div className="items-top flex justify-center text-xl font-normal"> <div className="items-top flex justify-center text-xl font-normal">
Benchmark your offers and profiles, learn from other's offer profile, Click into offers to view profiles, benchmark your offers and profiles,
and discuss with the community and discuss with the community
</div> </div>
</> </>

@ -2,26 +2,6 @@ import { EducationBackgroundType } from './types';
export const emptyOption = '----'; export const emptyOption = '----';
// TODO: use enums
export const titleOptions = [
{
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
];
export const locationOptions = [ export const locationOptions = [
{ {
label: 'Singapore, Singapore', label: 'Singapore, Singapore',

@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
type LeftTextCardProps = Readonly<{
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: string;
title: string;
}>;
const baseUrl = '/offers/home';
export default function LeftTextCard({
description,
icon,
imageAlt,
imageSrc,
title,
}: LeftTextCardProps) {
return (
<div className="lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
<div className="mx-auto max-w-xl px-4 sm:px-6 lg:mx-0 lg:max-w-none lg:py-16 lg:px-0">
<div>
<div>
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-gradient-to-r from-purple-600 to-indigo-600">
{icon}
</span>
</div>
<div className="mt-6">
<h2 className="text-3xl font-bold tracking-tight text-gray-900">
{title}
</h2>
<p className="mt-4 text-lg text-gray-500">{description}</p>
<div className="mt-6">
<a
className="inline-flex rounded-md border border-transparent bg-gradient-to-r from-purple-600 to-indigo-600 bg-origin-border px-4 py-2 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={baseUrl}>
Get started
</a>
</div>
</div>
</div>
</div>
<div className="mt-12 sm:mt-16 lg:mt-0">
<div className="-mr-48 pl-4 sm:pl-6 md:-mr-16 lg:relative lg:m-0 lg:h-full lg:px-0">
<img
alt={imageAlt}
className="w-full rounded-xl shadow-xl ring-1 ring-black ring-opacity-5 lg:absolute lg:left-0 lg:h-full lg:w-auto lg:max-w-none"
src={imageSrc}
/>
</div>
</div>
</div>
);
}

@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
type RightTextCarddProps = Readonly<{
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: string;
title: string;
}>;
const baseUrl = '/offers/home';
export default function RightTextCard({
description,
icon,
imageAlt,
imageSrc,
title,
}: RightTextCarddProps) {
return (
<div className="lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
<div className="mx-auto max-w-xl px-4 sm:px-6 lg:col-start-2 lg:mx-0 lg:max-w-none lg:py-32 lg:px-0">
<div>
<div>
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-gradient-to-r from-purple-600 to-indigo-600">
{icon}
</span>
</div>
<div className="mt-6">
<h2 className="text-3xl font-bold tracking-tight text-gray-900">
{title}
</h2>
<p className="mt-4 text-lg text-gray-500">{description}</p>
<div className="mt-6">
<a
className="inline-flex rounded-md border border-transparent bg-gradient-to-r from-purple-600 to-indigo-600 bg-origin-border px-4 py-2 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={baseUrl}>
Get started
</a>
</div>
</div>
</div>
</div>
<div className="mt-12 sm:mt-16 lg:col-start-1 lg:mt-0">
<div className="-ml-48 pr-4 sm:pr-6 md:-ml-16 lg:relative lg:m-0 lg:h-full lg:px-0">
<img
alt={imageAlt}
className="w-full rounded-xl shadow-xl ring-1 ring-black ring-opacity-5 lg:absolute lg:right-0 lg:h-full lg:w-auto lg:max-w-none"
src={imageSrc}
/>
</div>
</div>
</div>
);
}

@ -115,7 +115,7 @@ export default function OffersSubmissionForm({
), ),
hasNext: true, hasNext: true,
hasPrevious: false, hasPrevious: false,
label: 'Offer details', label: 'Offers',
}, },
{ {
component: <BackgroundForm key={1} />, component: <BackgroundForm key={1} />,
@ -125,28 +125,33 @@ export default function OffersSubmissionForm({
}, },
{ {
component: ( component: (
<OfferAnalysis <OffersProfileSave
key={2} key={2}
allAnalysis={analysis} profileId={createProfileResponse.id || ''}
isError={generateAnalysisMutation.isError} token={createProfileResponse.token}
isLoading={generateAnalysisMutation.isLoading}
/> />
), ),
hasNext: true, hasNext: true,
hasPrevious: false, hasPrevious: false,
label: 'Analysis', label: 'Save profile',
}, },
{ {
component: ( component: (
<OffersProfileSave <div>
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result
</h5>
<OfferAnalysis
key={3} key={3}
profileId={createProfileResponse.id || ''} allAnalysis={analysis}
token={createProfileResponse.token} isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
/> />
</div>
), ),
hasNext: false, hasNext: false,
hasPrevious: false, hasPrevious: true,
label: 'Save', label: 'Analysis',
}, },
]; ];
@ -231,7 +236,7 @@ export default function OffersSubmissionForm({
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component} {formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */} <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre>
{formSteps[formStep].hasNext && ( {formSteps[formStep].hasNext && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button

@ -8,13 +8,17 @@ import {
emptyOption, emptyOption,
FieldError, FieldError,
locationOptions, locationOptions,
titleOptions,
} from '~/components/offers/constants'; } from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types'; import type { BackgroundPostData } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; import {
Currency,
CURRENCY_OPTIONS,
} from '~/utils/offers/currency/CurrencyEnum';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormRadioList from '../../forms/FormRadioList'; import FormRadioList from '../../forms/FormRadioList';
import FormSelect from '../../forms/FormSelect'; import FormSelect from '../../forms/FormSelect';
import FormTextInput from '../../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
@ -92,13 +96,13 @@ function FullTimeJobFields() {
return ( return (
<> <>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <JobTitlesTypeahead
label="Title" onSelect={({ value }) =>
options={titleOptions} setValue(`background.experiences.0.title`, value)
placeholder={emptyOption} }
{...register(`background.experiences.0.title`)}
/> />
</div>
<div> <div>
<CompaniesTypeahead <CompaniesTypeahead
onSelect={({ value }) => onSelect={({ value }) =>
@ -112,6 +116,7 @@ function FullTimeJobFields() {
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
@ -177,13 +182,13 @@ function InternshipJobFields() {
return ( return (
<> <>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <JobTitlesTypeahead
label="Title" onSelect={({ value }) =>
options={titleOptions} setValue(`background.experiences.0.title`, value)
placeholder={emptyOption} }
{...register(`background.experiences.0.title`)}
/> />
</div>
<div> <div>
<CompaniesTypeahead <CompaniesTypeahead
onSelect={({ value }) => onSelect={({ value }) =>
@ -197,6 +202,7 @@ function InternshipJobFields() {
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
@ -310,6 +316,22 @@ function EducationSection() {
{...register(`background.educations.0.school`)} {...register(`background.educations.0.school`)}
/> />
</div> </div>
<div className="grid grid-cols-2 space-x-3">
<FormMonthYearPicker
monthLabel="Candidature Start"
yearLabel=""
{...register(`background.educations.0.startDate`, {
required: FieldError.REQUIRED,
})}
/>
<FormMonthYearPicker
monthLabel="Candidature End"
yearLabel=""
{...register(`background.educations.0.endDate`, {
required: FieldError.REQUIRED,
})}
/>
</div>
</Collapsible> </Collapsible>
</div> </div>
</> </>
@ -319,13 +341,9 @@ function EducationSection() {
export default function BackgroundForm() { export default function BackgroundForm() {
return ( return (
<div> <div>
<h5 className="mb-2 text-center text-4xl font-bold text-slate-900"> <h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Help us better gauge your offers Help us better gauge your offers
</h5> </h5>
<h6 className="text-md mx-10 mb-8 text-center font-light text-slate-600">
This section is mostly optional, but your background information helps
us benchmark your offers.
</h6>
<div> <div>
<YoeSection /> <YoeSection />
<CurrentJobSection /> <CurrentJobSection />

@ -13,6 +13,7 @@ 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';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import { import {
defaultFullTimeOfferValues, defaultFullTimeOfferValues,
@ -23,7 +24,6 @@ import {
FieldError, FieldError,
internshipCycleOptions, internshipCycleOptions,
locationOptions, locationOptions,
titleOptions,
yearOptions, yearOptions,
} from '../../constants'; } from '../../constants';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker'; import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
@ -32,7 +32,10 @@ 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 { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum'; import {
Currency,
CURRENCY_OPTIONS,
} from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{ type FullTimeOfferDetailsFormProps = Readonly<{
index: number; index: number;
@ -64,23 +67,20 @@ function FullTimeOfferDetailsForm({
return ( return (
<div className="my-5 rounded-lg border border-slate-200 px-10 py-5"> <div className="my-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <JobTitlesTypeahead
errorMessage={offerFields?.offersFullTime?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.offersFullTime.title`, { onSelect={({ value }) =>
required: FieldError.REQUIRED, setValue(`offers.${index}.offersFullTime.title`, value)
})} }
/> />
</div>
<FormTextInput <FormTextInput
errorMessage={offerFields?.offersFullTime?.specialization?.message} errorMessage={offerFields?.offersFullTime?.level?.message}
label="Focus / Specialization" label="Level"
placeholder="e.g. Front End" placeholder="e.g. L4, Junior"
required={true} required={true}
{...register(`offers.${index}.offersFullTime.specialization`, { {...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.REQUIRED, required: FieldError.REQUIRED,
})} })}
/> />
@ -88,22 +88,12 @@ function FullTimeOfferDetailsForm({
<div className="mb-5 flex grid grid-cols-2 space-x-3"> <div className="mb-5 flex grid grid-cols-2 space-x-3">
<div> <div>
<CompaniesTypeahead <CompaniesTypeahead
required={true}
onSelect={({ value }) => onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value) setValue(`offers.${index}.companyId`, value)
} }
/> />
</div> </div>
<FormTextInput
errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3">
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.location?.message} errorMessage={offerFields?.location?.message}
@ -115,6 +105,8 @@ function FullTimeOfferDetailsForm({
required: FieldError.REQUIRED, required: FieldError.REQUIRED,
})} })}
/> />
</div>
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3">
<FormMonthYearPicker <FormMonthYearPicker
monthLabel="Date Received" monthLabel="Date Received"
monthRequired={true} monthRequired={true}
@ -129,6 +121,7 @@ function FullTimeOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
@ -165,14 +158,12 @@ function FullTimeOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register( {...register(
`offers.${index}.offersFullTime.baseSalary.currency`, `offers.${index}.offersFullTime.baseSalary.currency`,
{
required: FieldError.REQUIRED,
},
)} )}
/> />
} }
@ -180,13 +171,11 @@ function FullTimeOfferDetailsForm({
errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message} errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message}
label="Base Salary (Annual)" label="Base Salary (Annual)"
placeholder="0" placeholder="0"
required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.baseSalary.value`, { {...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -194,25 +183,22 @@ function FullTimeOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.bonus.currency`, { {...register(`offers.${index}.offersFullTime.bonus.currency`)}
required: FieldError.REQUIRED,
})}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.offersFullTime?.bonus?.value?.message} errorMessage={offerFields?.offersFullTime?.bonus?.value?.message}
label="Bonus (Annual)" label="Bonus (Annual)"
placeholder="0" placeholder="0"
required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.bonus.value`, { {...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -222,25 +208,22 @@ function FullTimeOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.stocks.currency`, { {...register(`offers.${index}.offersFullTime.stocks.currency`)}
required: FieldError.REQUIRED,
})}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.offersFullTime?.stocks?.value?.message} errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
label="Stocks (Annual)" label="Stocks (Annual)"
placeholder="0" placeholder="0"
required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.stocks.value`, { {...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -291,32 +274,19 @@ function InternshipOfferDetailsForm({
return ( return (
<div className="my-5 rounded-lg border border-slate-200 px-10 py-5"> <div className="my-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <JobTitlesTypeahead
errorMessage={offerFields?.offersIntern?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.offersIntern.title`, {
minLength: 1,
required: FieldError.REQUIRED,
})}
/>
<FormTextInput
errorMessage={offerFields?.offersIntern?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true} required={true}
{...register(`offers.${index}.offersIntern.specialization`, { onSelect={({ value }) =>
minLength: 1, setValue(`offers.${index}.offersIntern.title`, value)
required: FieldError.REQUIRED, }
})}
/> />
</div> </div>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<div> <div>
<CompaniesTypeahead <CompaniesTypeahead
required={true}
onSelect={({ value }) => onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value) setValue(`offers.${index}.companyId`, value)
} }
@ -374,6 +344,7 @@ function InternshipOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}

@ -142,6 +142,7 @@ export default function ProfileComments({
/> />
</div> </div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2> <h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
{isEditable || session?.user?.name ? (
<div> <div>
<TextArea <TextArea
label={`Comment as ${ label={`Comment as ${
@ -154,7 +155,43 @@ export default function ProfileComments({
<div className="mt-2 flex w-full justify-end"> <div className="mt-2 flex w-full justify-end">
<div className="w-fit"> <div className="w-fit">
<Button <Button
disabled={commentsQuery.isLoading || !currentReply.length} disabled={
commentsQuery.isLoading ||
!currentReply.length ||
createCommentMutation.isLoading
}
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Comment"
size="sm"
variant="primary"
onClick={() => handleComment(currentReply)}
/>
</div>
</div>
<HorizontalDivider />
</div>
) : (
<div>Please log in before commenting on this profile.</div>
)}
<div>
<TextArea
label={`Comment as ${
isEditable ? profileName : session?.user?.name ?? 'anonymous'
}`}
placeholder="Type your comment here"
value={currentReply}
onChange={(value) => setCurrentReply(value)}
/>
<div className="mt-2 flex w-full justify-end">
<div className="w-fit">
<Button
disabled={
commentsQuery.isLoading ||
!currentReply.length ||
createCommentMutation.isLoading
}
display="block" display="block"
isLabelHidden={false} isLabelHidden={false}
isLoading={createCommentMutation.isLoading} isLoading={createCommentMutation.isLoading}

@ -128,7 +128,7 @@ export default function CommentCard({
<TextArea <TextArea
isLabelHidden={true} isLabelHidden={true}
label="Comment" label="Comment"
placeholder="Type your comment here" placeholder="Type your reply here"
resize="none" resize="none"
value={currentReply} value={currentReply}
onChange={(value) => setCurrentReply(value)} onChange={(value) => setCurrentReply(value)}
@ -136,7 +136,9 @@ export default function CommentCard({
<div className="mt-2 flex w-full justify-end"> <div className="mt-2 flex w-full justify-end">
<div className="w-fit"> <div className="w-fit">
<Button <Button
disabled={!currentReply.length} disabled={
!currentReply.length || createCommentMutation.isLoading
}
display="block" display="block"
isLabelHidden={false} isLabelHidden={false}
isLoading={createCommentMutation.isLoading} isLoading={createCommentMutation.isLoading}

@ -1,5 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { convertMoneyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
@ -19,7 +22,9 @@ export default function OfferTableRow({
scope="row"> scope="row">
{company.name} {company.name}
</th> </th>
<td className="py-4 px-6">{title}</td> <td className="py-4 px-6">
{getLabelForJobTitleType(title as JobTitleType)}
</td>
<td className="py-4 px-6">{totalYoe}</td> <td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertMoneyToString(income)}</td> <td className="py-4 px-6">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td> <td className="py-4 px-6">{formatDate(monthYearReceived)}</td>

@ -17,29 +17,26 @@ export type FilterChoices<V extends string = string> = ReadonlyArray<
FilterChoice<V> FilterChoice<V>
>; >;
type FilterSectionType<FilterOptions extends Array<FilterOption>> = type FilterSectionType<V extends string> =
| { | {
isSingleSelect: true; isSingleSelect: true;
onOptionChange: (optionValue: FilterOptions[number]['value']) => void; onOptionChange: (option: FilterOption<V>) => void;
} }
| { | {
isSingleSelect?: false; isSingleSelect?: false;
onOptionChange: ( onOptionChange: (option: FilterOption<V>) => void;
optionValue: FilterOptions[number]['value'],
checked: boolean,
) => void;
}; };
export type FilterSectionProps<FilterOptions extends Array<FilterOption>> = export type FilterSectionProps<V extends string = string> =
FilterSectionType<FilterOptions> & { FilterSectionType<V> & {
label: string; label: string;
options: FilterOptions; options: Array<FilterOption<V>>;
} & ( } & (
| { | {
renderInput: (props: { renderInput: (props: {
field: UseFormRegisterReturn<'search'>; field: UseFormRegisterReturn<'search'>;
onOptionChange: FilterSectionType<FilterOptions>['onOptionChange']; onOptionChange: FilterSectionType<V>['onOptionChange'];
options: FilterOptions; options: Array<FilterOption<V>>;
}) => React.ReactNode; }) => React.ReactNode;
showAll?: never; showAll?: never;
} }
@ -53,16 +50,14 @@ export type FilterSectionFormData = {
search: string; search: string;
}; };
export default function FilterSection< export default function FilterSection<V extends string>({
FilterOptions extends Array<FilterOption>,
>({
label, label,
options, options,
showAll, showAll,
onOptionChange, onOptionChange,
isSingleSelect, isSingleSelect,
renderInput, renderInput,
}: FilterSectionProps<FilterOptions>) { }: FilterSectionProps<V>) {
const { register, reset } = useForm<FilterSectionFormData>(); const { register, reset } = useForm<FilterSectionFormData>();
const registerSearch = register('search'); const registerSearch = register('search');
@ -76,7 +71,9 @@ export default function FilterSection<
}; };
const autocompleteOptions = useMemo(() => { const autocompleteOptions = useMemo(() => {
return options.filter((option) => !option.checked) as FilterOptions; return options.filter((option) => !option.checked) as Array<
FilterOption<V>
>;
}, [options]); }, [options]);
const selectedCount = useMemo(() => { const selectedCount = useMemo(() => {
@ -102,11 +99,12 @@ export default function FilterSection<
<div className="z-10"> <div className="z-10">
{renderInput({ {renderInput({
field, field,
onOptionChange: async ( onOptionChange: async (option: FilterOption<V>) => {
optionValue: FilterOptions[number]['value'],
) => {
reset(); reset();
return onOptionChange(optionValue, true); return onOptionChange({
...option,
checked: true,
});
}, },
options: autocompleteOptions, options: autocompleteOptions,
})} })}
@ -119,7 +117,13 @@ export default function FilterSection<
label={label} label={label}
value={options.find((option) => option.checked)?.value} value={options.find((option) => option.checked)?.value}
onChange={(value) => { onChange={(value) => {
onOptionChange(value); const changedOption = options.find(
(option) => option.value === value,
)!;
onOptionChange({
...changedOption,
checked: !changedOption.checked,
});
}}> }}>
{options.map((option) => ( {options.map((option) => (
<RadioList.Item <RadioList.Item
@ -140,7 +144,10 @@ export default function FilterSection<
label={option.label} label={option.label}
value={option.checked} value={option.checked}
onChange={(checked) => { onChange={(checked) => {
onOptionChange(option.value, checked); onOptionChange({
...option,
checked,
});
}} }}
/> />
))} ))}

@ -1,4 +1,6 @@
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { useState } from 'react';
import { useMemo } from 'react';
import { Button, Typeahead } from '@tih/ui'; import { Button, Typeahead } from '@tih/ui';
import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone'; import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
@ -7,6 +9,8 @@ type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number]; type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{ export type ExpandedTypeaheadProps = RequireAllOrNone<{
clearOnSelect?: boolean;
filterOption: (option: TypeaheadOption) => boolean;
onSuggestionClick: (option: TypeaheadOption) => void; onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number; suggestedCount: number;
}> & }> &
@ -15,9 +19,20 @@ export type ExpandedTypeaheadProps = RequireAllOrNone<{
export default function ExpandedTypeahead({ export default function ExpandedTypeahead({
suggestedCount = 0, suggestedCount = 0,
onSuggestionClick, onSuggestionClick,
filterOption = () => true,
clearOnSelect = false,
options,
onSelect,
...typeaheadProps ...typeaheadProps
}: ExpandedTypeaheadProps) { }: ExpandedTypeaheadProps) {
const suggestions = typeaheadProps.options.slice(0, suggestedCount); const [key, setKey] = useState(0);
const filteredOptions = useMemo(() => {
return options.filter(filterOption);
}, [options, filterOption]);
const suggestions = useMemo(
() => filteredOptions.slice(0, suggestedCount),
[filteredOptions, suggestedCount],
);
return ( return (
<div className="flex flex-wrap gap-x-2"> <div className="flex flex-wrap gap-x-2">
@ -32,7 +47,17 @@ export default function ExpandedTypeahead({
/> />
))} ))}
<div className="flex-1"> <div className="flex-1">
<Typeahead {...typeaheadProps} /> <Typeahead
key={key}
options={filteredOptions}
{...typeaheadProps}
onSelect={(option) => {
if (clearOnSelect) {
setKey((key + 1) % 2);
}
onSelect(option);
}}
/>
</div> </div>
</div> </div>
); );

@ -14,11 +14,12 @@ export default function ResumeUserBadge({
return ( return (
<div className="group relative flex items-center justify-center"> <div className="group relative flex items-center justify-center">
<div <div
className="absolute -top-3 hidden w-48 -translate-y-full flex-col className="absolute left-6 z-10 hidden w-48 flex-col
justify-center gap-1 rounded-lg bg-white px-2 py-2 text-center drop-shadow-xl justify-center gap-1 rounded-xl bg-white px-2 py-2 text-center drop-shadow-lg
after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2
after:border-8 after:border-x-transparent after:border-b-transparent before:absolute before:top-12 before:-translate-x-6
after:border-t-white after:drop-shadow-lg after:content-[''] before:border-8 before:border-y-transparent before:border-l-transparent
before:border-r-white before:drop-shadow-lg before:content-['']
group-hover:flex"> group-hover:flex">
<Icon className="h-12 w-12 self-center" /> <Icon className="h-12 w-12 self-center" />
<p className="font-medium">{title}</p> <p className="font-medium">{title}</p>

@ -33,7 +33,7 @@ const TIER_ONE = 5;
export const RESUME_USER_BADGES: Array<BadgeInfo> = [ export const RESUME_USER_BADGES: Array<BadgeInfo> = [
{ {
description: `Reviewed over ${TIER_THREE} resumes`, description: `Reviewed ${TIER_THREE} resumes`,
icon: ResumeBadgeSuperheroIcon, icon: ResumeBadgeSuperheroIcon,
id: 'Superhero', id: 'Superhero',
isValid: (payload: BadgePayload) => isValid: (payload: BadgePayload) =>
@ -41,7 +41,7 @@ export const RESUME_USER_BADGES: Array<BadgeInfo> = [
title: 'True saviour of the people', title: 'True saviour of the people',
}, },
{ {
description: `Reviewed over ${TIER_TWO} resumes`, description: `Reviewed ${TIER_TWO} resumes`,
icon: ResumeBadgeDetectiveIcon, icon: ResumeBadgeDetectiveIcon,
id: 'Detective', id: 'Detective',
isValid: (payload: BadgePayload) => isValid: (payload: BadgePayload) =>
@ -50,7 +50,7 @@ export const RESUME_USER_BADGES: Array<BadgeInfo> = [
title: 'Keen eye for details like a private eye', title: 'Keen eye for details like a private eye',
}, },
{ {
description: `Reviewed over ${TIER_ONE} resumes`, description: `Reviewed ${TIER_ONE} resumes`,
icon: ResumeBadgeEagleIcon, icon: ResumeBadgeEagleIcon,
id: 'Eagle', id: 'Eagle',
isValid: (payload: BadgePayload) => isValid: (payload: BadgePayload) =>

@ -0,0 +1,45 @@
import { useState } from 'react';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('software-engineer');
const [companyFilter, setCompanyFilter] = useState('');
return (
<main className="flex-1 overflow-y-auto">
<div className="grid-rows grid h-1/2 bg-slate-100">
<OffersTitle />
<div className="flex items-start justify-center">
<div className="mt-4 flex items-center">
Viewing offers for
<div className="mx-4">
<JobTitlesTypeahead
isLabelHidden={true}
placeHolder="Software Engineer"
onSelect={({ value }) => setjobTitleFilter(value)}
/>
</div>
in
<div className="ml-4">
<CompaniesTypeahead
isLabelHidden={true}
placeHolder="All Companies"
onSelect={({ value }) => setCompanyFilter(value)}
/>
</div>
</div>
</div>
</div>
<div className="flex justify-center bg-white pb-20 pt-10">
<OffersTable
companyFilter={companyFilter}
jobTitleFilter={jobTitleFilter}
/>
</div>
</main>
);
}

@ -1,48 +1,244 @@
import { useState } from 'react'; import type { SVGProps } from 'react';
import { Select } from '@tih/ui'; import {
BookmarkSquareIcon,
ChartBarSquareIcon,
InformationCircleIcon,
ShareIcon,
TableCellsIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
import { titleOptions } from '~/components/offers/constants'; import LeftTextCard from '~/components/offers/landing/LeftTextCard';
import OffersTitle from '~/components/offers/OffersTitle'; import RightTextCard from '~/components/offers/landing/RightTextCard';
import OffersTable from '~/components/offers/table/OffersTable'; const baseUrl = '/offers/home';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
export default function OffersHomePage() { const features = [
const [jobTitleFilter, setjobTitleFilter] = useState('Software Engineer'); {
const [companyFilter, setCompanyFilter] = useState(''); description:
'Name of the profile creator is stricly anonymous by using randomly generated names.',
icon: UsersIcon,
name: 'Anonymisd Profile Name',
},
{
description:
' Only people with the edit link can edit that profile. Share profiles to others using public link without giving edit permission.',
icon: ShareIcon,
name: 'Edit Link v.s. Public Link',
},
{
description:
"Offer profiles will not be automatically saved under creator's account in database unless explicit permission is given.",
icon: BookmarkSquareIcon,
name: 'No Auto-Save to User Account',
},
];
return ( const footerNavigation = {
<main className="flex-1 overflow-y-auto"> social: [
<div className="grid-rows grid h-1/2 bg-slate-100"> {
<OffersTitle /> href: '#',
<div className="flex items-start justify-center"> icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<div className="mt-4 flex items-center"> <svg fill="currentColor" viewBox="0 0 24 24" {...props}>
Viewing offers for <path
<div className="mx-4"> clipRule="evenodd"
<Select d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"
isLabelHidden={true} fillRule="evenodd"
label="Select a job title" />
options={titleOptions} </svg>
value={jobTitleFilter} ),
onChange={setjobTitleFilter} name: 'Facebook',
},
{
href: '#',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
fillRule="evenodd"
/> />
</svg>
),
name: 'Instagram',
},
{
href: 'https://github.com/yangshun/tech-interview-handbook',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
fillRule="evenodd"
/>
</svg>
),
name: 'GitHub',
},
],
};
export default function LandingPage() {
return (
<div className="overflow-y-auto bg-white">
<main>
{/* Hero section */}
<div className="relative h-full">
<div className="relative px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
<h1 className="text-center text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
<span className="block">Choosing offers made easier</span>
<span className="from-primary-600 -mb-1 block bg-gradient-to-r to-purple-500 bg-clip-text pb-1 text-transparent">
using profiles behind offers.
</span>
</h1>
<p className="text-primary-600 mx-auto mt-6 max-w-lg text-center text-xl sm:max-w-3xl">
Analyse your offers using profiles from fellow software engineers.
</p>
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
<a
className="border-grey-600 flex items-center justify-center rounded-md border bg-white bg-white px-4 py-3 text-base font-medium text-indigo-700 shadow-sm hover:bg-indigo-50 sm:px-8"
href={baseUrl}>
Get started
</a>
<a
className="bg-primary-600 flex items-center justify-center rounded-md border border-transparent px-4 py-3 text-base font-medium text-white shadow-sm hover:bg-opacity-70 sm:px-8"
href="#">
Live demo
</a>
</div>
</div>
</div>
</div> </div>
in
<div className="ml-4"> {/* Alternating Feature Sections */}
<CompaniesTypeahead <div className="relative overflow-hidden pt-16 pb-32">
isLabelHidden={true} <div
placeHolder="All companies" aria-hidden="true"
onSelect={({ value }) => setCompanyFilter(value)} className="absolute inset-x-0 top-0 h-48 bg-gradient-to-b from-gray-100"
/>
<div className="relative">
<LeftTextCard
description="An offer profile includes not only offers that a person get in their application cycle, but also background information such as education and work experience. Use offer profiles to help you better contaxtualize offers."
icon={
<InformationCircleIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-1.jpg"
title="Choosing an offer needs context"
/> />
</div> </div>
<div className="mt-36">
<RightTextCard
description="With our offer engine analysis, you can compare your offers with other offers on the market and make an informed decision."
icon={
<ChartBarSquareIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Customer profile user interface"
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-2.jpg"
title="Better understand your offers"
/>
</div> </div>
<div className="mt-36">
<LeftTextCard
description="Filter relevant offers by job title, company, submission date, salary and more."
icon={
<TableCellsIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-1.jpg"
title="Stay informed of recent offers"
/>
</div> </div>
</div> </div>
<div className="flex justify-center bg-white pb-20 pt-10">
<OffersTable {/* Gradient Feature Section */}
companyFilter={companyFilter} <div className="bg-gradient-to-r from-purple-800 to-indigo-700">
jobTitleFilter={jobTitleFilter} <div className="mx-auto max-w-4xl px-4 py-16 sm:px-6 sm:pt-20 sm:pb-24 lg:max-w-7xl lg:px-8 lg:pt-24">
<h2 className="flex justify-center text-4xl font-bold tracking-tight text-white">
Your privacy is our priority.
</h2>
<p className="mt-4 flex flex-row justify-center text-lg text-purple-200">
All offer profiles are anonymised and we do not store information
about your personal identity.
</p>
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-x-8 lg:gap-y-16">
{features.map((feature) => (
<div key={feature.name}>
<div>
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-white bg-opacity-10">
<feature.icon
aria-hidden="true"
className="h-6 w-6 text-white"
/> />
</span>
</div>
<div className="mt-6">
<h3 className="text-lg font-medium text-white">
{feature.name}
</h3>
<p className="mt-2 text-base text-purple-200">
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* CTA Section */}
<div className="bg-white">
<div className="mx-auto max-w-4xl py-16 px-4 sm:px-6 sm:py-24 lg:flex lg:max-w-7xl lg:items-center lg:justify-between lg:px-8">
<h2 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-4xl">
<span className="block">Ready to get started?</span>
<span className="-mb-1 block bg-gradient-to-r from-purple-600 to-indigo-600 bg-clip-text pb-1 text-transparent">
Create your own offer profile today.
</span>
</h2>
<div className="mt-6 space-y-4 sm:flex sm:space-y-0 sm:space-x-5">
<a
className="flex items-center justify-center rounded-md border border-transparent bg-gradient-to-r from-purple-600 to-indigo-600 bg-origin-border px-4 py-3 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={baseUrl}>
Get Started
</a>
</div>
</div>
</div> </div>
</main> </main>
<footer aria-labelledby="footer-heading" className="bg-gray-50">
<h2 className="sr-only" id="footer-heading">
Footer
</h2>
<div className="mx-auto max-w-7xl px-4 pt-0 pb-8 sm:px-6 lg:px-8">
<div className="mt-12 border-t border-gray-200 pt-8 md:flex md:items-center md:justify-between lg:mt-16">
<div className="flex space-x-6 md:order-2">
{footerNavigation.social.map((item) => (
<a
key={item.name}
className="text-gray-400 hover:text-gray-500"
href={item.href}>
<span className="sr-only">{item.name}</span>
<item.icon aria-hidden="true" className="h-6 w-6" />
</a>
))}
</div>
<p className="mt-8 text-base text-gray-400 md:order-1 md:mt-0">
&copy; 2022 Tech Interview Handbook Offer Profile Repository. All
rights reserved.
</p>
</div>
</div>
</footer>
</div>
); );
} }

@ -10,6 +10,8 @@ import type {
BackgroundDisplayData, BackgroundDisplayData,
OfferDisplayData, OfferDisplayData,
} from '~/components/offers/types'; } from '~/components/offers/types';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { useToast } from '~/../../../packages/ui/dist'; import { useToast } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
@ -44,7 +46,7 @@ export default function OfferProfile() {
enabled: typeof offerProfileId === 'string', enabled: typeof offerProfileId === 'string',
onSuccess: (data: Profile) => { onSuccess: (data: Profile) => {
if (!data) { if (!data) {
router.push('/offers'); router.push('/offers/home');
} }
// If the profile is not editable with a wrong token, redirect to the profile page // If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') { if (!data?.isEditable && token !== '') {
@ -62,7 +64,9 @@ export default function OfferProfile() {
companyName: res.company.name, companyName: res.company.name,
id: res.offersFullTime.id, id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level, jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title, jobTitle: getLabelForJobTitleType(
res.offersFullTime.title as JobTitleType,
),
location: res.location, location: res.location,
negotiationStrategy: res.negotiationStrategy, negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments, otherComment: res.comments,
@ -77,7 +81,9 @@ export default function OfferProfile() {
const filteredOffer: OfferDisplayData = { const filteredOffer: OfferDisplayData = {
companyName: res.company.name, companyName: res.company.name,
id: res.offersIntern!.id, id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title, jobTitle: getLabelForJobTitleType(
res.offersIntern!.title as JobTitleType,
),
location: res.location, location: res.location,
monthlySalary: convertMoneyToString( monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary, res.offersIntern!.monthlySalary,
@ -107,7 +113,9 @@ export default function OfferProfile() {
companyName: experience.company?.name, companyName: experience.company?.name,
duration: experience.durationInMonths, duration: experience.durationInMonths,
jobLevel: experience.level, jobLevel: experience.level,
jobTitle: experience.title, jobTitle: experience.title
? getLabelForJobTitleType(experience.title as JobTitleType)
: null,
monthlySalary: experience.monthlySalary monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary) ? convertMoneyToString(experience.monthlySalary)
: null, : null,
@ -140,7 +148,7 @@ export default function OfferProfile() {
}, },
onSuccess: () => { onSuccess: () => {
trpcContext.invalidateQueries(['offers.profile.listOne']); trpcContext.invalidateQueries(['offers.profile.listOne']);
router.push('/offers'); router.push('/offers/home');
showToast({ showToast({
title: `Offers profile successfully deleted!`, title: `Offers profile successfully deleted!`,
variant: 'success', variant: 'success',

@ -5,24 +5,22 @@ import { useEffect, useMemo, useState } from 'react';
import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid'; import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
import { NoSymbolIcon } from '@heroicons/react/24/outline'; import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { Button, SlideOut, Typeahead } from '@tih/ui'; import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard'; import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOption } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection'; import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar'; import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
import LocationTypeahead from '~/components/questions/typeahead/LocationTypeahead';
import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
import type { QuestionAge } from '~/utils/questions/constants'; import type { QuestionAge } from '~/utils/questions/constants';
import { SORT_TYPES } from '~/utils/questions/constants'; import { SORT_TYPES } from '~/utils/questions/constants';
import { SORT_ORDERS } from '~/utils/questions/constants'; import { SORT_ORDERS } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import { ROLES } from '~/utils/questions/constants'; import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import { import {
useSearchParam, useSearchParam,
@ -148,12 +146,18 @@ export default function QuestionsBrowsePage() {
: undefined; : undefined;
}, [selectedQuestionAge]); }, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery( const {
data: questionsQueryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.useInfiniteQuery(
[ [
'questions.questions.getQuestionsByFilter', 'questions.questions.getQuestionsByFilter',
{ {
companyNames: selectedCompanies, companyNames: selectedCompanies,
endDate: today, endDate: today,
limit: 10,
locations: selectedLocations, locations: selectedLocations,
questionTypes: selectedQuestionTypes, questionTypes: selectedQuestionTypes,
roles: selectedRoles, roles: selectedRoles,
@ -163,10 +167,21 @@ export default function QuestionsBrowsePage() {
}, },
], ],
{ {
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true, keepPreviousData: true,
}, },
); );
const questionCount = useMemo(() => {
if (!questionsQueryData) {
return undefined;
}
return questionsQueryData.pages.reduce(
(acc, page) => acc + page.data.length,
0,
);
}, [questionsQueryData]);
const utils = trpc.useContext(); const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation( const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create', 'questions.questions.create',
@ -180,12 +195,17 @@ export default function QuestionsBrowsePage() {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const companyFilterOptions = useMemo(() => { const [selectedCompanyOptions, setSelectedCompanyOptions] = useState<
return COMPANIES.map((company) => ({ Array<FilterOption>
...company, >([]);
checked: selectedCompanies.includes(company.value),
})); const [selectedRoleOptions, setSelectedRoleOptions] = useState<
}, [selectedCompanies]); Array<FilterOption>
>([]);
const [selectedLocationOptions, setSelectedLocationOptions] = useState<
Array<FilterOption>
>([]);
const questionTypeFilterOptions = useMemo(() => { const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({ return QUESTION_TYPES.map((questionType) => ({
@ -201,20 +221,6 @@ export default function QuestionsBrowsePage() {
})); }));
}, [selectedQuestionAge]); }, [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(() => { const areSearchOptionsInitialized = useMemo(() => {
return ( return (
areCompaniesInitialized && areCompaniesInitialized &&
@ -287,35 +293,89 @@ export default function QuestionsBrowsePage() {
setSelectedQuestionAge('all'); setSelectedQuestionAge('all');
setSelectedRoles([]); setSelectedRoles([]);
setSelectedLocations([]); setSelectedLocations([]);
setSelectedCompanyOptions([]);
setSelectedRoleOptions([]);
setSelectedLocationOptions([]);
}} }}
/> />
<FilterSection <FilterSection
label="Company" label="Companies"
options={companyFilterOptions} options={selectedCompanyOptions}
renderInput={({ renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
onOptionChange, <CompanyTypeahead
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field} {...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedCompanyOptions.some((selectedOption) => {
return selectedOption.value === option.value;
});
}}
isLabelHidden={true} isLabelHidden={true}
label="Companies"
options={options}
placeholder="Search companies" placeholder="Search companies"
// eslint-disable-next-line @typescript-eslint/no-empty-function onSelect={(option) => {
onQueryChange={() => {}} onOptionChange({
onSelect={({ value }) => { ...option,
onOptionChange(value, true); checked: true,
});
}} }}
/> />
)} )}
onOptionChange={(optionValue, checked) => { onOptionChange={(option) => {
if (checked) { if (option.checked) {
setSelectedCompanies([...selectedCompanies, optionValue]); setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanyOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else { } else {
setSelectedCompanies( setSelectedCompanies(
selectedCompanies.filter((company) => company !== optionValue), selectedCompanies.filter((company) => company !== option.label),
);
setSelectedCompanyOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.label !== option.label,
),
);
}
}}
/>
<FilterSection
label="Roles"
options={selectedRoleOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
<RoleTypeahead
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedRoleOptions.some((selectedOption) => {
return selectedOption.value === option.value;
});
}}
isLabelHidden={true}
placeholder="Search roles"
onSelect={(option) => {
onOptionChange({
...option,
checked: true,
});
}}
/>
)}
onOptionChange={(option) => {
if (option.checked) {
setSelectedRoles([...selectedRoles, option.value]);
setSelectedRoleOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
setSelectedRoles(
selectedCompanies.filter((role) => role !== option.value),
);
setSelectedRoleOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
); );
} }
}} }}
@ -324,13 +384,13 @@ export default function QuestionsBrowsePage() {
label="Question types" label="Question types"
options={questionTypeFilterOptions} options={questionTypeFilterOptions}
showAll={true} showAll={true}
onOptionChange={(optionValue, checked) => { onOptionChange={(option) => {
if (checked) { if (option.checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]); setSelectedQuestionTypes([...selectedQuestionTypes, option.value]);
} else { } else {
setSelectedQuestionTypes( setSelectedQuestionTypes(
selectedQuestionTypes.filter( selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue, (questionType) => questionType !== option.value,
), ),
); );
} }
@ -341,68 +401,47 @@ export default function QuestionsBrowsePage() {
label="Question age" label="Question age"
options={questionAgeFilterOptions} options={questionAgeFilterOptions}
showAll={true} showAll={true}
onOptionChange={(optionValue) => { onOptionChange={({ value }) => {
setSelectedQuestionAge(optionValue); setSelectedQuestionAge(value);
}} }}
/> />
<FilterSection <FilterSection
label="Roles" label="Locations"
options={roleFilterOptions} options={selectedLocationOptions}
renderInput={({ renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
onOptionChange, <LocationTypeahead
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field} {...field}
isLabelHidden={true} clearOnSelect={true}
label="Roles" filterOption={(option) => {
options={options} return !selectedLocationOptions.some((selectedOption) => {
placeholder="Search roles" return selectedOption.value === option.value;
// 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} isLabelHidden={true}
label="Locations"
options={options}
placeholder="Search locations" placeholder="Search locations"
// eslint-disable-next-line @typescript-eslint/no-empty-function onSelect={(option) => {
onQueryChange={() => {}} onOptionChange({
onSelect={({ value }) => { ...option,
onOptionChange(value, true); checked: true,
});
}} }}
/> />
)} )}
onOptionChange={(optionValue, checked) => { onOptionChange={(option) => {
if (checked) { if (option.checked) {
setSelectedLocations([...selectedLocations, optionValue]); setSelectedLocations([...selectedLocations, option.value]);
setSelectedLocationOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else { } else {
setSelectedLocations( setSelectedLocations(
selectedLocations.filter((location) => location !== optionValue), selectedLocations.filter((role) => role !== option.value),
);
setSelectedLocationOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
); );
} }
}} }}
@ -443,29 +482,50 @@ export default function QuestionsBrowsePage() {
onSortOrderChange={setSortOrder} onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType} onSortTypeChange={setSortType}
/> />
<div className="flex flex-col gap-4 pb-4"> <div className="flex flex-col gap-2 pb-4">
{(questions ?? []).map((question) => ( {(questionsQueryData?.pages ?? []).flatMap(
({ data: questions }) =>
questions.map((question) => (
<QuestionOverviewCard <QuestionOverviewCard
key={question.id} key={question.id}
answerCount={question.numAnswers} answerCount={question.numAnswers}
companies={{ [question.company]: 1 }} companies={
question.aggregatedQuestionEncounters.companyCounts
}
content={question.content} content={question.content}
href={`/questions/${question.id}/${createSlug( href={`/questions/${question.id}/${createSlug(
question.content, question.content,
)}`} )}`}
locations={{ [question.location]: 1 }} locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id} questionId={question.id}
receivedCount={question.receivedCount} receivedCount={question.receivedCount}
roles={{ [question.role]: 1 }} roles={
timestamp={question.seenAt.toLocaleDateString(undefined, { question.aggregatedQuestionEncounters.roleCounts
}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
})} },
)}
type={question.type} type={question.type}
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
/> />
))} )),
{questions?.length === 0 && ( )}
<Button
disabled={!hasNextPage || isFetchingNextPage}
isLoading={isFetchingNextPage}
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
variant="tertiary"
onClick={() => {
fetchNextPage();
}}
/>
{questionCount === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600"> <div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" /> <NoSymbolIcon className="h-6 w-6" />
<p>Nothing found.</p> <p>Nothing found.</p>

@ -1,4 +1,4 @@
import crypto, { randomUUID } from 'crypto'; import crypto from 'crypto';
import { z } from 'zod'; import { z } from 'zod';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import * as trpc from '@trpc/server'; import * as trpc from '@trpc/server';
@ -10,6 +10,7 @@ import {
} 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/currencyExchange'; import { convert } from '~/utils/offers/currency/currencyExchange';
import { generateRandomName, generateRandomStringForToken } from '~/utils/offers/randomGenerator';
import { createValidationRegex } from '~/utils/offers/zodRegex'; import { createValidationRegex } from '~/utils/offers/zodRegex';
import { createRouter } from '../context'; import { createRouter } from '../context';
@ -263,9 +264,12 @@ export const offersProfileRouter = createRouter()
// TODO: add more // TODO: add more
const token = crypto const token = crypto
.createHash('sha256') .createHash('sha256')
.update(Date.now().toString()) .update(Date.now().toString() + generateRandomStringForToken())
.digest('hex'); .digest('hex');
// Generate random name until unique
const uniqueName: string = await generateRandomName();
const profile = await ctx.prisma.offersProfile.create({ const profile = await ctx.prisma.offersProfile.create({
data: { data: {
background: { background: {
@ -538,7 +542,7 @@ export const offersProfileRouter = createRouter()
}), }),
), ),
}, },
profileName: randomUUID().substring(0, 10), profileName: uniqueName,
}, },
}); });

@ -103,22 +103,32 @@ export const offersRouter = createRouter().query('list', {
monthYearReceived: order, monthYearReceived: order,
} }
: sortingKey === sortingKeysMap.totalCompensation : sortingKey === sortingKeysMap.totalCompensation
? { ? [
{
offersIntern: { offersIntern: {
monthlySalary: { monthlySalary: {
baseValue: order, baseValue: order,
}, },
}, },
} },
{
monthYearReceived: 'desc',
},
]
: sortingKey === sortingKeysMap.totalYoe : sortingKey === sortingKeysMap.totalYoe
? { ? [
{
profile: { profile: {
background: { background: {
totalYoe: order, totalYoe: order,
}, },
}, },
} },
: undefined, {
monthYearReceived: 'desc',
},
]
: { monthYearReceived: 'desc' },
where: { where: {
AND: [ AND: [
{ {
@ -207,22 +217,32 @@ export const offersRouter = createRouter().query('list', {
monthYearReceived: order, monthYearReceived: order,
} }
: sortingKey === sortingKeysMap.totalCompensation : sortingKey === sortingKeysMap.totalCompensation
? { ? [
offersFullTime: { {
totalCompensation: { offersIntern: {
monthlySalary: {
baseValue: order, baseValue: order,
}, },
}, },
} },
{
monthYearReceived: 'desc',
},
]
: sortingKey === sortingKeysMap.totalYoe : sortingKey === sortingKeysMap.totalYoe
? { ? [
{
profile: { profile: {
background: { background: {
totalYoe: order, totalYoe: order,
}, },
}, },
} },
: undefined, {
monthYearReceived: 'desc',
},
]
: { monthYearReceived: 'desc' },
where: { where: {
AND: [ AND: [
{ {

@ -166,13 +166,29 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
const { answerCommentId, vote } = input; const { answerCommentId, vote } = input;
return await ctx.prisma.questionsAnswerCommentVote.create({ const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.create({
data: { data: {
answerCommentId, answerCommentId,
userId, userId,
vote, vote,
}, },
}); }),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: answerCommentId,
},
}),
]);
return answerCommentVote;
}, },
}) })
.mutation('updateVote', { .mutation('updateVote', {
@ -198,14 +214,30 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsAnswerCommentVote.update({ const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.update({
data: { data: {
vote, vote,
}, },
where: { where: {
id, id,
}, },
}); }),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.answerCommentId,
},
}),
]);
return answerCommentVote;
}, },
}) })
.mutation('deleteVote', { .mutation('deleteVote', {
@ -229,10 +261,26 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsAnswerCommentVote.delete({ const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.delete({
where: { where: {
id: input.id, id: input.id,
}, },
}); }),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.answerCommentId,
},
}),
]);
return answerCommentVote;
}, },
}); });

@ -229,13 +229,28 @@ export const questionsAnswerRouter = createProtectedRouter()
const { answerId, vote } = input; const { answerId, vote } = input;
return await ctx.prisma.questionsAnswerVote.create({ const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [answerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.create({
data: { data: {
answerId, answerId,
userId, userId,
vote, vote,
}, },
}); }),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: answerId,
},
}),
]);
return answerVote;
}, },
}) })
.mutation('updateVote', { .mutation('updateVote', {
@ -260,14 +275,30 @@ export const questionsAnswerRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsAnswerVote.update({ const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.update({
data: { data: {
vote, vote,
}, },
where: { where: {
id, id,
}, },
}); }),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.answerId,
},
}),
]);
return questionsAnswerVote;
}, },
}) })
.mutation('deleteVote', { .mutation('deleteVote', {
@ -290,10 +321,26 @@ export const questionsAnswerRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsAnswerVote.delete({ const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.delete({
where: { where: {
id: input.id, id: input.id,
}, },
}); }),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.answerId,
},
}),
]);
return questionsAnswerVote;
}, },
}); });

@ -166,13 +166,28 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { questionCommentId, vote } = input; const { questionCommentId, vote } = input;
return await ctx.prisma.questionsQuestionCommentVote.create({ const incrementValue: number = vote === Vote.UPVOTE ? 1 : -1;
const [ questionCommentVote ] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.create({
data: { data: {
questionCommentId, questionCommentId,
userId, userId,
vote, vote,
}, },
}); }),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: questionCommentId,
},
}),
]);
return questionCommentVote;
}, },
}) })
.mutation('updateVote', { .mutation('updateVote', {
@ -198,14 +213,30 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsQuestionCommentVote.update({ const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.update({
data: { data: {
vote, vote,
}, },
where: { where: {
id, id,
}, },
}); }),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.questionCommentId,
},
}),
]);
return questionCommentVote;
}, },
}) })
.mutation('deleteVote', { .mutation('deleteVote', {
@ -229,10 +260,25 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsQuestionCommentVote.delete({ const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.delete({
where: { where: {
id: input.id, id: input.id,
}, },
}); }),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.questionCommentId,
},
}),
]);
return questionCommentVote;
}, },
}); });

@ -27,9 +27,13 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
const locationCounts: Record<string, number> = {}; const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {}; const roleCounts: Record<string, number> = {};
let latestSeenAt = questionEncountersData[0].seenAt;
for (let i = 0; i < questionEncountersData.length; i++) { for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i]; const encounter = questionEncountersData[i];
latestSeenAt = latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) { if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1; companyCounts[encounter.company!.name] = 1;
} }
@ -48,6 +52,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
const questionEncounter: AggregatedQuestionEncounter = { const questionEncounter: AggregatedQuestionEncounter = {
companyCounts, companyCounts,
latestSeenAt,
locationCounts, locationCounts,
roleCounts, roleCounts,
}; };

@ -11,9 +11,16 @@ export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', { .query('getQuestionsByFilter', {
input: z.object({ input: z.object({
companyNames: z.string().array(), companyNames: z.string().array(),
cursor: z
.object({
idCursor: z.string().optional(),
lastSeenCursor: z.date().optional(),
upvoteCursor: z.number().optional(),
})
.nullish(),
endDate: z.date().default(new Date()), endDate: z.date().default(new Date()),
limit: z.number().min(1).default(50),
locations: z.string().array(), locations: z.string().array(),
pageSize: z.number().default(50),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(), questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(), roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder), sortOrder: z.nativeEnum(SortOrder),
@ -21,16 +28,36 @@ export const questionsQuestionRouter = createProtectedRouter()
startDate: z.date().optional(), startDate: z.date().optional(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const { cursor } = input;
const sortCondition = const sortCondition =
input.sortType === SortType.TOP input.sortType === SortType.TOP
? { ? [
{
upvotes: input.sortOrder, upvotes: input.sortOrder,
} },
: { {
id: input.sortOrder,
},
]
: [
{
lastSeenAt: input.sortOrder, lastSeenAt: input.sortOrder,
}; },
{
id: input.sortOrder,
},
];
const toSkip = cursor ? 1 : 0;
const questionsData = await ctx.prisma.questionsQuestion.findMany({ const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor:
cursor !== undefined
? {
id: cursor ? cursor!.idCursor : undefined,
}
: undefined,
include: { include: {
_count: { _count: {
select: { select: {
@ -53,9 +80,9 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
votes: true, votes: true,
}, },
orderBy: { orderBy: sortCondition,
...sortCondition, skip: toSkip,
}, take: input.limit + 1,
where: { where: {
...(input.questionTypes.length > 0 ...(input.questionTypes.length > 0
? { ? {
@ -98,7 +125,7 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
}); });
return questionsData.map((data) => { const processedQuestionsData = questionsData.map((data) => {
const votes: number = data.votes.reduce( const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => { (previousValue: number, currentValue) => {
let result: number = previousValue; let result: number = previousValue;
@ -116,23 +143,78 @@ export const questionsQuestionRouter = createProtectedRouter()
0, 0,
); );
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = data.encounters[0].seenAt;
for (let i = 0; i < data.encounters.length; i++) {
const encounter = data.encounters[i];
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = { const question: Question = {
company: data.encounters[0].company!.name ?? 'Unknown company', aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: data.content, content: data.content,
id: data.id, id: data.id,
location: data.encounters[0].location ?? 'Unknown location',
numAnswers: data._count.answers, numAnswers: data._count.answers,
numComments: data._count.comments, numComments: data._count.comments,
numVotes: votes, numVotes: votes,
receivedCount: data.encounters.length, receivedCount: data.encounters.length,
role: data.encounters[0].role ?? 'Unknown role', seenAt: latestSeenAt,
seenAt: data.encounters[0].seenAt,
type: data.questionType, type: data.questionType,
updatedAt: data.updatedAt, updatedAt: data.updatedAt,
user: data.user?.name ?? '', user: data.user?.name ?? '',
}; };
return question; return question;
}); });
let nextCursor: typeof cursor | undefined = undefined;
if (questionsData.length > input.limit) {
const nextItem = questionsData.pop()!;
processedQuestionsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
const nextLastSeenCursor =
input.sortType === SortType.NEW ? nextItem.lastSeenAt : undefined;
const nextUpvoteCursor =
input.sortType === SortType.TOP ? nextItem.upvotes : undefined;
nextCursor = {
idCursor: nextIdCursor,
lastSeenCursor: nextLastSeenCursor,
upvoteCursor: nextUpvoteCursor,
};
}
return {
data: processedQuestionsData,
nextCursor,
};
}, },
}) })
.query('getQuestionById', { .query('getQuestionById', {
@ -190,16 +272,45 @@ export const questionsQuestionRouter = createProtectedRouter()
0, 0,
); );
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionData.encounters[0].seenAt;
for (const encounter of questionData.encounters) {
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = { const question: Question = {
company: questionData.encounters[0].company!.name ?? 'Unknown company', aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: questionData.content, content: questionData.content,
id: questionData.id, id: questionData.id,
location: questionData.encounters[0].location ?? 'Unknown location',
numAnswers: questionData._count.answers, numAnswers: questionData._count.answers,
numComments: questionData._count.comments, numComments: questionData._count.comments,
numVotes: votes, numVotes: votes,
receivedCount: questionData.encounters.length, receivedCount: questionData.encounters.length,
role: questionData.encounters[0].role ?? 'Unknown role',
seenAt: questionData.encounters[0].seenAt, seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType, type: questionData.questionType,
updatedAt: questionData.updatedAt, updatedAt: questionData.updatedAt,

@ -1,16 +1,13 @@
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
export type Question = { export type Question = {
// TODO: company, location, role maps aggregatedQuestionEncounters: AggregatedQuestionEncounter;
company: string;
content: string; content: string;
id: string; id: string;
location: string;
numAnswers: number; numAnswers: number;
numComments: number; numComments: number;
numVotes: number; numVotes: number;
receivedCount: number; receivedCount: number;
role: string;
seenAt: Date; seenAt: Date;
type: QuestionsQuestionType; type: QuestionsQuestionType;
updatedAt: Date; updatedAt: Date;
@ -19,6 +16,7 @@ export type Question = {
export type AggregatedQuestionEncounter = { export type AggregatedQuestionEncounter = {
companyCounts: Record<string, number>; companyCounts: Record<string, number>;
latestSeenAt: Date;
locationCounts: Record<string, number>; locationCounts: Record<string, number>;
roleCounts: Record<string, number>; roleCounts: Record<string, number>;
}; };

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

@ -25,9 +25,11 @@ export function timeSinceNow(date: Date | number | string) {
} }
interval = seconds / 60; interval = seconds / 60;
if (interval > 1) { if (interval > 1) {
return `${Math.floor(interval)} minutes`; const time: number = Math.floor(interval);
return time === 1 ? `${time} minute` : `${time} minutes`;
} }
return `${Math.floor(interval)} seconds`; const time: number = Math.floor(interval);
return time === 1 ? `${time} second` : `${time} seconds`;
} }
export function formatDate(value: Date | number | string) { export function formatDate(value: Date | number | string) {

@ -14405,6 +14405,11 @@ unique-filename@^1.1.1:
dependencies: dependencies:
unique-slug "^2.0.0" unique-slug "^2.0.0"
unique-names-generator@^4.7.1:
version "4.7.1"
resolved "https://registry.yarnpkg.com/unique-names-generator/-/unique-names-generator-4.7.1.tgz#966407b12ba97f618928f77322cfac8c80df5597"
integrity sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==
unique-slug@^2.0.0: unique-slug@^2.0.0:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz" resolved "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz"

Loading…
Cancel
Save