Merge branch 'main' into weilin/question-list

pull/468/head
Jeff Sieu 3 years ago
commit 5a3bbde9e9

@ -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;

@ -0,0 +1,19 @@
/*
Warnings:
- You are about to drop the column `specialization` on the `OffersExperience` table. All the data in the column will be lost.
- You are about to drop the column `specialization` on the `OffersFullTime` table. All the data in the column will be lost.
- You are about to drop the column `specialization` on the `OffersIntern` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "OffersExperience" DROP COLUMN "specialization";
-- AlterTable
ALTER TABLE "OffersFullTime" DROP COLUMN "specialization",
ALTER COLUMN "baseSalaryId" DROP NOT NULL,
ALTER COLUMN "bonusId" DROP NOT NULL,
ALTER COLUMN "stocksId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "OffersIntern" DROP COLUMN "specialization";

@ -234,7 +234,6 @@ model OffersExperience {
// Add more fields
durationInMonths Int?
specialization String?
location String?
// FULLTIME fields
@ -340,7 +339,6 @@ model OffersIntern {
id String @id @default(cuid())
title String
specialization String
internshipCycle String
startYear Int
monthlySalary OffersCurrency @relation(fields: [monthlySalaryId], references: [id], onDelete: Cascade)
@ -350,18 +348,17 @@ model OffersIntern {
}
model OffersFullTime {
id String @id @default(cuid())
id String @id @default(cuid())
title String
specialization String
level String
totalCompensation OffersCurrency @relation("OfferTotalCompensation", fields: [totalCompensationId], references: [id], onDelete: Cascade)
totalCompensationId String @unique
baseSalary OffersCurrency @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id], onDelete: Cascade)
baseSalaryId String @unique
bonus OffersCurrency @relation("OfferBonus", fields: [bonusId], references: [id], onDelete: Cascade)
bonusId String @unique
stocks OffersCurrency @relation("OfferStocks", fields: [stocksId], references: [id], onDelete: Cascade)
stocksId String @unique
totalCompensation OffersCurrency @relation("OfferTotalCompensation", fields: [totalCompensationId], references: [id], onDelete: Cascade)
totalCompensationId String @unique
baseSalary OffersCurrency? @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id], onDelete: Cascade)
baseSalaryId String? @unique
bonus OffersCurrency? @relation("OfferBonus", fields: [bonusId], references: [id], onDelete: Cascade)
bonusId String? @unique
stocks OffersCurrency? @relation("OfferStocks", fields: [stocksId], references: [id], onDelete: Cascade)
stocksId String? @unique
OffersOffer OffersOffer?
}
@ -454,6 +451,7 @@ model QuestionsQuestionComment {
id String @id @default(cuid())
questionId String
userId String?
upvotes Int @default(0)
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -482,6 +480,7 @@ model QuestionsAnswer {
questionId String
userId String?
content String @db.Text
upvotes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -510,6 +509,7 @@ model QuestionsAnswerComment {
answerId String
userId String?
content String @db.Text
upvotes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

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

@ -3,14 +3,14 @@ export default function OffersTitle() {
<>
<div className="flex items-end justify-center">
<h1 className="text-primary-600 mt-16 text-center text-4xl font-bold">
Tech Handbook Offers Repo
Offer Profile Repository
</h1>
</div>
<div className="text-primary-500 mt-2 text-center text-2xl font-normal">
Reveal profile stories behind offers
</div>
<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
</div>
</>

@ -2,26 +2,6 @@ import { EducationBackgroundType } from './types';
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 = [
{
label: 'Singapore, Singapore',

@ -0,0 +1,55 @@
import type { ReactNode } from 'react';
import { HOME_URL } from '~/components/offers/types';
type LeftTextCardProps = Readonly<{
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: string;
title: string;
}>;
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="to-primary-500 flex h-12 w-12 items-center justify-center rounded-md bg-gradient-to-r from-purple-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="to-primary-500 inline-flex rounded-md border border-transparent bg-gradient-to-r from-purple-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={HOME_URL}>
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,55 @@
import type { ReactNode } from 'react';
import { HOME_URL } from '~/components/offers/types';
type RightTextCarddProps = Readonly<{
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: string;
title: string;
}>;
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="to-primary-500 flex h-12 w-12 items-center justify-center rounded-md bg-gradient-to-r from-purple-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="to-primary-500 inline-flex rounded-md border border-transparent bg-gradient-to-r from-purple-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={HOME_URL}>
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>
);
}

@ -4,6 +4,9 @@ import {
} from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
@ -54,7 +57,9 @@ export default function OfferProfileCard({
<HorizontalDivider />
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<p className="font-bold">{title}</p>
<p className="font-bold">
{getLabelForJobTitleType(title as JobTitleType)}
</p>
<p>
Company: {company.name}, {location}
</p>

@ -115,7 +115,7 @@ export default function OffersSubmissionForm({
),
hasNext: true,
hasPrevious: false,
label: 'Offer details',
label: 'Offers',
},
{
component: <BackgroundForm key={1} />,
@ -125,28 +125,33 @@ export default function OffersSubmissionForm({
},
{
component: (
<OfferAnalysis
<OffersProfileSave
key={2}
allAnalysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
/>
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
label: 'Save profile',
},
{
component: (
<OffersProfileSave
key={3}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
/>
<div>
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result
</h5>
<OfferAnalysis
key={3}
allAnalysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
/>
</div>
),
hasNext: false,
hasPrevious: false,
label: 'Save',
hasPrevious: true,
label: 'Analysis',
},
];

@ -8,13 +8,17 @@ import {
emptyOption,
FieldError,
locationOptions,
titleOptions,
} from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types';
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 FormSelect from '../../forms/FormSelect';
import FormTextInput from '../../forms/FormTextInput';
@ -92,13 +96,13 @@ function FullTimeJobFields() {
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Title"
options={titleOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.title`)}
/>
<div>
<JobTitlesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.title`, value)
}
/>
</div>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
@ -112,6 +116,7 @@ function FullTimeJobFields() {
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
@ -135,24 +140,19 @@ function FullTimeJobFields() {
</div>
<Collapsible label="Add more details">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
label="Focus / Specialization"
placeholder="e.g. Front End"
{...register(`background.experiences.0.specialization`)}
/>
<FormTextInput
label="Level"
placeholder="e.g. L4, Junior"
{...register(`background.experiences.0.level`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Location"
options={locationOptions}
{...register(`background.experiences.0.location`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
errorMessage={experiencesField?.durationInMonths?.message}
label="Duration (months)"
@ -177,13 +177,13 @@ function InternshipJobFields() {
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Title"
options={titleOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.title`)}
/>
<div>
<JobTitlesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.title`, value)
}
/>
</div>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
@ -197,6 +197,7 @@ function InternshipJobFields() {
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
@ -218,11 +219,6 @@ function InternshipJobFields() {
</div>
<Collapsible label="Add more details">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
label="Focus / Specialization"
placeholder="e.g. Front End"
{...register(`background.experiences.0.specialization`)}
/>
<FormSelect
display="block"
label="Location"
@ -310,6 +306,22 @@ function EducationSection() {
{...register(`background.educations.0.school`)}
/>
</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>
</div>
</>
@ -319,13 +331,9 @@ function EducationSection() {
export default function BackgroundForm() {
return (
<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
</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>
<YoeSection />
<CurrentJobSection />

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

@ -72,18 +72,17 @@ export default function OfferCard({
<HorizontalDivider />
<div className="px-8">
<div className="flex flex-col py-2">
{totalCompensation ||
(monthlySalary && (
<div className="flex flex-row">
<CurrencyDollarIcon className="mr-1 h-5" />
<p>
{totalCompensation && `TC: ${totalCompensation}`}
{monthlySalary && `Monthly Salary: ${monthlySalary}`}
</p>
</div>
))}
{(totalCompensation || monthlySalary) && (
<div className="flex flex-row">
<CurrencyDollarIcon className="mr-1 h-5" />
<p>
{totalCompensation && `TC: ${totalCompensation}`}
{monthlySalary && `Monthly Salary: ${monthlySalary}`}
</p>
</div>
)}
{totalCompensation && (
<div className="ml-6 flex flex-row font-light text-slate-400">
<div className="ml-6 flex flex-row font-light">
<p>
Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '}
{bonus}

@ -142,32 +142,40 @@ export default function ProfileComments({
/>
</div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
<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}
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Comment"
size="sm"
variant="primary"
onClick={() => handleComment(currentReply)}
/>
{isEditable || session?.user?.name ? (
<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"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Comment"
size="sm"
variant="primary"
onClick={() => handleComment(currentReply)}
/>
</div>
</div>
<HorizontalDivider />
</div>
<HorizontalDivider />
</div>
<div className="h-full overflow-y-scroll">
) : (
<div>Please log in before commenting on this profile.</div>
)}
<div className="h-full overflow-y-auto">
<div className="h-content mb-96 w-full">
{replies?.map((reply: Reply) => (
<ExpandableCommentCard

@ -121,7 +121,7 @@ function ProfileAnalysis({
<Button
addonPosition="start"
icon={ArrowPathIcon}
label="Refresh Analysis"
label="Regenerate Analysis"
variant="secondary"
onClick={() => generateAnalysisMutation.mutate({ profileId })}
/>

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

@ -1,5 +1,8 @@
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 { formatDate } from '~/utils/offers/time';
@ -19,7 +22,9 @@ export default function OfferTableRow({
scope="row">
{company.name}
</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">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td>

@ -2,6 +2,8 @@ import type { JobType } from '@prisma/client';
import type { MonthYear } from '~/components/shared/MonthYearPicker';
export const HOME_URL = '/offers/browse';
/*
* Offer Profile
*/
@ -49,7 +51,6 @@ type ExperiencePostData = {
level?: string | null;
location?: string | null;
monthlySalary?: Money | null;
specialization?: string | null;
title?: string | null;
totalCompensation?: Money | null;
totalCompensationId?: string | null;
@ -89,12 +90,11 @@ export type OfferFormData = Omit<OfferPostData, 'monthYearReceived'> & {
};
export type OfferFullTimePostData = {
baseSalary: Money;
bonus: Money;
baseSalary: Money | null;
bonus: Money | null;
id?: string;
level: string;
specialization: string;
stocks: Money;
stocks: Money | null;
title: string;
totalCompensation: Money;
};
@ -103,7 +103,6 @@ export type OfferInternPostData = {
id?: string;
internshipCycle: string;
monthlySalary: Money;
specialization: string;
startYear: number;
title: string;
};

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

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

@ -14,17 +14,20 @@ export default function ResumeUserBadge({
return (
<div className="group relative flex items-center justify-center">
<div
className="absolute -top-3 hidden w-48 -translate-y-full flex-col
justify-center gap-1 rounded-lg bg-white px-2 py-2 text-center drop-shadow-xl
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
after:border-t-white after:drop-shadow-lg after:content-['']
className="h-34 absolute left-6 z-10 hidden w-48 flex-col justify-center
gap-1 rounded-xl bg-white pb-2 text-center drop-shadow-lg
before:absolute before:top-14 before:-translate-x-4
before:border-8 before:border-y-transparent before:border-l-transparent
before:border-r-white before:drop-shadow-lg before:content-['']
group-hover:flex">
<Icon className="h-12 w-12 self-center" />
<p className="font-medium">{title}</p>
<p className="text-sm">{description}.</p>
<Icon className="h-16 w-full self-center rounded-t-xl bg-slate-200 py-2" />
<div className="px-2">
<p className="font-medium">{title}</p>
<p className="text-sm">{description}.</p>
</div>
</div>
<Icon className="h-4 w-4" />
<Icon className="h-5 w-5 rounded-xl border bg-slate-200 shadow-sm" />
</div>
);
}

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

@ -73,10 +73,6 @@ const analysisOfferDtoMapper = (
?.filter((exp) => exp.company != null)
.map((exp) => exp.company?.name ?? '') ?? [],
profileName,
specialization:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.specialization ?? ''
: offer.offersIntern?.specialization ?? '',
title:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.title ?? ''
@ -120,22 +116,14 @@ const analysisDtoMapper = (
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & {
totalCompensation: OffersCurrency;
})
| null;
offersIntern:
| (OffersIntern & {
monthlySalary: OffersCurrency;
})
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & {
company: Company | null;
}
OffersExperience & { company: Company | null }
>;
})
| null;
@ -168,10 +156,6 @@ const analysisHighestOfferDtoMapper = (
id: offer.id,
level: offer.offersFullTime?.level ?? '',
location: offer.location,
specialization:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.specialization ?? ''
: offer.offersIntern?.specialization ?? '',
totalYoe: offer.profile.background?.totalYoe ?? -1,
};
return analysisHighestOfferDto;
@ -327,12 +311,11 @@ export const experienceDtoMapper = (
location: experience.location,
monthlySalary: experience.monthlySalary
? valuationDtoMapper(experience.monthlySalary)
: experience.monthlySalary,
specialization: experience.specialization,
: null,
title: experience.title,
totalCompensation: experience.totalCompensation
? valuationDtoMapper(experience.totalCompensation)
: experience.totalCompensation,
: null,
};
return experienceDto;
};
@ -398,9 +381,9 @@ export const profileOfferDtoMapper = (
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency;
bonus: OffersCurrency;
stocks: OffersCurrency;
baseSalary: OffersCurrency | null;
bonus: OffersCurrency | null;
stocks: OffersCurrency | null;
totalCompensation: OffersCurrency;
})
| null;
@ -421,12 +404,20 @@ export const profileOfferDtoMapper = (
if (offer.offersFullTime) {
profileOfferDto.offersFullTime = {
baseSalary: valuationDtoMapper(offer.offersFullTime.baseSalary),
bonus: valuationDtoMapper(offer.offersFullTime.bonus),
baseSalary:
offer.offersFullTime?.baseSalary != null
? valuationDtoMapper(offer.offersFullTime.baseSalary)
: null,
bonus:
offer.offersFullTime?.bonus != null
? valuationDtoMapper(offer.offersFullTime.bonus)
: null,
id: offer.offersFullTime.id,
level: offer.offersFullTime.level,
specialization: offer.offersFullTime.specialization,
stocks: valuationDtoMapper(offer.offersFullTime.stocks),
stocks:
offer.offersFullTime?.stocks != null
? valuationDtoMapper(offer.offersFullTime.stocks)
: null,
title: offer.offersFullTime.title,
totalCompensation: valuationDtoMapper(
offer.offersFullTime.totalCompensation,
@ -437,7 +428,6 @@ export const profileOfferDtoMapper = (
id: offer.offersIntern.id,
internshipCycle: offer.offersIntern.internshipCycle,
monthlySalary: valuationDtoMapper(offer.offersIntern.monthlySalary),
specialization: offer.offersIntern.specialization,
startYear: offer.offersIntern.startYear,
title: offer.offersIntern.title,
};
@ -527,9 +517,9 @@ export const profileDtoMapper = (
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency;
bonus: OffersCurrency;
stocks: OffersCurrency;
baseSalary: OffersCurrency | null;
bonus: OffersCurrency | null;
stocks: OffersCurrency | null;
totalCompensation: OffersCurrency;
})
| null;
@ -550,7 +540,7 @@ export const profileDtoMapper = (
};
if (inputToken === profile.editToken) {
profileDto.editToken = profile.editToken;
profileDto.editToken = profile.editToken ?? null;
profileDto.isEditable = true;
}
@ -587,9 +577,9 @@ export const dashboardOfferDtoMapper = (
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency;
bonus: OffersCurrency;
stocks: OffersCurrency;
baseSalary: OffersCurrency | null;
bonus: OffersCurrency | null;
stocks: OffersCurrency | null;
totalCompensation: OffersCurrency;
})
| null;

@ -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 { Select } from '@tih/ui';
import type { SVGProps } from 'react';
import {
BookmarkSquareIcon,
ChartBarSquareIcon,
InformationCircleIcon,
ShareIcon,
TableCellsIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
import { titleOptions } from '~/components/offers/constants';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import LeftTextCard from '~/components/offers/landing/LeftTextCard';
import RightTextCard from '~/components/offers/landing/RightTextCard';
import { HOME_URL } from '~/components/offers/types';
export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('Software Engineer');
const [companyFilter, setCompanyFilter] = useState('');
const features = [
{
description:
'Profile names are randomly generated to keep your offers strictly anonymous.',
icon: UsersIcon,
name: 'Anonymized Profile Name',
},
{
description:
'Only users with the edit link can edit that profile. Share profiles to others using a public link without giving edit permission.',
icon: ShareIcon,
name: 'Edit Link v.s. Public Link',
},
{
description:
"Offer profiles will not be automatically saved under creators' account in our database unless explicit permission is given.",
icon: BookmarkSquareIcon,
name: 'Save with Permission',
},
];
const footerNavigation = {
social: [
{
href: '#',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
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"
fillRule="evenodd"
/>
</svg>
),
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 (
<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">
<Select
isLabelHidden={true}
label="Select a job title"
options={titleOptions}
value={jobTitleFilter}
onChange={setjobTitleFilter}
/>
<div className="mx-auto w-full 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>Choosing offers </span>
<span className="from-primary-600 -mb-1 mr-2 bg-gradient-to-r to-purple-500 bg-clip-text pb-1 pr-4 italic text-transparent">
made easier
</span>
</h1>
<p className="mx-auto mt-6 max-w-lg text-center text-xl sm:max-w-3xl">
Analyze 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={HOME_URL}>
Get started
</a>
<a
className="bg-primary-500 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>
in
<div className="ml-4">
<CompaniesTypeahead
isLabelHidden={true}
placeHolder="All companies"
onSelect={({ value }) => setCompanyFilter(value)}
/>
</div>
</div>
{/* Alternating Feature Sections */}
<div className="relative overflow-hidden pt-16 pb-32">
<div
aria-hidden="true"
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 received in their application cycle, but also background information such as education and work experience. Use offer profiles to help you better contextualize 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 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 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>
{/* Gradient Feature Section */}
<div className="to-primary-600 bg-gradient-to-r from-purple-800">
<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="text-primary-100 mt-4 flex flex-row justify-center text-lg">
All offer profiles are anonymized 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="text-primary-100 mt-2 text-base">
{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="to-primary-600 -mb-1 block bg-gradient-to-r from-purple-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="to-primary-500 flex items-center justify-center rounded-md border border-transparent bg-gradient-to-r from-purple-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={HOME_URL}>
Get Started
</a>
</div>
</div>
</div>
</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>
</div>
<div className="flex justify-center bg-white pb-20 pt-10">
<OffersTable
companyFilter={companyFilter}
jobTitleFilter={jobTitleFilter}
/>
</div>
</main>
</footer>
</div>
);
}

@ -10,6 +10,9 @@ import type {
BackgroundDisplayData,
OfferDisplayData,
} from '~/components/offers/types';
import { HOME_URL } from '~/components/offers/types';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { useToast } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency';
@ -44,7 +47,7 @@ export default function OfferProfile() {
enabled: typeof offerProfileId === 'string',
onSuccess: (data: Profile) => {
if (!data) {
router.push('/offers');
router.push(HOME_URL);
}
// If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') {
@ -57,17 +60,28 @@ export default function OfferProfile() {
? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) {
const filteredOffer: OfferDisplayData = {
base: convertMoneyToString(res.offersFullTime.baseSalary),
bonus: convertMoneyToString(res.offersFullTime.bonus),
base:
res.offersFullTime.baseSalary != null
? convertMoneyToString(res.offersFullTime.baseSalary)
: undefined,
bonus:
res.offersFullTime.bonus != null
? convertMoneyToString(res.offersFullTime.bonus)
: undefined,
companyName: res.company.name,
id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title,
jobTitle: getLabelForJobTitleType(
res.offersFullTime.title as JobTitleType,
),
location: res.location,
negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments,
receivedMonth: formatDate(res.monthYearReceived),
stocks: convertMoneyToString(res.offersFullTime.stocks),
stocks:
res.offersFullTime.stocks != null
? convertMoneyToString(res.offersFullTime.stocks)
: undefined,
totalCompensation: convertMoneyToString(
res.offersFullTime.totalCompensation,
),
@ -77,7 +91,9 @@ export default function OfferProfile() {
const filteredOffer: OfferDisplayData = {
companyName: res.company.name,
id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title,
jobTitle: getLabelForJobTitleType(
res.offersIntern!.title as JobTitleType,
),
location: res.location,
monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary,
@ -107,7 +123,9 @@ export default function OfferProfile() {
companyName: experience.company?.name,
duration: experience.durationInMonths,
jobLevel: experience.level,
jobTitle: experience.title,
jobTitle: experience.title
? getLabelForJobTitleType(experience.title as JobTitleType)
: null,
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: null,
@ -140,7 +158,7 @@ export default function OfferProfile() {
},
onSuccess: () => {
trpcContext.invalidateQueries(['offers.profile.listOne']);
router.push('/offers');
router.push(HOME_URL);
showToast({
title: `Offers profile successfully deleted!`,
variant: 'success',

@ -107,8 +107,7 @@ function Test() {
durationInMonths: 24,
jobType: 'FULLTIME',
level: 'Junior',
specialization: 'Front End',
title: 'Software Engineer',
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
value: 104100,
@ -146,12 +145,11 @@ function Test() {
value: 2222,
},
level: 'Junior',
specialization: 'Front End',
stocks: {
currency: 'SGD',
value: 0,
},
title: 'Software Engineer',
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
value: 4444,
@ -175,12 +173,11 @@ function Test() {
value: 20000,
},
level: 'Junior',
specialization: 'Front End',
stocks: {
currency: 'SGD',
value: 100,
},
title: 'Software Engineer',
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
value: 104100,
@ -269,8 +266,7 @@ function Test() {
level: 'Junior',
monthlySalary: null,
monthlySalaryId: null,
specialization: 'Front End',
title: 'Software Engineer',
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
id: 'cl9i68fvc0005tthj7r1rhvb1',
@ -335,14 +331,13 @@ function Test() {
bonusId: 'cl9i68fve000rtthjqo2ktljt',
id: 'cl9i68fve000otthjqk0g01k0',
level: 'EXPERT',
specialization: 'FRONTEND',
stocks: {
currency: 'SGD',
id: 'cl9i68fvf000ttthjt2ode0cc',
value: -558038585,
},
stocksId: 'cl9i68fvf000ttthjt2ode0cc',
title: 'Software Engineer',
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
id: 'cl9i68fvf000vtthjg90s48nj',
@ -355,220 +350,8 @@ function Test() {
offersInternId: null,
profileId: 'cl9i68fv60000tthj8t3zkox0',
},
// {
// comments: '',
// company: {
// createdAt: new Date('2022-10-12T16:19:05.196Z'),
// description:
// 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
// id: 'cl9j4yawz0003utlp1uaa1t8o',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9j4yawz0003utlp1uaa1t8o',
// id: 'cl9i68fvf000ytthj0ltsqt1d',
// jobType: 'FULLTIME',
// location: 'Singapore, Singapore',
// monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
// negotiationStrategy: 'Leveraged having million offers',
// offersFullTime: {
// baseSalary: {
// currency: 'SGD',
// id: 'cl9i68fvf0010tthj0iym6woh',
// value: 84000,
// },
// baseSalaryId: 'cl9i68fvf0010tthj0iym6woh',
// bonus: {
// currency: 'SGD',
// id: 'cl9i68fvf0012tthjioltnspk',
// value: 123456789,
// },
// bonusId: 'cl9i68fvf0012tthjioltnspk',
// id: 'cl9i68fvf000ztthjcovbiehc',
// level: 'Junior',
// specialization: 'Front End',
// stocks: {
// currency: 'SGD',
// id: 'cl9i68fvf0014tthjz2gff3hs',
// value: 100,
// },
// stocksId: 'cl9i68fvf0014tthjz2gff3hs',
// title: 'Software Engineer',
// totalCompensation: {
// currency: 'SGD',
// id: 'cl9i68fvf0016tthjrtb7iuvj',
// value: 104100,
// },
// totalCompensationId: 'cl9i68fvf0016tthjrtb7iuvj',
// },
// offersFullTimeId: 'cl9i68fvf000ztthjcovbiehc',
// offersIntern: null,
// offersInternId: null,
// profileId: 'cl9i68fv60000tthj8t3zkox0',
// },
// {
// comments: '',
// company: {
// createdAt: new Date('2022-10-12T16:19:05.196Z'),
// description:
// 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
// id: 'cl9j4yawz0003utlp1uaa1t8o',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9j4yawz0003utlp1uaa1t8o',
// id: 'cl96stky9003bw32gc3l955vr',
// jobType: 'FULLTIME',
// location: 'Singapore, Singapore',
// monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
// negotiationStrategy: 'LOst out having multiple offers',
// offersFullTime: {
// baseSalary: {
// currency: 'SGD',
// id: 'cl96stky9003dw32gcvqbijlo',
// value: 1,
// },
// baseSalaryId: 'cl96stky9003dw32gcvqbijlo',
// bonus: {
// currency: 'SGD',
// id: 'cl96stky9003fw32goc3zqxwr',
// value: 0,
// },
// bonusId: 'cl96stky9003fw32goc3zqxwr',
// id: 'cl96stky9003cw32g5v10izfu',
// level: 'Senior',
// specialization: 'Front End',
// stocks: {
// currency: 'SGD',
// id: 'cl96stky9003hw32g1lbbkqqr',
// value: 999999,
// },
// stocksId: 'cl96stky9003hw32g1lbbkqqr',
// title: 'Software Engineer DOG',
// totalCompensation: {
// currency: 'SGD',
// id: 'cl96stky9003jw32gzumcoi7v',
// value: 999999,
// },
// totalCompensationId: 'cl96stky9003jw32gzumcoi7v',
// },
// offersFullTimeId: 'cl96stky9003cw32g5v10izfu',
// offersIntern: null,
// offersInternId: null,
// profileId: 'cl96stky5002ew32gx2kale2x',
// },
// {
// comments: 'this IS SO COOL',
// company: {
// createdAt: new Date('2022-10-12T16:19:05.196Z'),
// description:
// 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
// id: 'cl9j4yawz0003utlp1uaa1t8o',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9j4yawz0003utlp1uaa1t8o',
// id: 'cl976wf28000t7iyga4noyz7s',
// jobType: 'FULLTIME',
// location: 'Singapore, Singapore',
// monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
// negotiationStrategy: 'Charmed the guy with my face',
// offersFullTime: {
// baseSalary: {
// currency: 'SGD',
// id: 'cl976wf28000v7iygmk1b7qaq',
// value: 1999999999,
// },
// baseSalaryId: 'cl976wf28000v7iygmk1b7qaq',
// bonus: {
// currency: 'SGD',
// id: 'cl976wf28000x7iyg63w7kcli',
// value: 1410065407,
// },
// bonusId: 'cl976wf28000x7iyg63w7kcli',
// id: 'cl976wf28000u7iyg6euei8e9',
// level: 'EXPERT',
// specialization: 'FRONTEND',
// stocks: {
// currency: 'SGD',
// id: 'cl976wf28000z7iyg9ivun6ap',
// value: 111222333,
// },
// stocksId: 'cl976wf28000z7iyg9ivun6ap',
// title: 'Software Engineer',
// totalCompensation: {
// currency: 'SGD',
// id: 'cl976wf2800117iygmzsc0xit',
// value: 55555555,
// },
// totalCompensationId: 'cl976wf2800117iygmzsc0xit',
// },
// offersFullTimeId: 'cl976wf28000u7iyg6euei8e9',
// offersIntern: null,
// offersInternId: null,
// profileId: 'cl96stky5002ew32gx2kale2x',
// },
// {
// comments: 'this rocks',
// company: {
// createdAt: new Date('2022-10-12T16:19:05.196Z'),
// description:
// 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
// id: 'cl9j4yawz0003utlp1uaa1t8o',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9j4yawz0003utlp1uaa1t8o',
// id: 'cl96tbb3o0051w32gjrpaiiit',
// jobType: 'FULLTIME',
// location: 'Singapore, Singapore',
// monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
// negotiationStrategy: 'Charmed the guy with my face',
// offersFullTime: {
// baseSalary: {
// currency: 'SGD',
// id: 'cl96tbb3o0053w32gz11paaxu',
// value: 1999999999,
// },
// baseSalaryId: 'cl96tbb3o0053w32gz11paaxu',
// bonus: {
// currency: 'SGD',
// id: 'cl96tbb3o0055w32gpyqgz5hx',
// value: 1410065407,
// },
// bonusId: 'cl96tbb3o0055w32gpyqgz5hx',
// id: 'cl96tbb3o0052w32guguajzin',
// level: 'EXPERT',
// specialization: 'FRONTEND',
// stocks: {
// currency: 'SGD',
// id: 'cl96tbb3o0057w32gu4nyxguf',
// value: 500,
// },
// stocksId: 'cl96tbb3o0057w32gu4nyxguf',
// title: 'Software Engineer',
// totalCompensation: {
// currency: 'SGD',
// id: 'cl96tbb3o0059w32gm3iy1zk4',
// value: 55555555,
// },
// totalCompensationId: 'cl96tbb3o0059w32gm3iy1zk4',
// },
// offersFullTimeId: 'cl96tbb3o0052w32guguajzin',
// offersIntern: null,
// offersInternId: null,
// profileId: 'cl96stky5002ew32gx2kale2x',
// },
],
// ProfileName: 'ailing bryann stuart ziqing',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: null,
});

@ -5,24 +5,22 @@ 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 { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOption } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
import LocationTypeahead from '~/components/questions/typeahead/LocationTypeahead';
import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
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 { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useSearchParam,
@ -148,12 +146,18 @@ export default function QuestionsBrowsePage() {
: undefined;
}, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery(
const {
data: questionsQueryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.useInfiniteQuery(
[
'questions.questions.getQuestionsByFilter',
{
companyNames: selectedCompanies,
endDate: today,
limit: 10,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
@ -163,10 +167,21 @@ export default function QuestionsBrowsePage() {
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
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 { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
@ -180,12 +195,17 @@ export default function QuestionsBrowsePage() {
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 [selectedCompanyOptions, setSelectedCompanyOptions] = useState<
Array<FilterOption>
>([]);
const [selectedRoleOptions, setSelectedRoleOptions] = useState<
Array<FilterOption>
>([]);
const [selectedLocationOptions, setSelectedLocationOptions] = useState<
Array<FilterOption>
>([]);
const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
@ -201,20 +221,6 @@ export default function QuestionsBrowsePage() {
}));
}, [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 &&
@ -287,35 +293,89 @@ export default function QuestionsBrowsePage() {
setSelectedQuestionAge('all');
setSelectedRoles([]);
setSelectedLocations([]);
setSelectedCompanyOptions([]);
setSelectedRoleOptions([]);
setSelectedLocationOptions([]);
}}
/>
<FilterSection
label="Company"
options={companyFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
label="Companies"
options={selectedCompanyOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
<CompanyTypeahead
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedCompanyOptions.some((selectedOption) => {
return selectedOption.value === option.value;
});
}}
isLabelHidden={true}
label="Companies"
options={options}
placeholder="Search companies"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
onSelect={(option) => {
onOptionChange({
...option,
checked: true,
});
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies([...selectedCompanies, optionValue]);
onOptionChange={(option) => {
if (option.checked) {
setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanyOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
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"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
onOptionChange={(option) => {
if (option.checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, option.value]);
} else {
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
(questionType) => questionType !== option.value,
),
);
}
@ -341,68 +401,47 @@ export default function QuestionsBrowsePage() {
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
onOptionChange={({ value }) => {
setSelectedQuestionAge(value);
}}
/>
<FilterSection
label="Roles"
options={roleFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
label="Locations"
options={selectedLocationOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
<LocationTypeahead
{...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);
clearOnSelect={true}
filterOption={(option) => {
return !selectedLocationOptions.some((selectedOption) => {
return selectedOption.value === option.value;
});
}}
/>
)}
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);
onSelect={(option) => {
onOptionChange({
...option,
checked: true,
});
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations([...selectedLocations, optionValue]);
onOptionChange={(option) => {
if (option.checked) {
setSelectedLocations([...selectedLocations, option.value]);
setSelectedLocationOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
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}
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 flex-col gap-2 pb-4">
{(questionsQueryData?.pages ?? []).flatMap(
({ data: questions }) =>
questions.map((question) => (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
companies={
question.aggregatedQuestionEncounters.companyCounts
}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id}
receivedCount={question.receivedCount}
roles={
question.aggregatedQuestionEncounters.roleCounts
}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
},
)}
type={question.type}
upvoteCount={question.numVotes}
/>
)),
)}
<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">
<NoSymbolIcon className="h-6 w-6" />
<p>Nothing found.</p>

@ -100,7 +100,7 @@ export default function ResumeReviewPage() {
}
return (
<Button
className="h-10 py-2"
className="h-10 py-2 shadow-md"
display="block"
label="Add your review"
variant="tertiary"
@ -151,18 +151,18 @@ export default function ResumeReviewPage() {
<h1 className="pr-2 text-2xl font-semibold leading-7 text-slate-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title}
</h1>
<div className="flex gap-4 xl:pr-4">
<div className="flex gap-3 xl:pr-4">
{userIsOwner && (
<button
className="p h-10 rounded-md border border-slate-300 bg-white py-1 px-2 text-center"
className="h-10 rounded-md border border-slate-300 bg-white py-1 px-2 text-center shadow-md hover:bg-slate-50"
type="button"
onClick={onEditButtonClick}>
<PencilSquareIcon className="text-primary-600 hover:text-primary-300 h-6 w-6" />
<PencilSquareIcon className="text-primary-600 h-6 w-6" />
</button>
)}
<button
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:hover:bg-white"
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 disabled:hover:bg-white"
disabled={starMutation.isLoading || unstarMutation.isLoading}
type="button"
onClick={onStarButtonClick}>

@ -86,10 +86,16 @@ export default function SubmitResumeForm({
setValue,
reset,
watch,
clearErrors,
formState: { errors, isDirty, dirtyFields },
} = useForm<IFormInput>({
defaultValues: {
additionalInfo: '',
experience: '',
isChecked: false,
location: '',
role: '',
title: '',
...initFormDetails,
},
});
@ -296,7 +302,7 @@ export default function SubmitResumeForm({
options={ROLES}
placeholder=" "
required={true}
onChange={(val) => setValue('role', val)}
onChange={(val) => onValueChange('role', val)}
/>
<Select
{...register('experience', { required: true })}
@ -305,7 +311,7 @@ export default function SubmitResumeForm({
options={EXPERIENCES}
placeholder=" "
required={true}
onChange={(val) => setValue('experience', val)}
onChange={(val) => onValueChange('experience', val)}
/>
</div>
<Select
@ -315,7 +321,7 @@ export default function SubmitResumeForm({
options={LOCATIONS}
placeholder=" "
required={true}
onChange={(val) => setValue('location', val)}
onChange={(val) => onValueChange('location', val)}
/>
{/* Upload resume form */}
{isNewForm && (
@ -335,6 +341,16 @@ export default function SubmitResumeForm({
: 'border-slate-300',
'flex cursor-pointer justify-center rounded-md border-2 border-dashed bg-slate-100 py-4',
)}>
<input
{...register('file', { required: true })}
{...getInputProps()}
accept="application/pdf"
className="sr-only"
disabled={isLoading}
id="file-upload"
name="file-upload"
type="file"
/>
<div className="space-y-1 text-center">
{resumeFile == null ? (
<ArrowUpCircleIcon className="text-primary-500 m-auto h-10 w-10" />
@ -345,29 +361,15 @@ export default function SubmitResumeForm({
{resumeFile.name}
</p>
)}
<div className="flex items-center text-sm">
<label
className="focus-within:ring-primary-500 rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2"
htmlFor="file-upload">
<span className="font-medium">Drop file here</span>
<span className="mr-1 ml-1 font-light">or</span>
<span className="text-primary-600 hover:text-primary-400 cursor-pointer font-medium">
{resumeFile == null
? 'Select file'
: 'Replace file'}
</span>
<input
{...register('file', { required: true })}
{...getInputProps()}
accept="application/pdf"
className="sr-only"
disabled={isLoading}
id="file-upload"
name="file-upload"
type="file"
/>
</label>
</div>
<label
className="focus-within:ring-primary-500 flex items-center rounded-md text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2"
htmlFor="file-upload">
<span className="font-medium">Drop file here</span>
<span className="mr-1 ml-1 font-light">or</span>
<span className="text-primary-600 hover:text-primary-400 cursor-pointer font-medium">
{resumeFile == null ? 'Select file' : 'Replace file'}
</span>
</label>
<p className="text-xs text-slate-500">
PDF up to {FILE_SIZE_LIMIT_MB}MB
</p>
@ -394,8 +396,18 @@ export default function SubmitResumeForm({
<CheckboxInput
{...register('isChecked', { required: true })}
disabled={isLoading}
errorMessage={
!errors.file && errors.isChecked
? 'Please tick the checkbox after reading through the guidelines.'
: undefined
}
label="I have read and will follow the guidelines stated."
onChange={(val) => setValue('isChecked', val)}
onChange={(val) => {
if (val) {
clearErrors('isChecked');
}
setValue('isChecked', val);
}}
/>
</>
)}

@ -19,9 +19,9 @@ const searchOfferPercentile = (
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency;
bonus: OffersCurrency;
stocks: OffersCurrency;
baseSalary: OffersCurrency | null;
bonus: OffersCurrency | null;
stocks: OffersCurrency | null;
totalCompensation: OffersCurrency;
})
| null;

@ -10,7 +10,10 @@ import {
} from '~/mappers/offers-mappers';
import { baseCurrencyString } from '~/utils/offers/currency';
import { convert } from '~/utils/offers/currency/currencyExchange';
import { generateRandomName, generateRandomStringForToken } from '~/utils/offers/randomGenerator';
import {
generateRandomName,
generateRandomStringForToken,
} from '~/utils/offers/randomGenerator';
import { createValidationRegex } from '~/utils/offers/zodRegex';
import { createRouter } from '../context';
@ -48,7 +51,6 @@ const offer = z.object({
bonusId: z.string().nullish(),
id: z.string().optional(),
level: z.string().nullish(),
specialization: z.string(),
stocks: valuation.nullish(),
stocksId: z.string().nullish(),
title: z.string(),
@ -62,7 +64,6 @@ const offer = z.object({
id: z.string().optional(),
internshipCycle: z.string().nullish(),
monthlySalary: valuation.nullish(),
specialization: z.string(),
startYear: z.number().nullish(),
title: z.string(),
totalCompensation: valuation.nullish(), // Full time
@ -86,7 +87,6 @@ const experience = z.object({
location: z.string().nullish(),
monthlySalary: valuation.nullish(),
monthlySalaryId: z.string().nullish(),
specialization: z.string().nullish(),
title: z.string().nullish(),
totalCompensation: valuation.nullish(),
totalCompensationId: z.string().nullish(),
@ -300,7 +300,6 @@ export const offersProfileRouter = createRouter()
durationInMonths: x.durationInMonths,
jobType: x.jobType,
level: x.level,
specialization: x.specialization,
title: x.title,
totalCompensation: {
create: {
@ -321,7 +320,6 @@ export const offersProfileRouter = createRouter()
jobType: x.jobType,
level: x.level,
location: x.location,
specialization: x.specialization,
title: x.title,
totalCompensation: {
create: {
@ -363,7 +361,6 @@ export const offersProfileRouter = createRouter()
value: x.monthlySalary.value,
},
},
specialization: x.specialization,
title: x.title,
};
}
@ -382,7 +379,6 @@ export const offersProfileRouter = createRouter()
value: x.monthlySalary.value,
},
},
specialization: x.specialization,
title: x.title,
};
}
@ -442,7 +438,6 @@ export const offersProfileRouter = createRouter()
value: x.offersIntern.monthlySalary.value,
},
},
specialization: x.offersIntern.specialization,
startYear: x.offersIntern.startYear,
title: x.offersIntern.title,
},
@ -452,17 +447,10 @@ export const offersProfileRouter = createRouter()
if (
x.jobType === JobType.FULLTIME &&
x.offersFullTime &&
x.offersFullTime.baseSalary?.currency != null &&
x.offersFullTime.baseSalary?.value != null &&
x.offersFullTime.bonus?.currency != null &&
x.offersFullTime.bonus?.value != null &&
x.offersFullTime.stocks?.currency != null &&
x.offersFullTime.stocks?.value != null &&
x.offersFullTime.totalCompensation?.currency != null &&
x.offersFullTime.totalCompensation?.value != null &&
x.offersFullTime.level != null &&
x.offersFullTime.title != null &&
x.offersFullTime.specialization != null
x.offersFullTime.title != null
) {
return {
comments: x.comments,
@ -477,44 +465,53 @@ export const offersProfileRouter = createRouter()
negotiationStrategy: x.negotiationStrategy,
offersFullTime: {
create: {
baseSalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.offersFullTime.baseSalary.value,
x.offersFullTime.baseSalary.currency,
baseCurrencyString,
),
currency: x.offersFullTime.baseSalary.currency,
value: x.offersFullTime.baseSalary.value,
},
},
bonus: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.offersFullTime.bonus.value,
x.offersFullTime.bonus.currency,
baseCurrencyString,
),
currency: x.offersFullTime.bonus.currency,
value: x.offersFullTime.bonus.value,
},
},
baseSalary:
x.offersFullTime?.baseSalary != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.offersFullTime.baseSalary.value,
x.offersFullTime.baseSalary.currency,
baseCurrencyString,
),
currency:
x.offersFullTime.baseSalary.currency,
value: x.offersFullTime.baseSalary.value,
},
}
: undefined,
bonus:
x.offersFullTime?.bonus != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.offersFullTime.bonus.value,
x.offersFullTime.bonus.currency,
baseCurrencyString,
),
currency: x.offersFullTime.bonus.currency,
value: x.offersFullTime.bonus.value,
},
}
: undefined,
level: x.offersFullTime.level,
specialization: x.offersFullTime.specialization,
stocks: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.offersFullTime.stocks.value,
x.offersFullTime.stocks.currency,
baseCurrencyString,
),
currency: x.offersFullTime.stocks.currency,
value: x.offersFullTime.stocks.value,
},
},
stocks:
x.offersFullTime?.stocks != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
x.offersFullTime.stocks.value,
x.offersFullTime.stocks.currency,
baseCurrencyString,
),
currency: x.offersFullTime.stocks.currency,
value: x.offersFullTime.stocks.value,
},
}
: undefined,
title: x.offersFullTime.title,
totalCompensation: {
create: {
@ -714,7 +711,6 @@ export const offersProfileRouter = createRouter()
companyId: exp.companyId, // TODO: check if can change with connect or whether there is a difference
durationInMonths: exp.durationInMonths,
level: exp.level,
specialization: exp.specialization,
},
where: {
id: exp.id,
@ -821,7 +817,6 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType,
level: exp.level,
location: exp.location,
specialization: exp.specialization,
title: exp.title,
totalCompensation: {
create: {
@ -851,7 +846,6 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType,
level: exp.level,
location: exp.location,
specialization: exp.specialization,
title: exp.title,
totalCompensation: {
create: {
@ -887,7 +881,6 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType,
level: exp.level,
location: exp.location,
specialization: exp.specialization,
title: exp.title,
},
},
@ -905,7 +898,6 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType,
level: exp.level,
location: exp.location,
specialization: exp.specialization,
title: exp.title,
},
},
@ -945,7 +937,6 @@ export const offersProfileRouter = createRouter()
value: exp.monthlySalary.value,
},
},
specialization: exp.specialization,
title: exp.title,
},
},
@ -974,7 +965,6 @@ export const offersProfileRouter = createRouter()
value: exp.monthlySalary.value,
},
},
specialization: exp.specialization,
title: exp.title,
},
},
@ -997,7 +987,6 @@ export const offersProfileRouter = createRouter()
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
location: exp.location,
specialization: exp.specialization,
title: exp.title,
},
},
@ -1014,7 +1003,6 @@ export const offersProfileRouter = createRouter()
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
location: exp.location,
specialization: exp.specialization,
title: exp.title,
},
},
@ -1121,7 +1109,6 @@ export const offersProfileRouter = createRouter()
data: {
internshipCycle:
offerToUpdate.offersIntern.internshipCycle ?? undefined,
specialization: offerToUpdate.offersIntern.specialization,
startYear: offerToUpdate.offersIntern.startYear ?? undefined,
title: offerToUpdate.offersIntern.title,
},
@ -1150,7 +1137,6 @@ export const offersProfileRouter = createRouter()
await ctx.prisma.offersFullTime.update({
data: {
level: offerToUpdate.offersFullTime.level ?? undefined,
specialization: offerToUpdate.offersFullTime.specialization,
title: offerToUpdate.offersFullTime.title,
},
where: {
@ -1174,7 +1160,7 @@ export const offersProfileRouter = createRouter()
},
});
}
if (offerToUpdate.offersFullTime.bonus) {
if (offerToUpdate.offersFullTime.bonus != null) {
await ctx.prisma.offersCurrency.update({
data: {
baseCurrency: baseCurrencyString,
@ -1191,7 +1177,7 @@ export const offersProfileRouter = createRouter()
},
});
}
if (offerToUpdate.offersFullTime.stocks) {
if (offerToUpdate.offersFullTime.stocks != null) {
await ctx.prisma.offersCurrency.update({
data: {
baseCurrency: baseCurrencyString,
@ -1269,8 +1255,6 @@ export const offersProfileRouter = createRouter()
offerToUpdate.offersIntern.monthlySalary.value,
},
},
specialization:
offerToUpdate.offersIntern.specialization,
startYear: offerToUpdate.offersIntern.startYear,
title: offerToUpdate.offersIntern.title,
},
@ -1286,12 +1270,6 @@ export const offersProfileRouter = createRouter()
if (
offerToUpdate.jobType === JobType.FULLTIME &&
offerToUpdate.offersFullTime &&
offerToUpdate.offersFullTime.baseSalary?.currency != null &&
offerToUpdate.offersFullTime.baseSalary?.value != null &&
offerToUpdate.offersFullTime.bonus?.currency != null &&
offerToUpdate.offersFullTime.bonus?.value != null &&
offerToUpdate.offersFullTime.stocks?.currency != null &&
offerToUpdate.offersFullTime.stocks?.value != null &&
offerToUpdate.offersFullTime.totalCompensation?.currency !=
null &&
offerToUpdate.offersFullTime.totalCompensation?.value != null &&
@ -1313,51 +1291,66 @@ export const offersProfileRouter = createRouter()
negotiationStrategy: offerToUpdate.negotiationStrategy,
offersFullTime: {
create: {
baseSalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.baseSalary.value,
offerToUpdate.offersFullTime.baseSalary
.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.baseSalary
.currency,
value:
offerToUpdate.offersFullTime.baseSalary.value,
},
},
bonus: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.bonus.value,
offerToUpdate.offersFullTime.bonus.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.bonus.currency,
value: offerToUpdate.offersFullTime.bonus.value,
},
},
baseSalary:
offerToUpdate.offersFullTime?.baseSalary != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.baseSalary
.value,
offerToUpdate.offersFullTime.baseSalary
.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.baseSalary
.currency,
value:
offerToUpdate.offersFullTime.baseSalary
.value,
},
}
: undefined,
bonus:
offerToUpdate.offersFullTime?.bonus != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.bonus.value,
offerToUpdate.offersFullTime.bonus
.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.bonus
.currency,
value:
offerToUpdate.offersFullTime.bonus.value,
},
}
: undefined,
level: offerToUpdate.offersFullTime.level,
specialization:
offerToUpdate.offersFullTime.specialization,
stocks: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.stocks.value,
offerToUpdate.offersFullTime.stocks.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.stocks.currency,
value: offerToUpdate.offersFullTime.stocks.value,
},
},
stocks:
offerToUpdate.offersFullTime?.stocks != null
? {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.stocks.value,
offerToUpdate.offersFullTime.stocks
.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.stocks
.currency,
value:
offerToUpdate.offersFullTime.stocks.value,
},
}
: undefined,
title: offerToUpdate.offersFullTime.title,
totalCompensation: {
create: {

@ -317,27 +317,36 @@ export const offersRouter = createRouter().query('list', {
offer.offersFullTime.totalCompensation.updatedAt,
);
offer.offersFullTime.totalCompensation.currency = currency;
offer.offersFullTime.baseSalary.value = await convertWithDate(
offer.offersFullTime.baseSalary.value,
offer.offersFullTime.baseSalary.currency,
currency,
offer.offersFullTime.baseSalary.updatedAt,
);
offer.offersFullTime.baseSalary.currency = currency;
offer.offersFullTime.stocks.value = await convertWithDate(
offer.offersFullTime.stocks.value,
offer.offersFullTime.stocks.currency,
currency,
offer.offersFullTime.stocks.updatedAt,
);
offer.offersFullTime.stocks.currency = currency;
offer.offersFullTime.bonus.value = await convertWithDate(
offer.offersFullTime.bonus.value,
offer.offersFullTime.bonus.currency,
currency,
offer.offersFullTime.bonus.updatedAt,
);
offer.offersFullTime.bonus.currency = currency;
if (offer.offersFullTime?.baseSalary != null) {
offer.offersFullTime.baseSalary.value = await convertWithDate(
offer.offersFullTime.baseSalary.value,
offer.offersFullTime.baseSalary.currency,
currency,
offer.offersFullTime.baseSalary.updatedAt,
);
offer.offersFullTime.baseSalary.currency = currency;
}
if (offer.offersFullTime?.stocks != null) {
offer.offersFullTime.stocks.value = await convertWithDate(
offer.offersFullTime.stocks.value,
offer.offersFullTime.stocks.currency,
currency,
offer.offersFullTime.stocks.updatedAt,
);
offer.offersFullTime.stocks.currency = currency;
}
if (offer.offersFullTime?.bonus != null) {
offer.offersFullTime.bonus.value = await convertWithDate(
offer.offersFullTime.bonus.value,
offer.offersFullTime.bonus.currency,
currency,
offer.offersFullTime.bonus.updatedAt,
);
offer.offersFullTime.bonus.currency = currency;
}
} else if (offer.offersIntern?.monthlySalary != null) {
offer.offersIntern.monthlySalary.value = await convertWithDate(
offer.offersIntern.monthlySalary.value,

@ -166,13 +166,29 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
const { answerCommentId, vote } = input;
return await ctx.prisma.questionsAnswerCommentVote.create({
data: {
answerCommentId,
userId,
vote,
},
});
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.create({
data: {
answerCommentId,
userId,
vote,
},
}),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: answerCommentId,
},
}),
]);
return answerCommentVote;
},
})
.mutation('updateVote', {
@ -198,14 +214,30 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
});
}
return await ctx.prisma.questionsAnswerCommentVote.update({
data: {
vote,
},
where: {
id,
},
});
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.update({
data: {
vote,
},
where: {
id,
},
}),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.answerCommentId,
},
}),
]);
return answerCommentVote;
},
})
.mutation('deleteVote', {
@ -229,10 +261,26 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
});
}
return await ctx.prisma.questionsAnswerCommentVote.delete({
where: {
id: input.id,
},
});
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.delete({
where: {
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;
return await ctx.prisma.questionsAnswerVote.create({
data: {
answerId,
userId,
vote,
},
});
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [answerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.create({
data: {
answerId,
userId,
vote,
},
}),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: answerId,
},
}),
]);
return answerVote;
},
})
.mutation('updateVote', {
@ -260,14 +275,30 @@ export const questionsAnswerRouter = createProtectedRouter()
});
}
return await ctx.prisma.questionsAnswerVote.update({
data: {
vote,
},
where: {
id,
},
});
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.update({
data: {
vote,
},
where: {
id,
},
}),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.answerId,
},
}),
]);
return questionsAnswerVote;
},
})
.mutation('deleteVote', {
@ -290,10 +321,26 @@ export const questionsAnswerRouter = createProtectedRouter()
});
}
return await ctx.prisma.questionsAnswerVote.delete({
where: {
id: input.id,
},
});
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.delete({
where: {
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 { questionCommentId, vote } = input;
return await ctx.prisma.questionsQuestionCommentVote.create({
data: {
questionCommentId,
userId,
vote,
},
});
const incrementValue: number = vote === Vote.UPVOTE ? 1 : -1;
const [ questionCommentVote ] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.create({
data: {
questionCommentId,
userId,
vote,
},
}),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: questionCommentId,
},
}),
]);
return questionCommentVote;
},
})
.mutation('updateVote', {
@ -198,14 +213,30 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
});
}
return await ctx.prisma.questionsQuestionCommentVote.update({
data: {
vote,
},
where: {
id,
},
});
const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.update({
data: {
vote,
},
where: {
id,
},
}),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.questionCommentId,
},
}),
]);
return questionCommentVote;
},
})
.mutation('deleteVote', {
@ -229,10 +260,25 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
});
}
return await ctx.prisma.questionsQuestionCommentVote.delete({
where: {
id: input.id,
},
});
const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.delete({
where: {
id: input.id,
},
}),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.questionCommentId,
},
}),
]);
return questionCommentVote;
},
});

@ -25,9 +25,13 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionEncountersData[0].seenAt;
for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i];
latestSeenAt = latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
@ -46,6 +50,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
const questionEncounter: AggregatedQuestionEncounter = {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
};
@ -72,7 +77,6 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
},
})
.mutation('update', {
//
input: z.object({
companyId: z.string().optional(),
id: z.string(),

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

@ -26,7 +26,6 @@ export type Experience = {
level: string?;
location: string?;
monthlySalary: Valuation?;
specialization: string?;
title: string?;
totalCompensation: Valuation?;
};
@ -87,12 +86,11 @@ export type ProfileOffer = {
};
export type FullTime = {
baseSalary: Valuation;
bonus: Valuation;
baseSalary: Valuation?;
bonus: Valuation?;
id: string;
level: string;
specialization: string;
stocks: Valuation;
stocks: Valuation?;
title: string;
totalCompensation: Valuation;
};
@ -101,7 +99,6 @@ export type Intern = {
id: string;
internshipCycle: string;
monthlySalary: Valuation;
specialization: string;
startYear: number;
title: string;
};
@ -163,7 +160,6 @@ export type AnalysisHighestOffer = {
id: string;
level: string;
location: string;
specialization: string;
totalYoe: number;
};
@ -178,7 +174,6 @@ export type AnalysisOffer = {
negotiationStrategy: string;
previousCompanies: Array<string>;
profileName: string;
specialization: string;
title: string;
totalYoe: number;
};

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

@ -25,9 +25,11 @@ export function timeSinceNow(date: Date | number | string) {
}
interval = seconds / 60;
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) {

@ -7,6 +7,7 @@ type Props = Readonly<{
defaultValue?: boolean;
description?: string;
disabled?: boolean;
errorMessage?: string;
label: string;
name?: string;
onChange?: (
@ -21,6 +22,7 @@ function CheckboxInput(
defaultValue,
description,
disabled = false,
errorMessage,
label,
name,
value,
@ -30,59 +32,67 @@ function CheckboxInput(
) {
const id = useId();
const descriptionId = useId();
const errorId = useId();
return (
<div
className={clsx(
'relative flex',
// Vertically center only when there's no description.
description == null && 'items-center',
)}>
<div className="flex h-5 items-center">
<input
ref={ref}
aria-describedby={description != null ? descriptionId : undefined}
checked={value}
className={clsx(
'h-4 w-4 rounded border-slate-300',
disabled
? 'bg-slate-100 text-slate-400'
: 'text-primary-600 focus:ring-primary-500',
)}
defaultChecked={defaultValue}
disabled={disabled}
id={id}
name={name}
type="checkbox"
onChange={
onChange != null
? (event) => {
onChange?.(event.target.checked, event);
}
: undefined
}
/>
</div>
<div className="ml-3 text-sm">
<label
className={clsx(
'block font-medium',
disabled ? 'text-slate-400' : 'text-slate-700',
)}
htmlFor={id}>
{label}
</label>
{description && (
<p
<div>
<div
className={clsx(
'relative flex',
// Vertically center only when there's no description.
description == null && 'items-center',
)}>
<div className="flex h-5 items-center">
<input
ref={ref}
aria-describedby={description != null ? descriptionId : undefined}
checked={value}
className={clsx(
'text-xs',
disabled ? 'text-slate-400' : 'text-slate-500',
'h-4 w-4 rounded border-slate-300',
disabled
? 'bg-slate-100 text-slate-400'
: 'text-primary-600 focus:ring-primary-500',
)}
id={descriptionId}>
{description}
</p>
)}
defaultChecked={defaultValue}
disabled={disabled}
id={id}
name={name}
type="checkbox"
onChange={(event) => {
if (!onChange) {
return;
}
onChange(event.target.checked, event);
}}
/>
</div>
<div className="ml-3 text-sm">
<label
className={clsx(
'block font-medium',
disabled ? 'text-slate-400' : 'text-slate-700',
)}
htmlFor={id}>
{label}
</label>
{description && (
<p
className={clsx(
'text-xs',
disabled ? 'text-slate-400' : 'text-slate-500',
)}
id={descriptionId}>
{description}
</p>
)}
</div>
</div>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
{errorMessage}
</p>
)}
</div>
);
}

@ -154,14 +154,14 @@ function TextInput(
switch (startAddOnType) {
case 'label':
return (
<div className="pointer-events-none flex items-center pl-3 text-slate-500">
<div className="pointer-events-none flex items-center px-2 text-slate-500">
{startAddOn}
</div>
);
case 'icon': {
const StartAddOn = startAddOn;
return (
<div className="pointer-events-none flex items-center pl-3">
<div className="pointer-events-none flex items-center px-2">
<StartAddOn
aria-hidden="true"
className="h-5 w-5 text-slate-400"

Loading…
Cancel
Save