Merge branch 'main' into hongpo/fix-contentsearch

pull/515/head
hpkoh 3 years ago committed by GitHub
commit caf86100d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -10,6 +10,7 @@
"tsc": "tsc",
"postinstall": "prisma generate",
"seed": "ts-node prisma/seed.ts",
"seed-salaries": "ts-node prisma/seed-salaries.ts",
"seed-questions": "ts-node prisma/seed-questions.ts"
},
"dependencies": {
@ -26,10 +27,12 @@
"@trpc/server": "^9.27.2",
"axios": "^1.1.2",
"clsx": "^1.2.1",
"cross-fetch": "^3.1.5",
"date-fns": "^2.29.3",
"formidable": "^2.0.1",
"next": "12.3.1",
"next-auth": "~4.10.3",
"node-fetch": "^3.2.10",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
@ -38,8 +41,10 @@
"react-popper": "^2.3.0",
"react-popper-tooltip": "^4.4.2",
"react-query": "^3.39.2",
"read-excel-file": "^5.5.3",
"superjson": "^1.10.0",
"unique-names-generator": "^4.7.1",
"xlsx": "^0.18.5",
"zod": "^3.18.0"
},
"devDependencies": {

@ -0,0 +1,19 @@
import reader from 'xlsx';
const file = reader.readFile('prisma/salaries.xlsx');
export const COMPANIES: Array<CompanyData> = []
type CompanyData = {
Finalized: string;
description: string;
logoUrl: string;
name: string;
slug: string;
website: string;
};
const temp = reader.utils.sheet_to_json(file.Sheets[file.SheetNames[1]]);
temp.forEach((res: CompanyData) => {
COMPANIES.push(res);
});

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OffersBackground" ALTER COLUMN "totalYoe" SET DEFAULT 0;

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "OffersAnalysisUnit" DROP CONSTRAINT "OffersAnalysisUnit_analysedOfferId_fkey";
-- AddForeignKey
ALTER TABLE "OffersAnalysisUnit" ADD CONSTRAINT "OffersAnalysisUnit_analysedOfferId_fkey" FOREIGN KEY ("analysedOfferId") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "OffersAnalysis" DROP CONSTRAINT "OffersAnalysis_overallAnalysisUnitId_fkey";
-- AddForeignKey
ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_overallAnalysisUnitId_fkey" FOREIGN KEY ("overallAnalysisUnitId") REFERENCES "OffersAnalysisUnit"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `location` on the `ResumesResume` table. All the data in the column will be lost.
- Added the required column `locationId` to the `ResumesResume` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable. Set default location to Singapore.
ALTER TABLE "ResumesResume" DROP COLUMN "location",
ADD COLUMN "locationId" TEXT NOT NULL DEFAULT '196';
-- AddForeignKey
ALTER TABLE "ResumesResume" ADD CONSTRAINT "ResumesResume_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Country"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "ResumesResume" ALTER COLUMN "locationId" DROP DEFAULT;

Binary file not shown.

@ -112,6 +112,7 @@ model Country {
code String @unique
states State[]
questionsQuestionEncounters QuestionsQuestionEncounter[]
ResumesResume ResumesResume[]
}
model State {
@ -148,13 +149,14 @@ model ResumesResume {
// TODO: Update role, experience, location to use Enums
role String @db.Text
experience String @db.Text
location String @db.Text
locationId String
url String
additionalInfo String? @db.Text
isResolved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
location Country @relation(fields: [locationId], references: [id], onDelete: Cascade)
stars ResumesStar[]
comments ResumesComment[]
}
@ -235,7 +237,7 @@ model OffersProfile {
model OffersBackground {
id String @id @default(cuid())
totalYoe Int
totalYoe Int @default(0)
specificYoes OffersSpecificYoe[]
experiences OffersExperience[]
@ -410,7 +412,7 @@ model OffersAnalysis {
offerId String @unique
// OVERALL
overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id])
overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id], onDelete: Cascade)
overallAnalysisUnitId String
companyAnalysis OffersAnalysisUnit[] @relation("CompanyAnalysis")
@ -419,7 +421,7 @@ model OffersAnalysis {
model OffersAnalysisUnit {
id String @id @default(cuid())
analysedOffer OffersOffer @relation("Analysed Offer", fields: [analysedOfferId], references: [id])
analysedOffer OffersOffer @relation("Analysed Offer", fields: [analysedOfferId], references: [id], onDelete: Cascade)
analysedOfferId String
percentile Float

@ -0,0 +1,377 @@
import reader from 'xlsx';
import { PrismaClient } from '@prisma/client';
import crypto from 'crypto';
import { baseCurrencyString } from '../src/utils/offers/currency';
import { convert } from '../src/utils/offers/currency/currencyExchange';
import { generateAnalysis } from '../src/utils/offers/analysis/analysisGeneration';
import {
generateRandomName,
generateRandomStringForToken,
} from '../src/utils/offers/randomGenerator';
const prisma = new PrismaClient();
// Reading our test file
const file = reader.readFile('prisma/salaries.xlsx');
let data: Array<ExcelData> = [];
type ExcelData = {
Timestamp: Date;
Type: string;
Company: string;
Role: string;
Income?: number | string;
Stocks?: number | string;
SignOn?: number | string;
TC?: number | string;
Bonus?: number | string;
Comments?: string;
};
const sheets = file.SheetNames;
for (let i = 0; i < 1; i++) {
const temp = reader.utils.sheet_to_json(file.Sheets[file.SheetNames[i]]);
temp.forEach((res: ExcelData) => {
data.push(res);
});
}
function xlSerialToJsDate(xlSerial) {
return new Date(Date.UTC(0, 0, xlSerial - 1));
}
const getJobTitle = (role: string) => {
const processedRole = role.toUpperCase().trim();
if (processedRole.includes('ML ENGINEER')) {
return 'machine-learning-engineer';
} else if (processedRole.includes('BACKEND')) {
return 'back-end-engineer';
} else if (processedRole.includes('DATA')) {
return 'data-engineer';
} else if (processedRole.includes('DEVOPS')) {
return 'devops-engineer';
} else if (processedRole.includes('ENTERPRISE')) {
return 'enterprise-engineer';
} else if (processedRole.includes('RESEARCH')) {
return 'research-engineer';
} else if (
processedRole.includes('CYBER') ||
processedRole.includes('SECURITY')
) {
return 'security-engineer';
} else if (processedRole.includes('QA')) {
return 'test-engineer';
} else if (processedRole.includes('SYSTEM')) {
return 'systems-engineer';
} else {
return 'software-engineer'; // Assume default SWE
}
};
const getYoe = (type: string) => {
const processedType = type.toUpperCase().trim();
if (
processedType.includes('FRESH GRAD') ||
processedType.includes('JUNIOR')
) {
return Math.floor(Math.random() * 3);
} else if (processedType.includes('MID')) {
return Math.floor(Math.random() * 3) + 3;
} else if (processedType.includes('SENIOR')) {
return Math.floor(Math.random() * 5) + 6;
} else {
return 0; // INTERNSHIP OR ERROR -> 0 YOE
}
};
const getLevel = (type: string) => {
const processedType = type.toUpperCase().trim();
if (
processedType.includes('FRESH GRAD') ||
processedType.includes('JUNIOR')
) {
return 'L1';
} else if (processedType.includes('MID')) {
return 'L2';
} else if (processedType.includes('SENIOR')) {
return 'L4';
} else {
return 'L0';
}
};
const createdProfileIds: Array<string> = [];
const seedSalaries = async () => {
console.log('Seeding from salaries sheet...');
const companyIdMappings = {};
(await prisma.company.findMany()).forEach((company) => {
companyIdMappings[company.slug] = company.id;
});
// get countryId of Singapore
const singapore = await prisma.city.findFirst({
where: {
name: 'Singapore',
},
});
console.log('Singapore ID: ' + singapore?.id);
// break;
// seed here
if (singapore) {
return await Promise.all(
data.map(async (data: ExcelData) => {
if (data.TC && typeof data.TC === 'number') {
// Generate random name until unique
let uniqueName: string = await generateRandomName();
const jobTitle = getJobTitle(data.Role);
const yoe = getYoe(data.Type);
const level = getLevel(data.Type);
// check if we have company id
if (companyIdMappings[data.Company]) {
const token = crypto
.createHash('sha256')
.update(
xlSerialToJsDate(data.Timestamp).toString() +
generateRandomStringForToken(),
)
.digest('hex');
if (data.Type.toUpperCase() === 'INTERNSHIP') {
// create profile
const dataAdded = await prisma.offersProfile.create({
data: {
profileName: uniqueName,
createdAt: xlSerialToJsDate(data.Timestamp),
editToken: token,
background: {
create: {
totalYoe: yoe,
},
},
offers: {
create: {
comments: data.Comments ?? '',
company: {
connect: {
id: companyIdMappings[data.Company],
},
},
jobType: 'INTERN',
location: {
connect: {
id: singapore.id,
},
}, // TODO: DEFAULT AS SG
monthYearReceived: xlSerialToJsDate(data.Timestamp),
negotiationStrategy: '',
offersIntern: {
create: {
internshipCycle: 'Summer',
monthlySalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
data.Income
? typeof data.Income === 'number'
? data.Income
: 0
: 0,
'SGD', // assume sgd
baseCurrencyString,
),
currency: 'SGD', // assume sgd
value: data.Income
? typeof data.Income === 'number'
? data.Income
: 0
: 0,
},
},
startYear: xlSerialToJsDate(
data.Timestamp,
).getFullYear(),
title: jobTitle,
},
},
},
},
},
});
console.log('Profile created:', dataAdded.id);
createdProfileIds.push(dataAdded.id);
} else {
// assume rest full time
const dataAdded = await prisma.offersProfile.create({
data: {
profileName: uniqueName,
createdAt: xlSerialToJsDate(data.Timestamp),
editToken: token,
background: {
create: {
totalYoe: yoe,
},
},
offers: {
create: {
comments: data.Comments ?? '',
company: {
connect: {
id: companyIdMappings[data.Company],
},
},
jobType: 'FULLTIME',
location: {
connect: {
id: singapore.id,
},
}, // TODO: DEFAULT AS SG
monthYearReceived: xlSerialToJsDate(data.Timestamp),
negotiationStrategy: '',
offersFullTime: {
create: {
baseSalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
data.Income
? typeof data.Income === 'number'
? data.Income
: 0
: 0,
'SGD', // assume sgd
baseCurrencyString,
),
currency: 'SGD', // assume sgd
value: data.Income
? typeof data.Income === 'number'
? data.Income
: 0
: 0,
},
},
bonus: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
data.Bonus
? typeof data.Bonus === 'number'
? data.Bonus
: 0
: 0,
'SGD',
baseCurrencyString,
),
currency: 'SGD',
value: data.Bonus
? typeof data.Bonus === 'number'
? data.Bonus
: 0
: 0,
},
},
level: level,
stocks: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
data.Stocks
? typeof data.Stocks === 'number'
? data.Stocks
: 0
: 0,
'SGD',
baseCurrencyString,
),
currency: 'SGD',
value: data.Stocks
? typeof data.Stocks === 'number'
? data.Stocks
: 0
: 0,
},
},
title: jobTitle,
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
data.TC
? typeof data.TC === 'number'
? data.TC
: 0
: 0,
'SGD',
baseCurrencyString,
),
currency: 'SGD',
value: data.TC
? typeof data.TC === 'number'
? data.TC
: 0
: 0,
},
},
},
},
},
},
},
});
console.log('Profile created:', dataAdded.id);
createdProfileIds.push(dataAdded.id);
}
} else {
console.log('Invalid Company: ' + data.Company);
}
} else {
console.log('Invalid TC not a number: ' + data.TC);
}
}),
);
}
};
const generateAllAnalysis = async () => {
return await Promise.all(
createdProfileIds.map(async (profileId) => {
await generateAnalysis({
ctx: { prisma, session: null },
input: { profileId },
});
console.log('Analysis generated for profile with id:', profileId);
}),
);
};
Promise.all([seedSalaries()])
.then(() => {
console.log(createdProfileIds.length + ' profiles created');
console.log('Busy crunching analysis.....');
})
.then(() => generateAllAnalysis())
.then((_data) => {
console.log('Seeding from salaries sheet complete');
})
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});
export {};

@ -1,3 +1,4 @@
import { COMPANIES } from './companySeed';
const { PrismaClient } = require('@prisma/client');
const cities = require('./data/cities.json');
@ -6,45 +7,6 @@ const states = require('./data/states.json');
const prisma = new PrismaClient();
const COMPANIES = [
{
name: 'Meta',
slug: 'meta',
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.`,
logoUrl: 'https://logo.clearbit.com/meta.com',
},
{
name: 'Google',
slug: 'google',
description: `Google LLC is an American multinational technology company that focuses on search engine technology, online advertising, cloud computing, computer software, quantum computing, e-commerce, artificial intelligence, and consumer electronics.`,
logoUrl: 'https://logo.clearbit.com/google.com',
},
{
name: 'Apple',
slug: 'apple',
description: `Apple Inc. is an American multinational technology company that specializes in consumer electronics, software and online services headquartered in Cupertino, California, United States.`,
logoUrl: 'https://logo.clearbit.com/apple.com',
},
{
name: 'Amazon',
slug: 'amazon',
description: `Amazon.com, Inc. is an American multinational technology company that focuses on e-commerce, cloud computing, digital streaming, and artificial intelligence.`,
logoUrl: 'https://logo.clearbit.com/amazon.com',
},
{
name: 'Microsoft',
slug: 'microsoft',
description: `Microsoft Corporation is an American multinational technology corporation which produces computer software, consumer electronics, personal computers, and related services headquartered at the Microsoft Redmond campus located in Redmond, Washington, United States.`,
logoUrl: 'https://logo.clearbit.com/microsoft.com',
},
{
name: 'Netflix',
slug: 'netflix',
description: null,
logoUrl: 'https://logo.clearbit.com/netflix.com',
},
];
async function main() {
console.log('Seeding started...');

@ -194,7 +194,7 @@ export default function AppShell({ children }: Props) {
<span className="sr-only">Open sidebar</span>
<Bars3BottomLeftIcon aria-hidden="true" className="h-6 w-6" />
</button>
<div className="flex flex-1 justify-between px-4 sm:px-6">
<div className="flex flex-1 justify-between px-4 sm:px-6 lg:px-8">
<div className="flex flex-1 items-center">
<ProductNavigation
items={currentProductNavigation.navigation}

@ -1,16 +1,63 @@
import { emptyOption } from './constants';
export const EducationFieldLabels = [
'Business Analytics',
'Computer Science',
'Data Science and Analytics',
'Information Security',
'Information Systems',
];
const EducationFieldLabels = {
'aerospace-engineering': 'Aerospace Engineering',
'applied-mathematics': 'Applied Mathematics',
biology: 'Biology',
'biomedical-engineering': 'Biomedical Engineering',
'business-analytics': 'Business Analytics',
'chemical-engineering': 'Chemical Engineering',
chemistry: 'Chemistry',
'civil-engineering': 'Civil Engineering',
'computational-biology': 'Computational Biology',
'computer-engineering': 'Computer Engineering',
'computer-science': 'Computer Science',
'computer-science-engineering': 'Computer Science and Engineering',
'computer-science-molecular-biology':
'Computer Science and Molecular Biology',
'data-science': 'Data Science',
'data-science-analytics': 'Data Science and Analytics',
'electrical-engineering': 'Electrical Engineering',
'electrical-engineering-computer-science':
'Electrical Engineering and Computer Science (EECS)',
'electrical-science-and-engineering': 'Electrical Science and Engineering',
'engineering-mathematics-statistics':
'Engineering Mathematics and Statistics',
'engineering-physics': 'Engineering Physics',
'engineering-science': 'Engineering Science',
'environmental-engineering': 'Environmental Engineering',
'environmental-science': 'Environmental Science',
'industrial-engineering-operations-research':
'Industrial Engineering and Operations Research',
'industrial-systems-engineering': 'Industrial Systems Engineering',
'information-security': 'Information Security',
'information-systems': 'Information Systems',
'management-science-and-engineering':
'Management Science and Engineering (MS&E)',
'materials-science': 'Materials Science',
mathematics: 'Mathematics',
'mechanical-engineering': 'Mechanical Engineering',
'nuclear-engineering': 'Nuclear Engineering',
'operations-research': 'Operations Research',
physics: 'Physics',
'software-engineering': 'Software Engineering',
'systems-engineering': 'Systems Engineering',
'web-development': 'Web Development',
};
export type EducationType = keyof typeof EducationFieldLabels;
export function getLabelForEducationFieldType(
educationType: EducationType,
): string {
return EducationFieldLabels[educationType];
}
export type EducationFieldType = keyof typeof EducationFieldLabels;
export const EducationFieldOptions = [emptyOption].concat(
EducationFieldLabels.map((label) => ({
Object.entries(EducationFieldLabels).map(([value, label]) => ({
label,
value: label.replace(/\s+/g, '-').toLowerCase(),
value,
})),
);

@ -8,6 +8,7 @@ export const EducationLevelLabels = [
'Professional',
'Secondary',
'Self-taught',
'Bootcamp',
];
export const EducationLevelOptions = [emptyOption].concat(

@ -1,4 +1,5 @@
export const HOME_URL = '/offers';
export const OFFERS_SUBMIT_URL = '/offers/submit';
export const JobTypeLabel = {
FULLTIME: 'Full-time',

@ -80,4 +80,4 @@ export default function DashboardProfileCard({
</div>
</div>
);
}
}

@ -63,7 +63,7 @@ export default function DashboardProfileCard({
{profileName}
</h2>
<p className="text-sm text-slate-500">
<span>Created at {formatDate(createdAt)}</span>
<span>Created in {formatDate(createdAt)}</span>
</p>
</div>
</div>

@ -2,14 +2,14 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import type { ReactNode } from 'react';
import { HOME_URL } from '../constants';
type LeftTextCardProps = Readonly<{
buttonLabel: string;
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: StaticImageData;
title: string;
url: string;
}>;
export default function LeftTextCard({
@ -18,6 +18,8 @@ export default function LeftTextCard({
imageAlt,
imageSrc,
title,
buttonLabel,
url,
}: LeftTextCardProps) {
return (
<div className="items-center lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
@ -36,8 +38,8 @@ export default function LeftTextCard({
<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
href={url}>
{buttonLabel}
</a>
</div>
</div>

@ -2,14 +2,14 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image';
import type { ReactNode } from 'react';
import { HOME_URL } from '../constants';
type RightTextCarddProps = Readonly<{
buttonLabel: string;
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: StaticImageData;
title: string;
url: string;
}>;
export default function RightTextCard({
@ -18,6 +18,8 @@ export default function RightTextCard({
imageAlt,
imageSrc,
title,
url,
buttonLabel,
}: RightTextCarddProps) {
return (
<div className="items-center lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
@ -36,8 +38,8 @@ export default function RightTextCard({
<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
href={url}>
{buttonLabel}
</a>
</div>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 KiB

After

Width:  |  Height:  |  Size: 277 KiB

@ -0,0 +1,37 @@
import type { ComponentProps } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
type Props = Omit<
ComponentProps<typeof CitiesTypeahead>,
'onSelect' | 'value'
> & {
names: { label: string; value: string };
};
export default function FormCitiesTypeahead({ names, ...props }: Props) {
const { setValue } = useFormContext();
const watchCityId = useWatch({
name: names.value,
});
const watchCityName = useWatch({
name: names.label,
});
return (
<CitiesTypeahead
label="Location"
{...props}
value={{
id: watchCityId,
label: watchCityName,
value: watchCityId,
}}
onSelect={(option) => {
setValue(names.value, option?.value);
setValue(names.label, option?.label);
}}
/>
);
}

@ -0,0 +1,36 @@
import type { ComponentProps } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
type Props = Omit<
ComponentProps<typeof CompaniesTypeahead>,
'onSelect' | 'value'
> & {
names: { label: string; value: string };
};
export default function FormCompaniesTypeahead({ names, ...props }: Props) {
const { setValue } = useFormContext();
const watchCompanyId = useWatch({
name: names.value,
});
const watchCompanyName = useWatch({
name: names.label,
});
return (
<CompaniesTypeahead
{...props}
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
setValue(names.value, option?.value);
setValue(names.label, option?.label);
}}
/>
);
}

@ -0,0 +1,34 @@
import type { ComponentProps } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
type Props = Omit<
ComponentProps<typeof JobTitlesTypeahead>,
'onSelect' | 'value'
> & {
name: string;
};
export default function FormJobTitlesTypeahead({ name, ...props }: Props) {
const { setValue } = useFormContext();
const watchJobTitle = useWatch({
name,
});
return (
<JobTitlesTypeahead
{...props}
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
setValue(name, option?.value);
}}
/>
);
}

@ -1,10 +1,13 @@
import { useEffect } from 'react';
import { useState } from 'react';
import { Alert, HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { ArrowUpRightIcon } from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client';
import { Alert, Button, HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../constants';
import { YOE_CATEGORY } from '../table/types';
import type { AnalysisUnit, ProfileAnalysis } from '~/types/offers';
@ -19,6 +22,16 @@ function OfferAnalysisContent({
tab,
isSubmission,
}: OfferAnalysisContentProps) {
const { companyId, companyName, title, totalYoe, jobType } = analysis;
const yoeCategory =
jobType === JobType.INTERN
? ''
: totalYoe <= 2
? YOE_CATEGORY.ENTRY
: totalYoe <= 5
? YOE_CATEGORY.MID
: YOE_CATEGORY.SENIOR;
if (!analysis || analysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) {
return (
@ -55,15 +68,22 @@ function OfferAnalysisContent({
offerProfile={topPercentileOffer}
/>
))}
{/* {offerAnalysis.topPercentileOffers.length > 0 && (
{analysis.topPercentileOffers.length > 0 && (
<div className="mb-4 flex justify-end">
<Button
icon={EllipsisHorizontalIcon}
href={
tab === OVERALL_TAB
? `/offers?jobTitleId=${title}&sortBy=-totalCompensation&yoeCategory=${yoeCategory}`
: `/offers?companyId=${companyId}&companyName=${companyName}&jobTitleId=${title}&sortBy=-totalCompensation&yoeCategory=${yoeCategory}`
}
icon={ArrowUpRightIcon}
label="View more offers"
rel="noreferrer"
target="_blank"
variant="tertiary"
/>
</div>
)} */}
)}
</>
);
}

@ -28,4 +28,4 @@ export default function OfferPercentileAnalysisText({
last year.
</p>
);
}
}

@ -1,4 +1,10 @@
import {
ArrowTrendingUpIcon,
BuildingOfficeIcon,
MapPinIcon,
} from '@heroicons/react/20/solid';
import {
ArrowTopRightOnSquareIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
} from '@heroicons/react/24/outline';
@ -7,9 +13,8 @@ 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 { Button } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency';
import { getCompanyDisplayText } from '~/utils/offers/string';
import { formatDate } from '~/utils/offers/time';
import { JobTypeLabel } from '../constants';
@ -36,52 +41,109 @@ export default function OfferProfileCard({
profileId,
},
}: OfferProfileCardProps) {
return (
<a
className="my-5 block rounded-lg border bg-white p-4 px-8 shadow-md"
href={`/offers/profile/${profileId}`}
rel="noreferrer"
target="_blank">
<div className="flex items-center gap-x-5">
<div>
<ProfilePhotoHolder size="sm" />
</div>
<div className="col-span-10">
<p className="font-bold">{profileName}</p>
{previousCompanies.length > 0 && (
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>{previousCompanies[0]}</span>
function UpperSection() {
return (
<div className="border-b px-4 py-5 sm:px-6">
<div className="-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap">
<div className="ml-4 mt-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<ProfilePhotoHolder size="sm" />
</div>
<div className="ml-4">
<h2 className="text-lg font-medium leading-6 text-slate-900">
{profileName}
</h2>
<p className="flex text-sm text-slate-500">
<CalendarDaysIcon className="mr-2 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span>{totalYoe}</span>
{previousCompanies.length > 0 && (
<>
<BuildingOffice2Icon className="ml-4 mr-2 h-5" />
<span className="mr-2 font-bold">Previous:</span>
<span>{previousCompanies[0]}</span>
</>
)}
</p>
</div>
</div>
)}
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span>{totalYoe}</span>
</div>
<div className="ml-4 mt-4 flex flex-shrink-0">
<Button
href={`/offers/profile/${profileId}`}
icon={ArrowTopRightOnSquareIcon}
isLabelHidden={true}
label="View Profile"
rel="noreferrer"
size="md"
target="_blank"
variant="tertiary"
/>
</div>
</div>
</div>
);
}
<HorizontalDivider />
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<p className="font-bold">
{getLabelForJobTitleType(title as JobTitleType)}{' '}
{`(${JobTypeLabel[jobType]})`}
</p>
<p>{`Company: ${getCompanyDisplayText(company.name, location)}`}</p>
{level && <p>Level: {level}</p>}
</div>
<div className="col-span-1 row-span-3">
<p className="text-end">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl">
{jobType === JobType.FULLTIME
? `${convertMoneyToString(income)} / year`
: `${convertMoneyToString(income)} / month`}
</p>
function BottomSection() {
return (
<div className="px-4 py-4 sm:px-6">
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<h4 className="font-medium">
{getLabelForJobTitleType(title as JobTitleType)}{' '}
{jobType && <>({JobTypeLabel[jobType]})</>}
</h4>
<div className="mt-1 flex flex-col sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-4">
{company?.name && (
<div className="mt-2 flex items-center text-sm text-slate-500">
<BuildingOfficeIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{company.name}
</div>
)}
{location && (
<div className="mt-2 flex items-center text-sm text-slate-500">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{location.cityName}
</div>
)}
{level && (
<div className="mt-2 flex items-center text-sm text-slate-500">
<ArrowTrendingUpIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-slate-400"
/>
{level}
</div>
)}
</div>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end text-lg font-medium leading-6 text-slate-900">
{jobType === JobType.FULLTIME
? `${convertMoneyToString(income)} / year`
: `${convertMoneyToString(income)} / month`}
</p>
<p className="text-end text-sm text-slate-500">
{formatDate(monthYearReceived)}
</p>
</div>
</div>
</div>
</a>
);
}
return (
<div className="my-5 block rounded-lg border border-slate-200 bg-white">
<UpperSection />
<BottomSection />
</div>
);
}

@ -33,4 +33,4 @@ export default function OffersSubmissionAnalysis({
)}
</div>
);
}
}

@ -13,10 +13,12 @@ import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type {
OfferFormData,
OfferPostData,
OffersProfileFormData,
} from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import {
cleanObject,
removeEmptyObjects,
@ -25,17 +27,19 @@ import {
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
export const DEFAULT_CURRENCY = Currency.SGD;
const defaultOfferValues = {
cityId: '',
comments: '',
companyId: '',
jobTitle: '',
jobType: JobType.FULLTIME,
monthYearReceived: {
month: getCurrentMonth() as Month,
year: getCurrentYear(),
},
negotiationStrategy: '',
title: '',
};
export const defaultFullTimeOfferValues = {
@ -43,21 +47,17 @@ export const defaultFullTimeOfferValues = {
jobType: JobType.FULLTIME,
offersFullTime: {
baseSalary: {
currency: 'SGD',
value: null,
currency: DEFAULT_CURRENCY,
},
bonus: {
currency: 'SGD',
value: null,
currency: DEFAULT_CURRENCY,
},
level: '',
stocks: {
currency: 'SGD',
value: null,
currency: DEFAULT_CURRENCY,
},
totalCompensation: {
currency: 'SGD',
value: null,
currency: DEFAULT_CURRENCY,
},
},
};
@ -66,16 +66,15 @@ export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.INTERN,
offersIntern: {
internshipCycle: null,
internshipCycle: '',
monthlySalary: {
currency: 'SGD',
value: null,
currency: DEFAULT_CURRENCY,
},
startYear: null,
},
};
const defaultOfferProfileValues = {
const defaultOfferProfileValues: OffersProfileFormData = {
background: {
educations: [],
experiences: [{ jobType: JobType.FULLTIME }],
@ -109,6 +108,7 @@ export default function OffersSubmissionForm({
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: initialOfferProfileValues,
mode: 'all',
@ -116,7 +116,7 @@ export default function OffersSubmissionForm({
const {
handleSubmit,
trigger,
formState: { isSubmitting },
formState: { isSubmitting, isDirty },
} = formMethods;
const generateAnalysisMutation = trpc.useMutation(
@ -218,7 +218,7 @@ export default function OffersSubmissionForm({
offer.monthYearReceived.year,
offer.monthYearReceived.month - 1, // Convert month to monthIndex
),
}));
})) as Array<OfferPostData>;
if (params.profileId && params.token) {
createOrUpdateMutation.mutate({
@ -254,11 +254,14 @@ export default function OffersSubmissionForm({
const warningText =
'Leave this page? Changes that you made will not be saved.';
const handleWindowClose = (e: BeforeUnloadEvent) => {
if (!isDirty) {
return;
}
e.preventDefault();
return (e.returnValue = warningText);
};
const handleRouteChange = (url: string) => {
if (url.includes('/offers/submit/result')) {
if (url.includes('/offers/submit/result') || !isDirty) {
return;
}
if (window.confirm(warningText)) {
@ -274,7 +277,7 @@ export default function OffersSubmissionForm({
router.events.off('routeChangeStart', handleRouteChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [isDirty]);
return generateAnalysisMutation.isLoading ? (
<Spinner className="m-10" display="block" size="lg" />

@ -4,11 +4,6 @@ import { Collapsible, RadioList } from '@tih/ui';
import { FieldError } from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import {
Currency,
@ -17,6 +12,9 @@ import {
import { EducationFieldOptions } from '../../EducationFields';
import { EducationLevelOptions } from '../../EducationLevels';
import FormCitiesTypeahead from '../../forms/FormCitiesTypeahead';
import FormCompaniesTypeahead from '../../forms/FormCompaniesTypeahead';
import FormJobTitlesTypeahead from '../../forms/FormJobTitlesTypeahead';
import FormRadioList from '../../forms/FormRadioList';
import FormSection from '../../forms/FormSection';
import FormSelect from '../../forms/FormSelect';
@ -85,56 +83,19 @@ function YoeSection() {
}
function FullTimeJobFields() {
const { register, setValue, formState } = useFormContext<{
const { register, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
const watchJobTitle = useWatch({
name: 'background.experiences.0.title',
});
const watchCompanyId = useWatch({
name: 'background.experiences.0.companyId',
});
const watchCompanyName = useWatch({
name: 'background.experiences.0.companyName',
});
const watchCityId = useWatch({
name: 'background.experiences.0.cityId',
});
const watchCityName = useWatch({
name: 'background.experiences.0.cityName',
});
return (
<>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.title', option.value);
}
}}
/>
<CompaniesTypeahead
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.companyId', option.value);
setValue('background.experiences.0.companyName', option.label);
} else {
setValue('background.experiences.0.companyId', '');
setValue('background.experiences.0.companyName', '');
}
<FormJobTitlesTypeahead name="background.experiences.0.title" />
<FormCompaniesTypeahead
names={{
label: 'background.experiences.0.companyName',
value: 'background.experiences.0.companyId',
}}
/>
</div>
@ -172,21 +133,10 @@ function FullTimeJobFields() {
placeholder="e.g. L4, Junior"
{...register(`background.experiences.0.level`)}
/>
<CitiesTypeahead
label="Location"
value={{
id: watchCityId,
label: watchCityName,
value: watchCityId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.cityId', option.value);
setValue('background.experiences.0.cityName', option.label);
} else {
setValue('background.experiences.0.cityId', '');
setValue('background.experiences.0.cityName', '');
}
<FormCitiesTypeahead
names={{
label: 'background.experiences.0.cityName',
value: 'background.experiences.0.cityId',
}}
/>
<FormTextInput
@ -205,53 +155,19 @@ function FullTimeJobFields() {
}
function InternshipJobFields() {
const { register, setValue, formState } = useFormContext<{
const { register, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
const watchJobTitle = useWatch({
name: 'background.experiences.0.title',
});
const watchCompanyId = useWatch({
name: 'background.experiences.0.companyId',
});
const watchCompanyName = useWatch({
name: 'background.experiences.0.companyName',
});
const watchCityId = useWatch({
name: 'background.experiences.0.cityId',
});
const watchCityName = useWatch({
name: 'background.experiences.0.cityName',
});
return (
<>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.title', option.value);
}
}}
/>
<CompaniesTypeahead
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.companyId', option.value);
setValue('background.experiences.0.companyName', option.label);
}
<FormJobTitlesTypeahead name="background.experiences.0.title" />
<FormCompaniesTypeahead
names={{
label: 'background.experiences.0.companyName',
value: 'background.experiences.0.companyId',
}}
/>
</div>
@ -280,21 +196,10 @@ function InternshipJobFields() {
/>
<Collapsible label="Add more details">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<CitiesTypeahead
label="Location"
value={{
id: watchCityId,
label: watchCityName,
value: watchCityId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.cityId', option.value);
setValue('background.experiences.0.cityName', option.label);
} else {
setValue('background.experiences.0.cityId', '');
setValue('background.experiences.0.cityName', '');
}
<FormCitiesTypeahead
names={{
label: 'background.experiences.0.cityName',
value: 'background.experiences.0.cityId',
}}
/>
<FormTextInput

@ -4,6 +4,7 @@ import type {
UseFieldArrayRemove,
UseFieldArrayReturn,
} from 'react-hook-form';
import { Controller } from 'react-hook-form';
import { useWatch } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
@ -12,17 +13,14 @@ import { TrashIcon } from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client';
import { Button, Dialog, HorizontalDivider } from '@tih/ui';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import {
defaultFullTimeOfferValues,
defaultInternshipOfferValues,
} from '../OffersSubmissionForm';
import { FieldError, JobTypeLabel } from '../../constants';
import FormCitiesTypeahead from '../../forms/FormCitiesTypeahead';
import FormCompaniesTypeahead from '../../forms/FormCompaniesTypeahead';
import FormJobTitlesTypeahead from '../../forms/FormJobTitlesTypeahead';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormSection from '../../forms/FormSection';
import FormSelect from '../../forms/FormSelect';
@ -46,26 +44,11 @@ function FullTimeOfferDetailsForm({
index,
remove,
}: FullTimeOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{
const { register, formState, setValue, control } = useFormContext<{
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
const watchJobTitle = useWatch({
name: `offers.${index}.offersFullTime.title`,
});
const watchCompanyId = useWatch({
name: `offers.${index}.companyId`,
});
const watchCompanyName = useWatch({
name: `offers.${index}.companyName`,
});
const watchCityId = useWatch({
name: `offers.${index}.cityId`,
});
const watchCityName = useWatch({
name: `offers.${index}.cityName`,
});
const watchCurrency = useWatch({
name: `offers.${index}.offersFullTime.totalCompensation.currency`,
});
@ -83,18 +66,17 @@ function FullTimeOfferDetailsForm({
<div className="space-y-8 rounded-lg border border-slate-200 p-6 sm:space-y-16 sm:p-8">
<FormSection title="Company & Title Information">
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead
required={true}
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.offersFullTime.title`, option.value);
}
}}
<Controller
control={control}
name={`offers.${index}.offersFullTime.title`}
render={() => (
<FormJobTitlesTypeahead
errorMessage={offerFields?.offersFullTime?.title?.message}
name={`offers.${index}.offersFullTime.title`}
required={true}
/>
)}
rules={{ required: true }}
/>
<FormTextInput
errorMessage={offerFields?.offersFullTime?.level?.message}
@ -107,37 +89,35 @@ function FullTimeOfferDetailsForm({
/>
</div>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<CompaniesTypeahead
required={true}
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.companyId`, option.value);
setValue(`offers.${index}.companyName`, option.label);
}
}}
<Controller
control={control}
name={`offers.${index}.companyId`}
render={() => (
<FormCompaniesTypeahead
errorMessage={offerFields?.companyId?.message}
names={{
label: `offers.${index}.companyName`,
value: `offers.${index}.companyId`,
}}
required={true}
/>
)}
rules={{ required: true }}
/>
<CitiesTypeahead
label="Location"
required={true}
value={{
id: watchCityId,
label: watchCityName,
value: watchCityId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.cityId`, option.value);
setValue(`offers.${index}.cityName`, option.label);
} else {
setValue(`offers.${index}.cityId`, '');
setValue(`offers.${index}.cityName`, '');
}
}}
<Controller
control={control}
name={`offers.${index}.cityId`}
render={() => (
<FormCitiesTypeahead
errorMessage={offerFields?.cityId?.message}
names={{
label: `offers.${index}.cityName`,
value: `offers.${index}.cityId`,
}}
required={true}
/>
)}
rules={{ required: true }}
/>
</div>
</FormSection>
@ -303,76 +283,56 @@ function InternshipOfferDetailsForm({
index,
remove,
}: InternshipOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{
const { register, formState, control } = useFormContext<{
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
const watchJobTitle = useWatch({
name: `offers.${index}.offersIntern.title`,
});
const watchCompanyId = useWatch({
name: `offers.${index}.companyId`,
});
const watchCompanyName = useWatch({
name: `offers.${index}.companyName`,
});
const watchCityId = useWatch({
name: `offers.${index}.cityId`,
});
const watchCityName = useWatch({
name: `offers.${index}.cityName`,
});
return (
<div className="space-y-8 rounded-lg border border-slate-200 p-6 sm:space-y-16 sm:p-8">
<FormSection title="Company & Title Information">
<JobTitlesTypeahead
required={true}
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.offersIntern.title`, option.value);
}
}}
<Controller
control={control}
name={`offers.${index}.offersIntern.title`}
render={() => (
<FormJobTitlesTypeahead
errorMessage={offerFields?.offersIntern?.title?.message}
name={`offers.${index}.offersIntern.title`}
required={true}
/>
)}
rules={{ required: true }}
/>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<CompaniesTypeahead
required={true}
value={{
id: watchCompanyId,
label: watchCompanyName,
value: watchCompanyId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.companyId`, option.value);
setValue(`offers.${index}.companyName`, option.label);
}
}}
<Controller
control={control}
name={`offers.${index}.companyId`}
render={() => (
<FormCompaniesTypeahead
errorMessage={offerFields?.companyId?.message}
names={{
label: `offers.${index}.companyName`,
value: `offers.${index}.companyId`,
}}
required={true}
/>
)}
rules={{ required: true }}
/>
<CitiesTypeahead
label="Location"
required={true}
value={{
id: watchCityId,
label: watchCityName,
value: watchCityId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.cityId`, option.value);
setValue(`offers.${index}.cityName`, option.label);
} else {
setValue(`offers.${index}.cityId`, '');
setValue(`offers.${index}.cityName`, '');
}
}}
<Controller
control={control}
name={`offers.${index}.cityId`}
render={() => (
<FormCitiesTypeahead
errorMessage={offerFields?.cityId?.message}
names={{
label: `offers.${index}.cityName`,
value: `offers.${index}.cityId`,
}}
required={true}
/>
)}
rules={{ required: true }}
/>
</div>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
@ -551,7 +511,6 @@ export default function OfferDetailsForm() {
if (newJobType === jobType) {
return;
}
setDialogOpen(true);
}}
/>

@ -169,4 +169,4 @@ export default function OfferCard({
<BottomSection />
</div>
);
}
}

@ -186,7 +186,7 @@ export default function ProfileComments({
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Comment"
label="Submit"
size="sm"
variant="primary"
onClick={() => handleComment(currentReply)}

@ -131,9 +131,8 @@ function ProfileAnalysis({
{isEditable && (
<div className="flex justify-end">
<Button
addonPosition="start"
icon={ArrowPathIcon}
label="Regenerate Analysis"
label="Regenerate analysis"
variant="secondary"
onClick={() => generateAnalysisMutation.mutate({ profileId })}
/>

@ -1,10 +1,10 @@
import { formatDistanceToNow } from 'date-fns';
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import { Button, Dialog, TextArea, useToast } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import { timeSinceNow } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { Reply } from '~/types/offers';
@ -135,45 +135,45 @@ export default function CommentCard({
)}
</div>
<div className="w-full">
<div className="text-sm">
<p className="font-medium text-slate-900">
{user?.name ?? 'unknown user'}
<div className="flex flex-row items-center space-x-2">
<p className="text-sm font-medium text-slate-900">
{user?.name ?? 'Unknown user'}
</p>
<span className="font-medium text-slate-500">&middot;</span>
<div className="text-xs text-slate-500">
{formatDistanceToNow(createdAt, {
addSuffix: true,
})}
</div>
</div>
<div className="mt-1 text-sm text-slate-700">
<p className="break-all">{message}</p>
</div>
<div className="mt-2 space-x-2 text-xs">
<span className="font-medium text-slate-500">
{timeSinceNow(createdAt)} ago
</span>{' '}
{replyLength > 0 && (
<>
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button
className="font-medium text-slate-900"
type="button"
onClick={handleExpanded}>
{isExpanded ? `Hide replies` : `View replies (${replyLength})`}
</button>
</>
)}
<div className="-ml-2 mt-1 flex h-6 items-center text-xs">
{!disableReply && (
<>
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button
className="font-medium text-slate-900"
type="button"
onClick={() => setIsReplying(!isReplying)}>
Reply
</button>
</>
<button
className="-my-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-600"
type="button"
onClick={() => setIsReplying(!isReplying)}>
Reply
</button>
)}
{replyLength > 0 && (
<button
className="-my-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-600"
type="button"
onClick={handleExpanded}>
{isExpanded
? `Hide ${replyLength === 1 ? 'reply' : 'replies'}`
: `Show ${replyLength} ${
replyLength === 1 ? 'reply' : 'replies'
}`}
</button>
)}
{deletable && (
<>
<span className="font-medium text-slate-500">&middot;</span>{' '}
<button
className="font-medium text-slate-900"
className="-my-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-600"
disabled={deleteCommentMutation.isLoading}
type="button"
onClick={() => setIsDialogOpen(true)}>
@ -210,8 +210,9 @@ export default function CommentCard({
)}
</div>
{!disableReply && isReplying && (
<div className="mt-4 mr-2">
<div className="mt-2">
<form
className="space-y-2"
onSubmit={(event) => {
event.preventDefault();
event.stopPropagation();
@ -226,23 +227,29 @@ export default function CommentCard({
value={currentReply}
onChange={(value) => setCurrentReply(value)}
/>
<div className="mt-2 flex w-full justify-end">
<div className="w-fit">
<Button
disabled={
!currentReply.length ||
createCommentMutation.isLoading ||
deleteCommentMutation.isLoading
}
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Reply"
size="sm"
variant="primary"
onClick={handleReply}
/>
</div>
<div className="flex w-full justify-end space-x-2">
<Button
disabled={createCommentMutation.isLoading}
label="Cancel"
size="sm"
variant="tertiary"
onClick={() => {
setIsReplying(false);
}}
/>
<Button
disabled={
!currentReply.length ||
createCommentMutation.isLoading ||
deleteCommentMutation.isLoading
}
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Submit"
size="sm"
variant="primary"
onClick={handleReply}
/>
</div>
</form>
</div>

@ -27,19 +27,21 @@ export default function ExpandableCommentCard({
token={token}
/>
{comment.replies && comment.replies.length > 0 && isExpanded && (
<div className="pt-4">
<ul className="space-y-4 pl-14" role="list">
{comment.replies.map((reply) => (
<li key={reply.id}>
<CommentCard
comment={reply}
disableReply={true}
profileId={profileId}
token={token}
/>
</li>
))}
</ul>
<div className="pl-[52px] pt-2">
<div className="border-l-2 border-slate-200 pl-2">
<ul className="space-y-2" role="list">
{comment.replies.map((reply) => (
<li key={reply.id}>
<CommentCard
comment={reply}
disableReply={true}
profileId={profileId}
token={token}
/>
</li>
))}
</ul>
</div>
</div>
)}
</div>

@ -275,7 +275,7 @@ export default function OffersTable({
{!offers ||
(offers.length === 0 && (
<div className="py-16 text-lg">
<div className="flex justify-center">No data yet🥺</div>
<div className="flex justify-center">No data yet 🥺</div>
</div>
))}
</div>

@ -56,4 +56,4 @@ export type OfferTableSortByType =
| '-monthYearReceived'
| '-totalCompensation'
| '-totalYoe'
| '+totalYoe';
| '+totalYoe';

@ -4,16 +4,99 @@ import type { MonthYear } from '~/components/shared/MonthYearPicker';
import type { Location } from '~/types/offers';
export type OffersProfilePostData = {
background: BackgroundPostData;
/**
* Form data types
*/
export type OffersProfileFormData = {
background: BackgroundFormData;
id?: string;
offers: Array<OfferPostData>;
offers: Array<OfferFormData>;
};
export type OffersProfileFormData = {
export type BackgroundFormData = {
educations: Array<EducationFormData>;
experiences: Array<ExperienceFormData>;
id?: string;
specificYoes: Array<SpecificYoeFormData>;
totalYoe: number;
};
type EducationFormData = {
endDate?: Date | null;
field?: string | null;
school?: string | null;
startDate?: Date | null;
type?: string | null;
};
type ExperienceFormData = {
cityId?: string | null;
cityName?: string | null;
companyId?: string | null;
companyName?: string | null;
durationInMonths?: number | null;
id?: string;
jobType?: string | null;
level?: string | null;
monthlySalary?: MoneyFormData | null;
title?: string | null;
totalCompensation?: MoneyFormData | null;
totalCompensationId?: string | null;
};
type SpecificYoeFormData = {
domain: string;
id?: string;
yoe: number;
};
export type OfferFormData = {
cityId: string;
cityName?: string;
comments: string;
companyId: string;
companyName?: string;
id?: string;
jobType: JobType;
monthYearReceived: MonthYear;
negotiationStrategy: string;
offersFullTime?: OfferFullTimeFormData | null;
offersIntern?: OfferInternFormData | null;
};
export type OfferFullTimeFormData = {
baseSalary?: MoneyFormData | null;
bonus?: MoneyFormData | null;
id?: string;
level: string;
stocks?: MoneyFormData | null;
title: string;
totalCompensation: MoneyFormData;
};
export type OfferInternFormData = {
id?: string;
internshipCycle: string;
monthlySalary: MoneyFormData;
startYear: number;
title: string;
};
type MoneyFormData = {
currency: string;
id?: string;
value?: number;
};
/**
* Post request data types
*/
export type OffersProfilePostData = {
background: BackgroundPostData;
id?: string;
offers: Array<OfferFormData>;
offers: Array<OfferPostData>;
};
export type BackgroundPostData = {
@ -24,6 +107,8 @@ export type BackgroundPostData = {
totalYoe: number;
};
type EducationPostData = EducationFormData;
type ExperiencePostData = {
cityId?: string | null;
cityName?: string | null;
@ -39,47 +124,26 @@ type ExperiencePostData = {
totalCompensationId?: string | null;
};
type EducationPostData = {
endDate?: Date | null;
field?: string | null;
id?: string;
school?: string | null;
startDate?: Date | null;
type?: string | null;
};
type SpecificYoePostData = {
domain: string;
id?: string;
yoe: number;
};
type SpecificYoe = SpecificYoePostData;
type SpecificYoePostData = SpecificYoeFormData;
export type OfferPostData = {
cityId: string;
cityName?: string;
comments: string;
companyId: string;
companyName?: string;
id?: string;
jobType: JobType;
monthYearReceived: Date;
negotiationStrategy: string;
offersFullTime?: OfferFullTimePostData | null;
offersIntern?: OfferInternPostData | null;
};
export type OfferFormData = Omit<OfferPostData, 'monthYearReceived'> & {
monthYearReceived: MonthYear;
offersFullTime?: OfferFullTimePostData;
offersIntern?: OfferInternPostData;
};
export type OfferFullTimePostData = {
baseSalary: Money | null;
bonus: Money | null;
baseSalary: Money;
bonus: Money;
id?: string;
level: string;
stocks: Money | null;
stocks: Money;
title: string;
totalCompensation: Money;
};
@ -98,6 +162,10 @@ export type Money = {
value: number;
};
/**
* Display data types
*/
export type EducationDisplayData = {
endDate?: string | null;
field?: string | null;
@ -128,7 +196,7 @@ export type BackgroundDisplayData = {
educations: Array<EducationDisplayData>;
experiences: Array<OfferDisplayData>;
profileName: string;
specificYoes: Array<SpecificYoe>;
specificYoes: Array<SpecificYoePostData>;
totalYoe: number;
};
@ -141,4 +209,4 @@ export type CommentEntity = {
replyingToId: string;
userId: string;
username: string;
};
};

@ -3,22 +3,52 @@ import type { PropsWithChildren } from 'react';
import { useMemo } from 'react';
import { Fragment, useRef, useState } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { CheckIcon, HeartIcon } from '@heroicons/react/20/solid';
import { CheckIcon, HeartIcon, PlusIcon } from '@heroicons/react/20/solid';
import {
useAddQuestionToListAsync,
useCreateListAsync,
useRemoveQuestionFromListAsync,
} from '~/utils/questions/mutations';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc';
import CreateListDialog from './CreateListDialog';
export type AddToListDropdownProps = {
questionId: string;
};
export type DropdownButtonProps = PropsWithChildren<{
onClick: () => void;
}>;
function DropdownButton({ onClick, children }: DropdownButtonProps) {
return (
<Menu.Item>
{({ active }) => (
<button
className={clsx(
active ? 'bg-slate-100 text-slate-900' : 'text-slate-700',
'flex w-full items-center px-4 py-2 text-sm',
)}
type="button"
onClick={onClick}>
{children}
</button>
)}
</Menu.Item>
);
}
export default function AddToListDropdown({
questionId,
}: AddToListDropdownProps) {
const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const [show, setShow] = useState(false);
const utils = trpc.useContext();
const createListAsync = useCreateListAsync();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const listsWithQuestionData = useMemo(() => {
@ -30,25 +60,8 @@ export default function AddToListDropdown({
}));
}, [lists, questionId]);
const { mutateAsync: addQuestionToList } = trpc.useMutation(
'questions.lists.createQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const { mutateAsync: removeQuestionFromList } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const addQuestionToList = useAddQuestionToListAsync();
const removeQuestionFromList = useRemoveQuestionFromListAsync();
const addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true);
@ -101,63 +114,79 @@ export default function AddToListDropdown({
);
return (
<Menu ref={ref} as="div" className="relative inline-block text-left">
<div>
<Menu.Button as={CustomMenuButton}>
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
Add to List
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
show={menuOpened}>
<Menu.Items
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
static={true}>
{menuOpened && (
<>
{(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1">
<Menu.Item>
{({ active }) => (
<button
className={clsx(
active
? 'bg-slate-100 text-slate-900'
: 'text-slate-700',
'group flex w-full items-center px-4 py-2 text-sm',
)}
type="button"
onClick={() => {
if (list.hasQuestion) {
handleDeleteFromList(list.id);
} else {
handleAddToList(list.id);
}
}}>
<div>
<Menu ref={ref} as="div" className="relative inline-block text-left">
<div>
<Menu.Button as={CustomMenuButton}>
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
Add to list
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
show={menuOpened}>
<Menu.Items
className="absolute right-0 z-10 mt-2 w-56 origin-top-right divide-y divide-slate-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
static={true}>
{menuOpened && (
<>
{(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1">
<DropdownButton
onClick={() => {
if (list.hasQuestion) {
handleDeleteFromList(list.id);
} else {
handleAddToList(list.id);
}
}}>
<div className="flex w-full flex-1 justify-between">
<span className="flex-1 overflow-hidden text-ellipsis text-start">
{list.name}
</span>
{list.hasQuestion && (
<CheckIcon
aria-hidden="true"
className="mr-3 h-5 w-5 text-slate-400 group-hover:text-slate-500"
className="h-5 w-5 text-slate-400"
/>
)}
{list.name}
</button>
)}
</Menu.Item>
</div>
))}
</>
)}
</Menu.Items>
</Transition>
</Menu>
</div>
</DropdownButton>
</div>
))}
<DropdownButton
onClick={() => {
setShow(true);
}}>
<PlusIcon
aria-hidden="true"
className="mr-3 h-5 w-5 text-slate-500"
/>
<span className="font-semibold text-slate-500">
Create new list
</span>
</DropdownButton>
</>
)}
</Menu.Items>
</Transition>
</Menu>
<CreateListDialog
show={show}
onCancel={() => {
setShow(false);
}}
onSubmit={async (data) => {
await createListAsync(data);
setShow(false);
}}
/>
</div>
);
}

@ -1,9 +1,4 @@
import { useState } from 'react';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import { TextInput } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
@ -32,44 +27,19 @@ export default function ContributeQuestionCard({
return (
<div className="w-full">
<button
className="flex w-full flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
className="flex w-full flex-1 justify-between gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
type="button"
onClick={handleOpenContribute}>
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
onChange={handleOpenContribute}
/>
<div className="w-full">
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
onChange={handleOpenContribute}
/>
</div>
<div className="flex flex-wrap items-end justify-start gap-2">
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Company"
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Question type"
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}
label="Date"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
onChange={handleOpenContribute}
/>
</div>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>

@ -8,8 +8,7 @@ const navigation: ProductNavigationItems = [
];
const config = {
// TODO: Change this to your own GA4 measurement ID.
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
googleAnalyticsMeasurementID: 'G-0T4LYWMK8L',
logo: (
<img alt="Questions Bank" className="h-8 w-auto" src="/bank-logo.png" />
),

@ -8,16 +8,15 @@ export type SortOption<Value> = {
value: Value;
};
const sortTypeOptions = SORT_TYPES;
const sortOrderOptions = SORT_ORDERS;
type SortOrderProps<Order> = {
onSortOrderChange?: (sortValue: Order) => void;
sortOrderOptions?: Array<SortOption<Order>>;
sortOrderValue: Order;
};
type SortTypeProps<Type> = {
onSortTypeChange?: (sortType: Type) => void;
sortTypeOptions?: Array<SortOption<Type>>;
sortTypeValue: Type;
};
@ -29,17 +28,22 @@ export default function SortOptionsSelect({
sortOrderValue,
onSortTypeChange,
sortTypeValue,
sortOrderOptions,
sortTypeOptions,
}: SortOptionsSelectProps) {
const sortTypes = sortTypeOptions ?? SORT_TYPES;
const sortOrders = sortOrderOptions ?? SORT_ORDERS;
return (
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2">
<Select
display="inline"
label="Sort by"
options={sortTypeOptions}
options={sortTypes}
value={sortTypeValue}
onChange={(value) => {
const chosenOption = sortTypeOptions.find(
const chosenOption = sortTypes.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
@ -52,10 +56,10 @@ export default function SortOptionsSelect({
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
options={sortOrders}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
const chosenOption = sortOrders.find(
(option) => String(option.value) === value,
);
if (chosenOption) {

@ -90,7 +90,7 @@ type ReceivedStatisticsProps =
type CreateEncounterProps =
| {
createEncounterButtonText: string;
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
onReceivedSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
showCreateEncounterButton: true;
}
| {
@ -185,7 +185,7 @@ export default function BaseQuestionCard({
)}
<div className="flex flex-1 flex-col items-start gap-2">
<div className="flex items-baseline justify-between self-stretch">
<div className="flex items-center gap-2 text-slate-500">
<div className="z-10 flex items-center gap-2 text-slate-500">
{showAggregateStatistics && (
<>
<QuestionTypeBadge type={type} />
@ -263,9 +263,8 @@ export default function BaseQuestionCard({
onCancel={() => {
setShowReceivedForm(false);
}}
onSubmit={(data) => {
onReceivedSubmit?.(data);
setShowReceivedForm(false);
onSubmit={async (data) => {
await onReceivedSubmit?.(data);
}}
/>
)}

@ -5,7 +5,7 @@ import { ArrowPathIcon } from '@heroicons/react/20/solid';
import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { CheckboxInput } from '@tih/ui';
import { Button, HorizontalDivider, Select, TextArea } from '@tih/ui';
import { Button, Select, TextArea } from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
@ -187,11 +187,9 @@ export default function ContributeQuestionForm({
/>
</div>
</div>
<div className="w-full">
<HorizontalDivider />
</div>
<h2
className="text-primary-900 mb-3
className="text-primary-900
text-lg font-semibold
">
Are these questions the same as yours?
@ -243,11 +241,13 @@ export default function ContributeQuestionForm({
/>
);
})}
{similarQuestions?.length === 0 && (
<p className="font-semibold text-slate-900">
No similar questions found.
</p>
)}
{similarQuestions?.length === 0 &&
contentToCheck?.length !== 0 &&
questionContent === contentToCheck && (
<p className="font-semibold text-slate-900">
No similar questions found.
</p>
)}
</div>
<div
className="bg-primary-50 flex w-full flex-col gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)] sm:flex-row sm:justify-between"

@ -1,5 +1,6 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { CheckIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import type { Month } from '~/components/shared/MonthYearPicker';
@ -22,7 +23,7 @@ export type CreateQuestionEncounterData = {
export type CreateQuestionEncounterFormProps = {
onCancel: () => void;
onSubmit: (data: CreateQuestionEncounterData) => void;
onSubmit: (data: CreateQuestionEncounterData) => Promise<void>;
};
export default function CreateQuestionEncounterForm({
@ -30,6 +31,8 @@ export default function CreateQuestionEncounterForm({
onSubmit,
}: CreateQuestionEncounterFormProps) {
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<Location | null>(
@ -40,9 +43,18 @@ export default function CreateQuestionEncounterForm({
startOfMonth(new Date()),
);
if (submitted) {
return (
<div className="font-md flex items-center gap-1 rounded-full border bg-slate-50 py-1 pl-2 pr-3 text-sm text-slate-500">
<CheckIcon className="h-5 w-5" />
<p>Thank you for your response</p>
</div>
);
}
return (
<div className="flex items-center gap-2">
<p className="font-md text-md text-slate-600">
<p className="text-md text-slate-600">
I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
</p>
{step === 0 && (
@ -128,9 +140,10 @@ export default function CreateQuestionEncounterForm({
)}
{step === 3 && (
<Button
isLoading={loading}
label="Submit"
variant="primary"
onClick={() => {
onClick={async () => {
if (
selectedCompany &&
selectedLocation &&
@ -138,14 +151,20 @@ export default function CreateQuestionEncounterForm({
selectedDate
) {
const { cityId, stateId, countryId } = selectedLocation;
onSubmit({
cityId,
company: selectedCompany,
countryId,
role: selectedRole,
seenAt: selectedDate,
stateId,
});
setLoading(true);
try {
await onSubmit({
cityId,
company: selectedCompany,
countryId,
role: selectedRole,
seenAt: selectedDate,
stateId,
});
setSubmitted(true);
} finally {
setLoading(false);
}
}
}}
/>

@ -35,10 +35,12 @@ export default function ResumePdf({ url }: Props) {
}, [pageWidth]);
return (
<div className="w-full" id="pdfView">
<div className="group relative">
<div
className="w-full flex-col overflow-y-auto lg:flex lg:h-full"
id="pdfView">
<div className="group relative grow bg-slate-100 lg:h-0">
<Document
className="flex h-[calc(100vh-16rem)] flex-row justify-center overflow-auto"
className="flex flex-row justify-center overflow-auto py-8 lg:h-full"
file={url}
loading={<Spinner display="block" size="lg" />}
noData=""
@ -79,7 +81,7 @@ export default function ResumePdf({ url }: Props) {
</div>
</Document>
</div>
<div className="flex justify-center p-4">
<div className="flex justify-center border-t border-slate-200 bg-white py-4">
<Pagination
current={pageNumber}
end={numPages}

@ -39,18 +39,22 @@ export default function ResumeUserBadges({ userId }: Props) {
topUpvotedCommentCount: userTopUpvotedCommentCountQuery.data ?? 0,
};
const badges = RESUME_USER_BADGES.filter((badge) => badge.isValid(payload));
if (badges.length === 0) {
return null;
}
return (
<div className="flex items-center justify-center gap-1">
{RESUME_USER_BADGES.filter((badge) => badge.isValid(payload)).map(
(badge) => (
<ResumeUserBadge
key={badge.id}
description={badge.description}
icon={badge.icon}
title={badge.title}
/>
),
)}
{badges.map((badge) => (
<ResumeUserBadge
key={badge.id}
description={badge.description}
icon={badge.icon}
title={badge.title}
/>
))}
</div>
);
}

@ -12,17 +12,7 @@ import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import type {
ExperienceFilter,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
EXPERIENCES,
getFilterLabel,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { getFilterLabel } from '~/utils/resumes/resumeFilters';
import type { Resume } from '~/types/resume';
@ -47,15 +37,14 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
<div className="col-span-7 grid gap-4 border-b border-slate-200 p-4 hover:bg-slate-100 sm:grid-cols-7">
<div className="sm:col-span-4">
<div className="flex items-center gap-3">
{resumeInfo.title}
<p className="truncate">{resumeInfo.title}</p>
<p
className={clsx(
'w-auto items-center space-x-4 rounded-xl border border-slate-300 px-2 py-1 text-xs font-medium text-white opacity-60',
resumeInfo.isResolved ? 'bg-slate-400' : 'bg-success-500',
'w-auto items-center space-x-4 rounded-xl border px-2 py-1 text-xs font-medium',
resumeInfo.isResolved ? 'bg-slate-300' : 'bg-success-100',
resumeInfo.isResolved ? 'text-slate-600' : 'text-success-700',
)}>
<span className="opacity-100">
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</span>
{resumeInfo.isResolved ? 'Reviewed' : 'Unreviewed'}
</p>
</div>
<div className="text-primary-500 mt-2 flex items-center justify-start text-xs">
@ -64,17 +53,14 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(ROLES, resumeInfo.role as RoleFilter)}
{getFilterLabel('role', resumeInfo.role)}
</div>
<div className="ml-4 flex">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{getFilterLabel(
EXPERIENCES,
resumeInfo.experience as ExperienceFilter,
)}
{getFilterLabel('experience', resumeInfo.experience)}
</div>
</div>
<div className="mt-4 flex justify-start text-xs text-slate-500">
@ -102,9 +88,7 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
addSuffix: true,
})} by ${resumeInfo.user}`}
</div>
<div className="mt-2 text-slate-400">
{getFilterLabel(LOCATIONS, resumeInfo.location as LocationFilter)}
</div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div>
</div>
<ChevronRightIcon className="col-span-1 w-8 self-center justify-self-center text-slate-400" />

@ -1,7 +1,6 @@
import clsx from 'clsx';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
import { ChevronUpIcon } from '@heroicons/react/20/solid';
import { FaceSmileIcon } from '@heroicons/react/24/outline';
import ResumeCommentEditForm from './comment/ResumeCommentEditForm';
@ -12,10 +11,10 @@ import ResumeExpandableText from '../shared/ResumeExpandableText';
import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentListItemProps = {
type ResumeCommentListItemProps = Readonly<{
comment: ResumeComment;
userId: string | undefined;
};
}>;
export default function ResumeCommentListItem({
comment,
@ -28,14 +27,14 @@ export default function ResumeCommentListItem({
return (
<div className="min-w-fit">
<div className="flex flex-row space-x-2 p-1 align-top">
<div className="flex flex-row space-x-3 align-top">
{/* Image Icon */}
{comment.user.image ? (
<img
alt={comment.user.name ?? 'Reviewer'}
className={clsx(
'mt-1 rounded-full',
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
comment.parentId ? 'h-8 w-8' : 'h-10 w-10',
)}
src={comment.user.image!}
/>
@ -50,24 +49,18 @@ export default function ResumeCommentListItem({
<div className="flex w-full flex-col space-y-1">
{/* Name and creation time */}
<div className="flex flex-row justify-between">
<div className="flex flex-row items-center space-x-1">
<p
className={clsx(
'font-medium text-gray-800',
!!comment.parentId && 'text-sm',
)}>
{comment.user.name ?? 'Reviewer ABC'}
</p>
<p className="text-primary-800 text-xs font-medium">
{isCommentOwner ? '(Me)' : ''}
</p>
<ResumeUserBadges userId={comment.user.userId} />
</div>
<div className="px-2 text-xs text-slate-600">
<div className="flex flex-row items-center space-x-2">
<p className="text-sm font-medium text-slate-900">
{comment.user.name ?? 'Reviewer ABC'}
</p>
{isCommentOwner && (
<span className="bg-primary-100 text-primary-800 rounded-md py-0.5 px-1 text-xs">
Me
</span>
)}
<ResumeUserBadges userId={comment.user.userId} />
<span className="font-medium text-slate-500">&middot;</span>
<div className="text-xs text-slate-500">
{formatDistanceToNow(comment.createdAt, {
addSuffix: true,
})}
@ -81,7 +74,7 @@ export default function ResumeCommentListItem({
setIsEditingComment={setIsEditingComment}
/>
) : (
<div className="text-gray-800">
<div className="text-slate-800">
<ResumeExpandableText
key={comment.description}
text={comment.description}
@ -90,56 +83,25 @@ export default function ResumeCommentListItem({
)}
{/* Upvote and edit */}
<div className="flex flex-row space-x-1 pt-1 align-middle">
<div className="mt-1 flex h-6 items-center">
<ResumeCommentVoteButtons commentId={comment.id} userId={userId} />
{/* Action buttons; only present for authenticated user when not editing/replying */}
{userId && !isEditingComment && !isReplyingComment && (
<>
{isCommentOwner && (
<button
className="text-primary-800 hover:text-primary-400 px-1 text-xs"
type="button"
onClick={() => setIsEditingComment(true)}>
Edit
</button>
)}
{!comment.parentId && (
<button
className="text-primary-800 hover:text-primary-400 px-1 text-xs"
type="button"
onClick={() => setIsReplyingComment(true)}>
Reply
</button>
)}
</>
{userId && !comment.parentId && (
<button
className="-my-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-600"
type="button"
onClick={() => {
setIsReplyingComment(!isReplyingComment);
setIsEditingComment(false);
}}>
Reply
</button>
)}
</div>
{/* Reply Form */}
{isReplyingComment && (
<ResumeCommentReplyForm
parentId={comment.id}
resumeId={comment.resumeId}
section={comment.section}
setIsReplyingComment={setIsReplyingComment}
/>
)}
{/* Replies */}
{comment.children.length > 0 && (
<div className="min-w-fit space-y-1 pt-2">
{comment.children.length > 0 && (
<button
className="text-primary-800 hover:text-primary-300 flex items-center space-x-1 rounded-md text-xs font-medium"
className="-my-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-600"
type="button"
onClick={() => setShowReplies(!showReplies)}>
<ChevronUpIcon
className={clsx(
'h-5 w-5 ',
!showReplies && 'rotate-180 transform',
)}
/>
<span>
{showReplies
? `Hide ${
@ -150,26 +112,48 @@ export default function ResumeCommentListItem({
}`}
</span>
</button>
)}
{isCommentOwner && (
<button
className="-my-1 rounded-md px-2 py-1 text-xs font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-600"
type="button"
onClick={() => {
setIsEditingComment(!isEditingComment);
setIsReplyingComment(false);
}}>
Edit
</button>
)}
</div>
{showReplies && (
<div className="flex flex-row">
<div className="relative flex flex-col px-2 py-2">
<div className="flex-grow border-r border-slate-300" />
</div>
{/* Reply Form */}
{isReplyingComment && (
<div className="mt-2">
<ResumeCommentReplyForm
parentId={comment.id}
resumeId={comment.resumeId}
section={comment.section}
setIsReplyingComment={setIsReplyingComment}
/>
</div>
)}
<div className="flex flex-1 flex-col space-y-1">
{comment.children.map((child) => {
return (
<ResumeCommentListItem
key={child.id}
comment={child}
userId={userId}
/>
);
})}
</div>
{/* Replies */}
{comment.children.length > 0 && showReplies && (
<div className="min-w-fit space-y-1 pt-2">
<div className="flex flex-row border-l-2 border-slate-200 pl-2">
<div className="flex flex-1 flex-col space-y-1">
{comment.children.map((child) => {
return (
<ResumeCommentListItem
key={child.id}
comment={child}
userId={userId}
/>
);
})}
</div>
)}
</div>
</div>
)}
</div>

@ -115,10 +115,12 @@ export default function ResumeCommentsForm({
};
return (
<div className="h-[calc(100vh-13rem)] overflow-y-auto pb-4">
<h2 className="text-xl font-semibold text-slate-800">Add your review</h2>
<p className="text-slate-800">
Please fill in at least one section to submit your review
<div className="overflow-y-auto py-8 px-4">
<h2 className="text-xl font-medium text-slate-800">
Contribute a review
</h2>
<p className="mt-1 text-slate-600">
Please fill in at least one section to submit a review.
</p>
<form

@ -1,9 +1,7 @@
import clsx from 'clsx';
import { useSession } from 'next-auth/react';
import {
BookOpenIcon,
BriefcaseIcon,
ChatBubbleLeftRightIcon,
CodeBracketSquareIcon,
FaceSmileIcon,
IdentificationIcon,
@ -31,7 +29,7 @@ export default function ResumeCommentsList({
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }]);
const renderIcon = (section: ResumesSection) => {
const className = 'h-7 w-7';
const className = 'h-5 w-5';
switch (section) {
case ResumesSection.GENERAL:
return <IdentificationIcon className={className} />;
@ -57,7 +55,7 @@ export default function ResumeCommentsList({
}
return (
<div className="flow-root w-full flex-col space-y-10 overflow-y-auto overflow-x-hidden lg:h-[calc(100vh-13rem)]">
<div className="flow-root w-full space-y-4 overflow-y-auto overflow-x-hidden px-4 lg:py-8">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {
@ -67,22 +65,19 @@ export default function ResumeCommentsList({
const commentCount = comments.length;
return (
<div key={value} className="space-y-4 pr-4">
<div
key={value}
className="rounded-lg border border-slate-200 bg-white shadow-sm">
{/* CommentHeader Section */}
<div className="text-primary-800 flex items-center space-x-2">
<hr className="flex-grow border-slate-800" />
<div className="flex items-center space-x-2 border-b border-slate-200 px-4 py-3 font-medium text-slate-700">
{renderIcon(value)}
<span className="w-fit text-lg font-medium">{label}</span>
<hr className="flex-grow border-slate-800" />
<span className="w-fit text-sm font-medium uppercase tracking-wide">
{label}
</span>
</div>
{/* Comment Section */}
<div
className={clsx(
'space-y-2 rounded-md border-2 bg-white px-4 py-3 drop-shadow-md',
commentCount ? 'border-slate-300' : 'border-slate-300',
)}>
<div className="space-y-4 px-4 py-3">
{commentCount > 0 ? (
comments.map((comment) => {
return (
@ -95,10 +90,8 @@ export default function ResumeCommentsList({
})
) : (
<div className="flex flex-row items-center text-sm">
<ChatBubbleLeftRightIcon className="mr-2 h-6 w-6 text-slate-500" />
<div className="text-slate-500">
There are no comments for this section yet!
There are no comments for this section.
</div>
</div>
)}

@ -67,39 +67,35 @@ export default function ResumeCommentEditForm({
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex-column mt-1 space-y-2">
<TextArea
{...(register('description', {
required: 'Comments cannot be empty!',
}),
{})}
defaultValue={comment.description}
<form className="space-y-2" onSubmit={handleSubmit(onSubmit)}>
<TextArea
{...(register('description', {
required: 'Comments cannot be empty!',
}),
{})}
defaultValue={comment.description}
disabled={commentUpdateMutation.isLoading}
errorMessage={errors.description?.message}
label=""
placeholder="Leave your comment here"
onChange={setFormValue}
/>
<div className="flex w-full justify-end space-x-2">
<Button
disabled={commentUpdateMutation.isLoading}
errorMessage={errors.description?.message}
label=""
placeholder="Leave your comment here"
onChange={setFormValue}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentUpdateMutation.isLoading}
isLoading={commentUpdateMutation.isLoading}
label="Submit"
size="sm"
type="submit"
variant="primary"
/>
<div className="flex-row space-x-2">
<Button
disabled={commentUpdateMutation.isLoading}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentUpdateMutation.isLoading}
isLoading={commentUpdateMutation.isLoading}
label="Confirm"
size="sm"
type="submit"
variant="primary"
/>
</div>
</div>
</form>
);

@ -77,39 +77,37 @@ export default function ResumeCommentReplyForm({
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex-column space-y-2 pt-2">
<TextArea
{...(register('description', {
required: 'Reply cannot be empty!',
}),
{})}
defaultValue=""
<form className="space-y-2" onSubmit={handleSubmit(onSubmit)}>
<TextArea
{...(register('description', {
required: 'Reply cannot be empty!',
}),
{})}
autoFocus={true}
defaultValue=""
disabled={commentReplyMutation.isLoading}
errorMessage={errors.description?.message}
isLabelHidden={true}
label="Reply to comment"
placeholder="Type your reply here"
onChange={setFormValue}
/>
<div className="flex w-full justify-end space-x-2">
<Button
disabled={commentReplyMutation.isLoading}
errorMessage={errors.description?.message}
label=""
placeholder="Leave your reply here"
onChange={setFormValue}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentReplyMutation.isLoading}
isLoading={commentReplyMutation.isLoading}
label="Submit"
size="sm"
type="submit"
variant="primary"
/>
<div className="flex-row space-x-2">
<Button
disabled={commentReplyMutation.isLoading}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentReplyMutation.isLoading}
isLoading={commentReplyMutation.isLoading}
label="Confirm"
size="sm"
type="submit"
variant="primary"
/>
</div>
</div>
</form>
);

@ -1,10 +1,7 @@
import clsx from 'clsx';
import { useRouter } from 'next/router';
import { useState } from 'react';
import {
ArrowDownCircleIcon,
ArrowUpCircleIcon,
} from '@heroicons/react/20/solid';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid';
import { Vote } from '@prisma/client';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -92,8 +89,9 @@ export default function ResumeCommentVoteButtons({
};
return (
<>
<div className="flex items-center">
<button
className="-m-1 rounded-full p-1 hover:bg-slate-100"
disabled={
commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading ||
@ -101,27 +99,36 @@ export default function ResumeCommentVoteButtons({
}
type="button"
onClick={() => onVote(Vote.UPVOTE, setUpvoteAnimation)}>
<ArrowUpCircleIcon
<ChevronUpIcon
className={clsx(
'h-4 w-4',
'h-5 w-5',
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
upvoteAnimation
? 'fill-primary-500'
: 'fill-slate-400',
? 'text-primary-500'
: 'text-slate-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-primary-500',
'hover:text-primary-500',
upvoteAnimation && 'animate-[bounce_0.5s_infinite] cursor-default',
)}
/>
</button>
<div className="flex min-w-[1rem] justify-center text-xs font-semibold text-gray-700">
<div className="mx-1 flex min-w-[1rem] justify-center text-xs font-semibold text-gray-700">
{commentVotesQuery.data?.numVotes ?? 0}
</div>
<button
className={clsx(
'-m-1 rounded-full p-1 hover:bg-slate-100',
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
downvoteAnimation
? 'text-danger-500'
: 'text-slate-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:text-danger-500',
)}
disabled={
commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading ||
@ -129,22 +136,14 @@ export default function ResumeCommentVoteButtons({
}
type="button"
onClick={() => onVote(Vote.DOWNVOTE, setDownvoteAnimation)}>
<ArrowDownCircleIcon
<ChevronDownIcon
className={clsx(
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
downvoteAnimation
? 'fill-danger-500'
: 'fill-slate-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-danger-500',
'h-5 w-5',
downvoteAnimation &&
'animate-[bounce_0.5s_infinite] cursor-default',
)}
/>
</button>
</>
</div>
);
}

@ -1,25 +1,28 @@
import { Button } from '@tih/ui';
import { Container } from './Container';
export function CallToAction() {
return (
<section className="relative overflow-hidden py-32" id="get-started-today">
<Container className="relative">
<div className="mx-auto max-w-lg text-center">
<div className="mx-auto text-center">
<h2 className="font-display text-3xl tracking-tight text-slate-900 sm:text-4xl">
Resume review can start right now.
People are using it as we speak
</h2>
<p className="mt-4 text-lg tracking-tight text-slate-600">
It's free! Take charge of your resume game by learning from the top
engineers in the field.
Check out how Alwyn from Open Government Products uses the platform
to provide actionable feedback on a student's resume:
</p>
<Button
className="mt-4"
href="/resumes"
label="Start browsing now"
variant="primary"
/>
<div className="mt-10 flex justify-center">
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
frameBorder="0"
height="480"
src="https://www.youtube.com/embed/wVi5dhjDT8Y"
title="Resume Review with Alwyn from OGP"
width="853"
/>
</div>
</div>
</Container>
</section>

@ -1,4 +1,3 @@
import Link from 'next/link';
import { Button } from '@tih/ui';
import { Container } from './Container';
@ -26,19 +25,18 @@ export function Hero() {
</p>
<div className="mt-10 flex justify-center gap-x-4">
<Button href="/resumes" label="Start browsing now" variant="primary" />
{/* TODO: Update video */}
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
<button
className="focus-visible:outline-primary-600 group inline-flex items-center justify-center rounded-md py-2 px-4 text-sm ring-1 ring-slate-200 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600"
type="button">
<svg
aria-hidden="true"
className="fill-primary-600 h-3 w-3 flex-none group-active:fill-current">
<path d="m9.997 6.91-7.583 3.447A1 1 0 0 1 1 9.447V2.553a1 1 0 0 1 1.414-.91L9.997 5.09c.782.355.782 1.465 0 1.82Z" />
</svg>
<span className="ml-3">Watch video</span>
</button>
</Link>
</div>
<div className="mt-10 flex justify-center">
<iframe
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
frameBorder="0"
height="480"
src="https://www.youtube.com/embed/7jNiW4extlI"
title="Resume Review Walkthrough"
width="853"
/>
</div>
</Container>
);

@ -84,7 +84,7 @@ export function PrimaryFeatures() {
: 'text-blue-100 hover:text-white lg:text-white',
)}>
<span className="absolute inset-0 rounded-full lg:rounded-r-none lg:rounded-l-xl" />
{feature.title}
<div className="font-bold">{feature.title}</div>
</Tab>
</h3>
<p

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 KiB

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 909 KiB

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 396 KiB

After

Width:  |  Height:  |  Size: 134 KiB

@ -25,21 +25,22 @@ export default function ResumeExpandableText({
};
return (
<div>
<div className="space-y-1">
<span
ref={ref}
className={clsx(
'line-clamp-3 whitespace-pre-wrap text-sm',
'line-clamp-3 whitespace-pre-wrap text-xs sm:text-sm',
isExpanded ? 'line-clamp-none' : '',
)}>
{text}
</span>
{descriptionOverflow && (
<p
className="text-primary-500 hover:text-primary-300 mt-1 cursor-pointer text-xs"
<button
className="text-primary-500 hover:text-primary-600 text-xs font-medium"
type="button"
onClick={onSeeActionClicked}>
{isExpanded ? 'See Less' : 'See More'}
</p>
</button>
)}
</div>
);

@ -0,0 +1,51 @@
import type { ComponentProps } from 'react';
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { EXPERIENCES } from '~/utils/resumes/resumeFilters';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,
| 'disabled'
| 'errorMessage'
| 'isLabelHidden'
| 'placeholder'
| 'required'
| 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
selectedValues?: Set<string>;
value?: TypeaheadOption | null;
}>;
export default function ResumeExperienceTypeahead({
onSelect,
selectedValues = new Set(),
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const options = EXPERIENCES.filter(
(option) => !selectedValues.has(option.value),
).filter(
({ label }) =>
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1,
);
return (
<Typeahead
label="Experiences"
noResultsMessage="No available experiences."
nullable={true}
options={options}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
{...props}
/>
);
}

@ -0,0 +1,66 @@
import type { ComponentProps } from 'react';
import { useMemo, useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,
| 'disabled'
| 'errorMessage'
| 'isLabelHidden'
| 'placeholder'
| 'required'
| 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
selectedValues?: Set<string>;
value?: TypeaheadOption | null;
}>;
export default function ResumeLocationTypeahead({
onSelect,
selectedValues = new Set(),
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const countries = trpc.useQuery([
'locations.countries.list',
{
name: query,
},
]);
const options = useMemo(() => {
const { data } = countries;
if (data == null) {
return [];
}
return data
.map(({ id, name }) => ({
id,
label: name,
value: id,
}))
.filter((option) => !selectedValues.has(option.value));
}, [countries, selectedValues]);
return (
<Typeahead
label="Location"
noResultsMessage="No location found"
nullable={true}
options={options}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
{...props}
/>
);
}

@ -0,0 +1,56 @@
import type { ComponentProps } from 'react';
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui';
import { JobTitleLabels } from '~/components/shared/JobTitles';
type BaseProps = Pick<
ComponentProps<typeof Typeahead>,
| 'disabled'
| 'errorMessage'
| 'isLabelHidden'
| 'placeholder'
| 'required'
| 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
selectedValues?: Set<string>;
value?: TypeaheadOption | null;
}>;
export default function ResumeRoleTypeahead({
onSelect,
selectedValues = new Set(),
value,
...props
}: Props) {
const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels)
.map(([slug, label]) => ({
id: slug,
label,
value: slug,
}))
.filter((option) => !selectedValues.has(option.value))
.filter(
({ label }) =>
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1,
);
return (
<Typeahead
label="Role"
noResultsMessage="No available roles."
nullable={true}
options={options}
value={value}
onQueryChange={setQuery}
onSelect={onSelect}
{...props}
/>
);
}

@ -1,27 +1,52 @@
export const JobTitleLabels = {
'ai-ml-engineer': 'AI/ML Engineer',
'ai-engineer': 'Artificial Intelligence (AI) Engineer',
'algorithms-engineer': 'Algorithms Engineer',
'android-engineer': 'Android Software Engineer',
'applications-engineer': 'Applications Engineer',
'back-end-engineer': 'Back End Engineer',
'business-analyst': 'Business Analyst',
'business-engineer': 'Business Engineer',
'capacity-engineer': 'Capacity Engineer',
'customer-engineer': 'Customer Engineer',
'data-analyst': 'Data Analyst',
'data-engineer': 'Data Engineer',
'data-scientist': 'Data Scientist',
'devops-engineer': 'DevOps Engineer',
'engineering-director': 'Engineering Director',
'engineering-manager': 'Engineering Manager',
'enterprise-engineer': 'Enterprise Engineer',
'forward-deployed-engineer': 'Forward Deployed Engineer',
'front-end-engineer': 'Front End Engineer',
'full-stack-engineer': 'Full Stack Engineer',
'gameplay-engineer': 'Gameplay Engineer',
'hardware-engineer': 'Hardware Engineer',
'infrastructure-engineer': 'Infrastructure Engineer',
'ios-engineer': 'iOS Software Engineer',
'machine-learning-engineer': 'Machine Learning (ML) Engineer',
'machine-learning-researcher': 'Machine Learning (ML) Researcher',
'mobile-engineer': 'Mobile Software Engineer (iOS + Android)',
'networks-engineer': 'Networks Engineer',
'partner-engineer': 'Partner Engineer',
'product-engineer': 'Product Engineer',
'product-manager': 'Product Manager',
'production-engineer': 'Production Engineer',
'project-manager': 'Project Manager',
'release-engineer': 'Release Engineer',
'research-engineer': 'Research Engineer',
'research-scientist': 'Research Scientist',
'rotational-engineer': 'Rotational Engineer',
'sales-engineer': 'Sales Engineer',
'security-engineer': 'Security Engineer',
'site-reliability-engineer': 'Site Reliability Engineer (SRE)',
'software-engineer': 'Software Engineer',
'solutions-architect': 'Solutions Architect',
'solutions-engineer': 'Solutions Engineer',
'systems-analyst': 'Systems Analyst',
'systems-engineer': 'Systems Engineer',
'tech-ops-engineer': 'Tech Ops Engineer',
'technical-program-manager': 'Technical Program Manager',
'test-engineer': 'QA/Test Engineer (SDET)',
'ux-engineer': 'User Experience (UX) Engineer',
};
export type JobTitleType = keyof typeof JobTitleLabels;

@ -934,4 +934,4 @@ const userProfileOfferDtoMapper = (
}
return mappedOffer;
};
};

@ -8,7 +8,7 @@ import {
UsersIcon,
} from '@heroicons/react/24/outline';
import { HOME_URL } from '~/components/offers/constants';
import { HOME_URL, OFFERS_SUBMIT_URL } from '~/components/offers/constants';
import offersAnalysis from '~/components/offers/features/images/offers-analysis.png';
import offersBrowse from '~/components/offers/features/images/offers-browse.png';
import offersProfile from '~/components/offers/features/images/offers-profile.png';
@ -126,6 +126,7 @@ export default function LandingPage() {
/>
<div className="relative">
<LeftTextCard
buttonLabel="View offers"
description="Filter relevant offers by job title, company, submission date, salary and more."
icon={
<TableCellsIcon
@ -133,27 +134,31 @@ export default function LandingPage() {
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageAlt="Browse page"
imageSrc={offersBrowse}
title="Stay informed of recent offers"
url={HOME_URL}
/>
</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."
buttonLabel="Analyse offers"
description="With our offer engine analysis, you can benchmark your offers against 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"
imageAlt="Offers analysis page"
imageSrc={offersAnalysis}
title="Better understand your offers"
url={OFFERS_SUBMIT_URL}
/>
</div>
<div className="mt-36">
<LeftTextCard
buttonLabel="View offer profiles"
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
@ -161,9 +166,10 @@ export default function LandingPage() {
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageAlt="Offer profile page"
imageSrc={offersProfile}
title="Choosing an offer needs context"
url={HOME_URL}
/>
</div>
</div>
@ -215,7 +221,7 @@ export default function LandingPage() {
<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}>
href={OFFERS_SUBMIT_URL}>
Get Started
</a>
</div>

@ -139,4 +139,4 @@ export default function OffersHomePage() {
</Container>
</main>
);
}
}

@ -3,7 +3,9 @@ import { useRouter } from 'next/router';
import { useState } from 'react';
import { JobType } from '@prisma/client';
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
import OffersSubmissionForm, {
DEFAULT_CURRENCY,
} from '~/components/offers/offersSubmission/OffersSubmissionForm';
import type { OffersProfileFormData } from '~/components/offers/types';
import { Spinner } from '~/../../../packages/ui/dist';
@ -44,9 +46,16 @@ export default function OffersEditPage() {
id: exp.id,
jobType: exp.jobType,
level: exp.level,
monthlySalary: exp.monthlySalary,
monthlySalary: {
currency: exp.monthlySalary?.currency || DEFAULT_CURRENCY,
value: exp.monthlySalary?.value,
},
title: exp.title,
totalCompensation: exp.totalCompensation,
totalCompensation: {
currency:
exp.totalCompensation?.currency || DEFAULT_CURRENCY,
value: exp.totalCompensation?.value,
},
})),
id,
specificYoes,

@ -140,4 +140,4 @@ export default function OffersSubmissionResult() {
</div>
</div>
);
}
}

@ -139,7 +139,7 @@ export default function QuestionPage() {
},
);
const { mutate: addEncounter } = trpc.useMutation(
const { mutateAsync: addEncounterAsync } = trpc.useMutation(
'questions.questions.encounters.user.create',
{
onSuccess: () => {
@ -208,8 +208,8 @@ export default function QuestionPage() {
year: 'numeric',
})}
upvoteCount={question.numVotes}
onReceivedSubmit={(data) => {
addEncounter({
onReceivedSubmit={async (data) => {
await addEncounterAsync({
cityId: data.cityId,
companyId: data.company,
countryId: data.countryId,
@ -221,7 +221,7 @@ export default function QuestionPage() {
}}
/>
<div className="mx-2">
<Collapsible label={`${question.numComments} comment(s)`}>
<Collapsible label={`View ${question.numComments} comment(s)`}>
<div className="mt-4 px-4">
<form
className="mb-2"
@ -246,7 +246,7 @@ export default function QuestionPage() {
</div>
</form>
{/* TODO: Add button to load more */}
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2 text-black">
<div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p>
<div className="flex items-end gap-2">

@ -6,6 +6,7 @@ import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { useToast } from '@tih/ui';
import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
@ -19,6 +20,7 @@ import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { QuestionAge } from '~/utils/questions/constants';
import { QUESTION_SORT_TYPES } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants';
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
@ -34,6 +36,30 @@ import type { Location } from '~/types/questions.d';
import { SortType } from '~/types/questions.d';
import { SortOrder } from '~/types/questions.d';
function sortOrderToString(value: SortOrder): string | null {
switch (value) {
case SortOrder.ASC:
return 'ASC';
case SortOrder.DESC:
return 'DESC';
default:
return null;
}
}
function sortTypeToString(value: SortType): string | null {
switch (value) {
case SortType.TOP:
return 'TOP';
case SortType.NEW:
return 'NEW';
case SortType.ENCOUNTERS:
return 'ENCOUNTERS';
default:
return null;
}
}
export default function QuestionsBrowsePage() {
const router = useRouter();
@ -88,15 +114,7 @@ export default function QuestionsBrowsePage() {
const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', {
defaultValue: SortOrder.DESC,
paramToString: (value) => {
if (value === SortOrder.ASC) {
return 'ASC';
}
if (value === SortOrder.DESC) {
return 'DESC';
}
return null;
},
paramToString: sortOrderToString,
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'ASC') {
@ -112,15 +130,7 @@ export default function QuestionsBrowsePage() {
const [sortType, setSortType, isSortTypeInitialized] =
useSearchParamSingle<SortType>('sortType', {
defaultValue: SortType.TOP,
paramToString: (value) => {
if (value === SortType.NEW) {
return 'NEW';
}
if (value === SortType.TOP) {
return 'TOP';
}
return null;
},
paramToString: sortTypeToString,
stringToParam: (param) => {
const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'NEW') {
@ -129,6 +139,9 @@ export default function QuestionsBrowsePage() {
if (uppercaseParam === 'TOP') {
return SortType.TOP;
}
if (uppercaseParam === 'ENCOUNTERS') {
return SortType.ENCOUNTERS;
}
return null;
},
});
@ -205,6 +218,11 @@ export default function QuestionsBrowsePage() {
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
showToast({
// Duration: 10000 (optional)
title: `Thank you for submitting your question!`,
variant: 'success',
});
},
},
);
@ -260,8 +278,8 @@ export default function QuestionsBrowsePage() {
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
sortOrder: sortOrder === SortOrder.ASC ? 'ASC' : 'DESC',
sortType: sortType === SortType.TOP ? 'TOP' : 'NEW',
sortOrder: sortOrderToString(sortOrder),
sortType: sortTypeToString(sortType),
},
});
@ -280,6 +298,8 @@ export default function QuestionsBrowsePage() {
sortType,
]);
const { showToast } = useToast();
const selectedCompanyOptions = useMemo(() => {
return selectedCompanySlugs.map((company) => {
const [id, label] = company.split('_');
@ -473,7 +493,7 @@ export default function QuestionsBrowsePage() {
<Head>
<title>Home - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<section className="min-h-0 flex-1 overflow-auto">
<div className="my-4 mx-auto flex max-w-3xl flex-col items-stretch justify-start gap-6">
@ -497,6 +517,7 @@ export default function QuestionsBrowsePage() {
<QuestionSearchBar
query={query}
sortOrderValue={sortOrder}
sortTypeOptions={QUESTION_SORT_TYPES}
sortTypeValue={sortType}
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);

@ -5,16 +5,21 @@ import {
EllipsisVerticalIcon,
NoSymbolIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Select } from '@tih/ui';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
import type { CreateListFormData } from '~/components/questions/CreateListDialog';
import CreateListDialog from '~/components/questions/CreateListDialog';
import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useCreateListAsync,
useDeleteListAsync,
} from '~/utils/questions/mutations';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc';
@ -22,24 +27,10 @@ import { trpc } from '~/utils/trpc';
export default function ListPage() {
const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const { mutateAsync: createList } = trpc.useMutation(
'questions.lists.create',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const { mutateAsync: deleteList } = trpc.useMutation(
'questions.lists.delete',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
const createListAsync = useCreateListAsync();
const deleteListAsync = useDeleteListAsync();
const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
@ -57,7 +48,7 @@ export default function ListPage() {
const [listIdToDelete, setListIdToDelete] = useState('');
const handleDeleteList = async (listId: string) => {
await deleteList({
await deleteListAsync({
id: listId,
});
setShowDeleteListDialog(false);
@ -68,7 +59,7 @@ export default function ListPage() {
};
const handleCreateList = async (data: CreateListFormData) => {
await createList({
await createListAsync({
name: data.name,
});
setShowCreateListDialog(false);
@ -92,7 +83,7 @@ export default function ListPage() {
selectedListIndex === index ? 'bg-primary-100' : ''
}`}>
<button
className="flex w-full flex-1 justify-between "
className="flex w-full flex-1 justify-between"
type="button"
onClick={() => {
setSelectedListIndex(index);
@ -145,36 +136,69 @@ export default function ListPage() {
</>
);
const createButton = (
<Button
icon={PlusIcon}
isLabelHidden={true}
label="Create"
size="md"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleAddClick();
}}
/>
);
return (
<>
<Head>
<title>My Lists - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<aside className="w-[300px] overflow-y-auto border-r bg-white py-4 lg:block">
<aside className="hidden w-[300px] overflow-y-auto border-r bg-white py-4 lg:block">
<div className="mb-2 flex items-center justify-between">
<h2 className="px-4 text-xl font-semibold">My Lists</h2>
<div className="px-4">
<Button
icon={PlusIcon}
isLabelHidden={true}
label="Create"
size="md"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
handleAddClick();
}}
/>
</div>
<div className="px-4">{createButton}</div>
</div>
{listOptions}
</aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="flex min-h-0 max-w-3xl flex-1 p-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
<div className="flex items-end gap-2 lg:hidden">
<div className="flex-1">
<Select
label="My Lists"
options={
lists?.map((list) => ({
label: list.name,
value: list.id,
})) ?? []
}
value={lists?.[selectedListIndex]?.id ?? ''}
onChange={(value) => {
setSelectedListIndex(
lists?.findIndex((list) => list.id === value) ?? 0,
);
}}
/>
</div>
<Button
icon={TrashIcon}
isLabelHidden={true}
label="Delete"
size="md"
variant="tertiary"
onClick={() => {
setShowDeleteListDialog(true);
setListIdToDelete(lists?.[selectedListIndex]?.id ?? '');
}}
/>
{createButton}
</div>
{lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4">
{lists[selectedListIndex].questionEntries.map(

@ -24,23 +24,17 @@ import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import loginPageHref from '~/components/shared/loginPageHref';
import type {
ExperienceFilter,
FilterOption,
LocationFilter,
RoleFilter,
} from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
getTypeaheadOption,
INITIAL_FILTER_STATE,
LOCATIONS,
ROLES,
} from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
import SubmitResumeForm from './submit';
import type { JobTitleType } from '../../components/shared/JobTitles';
import { getLabelForJobTitleType } from '../../components/shared/JobTitles';
export default function ResumeReviewPage() {
const ErrorPage = (
@ -124,29 +118,24 @@ export default function ResumeReviewPage() {
};
const onInfoTagClick = ({
locationLabel,
experienceLabel,
roleLabel,
locationName,
locationValue,
experienceValue,
roleValue,
}: {
experienceLabel?: string;
locationLabel?: string;
roleLabel?: string;
experienceValue?: string;
locationName?: string;
locationValue?: string;
roleValue?: string;
}) => {
const getFilterValue = (
label: string,
filterOptions: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter>
>,
) => filterOptions.find((option) => option.label === label)?.value;
router.push({
pathname: '/resumes',
query: {
currentPage: JSON.stringify(1),
isFiltersOpen: JSON.stringify({
experience: experienceLabel !== undefined,
location: locationLabel !== undefined,
role: roleLabel !== undefined,
experience: experienceValue !== undefined,
location: locationValue !== undefined,
role: roleValue !== undefined,
}),
searchValue: JSON.stringify(''),
shortcutSelected: JSON.stringify('all'),
@ -154,14 +143,16 @@ export default function ResumeReviewPage() {
tabsValue: JSON.stringify(BROWSE_TABS_VALUES.ALL),
userFilters: JSON.stringify({
...INITIAL_FILTER_STATE,
...(locationLabel && {
location: [getFilterValue(locationLabel, LOCATIONS)],
...(locationValue && {
location: [
getTypeaheadOption('location', locationValue, locationName),
],
}),
...(roleLabel && {
role: [getFilterValue(roleLabel, ROLES)],
...(roleValue && {
role: [getTypeaheadOption('role', roleValue)],
}),
...(experienceLabel && {
experience: [getFilterValue(experienceLabel, EXPERIENCES)],
...(experienceValue && {
experience: [getTypeaheadOption('experience', experienceValue)],
}),
}),
},
@ -180,10 +171,9 @@ export default function ResumeReviewPage() {
};
const renderReviewButton = () => {
if (session === null) {
if (session == null) {
return (
<Button
className="h-10 shadow-md"
display="block"
href={loginPageHref()}
label="Log in to join discussion"
@ -191,9 +181,9 @@ export default function ResumeReviewPage() {
/>
);
}
return (
<Button
className="h-10 shadow-md"
display="block"
label="Add your review"
variant="primary"
@ -208,9 +198,19 @@ export default function ResumeReviewPage() {
initFormDetails={{
additionalInfo: detailsQuery.data.additionalInfo ?? '',
experience: detailsQuery.data.experience,
location: detailsQuery.data.location,
location: {
id: detailsQuery.data.locationId,
label: detailsQuery.data.location.name,
value: detailsQuery.data.locationId,
},
resumeId: resumeId as string,
role: detailsQuery.data.role,
role: {
id: detailsQuery.data.role,
label: getLabelForJobTitleType(
detailsQuery.data.role as JobTitleType,
),
value: detailsQuery.data.role,
},
title: detailsQuery.data.title,
url: detailsQuery.data.url,
}}
@ -224,182 +224,194 @@ export default function ResumeReviewPage() {
return (
<>
{/* Has to strict quality check (===), don't change it to == */}
{(detailsQuery.isError || detailsQuery.data === null) && ErrorPage}
{detailsQuery.isLoading && (
<div className="w-full pt-4">
{' '}
<Spinner display="block" size="lg" />{' '}
<Spinner display="block" size="lg" />
</div>
)}
{detailsQuery.isFetched && detailsQuery.data && (
<>
<Head>
<title>{detailsQuery.data.title}</title>
<title>{`${detailsQuery.data.title} | Resume Review`}</title>
</Head>
<main className="h-full flex-1 space-y-2 py-4 px-8 xl:px-12 2xl:pr-16">
<div className="flex flex-wrap justify-between">
<h1 className="w-[60%] pr-2 text-2xl font-semibold leading-7 text-slate-900">
{detailsQuery.data.title}
</h1>
<div className="flex gap-3 xl:pr-4">
{userIsOwner && (
<>
<Button
addonPosition="start"
className="h-10 shadow-md"
icon={PencilSquareIcon}
label="Edit"
variant="tertiary"
onClick={onEditButtonClick}
/>
<main className="flex h-[calc(100vh-4rem)] w-full flex-col bg-white">
<div className="mx-auto w-full space-y-4 border-b border-slate-200 px-4 py-6 sm:px-6 lg:px-8">
<div className="justify-between gap-4 space-y-4 lg:flex lg:space-y-0">
<h1 className="pr-2 text-xl font-medium leading-7 text-slate-900">
{detailsQuery.data.title}
</h1>
<div className="flex gap-3">
{userIsOwner && (
<>
<div>
<Button
addonPosition="start"
icon={PencilSquareIcon}
label="Edit"
variant="tertiary"
onClick={onEditButtonClick}
/>
</div>
<div>
<button
className="isolate inline-flex items-center space-x-4 whitespace-nowrap rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 focus:ring-slate-600 disabled:hover:bg-white"
disabled={resolveMutation.isLoading}
type="button"
onClick={onResolveButtonClick}>
<div className="-ml-1 mr-2 h-5 w-5">
{resolveMutation.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<CheckCircleIcon
aria-hidden="true"
className={
isResumeResolved
? 'text-slate-500'
: 'text-success-600'
}
/>
)}
</div>
{isResumeResolved
? 'Reopen for review'
: 'Mark as reviewed'}
</button>
</div>
</>
)}
<div>
<button
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 focus:ring-slate-600 disabled:hover:bg-white"
disabled={resolveMutation.isLoading}
className="isolate inline-flex items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 focus:ring-slate-600 disabled:hover:bg-white"
disabled={
starMutation.isLoading || unstarMutation.isLoading
}
type="button"
onClick={onResolveButtonClick}>
onClick={onStarButtonClick}>
<div className="-ml-1 mr-2 h-5 w-5">
{resolveMutation.isLoading ? (
{starMutation.isLoading ||
unstarMutation.isLoading ||
detailsQuery.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<CheckCircleIcon
<StarIcon
aria-hidden="true"
className={
isResumeResolved
? 'text-slate-500'
: 'text-success-600'
}
className={clsx(
detailsQuery.data?.stars.length
? 'text-orange-400'
: 'text-slate-400',
)}
/>
)}
</div>
{isResumeResolved
? 'Reopen for review'
: 'Mark as reviewed'}
{detailsQuery.data?.stars.length ? 'Starred' : 'Star'}
<span className="relative -ml-px inline-flex">
{detailsQuery.data?._count.stars}
</span>
</button>
</>
)}
<button
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-md hover:bg-slate-50 focus:ring-slate-600 disabled:hover:bg-white"
disabled={starMutation.isLoading || unstarMutation.isLoading}
type="button"
onClick={onStarButtonClick}>
<div className="-ml-1 mr-2 h-5 w-5">
{starMutation.isLoading ||
unstarMutation.isLoading ||
detailsQuery.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<StarIcon
aria-hidden="true"
className={clsx(
detailsQuery.data?.stars.length
? 'text-orange-400'
: 'text-slate-400',
)}
/>
)}
</div>
{detailsQuery.data?.stars.length ? 'Starred' : 'Star'}
<span className="relative -ml-px inline-flex">
{detailsQuery.data?._count.stars}
</span>
</button>
<div className="hidden xl:block">{renderReviewButton()}</div>
</div>
</div>
<div className="flex flex-col lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8">
<div className="mt-2 flex items-center text-sm text-slate-600 xl:mt-1">
<BriefcaseIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
roleLabel: detailsQuery.data?.role,
})
}>
{getFilterLabel(ROLES, detailsQuery.data.role as RoleFilter)}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
locationLabel: detailsQuery.data?.location,
})
}>
{getFilterLabel(
LOCATIONS,
detailsQuery.data.location as LocationFilter,
)}
</button>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
experienceLabel: detailsQuery.data?.experience,
})
}>
{getFilterLabel(
EXPERIENCES,
detailsQuery.data.experience as ExperienceFilter,
)}
</button>
<div className="hidden xl:block">{renderReviewButton()}</div>
</div>
</div>
<div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<CalendarIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{`Uploaded ${formatDistanceToNow(detailsQuery.data.createdAt, {
addSuffix: true,
})} by ${detailsQuery.data.user.name}`}
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2 lg:flex lg:flex-wrap lg:space-x-8">
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
<BriefcaseIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
roleValue: detailsQuery.data?.role,
})
}>
{getFilterLabel('role', detailsQuery.data.role)}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
locationName: detailsQuery.data?.location.name,
locationValue: detailsQuery.data?.locationId,
})
}>
{detailsQuery.data?.location.name}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<button
className="hover:text-primary-800 underline"
type="button"
onClick={() =>
onInfoTagClick({
experienceValue: detailsQuery.data?.experience,
})
}>
{getFilterLabel(
'experience',
detailsQuery.data.experience,
)}
</button>
</div>
<div className="col-span-1 flex items-center text-xs text-slate-600 sm:text-sm">
<CalendarIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
{`Uploaded ${formatDistanceToNow(
detailsQuery.data.createdAt,
{
addSuffix: true,
},
)} by ${detailsQuery.data.user.name}`}
</div>
</div>
{detailsQuery.data.additionalInfo && (
<div className="col-span-2 flex items-start whitespace-pre-wrap text-slate-600 xl:pt-1">
<InformationCircleIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<ResumeExpandableText
key={detailsQuery.data.additionalInfo}
text={detailsQuery.data.additionalInfo}
/>
</div>
)}
</div>
</div>
{detailsQuery.data.additionalInfo && (
<div className="flex items-start whitespace-pre-wrap pt-2 text-sm text-slate-600 xl:pt-1">
<InformationCircleIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/>
<ResumeExpandableText
key={detailsQuery.data.additionalInfo}
text={detailsQuery.data.additionalInfo}
/>
</div>
)}
<div className="flex w-full flex-col gap-6 py-4 xl:flex-row xl:py-0">
<div className="w-full xl:w-1/2">
<div className="flex w-full shrink-0 grow flex-col divide-x divide-slate-200 overflow-hidden lg:h-0 lg:flex-row xl:py-0">
<div className="w-full bg-slate-100 lg:h-full lg:w-1/2">
<ResumePdf url={detailsQuery.data.url} />
</div>
<div className="grow">
<div className="mb-6 space-y-4 xl:hidden">
{renderReviewButton()}
<div className="flex items-center space-x-2">
<hr className="flex-grow border-slate-300" />
<span className="bg-slate-50 px-3 text-lg font-medium text-slate-900">
Reviews
</span>
<hr className="flex-grow border-slate-300" />
<div className="grow overflow-y-auto border-t border-slate-200 bg-slate-50 pb-4 lg:h-full lg:border-t-0 lg:pb-0">
<div className="divide-y divide-slate-200 lg:hidden">
<div className="bg-white p-4 lg:p-0">
{renderReviewButton()}
</div>
{!showCommentsForm && (
<div className="p-4 lg:p-0">
<h2 className="text-xl font-medium text-slate-900">
Reviews
</h2>
</div>
)}
</div>
{showCommentsForm ? (
<ResumeCommentsForm
resumeId={resumeId as string}

@ -9,6 +9,7 @@ import {
NewspaperIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { TypeaheadOption } from '@tih/ui';
import {
Button,
CheckboxInput,
@ -23,23 +24,18 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeExperienceTypeahead from '~/components/resumes/shared/ResumeExperienceTypeahead';
import ResumeLocationTypeahead from '~/components/resumes/shared/ResumeLocationTypeahead';
import ResumeRoleTypeahead from '~/components/resumes/shared/ResumeRoleTypeahead';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import loginPageHref from '~/components/shared/loginPageHref';
import type {
Filter,
FilterId,
FilterLabel,
Shortcut,
} from '~/utils/resumes/resumeFilters';
import type { Filter, FilterId, Shortcut } from '~/utils/resumes/resumeFilters';
import type { SortOrder } from '~/utils/resumes/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel,
INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATIONS,
ROLES,
SHORTCUTS,
SORT_OPTIONS,
} from '~/utils/resumes/resumeFilters';
@ -47,8 +43,6 @@ import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800;
const PAGE_LIMIT = 10;
@ -56,17 +50,14 @@ const filters: Array<Filter> = [
{
id: 'role',
label: 'Role',
options: ROLES,
},
{
id: 'experience',
label: 'Experience',
options: EXPERIENCES,
},
{
id: 'location',
label: 'Location',
options: LOCATIONS,
},
];
@ -81,20 +72,14 @@ const getLoggedOutText = (tabsValue: string) => {
}
};
const getEmptyDataText = (
tabsValue: string,
searchValue: string,
userFilters: FilterState,
) => {
const getEmptyDataText = (tabsValue: string, searchValue: string) => {
if (searchValue.length > 0) {
return 'Try tweaking your search text to see more resumes.';
}
if (!isInitialFilterState(userFilters)) {
return 'Try tweaking your filters to see more resumes.';
}
switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL:
return "There's nothing to see here...";
return 'Oops, there is no resumes to see here. Maybe try tweaking your filters to see more.';
case BROWSE_TABS_VALUES.STARRED:
return 'You have not starred any resumes. Star one to see it here!';
case BROWSE_TABS_VALUES.MY:
@ -200,10 +185,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.findAll',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -219,10 +204,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.user.findUserStarred',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -239,10 +224,10 @@ export default function ResumeHomePage() {
[
'resumes.resume.user.findUserCreated',
{
experienceFilters: userFilters.experience,
experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location,
roleFilters: userFilters.role,
locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
@ -264,31 +249,6 @@ export default function ResumeHomePage() {
}
};
const onFilterCheckboxChange = (
isChecked: boolean,
filterSection: FilterId,
filterValue: string,
) => {
if (isChecked) {
setUserFilters({
...userFilters,
[filterSection]: [...userFilters[filterSection], filterValue],
});
} else {
setUserFilters({
...userFilters,
[filterSection]: userFilters[filterSection].filter(
(value) => value !== filterValue,
),
});
}
gaEvent({
action: 'resumes.filter_checkbox_click',
category: 'engagement',
label: 'Select Filter',
});
};
const onClearFilterClick = (filterSection: FilterId) => {
setUserFilters({
...userFilters,
@ -354,12 +314,71 @@ export default function ResumeHomePage() {
return getTabQueryData()?.filterCounts;
};
const getFilterCount = (filter: FilterLabel, value: string) => {
const getFilterTypeahead = (filterId: FilterId) => {
const onSelect = (option: TypeaheadOption | null) => {
if (option === null) {
return;
}
setUserFilters({
...userFilters,
[filterId]: [...userFilters[filterId], option],
});
gaEvent({
action: 'resumes.filter_typeahead_click',
category: 'engagement',
label: 'Select Filter',
});
};
switch (filterId) {
case 'experience':
return (
<ResumeExperienceTypeahead
isLabelHidden={true}
placeholder="Select experiences"
selectedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
onSelect={onSelect}
/>
);
case 'location':
return (
<ResumeLocationTypeahead
isLabelHidden={true}
placeholder="Select locations"
selectedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
onSelect={onSelect}
/>
);
case 'role':
return (
<ResumeRoleTypeahead
isLabelHidden={true}
placeholder="Select roles"
selectedValues={
new Set(userFilters[filterId].map(({ value }) => value))
}
onSelect={onSelect}
/>
);
default:
return null;
}
};
const getFilterCount = (filterId: FilterId, value: string) => {
const filterCountsData = getTabFilterCounts();
if (!filterCountsData) {
if (
filterCountsData === undefined ||
filterCountsData[filterId] === undefined ||
filterCountsData[filterId][value] === undefined
) {
return 0;
}
return filterCountsData[filter][value];
return filterCountsData[filterId][value];
};
return (
@ -461,29 +480,28 @@ export default function ResumeHomePage() {
</h3>
<Disclosure.Panel className="space-y-4 pt-6">
<div className="space-y-3">
{filter.options.map((option) => (
{getFilterTypeahead(filter.id)}
{userFilters[filter.id].map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
className="flex items-center px-1 text-sm">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
value={true}
onChange={() =>
setUserFilters({
...userFilters,
[filter.id]: userFilters[
filter.id
].filter(
({ value }) =>
value !== option.value,
),
})
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(
filter.label,
option.label,
)}
({getFilterCount(filter.id, option.value)}
)
</span>
</div>
@ -570,32 +588,32 @@ export default function ResumeHomePage() {
</Disclosure.Button>
</h3>
<Disclosure.Panel className="space-y-4 pt-4">
{getFilterTypeahead(filter.id)}
<CheckboxList
description=""
isLabelHidden={true}
label=""
orientation="vertical">
{filter.options.map((option) => (
{userFilters[filter.id].map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 flex items-center px-1 text-sm [&>div>div:nth-child(2)>label]:font-normal">
className="flex items-center px-1 text-sm">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
value={true}
onChange={() =>
setUserFilters({
...userFilters,
[filter.id]: userFilters[
filter.id
].filter(
({ value }) => value !== option.value,
),
})
}
/>
<span className="ml-1 text-slate-500">
(
{getFilterCount(filter.label, option.label)}
)
({getFilterCount(filter.id, option.value)})
</span>
</div>
))}
@ -614,7 +632,7 @@ export default function ResumeHomePage() {
</div>
</div>
<div className="relative lg:left-64 lg:w-[calc(100%-16rem)]">
<div className="lg:border-grey-200 sticky top-16 z-10 flex flex-wrap items-center justify-between bg-slate-50 pt-6 pb-2 lg:border-b">
<div className="lg:border-grey-200 z-1 sticky top-16 flex flex-wrap items-center justify-between bg-slate-50 pt-6 pb-2 lg:border-b">
<div className="border-grey-200 mb-4 flex w-full justify-between border-b pb-2 lg:mb-0 lg:w-auto lg:border-none xl:pb-0">
<div>
<Tabs
@ -660,7 +678,7 @@ export default function ResumeHomePage() {
</div>
<DropdownMenu
align="end"
label={getFilterLabel(SORT_OPTIONS, sortOrder)}>
label={getFilterLabel('sort', sortOrder)}>
{SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item
key={value}
@ -702,7 +720,7 @@ export default function ResumeHomePage() {
height={196}
width={196}
/>
{getEmptyDataText(tabsValue, searchValue, userFilters)}
{getEmptyDataText(tabsValue, searchValue)}
</div>
) : (
<div>

@ -7,8 +7,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { ArrowUpCircleIcon } from '@heroicons/react/24/outline';
import type { TypeaheadOption } from '@tih/ui';
import {
Button,
CheckboxInput,
@ -20,12 +21,14 @@ import {
} from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeLocationTypeahead from '~/components/resumes/shared/ResumeLocationTypeahead';
import ResumeRoleTypeahead from '~/components/resumes/shared/ResumeRoleTypeahead';
import ResumeSubmissionGuidelines from '~/components/resumes/submit-form/ResumeSubmissionGuidelines';
import Container from '~/components/shared/Container';
import loginPageHref from '~/components/shared/loginPageHref';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { EXPERIENCES } from '~/utils/resumes/resumeFilters';
import { trpc } from '~/utils/trpc';
const FILE_SIZE_LIMIT_MB = 3;
@ -41,19 +44,20 @@ type IFormInput = {
experience: string;
file: File;
isChecked: boolean;
location: string;
role: string;
location: TypeaheadOption;
role: TypeaheadOption;
title: string;
};
type InputKeys = keyof IFormInput;
type TypeAheadKeys = keyof Pick<IFormInput, 'location' | 'role'>;
type InitFormDetails = {
additionalInfo?: string;
experience: string;
location: string;
location: TypeaheadOption;
resumeId: string;
role: string;
role: TypeaheadOption;
title: string;
url: string;
};
@ -85,6 +89,7 @@ export default function SubmitResumeForm({
register,
handleSubmit,
setValue,
control,
reset,
watch,
clearErrors,
@ -94,8 +99,6 @@ export default function SubmitResumeForm({
additionalInfo: '',
experience: '',
isChecked: false,
location: '',
role: '',
title: '',
...initFormDetails,
},
@ -136,6 +139,11 @@ export default function SubmitResumeForm({
}, [router, status]);
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
if (!isDirty) {
onClose();
return;
}
setIsLoading(true);
let fileUrl = initFormDetails?.url ?? '';
@ -158,8 +166,8 @@ export default function SubmitResumeForm({
additionalInfo: data.additionalInfo,
experience: data.experience,
id: initFormDetails?.resumeId,
location: data.location,
role: data.role,
locationId: data.location.value,
role: data.role.value,
title: data.title,
url: fileUrl,
},
@ -235,6 +243,13 @@ export default function SubmitResumeForm({
setValue(section, value.trim(), { shouldDirty: true });
};
const onSelect = (section: TypeAheadKeys, option: TypeaheadOption | null) => {
if (option == null) {
return;
}
setValue(section, option, { shouldDirty: true });
};
return (
<>
<Head>
@ -299,35 +314,42 @@ export default function SubmitResumeForm({
required={true}
onChange={(val) => onValueChange('title', val)}
/>
<div className="flex flex-wrap gap-6">
<Select
{...register('role', { required: true })}
defaultValue={undefined}
disabled={isLoading}
label="Role"
options={ROLES}
placeholder=" "
required={true}
onChange={(val) => onValueChange('role', val)}
/>
<Select
{...register('experience', { required: true })}
disabled={isLoading}
label="Experience Level"
options={EXPERIENCES}
placeholder=" "
required={true}
onChange={(val) => onValueChange('experience', val)}
/>
</div>
<Controller
control={control}
name="location"
render={({ field: { value } }) => (
<ResumeLocationTypeahead
disabled={isLoading}
placeholder="Select a location"
required={true}
value={value}
onSelect={(option) => onSelect('location', option)}
/>
)}
rules={{ required: true }}
/>
<Controller
control={control}
name="role"
render={({ field: { value } }) => (
<ResumeRoleTypeahead
disabled={isLoading}
placeholder="Select a role"
required={true}
value={value}
onSelect={(option) => onSelect('role', option)}
/>
)}
rules={{ required: true }}
/>
<Select
{...register('location', { required: true })}
{...register('experience', { required: true })}
disabled={isLoading}
label="Location"
options={LOCATIONS}
label="Experience Level"
options={EXPERIENCES}
placeholder=" "
required={true}
onChange={(val) => onValueChange('location', val)}
onChange={(val) => onValueChange('experience', val)}
/>
{/* Upload resume form */}
{isNewForm && (

@ -37,4 +37,4 @@ export const offersAnalysisRouter = createRouter()
async resolve({ ctx, input }) {
return generateAnalysis({ ctx, input });
},
});
});

@ -2218,4 +2218,4 @@ export const offersProfileRouter = createRouter()
message: 'Invalid token.',
});
},
});
});

@ -138,4 +138,4 @@ export const offersUserProfileRouter = createProtectedRouter()
},
});
},
});
});

@ -394,7 +394,7 @@ export const offersRouter = createRouter().query('list', {
numOfPages: Math.ceil(data.length / input.limit),
totalItems: data.length,
},
!yoeRange ? JobType.INTERN : JobType.FULLTIME
!yoeRange ? JobType.INTERN : JobType.FULLTIME,
);
},
});
});

@ -1,8 +1,6 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { createRouter } from '../context';
import type { Resume } from '~/types/resume';
@ -35,7 +33,7 @@ export const resumesRouter = createRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -49,6 +47,11 @@ export const resumesRouter = createRouter()
},
},
comments: true,
location: {
select: {
name: true,
},
},
stars: {
where: {
OR: {
@ -79,7 +82,7 @@ export const resumesRouter = createRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -92,7 +95,8 @@ export const resumesRouter = createRouter()
id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
location: r.location.name,
locationId: r.locationId,
numComments: r._count.comments,
numStars: r._count.stars,
role: r.role,
@ -103,7 +107,7 @@ export const resumesRouter = createRouter()
return resume;
});
// Group by role and count, taking into account all role/experience/location/isUnreviewed filters and search value
// Group by role and count, taking into account all role/experience/locationId/isUnreviewed filters and search value
const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
@ -112,7 +116,7 @@ export const resumesRouter = createRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
});
@ -122,20 +126,6 @@ export const resumesRouter = createRouter()
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
// Filter out roles with zero counts and map to object where key = role and value = 0
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
// Combine to form singular role counts object
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
@ -143,7 +133,7 @@ export const resumesRouter = createRouter()
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -151,21 +141,12 @@ export const resumesRouter = createRouter()
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
@ -174,23 +155,13 @@ export const resumesRouter = createRouter()
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
return {
@ -217,6 +188,11 @@ export const resumesRouter = createRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
stars: {
where: {
OR: {

@ -1,19 +1,16 @@
import { z } from 'zod';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { createProtectedRouter } from '../context';
import type { Resume } from '~/types/resume';
export const resumesResumeUserRouter = createProtectedRouter()
.mutation('upsert', {
// TODO: Use enums for experience, location, role
input: z.object({
additionalInfo: z.string().optional(),
experience: z.string(),
id: z.string().optional(),
location: z.string(),
locationId: z.string(),
role: z.string(),
title: z.string(),
url: z.string(),
@ -25,7 +22,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
create: {
additionalInfo: input.additionalInfo,
experience: input.experience,
location: input.location,
locationId: input.locationId,
role: input.role,
title: input.title,
url: input.url,
@ -34,7 +31,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
update: {
additionalInfo: input.additionalInfo,
experience: input.experience,
location: input.location,
locationId: input.locationId,
role: input.role,
title: input.title,
url: input.url,
@ -91,7 +88,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
resume: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -108,6 +105,11 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
user: {
select: {
name: true,
@ -144,7 +146,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
resume: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
},
@ -160,7 +162,8 @@ export const resumesResumeUserRouter = createProtectedRouter()
id: rs.resume.id,
isResolved: rs.resume.isResolved,
isStarredByUser: true,
location: rs.resume.location,
location: rs.resume.location.name,
locationId: rs.resume.locationId,
numComments: rs.resume._count.comments,
numStars: rs.resume._count.stars,
role: rs.resume.role,
@ -179,7 +182,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
stars: {
some: {
userId,
@ -191,16 +194,6 @@ export const resumesResumeUserRouter = createProtectedRouter()
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
@ -209,7 +202,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
stars: {
some: {
@ -222,21 +215,12 @@ export const resumesResumeUserRouter = createProtectedRouter()
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
@ -250,23 +234,13 @@ export const resumesResumeUserRouter = createProtectedRouter()
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
return { filterCounts, mappedResumeData, totalRecords };
@ -299,7 +273,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
@ -313,6 +287,11 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
location: {
select: {
name: true,
},
},
stars: {
where: {
userId,
@ -341,7 +320,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
@ -355,7 +334,8 @@ export const resumesResumeUserRouter = createProtectedRouter()
id: r.id,
isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0,
location: r.location,
location: r.location.name,
locationId: r.locationId,
numComments: r._count.comments,
numStars: r._count.stars,
role: r.role,
@ -374,7 +354,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
},
@ -382,16 +362,6 @@ export const resumesResumeUserRouter = createProtectedRouter()
const mappedRoleCounts = Object.fromEntries(
roleCounts.map((rc) => [rc.role, rc._count._all]),
);
const zeroRoleCounts = Object.fromEntries(
ROLES.filter((r) => !(r.value in mappedRoleCounts)).map((r) => [
r.value,
0,
]),
);
const processedRoleCounts = {
...mappedRoleCounts,
...zeroRoleCounts,
};
const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
@ -400,7 +370,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
by: ['experience'],
where: {
isResolved: isUnreviewed ? false : {},
location: { in: locationFilters },
locationId: { in: locationFilters },
role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId,
@ -409,21 +379,12 @@ export const resumesResumeUserRouter = createProtectedRouter()
const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]),
);
const zeroExperienceCounts = Object.fromEntries(
EXPERIENCES.filter((e) => !(e.value in mappedExperienceCounts)).map(
(e) => [e.value, 0],
),
);
const processedExperienceCounts = {
...mappedExperienceCounts,
...zeroExperienceCounts,
};
const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: {
_all: true,
},
by: ['location'],
by: ['locationId'],
where: {
experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {},
@ -433,23 +394,13 @@ export const resumesResumeUserRouter = createProtectedRouter()
},
});
const mappedLocationCounts = Object.fromEntries(
locationCounts.map((lc) => [lc.location, lc._count._all]),
locationCounts.map((lc) => [lc.locationId, lc._count._all]),
);
const zeroLocationCounts = Object.fromEntries(
LOCATIONS.filter((l) => !(l.value in mappedLocationCounts)).map((l) => [
l.value,
0,
]),
);
const processedLocationCounts = {
...mappedLocationCounts,
...zeroLocationCounts,
};
const filterCounts = {
Experience: processedExperienceCounts,
Location: processedLocationCounts,
Role: processedRoleCounts,
experience: mappedExperienceCounts,
location: mappedLocationCounts,
role: mappedRoleCounts,
};
return { filterCounts, mappedResumeData, totalRecords };

@ -225,4 +225,4 @@ export type Location = {
countryName: string;
stateId: string;
stateName: string;
};
};

@ -6,6 +6,7 @@ export type Resume = {
isResolved: boolean;
isStarredByUser: boolean;
location: string;
locationId: string;
numComments: number;
numStars: number;
role: string;

@ -251,6 +251,8 @@ export const generateAnalysis = async (params: {
},
});
const offerIds = offers.map((offer) => offer.id);
// COMPANY ANALYSIS
const companyMap = new Map<string, Offer>();
offers.forEach((offer) => {
@ -275,9 +277,9 @@ export const generateAnalysis = async (params: {
? 100
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// Get top offers (excluding user's offer)
// Get top offers (excluding user's offers)
similarCompanyOffers = similarCompanyOffers.filter(
(offer) => offer.id !== companyOffer.id,
(offer) => !offerIds.includes(offer.id),
);
const noOfSimilarCompanyOffers = similarCompanyOffers.length;
@ -311,9 +313,7 @@ export const generateAnalysis = async (params: {
? 100
: 100 - (100 * overallIndex) / (similarOffers.length - 1);
similarOffers = similarOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
);
similarOffers = similarOffers.filter((offer) => !offerIds.includes(offer.id));
const noOfSimilarOffers = similarOffers.length;
const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1);

@ -1,4 +1,5 @@
// API from https://github.com/fawazahmed0/currency-api#readme
import fetch from 'cross-fetch';
export const convert = async (
value: number,

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

@ -2,40 +2,6 @@ import { getMonth, getYear } from 'date-fns';
import type { MonthYear } from '~/components/shared/MonthYearPicker';
export function timeSinceNow(date: Date | number | string) {
const seconds = Math.floor(
new Date().getTime() / 1000 - new Date(date).getTime() / 1000,
);
let interval = seconds / 31536000;
if (interval > 1) {
const time: number = Math.floor(interval);
return time === 1 ? `${time} year` : `${time} years`;
}
interval = seconds / 2592000;
if (interval > 1) {
const time: number = Math.floor(interval);
return time === 1 ? `${time} month` : `${time} months`;
}
interval = seconds / 86400;
if (interval > 1) {
const time: number = Math.floor(interval);
return time === 1 ? `${time} day` : `${time} days`;
}
interval = seconds / 3600;
if (interval > 1) {
const time: number = Math.floor(interval);
return time === 1 ? `${time} hour` : `${time} hours`;
}
interval = seconds / 60;
if (interval > 1) {
const time: number = Math.floor(interval);
return time === 1 ? `${time} minute` : `${time} minutes`;
}
const time: number = Math.floor(interval);
return time === 1 ? `${time} second` : `${time} seconds`;
}
export function formatDate(value: Date | number | string) {
const date = new Date(value);
const month = date.toLocaleString('default', { month: 'short' });

@ -28,7 +28,7 @@ export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
},
{
id: 'SYSTEM_DESIGN',
label: 'Design',
label: 'System Design',
value: 'SYSTEM_DESIGN',
},
{
@ -85,6 +85,21 @@ export const SORT_TYPES = [
},
];
export const QUESTION_SORT_TYPES = [
{
label: 'New',
value: SortType.NEW,
},
{
label: 'Top',
value: SortType.TOP,
},
{
label: 'Encounters',
value: SortType.ENCOUNTERS,
},
];
export const SAMPLE_QUESTION = {
answerCount: 10,
commentCount: 10,

@ -0,0 +1,60 @@
import { trpc } from '../trpc';
export function useAddQuestionToListAsync() {
const utils = trpc.useContext();
const { mutateAsync: addQuestionToListAsync } = trpc.useMutation(
'questions.lists.createQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
return addQuestionToListAsync;
}
export function useRemoveQuestionFromListAsync() {
const utils = trpc.useContext();
const { mutateAsync: removeQuestionFromListAsync } = trpc.useMutation(
'questions.lists.deleteQuestionEntry',
{
// TODO: Add optimistic update
onSuccess: () => {
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
return removeQuestionFromListAsync;
}
export function useCreateListAsync() {
const utils = trpc.useContext();
const { mutateAsync: createListAsync } = trpc.useMutation(
'questions.lists.create',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
return createListAsync;
}
export function useDeleteListAsync() {
const utils = trpc.useContext();
const { mutateAsync: deleteListAsync } = trpc.useMutation(
'questions.lists.delete',
{
onSuccess: () => {
// TODO: Add optimistic update
utils.invalidateQueries(['questions.lists.getListsByUser']);
},
},
);
return deleteListAsync;
}

@ -1,9 +1,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback } from 'react';
import type { Vote } from '@prisma/client';
import type { InfiniteData } from 'react-query';
import { Vote } from '@prisma/client';
import { trpc } from '../trpc';
import type { Question } from '~/types/questions';
type UseVoteOptions = {
setDownVote: () => void;
setNoVote: () => void;
@ -46,12 +49,78 @@ type MutationKey = Parameters<typeof trpc.useMutation>[0];
type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
export const useQuestionVote = (id: string) => {
const utils = trpc.useContext();
return useVote(id, {
idKey: 'questionId',
invalidateKeys: [
'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionById',
// 'questions.questions.getQuestionById',
// 'questions.questions.getQuestionsByFilterAndContent',
],
onMutate: async (previousVote, currentVote) => {
const questionQueries = utils.queryClient.getQueriesData([
'questions.questions.getQuestionsByFilterAndContent',
]);
const getVoteValue = (vote: Vote | null) => {
if (vote === Vote.UPVOTE) {
return 1;
}
if (vote === Vote.DOWNVOTE) {
return -1;
}
return 0;
};
const voteValueChange =
getVoteValue(currentVote) - getVoteValue(previousVote);
for (const [key, query] of questionQueries) {
if (query === undefined) {
continue;
}
const { pages, ...restQuery } = query as InfiniteData<{
data: Array<Question>;
}>;
const newQuery = {
pages: pages.map(({ data, ...restPage }) => ({
data: data.map((question) => {
if (question.id === id) {
const { numVotes, ...restQuestion } = question;
return {
numVotes: numVotes + voteValueChange,
...restQuestion,
};
}
return question;
}),
...restPage,
})),
...restQuery,
};
utils.queryClient.setQueryData(key, newQuery);
}
const prevQuestion = utils.queryClient.getQueryData([
'questions.questions.getQuestionById',
{
id,
},
]) as Question;
const newQuestion = {
...prevQuestion,
numVotes: prevQuestion.numVotes + voteValueChange,
};
utils.queryClient.setQueryData(
['questions.questions.getQuestionById', { id }],
newQuestion,
);
},
query: 'questions.questions.user.getVote',
setDownVoteKey: 'questions.questions.user.setDownVote',
setNoVoteKey: 'questions.questions.user.setNoVote',
@ -63,8 +132,8 @@ export const useAnswerVote = (id: string) => {
return useVote(id, {
idKey: 'answerId',
invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById',
'questions.answers.getAnswers',
],
query: 'questions.answers.user.getVote',
setDownVoteKey: 'questions.answers.user.setDownVote',
@ -95,9 +164,17 @@ export const useAnswerCommentVote = (id: string) => {
});
};
type InvalidateFunction = (
previousVote: Vote | null,
currentVote: Vote | null,
) => Promise<void>;
type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
idKey: string;
invalidateKeys: Array<VoteQueryKey>;
invalidateKeys: Array<QueryKey>;
onMutate?: InvalidateFunction;
// Invalidate: Partial<Record<QueryKey, InvalidateFunction | null>>;
query: VoteQueryKey;
setDownVoteKey: MutationKey;
setNoVoteKey: MutationKey;
@ -116,6 +193,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const {
idKey,
invalidateKeys,
onMutate,
query,
setDownVoteKey,
setNoVoteKey,
@ -125,11 +203,16 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const onVoteUpdate = useCallback(() => {
// TODO: Optimise query invalidation
utils.invalidateQueries([query, { [idKey]: id } as any]);
// utils.invalidateQueries([query, { [idKey]: id } as any]);
for (const invalidateKey of invalidateKeys) {
utils.invalidateQueries([invalidateKey]);
utils.invalidateQueries(invalidateKey);
// If (invalidateFunction === null) {
// utils.invalidateQueries([invalidateKey as QueryKey]);
// } else {
// invalidateFunction(utils, previousVote, currentVote);
// }
}
}, [id, idKey, utils, query, invalidateKeys]);
}, [utils, invalidateKeys]);
const { data } = trpc.useQuery([
query,
@ -143,7 +226,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>(
setUpVoteKey,
{
onError: (err, variables, context) => {
onError: (_error, _variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
@ -154,6 +237,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any],
);
const currentData = {
...(vote as any),
vote: Vote.UPVOTE,
} as BackendVote;
utils.setQueryData(
[
query,
@ -161,9 +249,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id,
} as any,
],
vote as any,
currentData as any,
);
return { currentData: vote, previousData };
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
return { currentData, previousData };
},
onSettled: onVoteUpdate,
},
@ -171,7 +261,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>(
setDownVoteKey,
{
onError: (error, variables, context) => {
onError: (_error, _variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
@ -182,6 +272,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any],
);
const currentData = {
...vote,
vote: Vote.DOWNVOTE,
} as BackendVote;
utils.setQueryData(
[
query,
@ -189,9 +284,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id,
} as any,
],
vote,
currentData as any,
);
return { currentData: vote, previousData };
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
return { currentData, previousData };
},
onSettled: onVoteUpdate,
},
@ -200,23 +297,31 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>(
setNoVoteKey,
{
onError: (err, variables, context) => {
onError: (_error, _variables, context) => {
if (context !== undefined) {
utils.setQueryData([query], context.previousData);
}
},
onMutate: async (vote) => {
onMutate: async () => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]);
utils.setQueryData(
const previousData = utils.queryClient.getQueryData<BackendVote | null>(
[query, { [idKey]: id } as any],
);
const currentData: BackendVote | null = null;
utils.queryClient.setQueryData<BackendVote | null>(
[
query,
{
[idKey]: id,
} as any,
],
null as any,
currentData,
);
return { currentData: null, previousData: vote };
await onMutate?.(previousData?.vote ?? null, null);
return { currentData, previousData };
},
onSettled: onVoteUpdate,
},

@ -1,28 +1,14 @@
import type { TypeaheadOption } from '@tih/ui';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { JobTitleLabels } from '~/components/shared/JobTitles';
export type FilterId = 'experience' | 'location' | 'role';
export type FilterLabel = 'Experience' | 'Location' | 'Role';
export type CustomFilter = {
isUnreviewed: boolean;
};
export type RoleFilter =
| 'Android Engineer'
| 'Backend Engineer'
| 'DevOps Engineer'
| 'Frontend Engineer'
| 'Full-Stack Engineer'
| 'iOS Engineer';
export type ExperienceFilter =
| 'Entry Level (0 - 2 years)'
| 'Internship'
| 'Mid Level (3 - 5 years)'
| 'Senior Level (5+ years)';
export type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
export type FilterOption<T> = {
label: string;
value: T;
@ -30,11 +16,11 @@ export type FilterOption<T> = {
export type Filter = {
id: FilterId;
label: FilterLabel;
options: Array<FilterOption<FilterValue>>;
label: string;
};
export type FilterState = CustomFilter & Record<FilterId, Array<FilterValue>>;
export type FilterState = CustomFilter &
Record<FilterId, Array<TypeaheadOption>>;
export type SortOrder = 'latest' | 'mostComments' | 'popular';
@ -45,6 +31,31 @@ export type Shortcut = {
sortOrder: SortOrder;
};
export const getTypeaheadOption = (
filterId: FilterId,
filterValue: string,
locationName?: string,
) => {
switch (filterId) {
case 'experience':
return EXPERIENCES.find(({ value }) => value === filterValue);
case 'role':
return {
id: filterValue,
label: JobTitleLabels[filterValue as keyof typeof JobTitleLabels],
value: filterValue,
};
case 'location':
return {
id: filterValue,
label: locationName ?? '',
value: filterValue,
};
default:
break;
}
};
export const BROWSE_TABS_VALUES = {
ALL: 'all',
MY: 'my',
@ -57,45 +68,85 @@ export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
{ label: 'Most Comments', value: 'mostComments' },
];
export const ROLES: Array<FilterOption<RoleFilter>> = [
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{ label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ label: 'Backend Engineer', value: 'Backend Engineer' },
{ label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android Engineer' },
const INITIAL_ROLES_VALUES: Array<JobTitleType> = [
'software-engineer',
'back-end-engineer',
'front-end-engineer',
'full-stack-engineer',
'ios-engineer',
'android-engineer',
'data-engineer',
];
export const INITIAL_ROLES: Array<TypeaheadOption> = INITIAL_ROLES_VALUES.map(
(value) =>
getTypeaheadOption('role', value) ?? {
id: value,
label: value,
value,
},
);
export const EXPERIENCES: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Internship', value: 'Internship' },
export const EXPERIENCES: Array<TypeaheadOption> = [
{
id: 'internship',
label: 'Internship',
value: 'internship',
},
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
value: 'entry-level',
},
{
id: 'mid-level',
label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)',
value: 'mid-level',
},
{
id: 'senior-level',
label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)',
value: 'senior-level',
},
];
export const LOCATIONS: Array<FilterOption<LocationFilter>> = [
{ label: 'Singapore', value: 'Singapore' },
{ label: 'United States', value: 'United States' },
{ label: 'India', value: 'India' },
export const INITIAL_LOCATIONS: Array<TypeaheadOption> = [
{
id: '196',
label: 'Singapore',
value: '196',
},
{
id: '101',
label: 'India',
value: '101',
},
{
id: '231',
label: 'United States',
value: '231',
},
{
id: '230',
label: 'United Kingdom',
value: '230',
},
{
id: '102',
label: 'Indonesia',
value: '102',
},
{
id: '44',
label: 'China',
value: '44',
},
];
export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCES).map(({ value }) => value),
experience: EXPERIENCES,
isUnreviewed: true,
location: Object.values(LOCATIONS).map(({ value }) => value),
role: Object.values(ROLES).map(({ value }) => value),
location: INITIAL_LOCATIONS,
role: INITIAL_ROLES,
};
export const SHORTCUTS: Array<Shortcut> = [
@ -104,7 +155,7 @@ export const SHORTCUTS: Array<Shortcut> = [
...INITIAL_FILTER_STATE,
isUnreviewed: false,
},
name: 'All',
name: 'General',
sortOrder: 'latest',
},
{
@ -118,7 +169,13 @@ export const SHORTCUTS: Array<Shortcut> = [
{
filters: {
...INITIAL_FILTER_STATE,
experience: ['Entry Level (0 - 2 years)'],
experience: [
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)',
value: 'entry-level',
},
],
isUnreviewed: false,
},
name: 'Fresh Grad',
@ -136,26 +193,46 @@ export const SHORTCUTS: Array<Shortcut> = [
filters: {
...INITIAL_FILTER_STATE,
isUnreviewed: false,
location: ['United States'],
location: [
{
id: '231',
label: 'United States',
value: '231',
},
],
},
name: 'US Only',
sortOrder: 'latest',
},
];
export const isInitialFilterState = (filters: FilterState) =>
Object.keys(filters).every((filter) => {
if (!['experience', 'location', 'role'].includes(filter)) {
return true;
}
return INITIAL_FILTER_STATE[filter as FilterId].every((value) =>
filters[filter as FilterId].includes(value),
);
});
// We omit 'location' as its label should be fetched from the Country table.
export const getFilterLabel = (
filters: Array<
FilterOption<ExperienceFilter | LocationFilter | RoleFilter | SortOrder>
>,
filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder,
) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue;
filterId: Omit<FilterId | 'sort', 'location'>,
filterValue: SortOrder | string,
): string | undefined => {
if (filterId === 'location') {
return filterValue;
}
let filters: Array<TypeaheadOption> = [];
switch (filterId) {
case 'experience':
filters = EXPERIENCES;
break;
case 'role':
filters = Object.entries(JobTitleLabels).map(([slug, label]) => ({
id: slug,
label,
value: slug,
}));
break;
case 'sort':
return SORT_OPTIONS.find(({ value }) => value === filterValue)?.label;
default:
break;
}
return filters.find(({ value }) => value === filterValue)?.label;
};

@ -1,5 +1,6 @@
import clsx from 'clsx';
import Link from 'next/link';
import type { HTMLAttributeAnchorTarget } from 'react';
import type { UrlObject } from 'url';
import { Spinner } from '../';
@ -30,7 +31,9 @@ type Props = Readonly<{
isLoading?: boolean;
label: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
rel?: string;
size?: ButtonSize;
target?: HTMLAttributeAnchorTarget;
type?: ButtonType;
variant: ButtonVariant;
}>;
@ -115,6 +118,8 @@ export default function Button({
type = 'button',
variant,
onClick,
rel,
target,
}: Props) {
const iconSpacingClass = (() => {
if (!isLabelHidden && addonPosition === 'start') {
@ -166,6 +171,6 @@ export default function Button({
return (
// TODO: Allow passing in of Link component.
<Link href={href} {...commonProps} />
<Link href={href} rel={rel} target={target} {...commonProps} />
);
}

@ -4126,6 +4126,11 @@
"@webassemblyjs/wast-parser" "1.9.0"
"@xtuc/long" "4.2.2"
"@xmldom/xmldom@^0.8.2":
version "0.8.3"
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.3.tgz#beaf980612532aa9a3004aff7e428943aeaa0711"
integrity sha512-Lv2vySXypg4nfa51LY1nU8yDAGo/5YwF+EY/rUZgIbfvwVARcd67ttCM8SMsTeJy51YhHYavEq+FS6R0hW9PFQ==
"@xtuc/ieee754@^1.2.0":
version "1.2.0"
resolved "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz"
@ -4198,6 +4203,11 @@ address@^1.0.1, address@^1.1.2:
resolved "https://registry.npmjs.org/address/-/address-1.2.1.tgz"
integrity sha512-B+6bi5D34+fDYENiH5qOlA0cV2rAGKuWZ9LeyUUehbXy8e0VS9e498yO0Jeeh+iM+6KbfudHTFjXw2MmJD4QRA==
adler-32@~1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/adler-32/-/adler-32-1.3.1.tgz#1dbf0b36dda0012189a32b3679061932df1821e2"
integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==
aggregate-error@^3.0.0:
version "3.1.0"
resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz"
@ -4816,7 +4826,7 @@ better-opn@^2.1.1:
dependencies:
open "^7.0.3"
big-integer@^1.6.16, big-integer@^1.6.7:
big-integer@^1.6.16, big-integer@^1.6.17, big-integer@^1.6.7:
version "1.6.51"
resolved "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz"
integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==
@ -4836,6 +4846,14 @@ binary-extensions@^2.0.0:
resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
binary@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79"
integrity sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==
dependencies:
buffers "~0.1.1"
chainsaw "~0.1.0"
bindings@^1.5.0:
version "1.5.0"
resolved "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz"
@ -4848,6 +4866,11 @@ bluebird@^3.5.5:
resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
bluebird@~3.4.1:
version "3.4.7"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.4.7.tgz#f72d760be09b7f76d08ed8fae98b289a8d05fab3"
integrity sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9:
version "4.12.0"
resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz"
@ -5059,6 +5082,11 @@ buffer-from@^1.0.0:
resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
buffer-indexof-polyfill@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz#d2732135c5999c64b277fcf9b1abe3498254729c"
integrity sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==
buffer-xor@^1.0.3:
version "1.0.3"
resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz"
@ -5073,6 +5101,11 @@ buffer@^4.3.0:
ieee754 "^1.1.4"
isarray "^1.0.0"
buffers@~0.1.1:
version "0.1.1"
resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb"
integrity sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==
bufferutil@^4.0.1:
version "4.0.6"
resolved "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.6.tgz"
@ -5284,6 +5317,21 @@ ccount@^1.0.0:
resolved "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz"
integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==
cfb@~1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/cfb/-/cfb-1.2.2.tgz#94e687628c700e5155436dac05f74e08df23bc44"
integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==
dependencies:
adler-32 "~1.3.0"
crc-32 "~1.2.0"
chainsaw@~0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/chainsaw/-/chainsaw-0.1.0.tgz#5eab50b28afe58074d0d58291388828b5e5fbc98"
integrity sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==
dependencies:
traverse ">=0.3.0 <0.4"
chalk@2.4.1:
version "2.4.1"
resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz"
@ -5513,6 +5561,11 @@ clsx@^1.2.1:
resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
codepage@~1.15.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/codepage/-/codepage-1.15.0.tgz#2e00519024b39424ec66eeb3ec07227e692618ab"
integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==
collapse-white-space@^1.0.2:
version "1.0.6"
resolved "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz"
@ -5876,6 +5929,11 @@ cpy@^8.1.2:
p-filter "^2.1.0"
p-map "^3.0.0"
crc-32@~1.2.0, crc-32@~1.2.1:
version "1.2.2"
resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff"
integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==
create-ecdh@^4.0.0:
version "4.0.4"
resolved "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz"
@ -6149,6 +6207,11 @@ damerau-levenshtein@^1.0.8:
resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz"
integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==
data-uri-to-buffer@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz#b5db46aea50f6176428ac05b73be39a57701a64b"
integrity sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==
date-fns@^2.29.1, date-fns@^2.29.3:
version "2.29.3"
resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz"
@ -6545,6 +6608,13 @@ dotenv@^8.0.0:
resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz"
integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==
duplexer2@~0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
integrity sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==
dependencies:
readable-stream "^2.0.2"
duplexer3@^0.1.4:
version "0.1.5"
resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz"
@ -7725,11 +7795,24 @@ feed@^4.2.2:
dependencies:
xml-js "^1.6.11"
fetch-blob@^3.1.2, fetch-blob@^3.1.4:
version "3.2.0"
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9"
integrity sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==
dependencies:
node-domexception "^1.0.0"
web-streams-polyfill "^3.0.3"
fetch-retry@^5.0.2:
version "5.0.3"
resolved "https://registry.npmjs.org/fetch-retry/-/fetch-retry-5.0.3.tgz"
integrity sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw==
fflate@^0.7.3:
version "0.7.4"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.7.4.tgz#61587e5d958fdabb5a9368a302c25363f4f69f50"
integrity sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==
figgy-pudding@^3.5.1:
version "3.5.2"
resolved "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz"
@ -7951,6 +8034,13 @@ form-data@^4.0.0:
combined-stream "^1.0.8"
mime-types "^2.1.12"
formdata-polyfill@^4.0.10:
version "4.0.10"
resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423"
integrity sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==
dependencies:
fetch-blob "^3.1.2"
formidable@^2.0.1:
version "2.0.1"
resolved "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz"
@ -7966,6 +8056,11 @@ forwarded@0.2.0:
resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
frac@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b"
integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==
fraction.js@^4.2.0:
version "4.2.0"
resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz"
@ -8050,6 +8145,16 @@ fsevents@^2.1.2, fsevents@~2.3.2:
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
fstream@^1.0.12:
version "1.0.12"
resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
dependencies:
graceful-fs "^4.1.2"
inherits "~2.0.0"
mkdirp ">=0.5 0"
rimraf "2"
function-bind@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
@ -8347,7 +8452,7 @@ got@^9.6.0:
to-readable-stream "^1.0.0"
url-parse-lax "^3.0.0"
graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
version "4.2.10"
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz"
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
@ -8885,7 +8990,7 @@ inflight@^1.0.4:
once "^1.3.0"
wrappy "1"
inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3:
inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3:
version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@ -9783,6 +9888,11 @@ lines-and-columns@^1.1.6:
resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
listenercount@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937"
integrity sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==
load-json-file@^1.0.0:
version "1.1.0"
resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz"
@ -10377,7 +10487,7 @@ mixin-deep@^1.2.0:
for-in "^1.0.2"
is-extendable "^1.0.1"
mkdirp@^0.5.1, mkdirp@^0.5.3:
"mkdirp@>=0.5 0", mkdirp@^0.5.1, mkdirp@^0.5.3:
version "0.5.6"
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz"
integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
@ -10568,6 +10678,11 @@ node-dir@^0.1.10:
dependencies:
minimatch "^3.0.2"
node-domexception@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5"
integrity sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==
node-emoji@^1.10.0:
version "1.11.0"
resolved "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz"
@ -10582,6 +10697,15 @@ node-fetch@2.6.7, node-fetch@^2.6.1, node-fetch@^2.6.7:
dependencies:
whatwg-url "^5.0.0"
node-fetch@^3.2.10:
version "3.2.10"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.2.10.tgz#e8347f94b54ae18b57c9c049ef641cef398a85c8"
integrity sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==
dependencies:
data-uri-to-buffer "^4.0.0"
fetch-blob "^3.1.4"
formdata-polyfill "^4.0.10"
node-forge@^1:
version "1.3.1"
resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz"
@ -12387,6 +12511,15 @@ read-cache@^1.0.0:
dependencies:
pify "^2.3.0"
read-excel-file@^5.5.3:
version "5.5.3"
resolved "https://registry.yarnpkg.com/read-excel-file/-/read-excel-file-5.5.3.tgz#859737f97a1ee0aa845f4515aee43cde9c2875b5"
integrity sha512-g43pCe+Tyyq1Z40pNnghqAjoKd/ixGZ2qPgatomVrj158jIeLq7Zs874MxLG08RWEsYUQBL3qGSt/PHbaupKKA==
dependencies:
"@xmldom/xmldom" "^0.8.2"
fflate "^0.7.3"
unzipper "^0.10.11"
read-pkg-up@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz"
@ -12803,6 +12936,13 @@ reusify@^1.0.4:
resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
rimraf@2, rimraf@^2.5.4, rimraf@^2.6.3:
version "2.7.1"
resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
dependencies:
glob "^7.1.3"
rimraf@3.0.2, rimraf@^3.0.2:
version "3.0.2"
resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz"
@ -12810,13 +12950,6 @@ rimraf@3.0.2, rimraf@^3.0.2:
dependencies:
glob "^7.1.3"
rimraf@^2.5.4, rimraf@^2.6.3:
version "2.7.1"
resolved "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
dependencies:
glob "^7.1.3"
ripemd160@^2.0.0, ripemd160@^2.0.1:
version "2.0.2"
resolved "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz"
@ -13151,7 +13284,7 @@ set-value@^2.0.0, set-value@^2.0.1:
is-plain-object "^2.0.3"
split-string "^3.0.1"
setimmediate@^1.0.4, setimmediate@^1.0.5:
setimmediate@^1.0.4, setimmediate@^1.0.5, setimmediate@~1.0.4:
version "1.0.5"
resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==
@ -13453,6 +13586,13 @@ sprintf-js@~1.0.2:
resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==
ssf@~0.11.2:
version "0.11.2"
resolved "https://registry.yarnpkg.com/ssf/-/ssf-0.11.2.tgz#0b99698b237548d088fc43cdf2b70c1a7512c06c"
integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==
dependencies:
frac "~1.1.2"
ssri@^6.0.1:
version "6.0.2"
resolved "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz"
@ -14081,6 +14221,11 @@ tr46@~0.0.3:
resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
"traverse@>=0.3.0 <0.4":
version "0.3.9"
resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9"
integrity sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==
tree-kill@^1.2.2:
version "1.2.2"
resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz"
@ -14515,6 +14660,22 @@ untildify@^2.0.0:
dependencies:
os-homedir "^1.0.0"
unzipper@^0.10.11:
version "0.10.11"
resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e"
integrity sha512-+BrAq2oFqWod5IESRjL3S8baohbevGcVA+teAIOYWM3pDVdseogqbzhhvvmiyQrUNKFUnDMtELW3X8ykbyDCJw==
dependencies:
big-integer "^1.6.17"
binary "~0.3.0"
bluebird "~3.4.1"
buffer-indexof-polyfill "~1.0.0"
duplexer2 "~0.1.4"
fstream "^1.0.12"
graceful-fs "^4.2.2"
listenercount "~1.0.1"
readable-stream "~2.3.6"
setimmediate "~1.0.4"
upath@^1.1.1:
version "1.2.0"
resolved "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz"
@ -14823,7 +14984,7 @@ web-namespaces@^1.0.0:
resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz"
integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==
web-streams-polyfill@^3.2.1:
web-streams-polyfill@^3.0.3, web-streams-polyfill@^3.2.1:
version "3.2.1"
resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz"
integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==
@ -15123,11 +15284,21 @@ wildcard@^2.0.0:
resolved "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz"
integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
wmf@~1.0.1:
version "1.0.2"
resolved "https://registry.yarnpkg.com/wmf/-/wmf-1.0.2.tgz#7d19d621071a08c2bdc6b7e688a9c435298cc2da"
integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==
word-wrap@^1.2.3, word-wrap@~1.2.3:
version "1.2.3"
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
word@~0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/word/-/word-0.3.0.tgz#8542157e4f8e849f4a363a288992d47612db9961"
integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==
wordwrap@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz"
@ -15202,6 +15373,19 @@ xdg-basedir@^4.0.0:
resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz"
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
xlsx@^0.18.5:
version "0.18.5"
resolved "https://registry.yarnpkg.com/xlsx/-/xlsx-0.18.5.tgz#16711b9113c848076b8a177022799ad356eba7d0"
integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==
dependencies:
adler-32 "~1.3.0"
cfb "~1.2.1"
codepage "~1.15.0"
crc-32 "~1.2.1"
ssf "~0.11.2"
wmf "~1.0.1"
word "~0.3.0"
xml-js@^1.6.11:
version "1.6.11"
resolved "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz"

Loading…
Cancel
Save