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", "tsc": "tsc",
"postinstall": "prisma generate", "postinstall": "prisma generate",
"seed": "ts-node prisma/seed.ts", "seed": "ts-node prisma/seed.ts",
"seed-salaries": "ts-node prisma/seed-salaries.ts",
"seed-questions": "ts-node prisma/seed-questions.ts" "seed-questions": "ts-node prisma/seed-questions.ts"
}, },
"dependencies": { "dependencies": {
@ -26,10 +27,12 @@
"@trpc/server": "^9.27.2", "@trpc/server": "^9.27.2",
"axios": "^1.1.2", "axios": "^1.1.2",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"cross-fetch": "^3.1.5",
"date-fns": "^2.29.3", "date-fns": "^2.29.3",
"formidable": "^2.0.1", "formidable": "^2.0.1",
"next": "12.3.1", "next": "12.3.1",
"next-auth": "~4.10.3", "next-auth": "~4.10.3",
"node-fetch": "^3.2.10",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
@ -38,8 +41,10 @@
"react-popper": "^2.3.0", "react-popper": "^2.3.0",
"react-popper-tooltip": "^4.4.2", "react-popper-tooltip": "^4.4.2",
"react-query": "^3.39.2", "react-query": "^3.39.2",
"read-excel-file": "^5.5.3",
"superjson": "^1.10.0", "superjson": "^1.10.0",
"unique-names-generator": "^4.7.1", "unique-names-generator": "^4.7.1",
"xlsx": "^0.18.5",
"zod": "^3.18.0" "zod": "^3.18.0"
}, },
"devDependencies": { "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 code String @unique
states State[] states State[]
questionsQuestionEncounters QuestionsQuestionEncounter[] questionsQuestionEncounters QuestionsQuestionEncounter[]
ResumesResume ResumesResume[]
} }
model State { model State {
@ -148,13 +149,14 @@ model ResumesResume {
// TODO: Update role, experience, location to use Enums // TODO: Update role, experience, location to use Enums
role String @db.Text role String @db.Text
experience String @db.Text experience String @db.Text
location String @db.Text locationId String
url String url String
additionalInfo String? @db.Text additionalInfo String? @db.Text
isResolved Boolean @default(false) isResolved Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
location Country @relation(fields: [locationId], references: [id], onDelete: Cascade)
stars ResumesStar[] stars ResumesStar[]
comments ResumesComment[] comments ResumesComment[]
} }
@ -235,7 +237,7 @@ model OffersProfile {
model OffersBackground { model OffersBackground {
id String @id @default(cuid()) id String @id @default(cuid())
totalYoe Int totalYoe Int @default(0)
specificYoes OffersSpecificYoe[] specificYoes OffersSpecificYoe[]
experiences OffersExperience[] experiences OffersExperience[]
@ -410,7 +412,7 @@ model OffersAnalysis {
offerId String @unique offerId String @unique
// OVERALL // OVERALL
overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id]) overallAnalysis OffersAnalysisUnit @relation("OverallAnalysis", fields: [overallAnalysisUnitId], references: [id], onDelete: Cascade)
overallAnalysisUnitId String overallAnalysisUnitId String
companyAnalysis OffersAnalysisUnit[] @relation("CompanyAnalysis") companyAnalysis OffersAnalysisUnit[] @relation("CompanyAnalysis")
@ -419,7 +421,7 @@ model OffersAnalysis {
model OffersAnalysisUnit { model OffersAnalysisUnit {
id String @id @default(cuid()) 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 analysedOfferId String
percentile Float 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 { PrismaClient } = require('@prisma/client');
const cities = require('./data/cities.json'); const cities = require('./data/cities.json');
@ -6,45 +7,6 @@ const states = require('./data/states.json');
const prisma = new PrismaClient(); 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() { async function main() {
console.log('Seeding started...'); console.log('Seeding started...');

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

@ -1,16 +1,63 @@
import { emptyOption } from './constants'; import { emptyOption } from './constants';
export const EducationFieldLabels = [ const EducationFieldLabels = {
'Business Analytics', 'aerospace-engineering': 'Aerospace Engineering',
'Computer Science', 'applied-mathematics': 'Applied Mathematics',
'Data Science and Analytics', biology: 'Biology',
'Information Security', 'biomedical-engineering': 'Biomedical Engineering',
'Information Systems', '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( export const EducationFieldOptions = [emptyOption].concat(
EducationFieldLabels.map((label) => ({ Object.entries(EducationFieldLabels).map(([value, label]) => ({
label, label,
value: label.replace(/\s+/g, '-').toLowerCase(), value,
})), })),
); );

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

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

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

@ -2,14 +2,14 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image'; import Image from 'next/image';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { HOME_URL } from '../constants';
type LeftTextCardProps = Readonly<{ type LeftTextCardProps = Readonly<{
buttonLabel: string;
description: string; description: string;
icon: ReactNode; icon: ReactNode;
imageAlt: string; imageAlt: string;
imageSrc: StaticImageData; imageSrc: StaticImageData;
title: string; title: string;
url: string;
}>; }>;
export default function LeftTextCard({ export default function LeftTextCard({
@ -18,6 +18,8 @@ export default function LeftTextCard({
imageAlt, imageAlt,
imageSrc, imageSrc,
title, title,
buttonLabel,
url,
}: LeftTextCardProps) { }: LeftTextCardProps) {
return ( 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"> <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"> <div className="mt-6">
<a <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" 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}> href={url}>
Get started {buttonLabel}
</a> </a>
</div> </div>
</div> </div>

@ -2,14 +2,14 @@ import type { StaticImageData } from 'next/image';
import Image from 'next/image'; import Image from 'next/image';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { HOME_URL } from '../constants';
type RightTextCarddProps = Readonly<{ type RightTextCarddProps = Readonly<{
buttonLabel: string;
description: string; description: string;
icon: ReactNode; icon: ReactNode;
imageAlt: string; imageAlt: string;
imageSrc: StaticImageData; imageSrc: StaticImageData;
title: string; title: string;
url: string;
}>; }>;
export default function RightTextCard({ export default function RightTextCard({
@ -18,6 +18,8 @@ export default function RightTextCard({
imageAlt, imageAlt,
imageSrc, imageSrc,
title, title,
url,
buttonLabel,
}: RightTextCarddProps) { }: RightTextCarddProps) {
return ( 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"> <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"> <div className="mt-6">
<a <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" 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}> href={url}>
Get started {buttonLabel}
</a> </a>
</div> </div>
</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 { useEffect } from 'react';
import { useState } 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 OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferProfileCard from './OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../constants'; import { OVERALL_TAB } from '../constants';
import { YOE_CATEGORY } from '../table/types';
import type { AnalysisUnit, ProfileAnalysis } from '~/types/offers'; import type { AnalysisUnit, ProfileAnalysis } from '~/types/offers';
@ -19,6 +22,16 @@ function OfferAnalysisContent({
tab, tab,
isSubmission, isSubmission,
}: OfferAnalysisContentProps) { }: 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 (!analysis || analysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) { if (tab === OVERALL_TAB) {
return ( return (
@ -55,15 +68,22 @@ function OfferAnalysisContent({
offerProfile={topPercentileOffer} offerProfile={topPercentileOffer}
/> />
))} ))}
{/* {offerAnalysis.topPercentileOffers.length > 0 && ( {analysis.topPercentileOffers.length > 0 && (
<div className="mb-4 flex justify-end"> <div className="mb-4 flex justify-end">
<Button <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" label="View more offers"
rel="noreferrer"
target="_blank"
variant="tertiary" variant="tertiary"
/> />
</div> </div>
)} */} )}
</> </>
); );
} }

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

@ -13,10 +13,12 @@ import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm'; import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type { import type {
OfferFormData, OfferFormData,
OfferPostData,
OffersProfileFormData, OffersProfileFormData,
} from '~/components/offers/types'; } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker'; import type { Month } from '~/components/shared/MonthYearPicker';
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import { import {
cleanObject, cleanObject,
removeEmptyObjects, removeEmptyObjects,
@ -25,17 +27,19 @@ import {
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time'; import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
export const DEFAULT_CURRENCY = Currency.SGD;
const defaultOfferValues = { const defaultOfferValues = {
cityId: '', cityId: '',
comments: '', comments: '',
companyId: '', companyId: '',
jobTitle: '',
jobType: JobType.FULLTIME, jobType: JobType.FULLTIME,
monthYearReceived: { monthYearReceived: {
month: getCurrentMonth() as Month, month: getCurrentMonth() as Month,
year: getCurrentYear(), year: getCurrentYear(),
}, },
negotiationStrategy: '', negotiationStrategy: '',
title: '',
}; };
export const defaultFullTimeOfferValues = { export const defaultFullTimeOfferValues = {
@ -43,21 +47,17 @@ export const defaultFullTimeOfferValues = {
jobType: JobType.FULLTIME, jobType: JobType.FULLTIME,
offersFullTime: { offersFullTime: {
baseSalary: { baseSalary: {
currency: 'SGD', currency: DEFAULT_CURRENCY,
value: null,
}, },
bonus: { bonus: {
currency: 'SGD', currency: DEFAULT_CURRENCY,
value: null,
}, },
level: '', level: '',
stocks: { stocks: {
currency: 'SGD', currency: DEFAULT_CURRENCY,
value: null,
}, },
totalCompensation: { totalCompensation: {
currency: 'SGD', currency: DEFAULT_CURRENCY,
value: null,
}, },
}, },
}; };
@ -66,16 +66,15 @@ export const defaultInternshipOfferValues = {
...defaultOfferValues, ...defaultOfferValues,
jobType: JobType.INTERN, jobType: JobType.INTERN,
offersIntern: { offersIntern: {
internshipCycle: null, internshipCycle: '',
monthlySalary: { monthlySalary: {
currency: 'SGD', currency: DEFAULT_CURRENCY,
value: null,
}, },
startYear: null, startYear: null,
}, },
}; };
const defaultOfferProfileValues = { const defaultOfferProfileValues: OffersProfileFormData = {
background: { background: {
educations: [], educations: [],
experiences: [{ jobType: JobType.FULLTIME }], experiences: [{ jobType: JobType.FULLTIME }],
@ -109,6 +108,7 @@ export default function OffersSubmissionForm({
const pageRef = useRef<HTMLDivElement>(null); const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () => const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 }); pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({ const formMethods = useForm<OffersProfileFormData>({
defaultValues: initialOfferProfileValues, defaultValues: initialOfferProfileValues,
mode: 'all', mode: 'all',
@ -116,7 +116,7 @@ export default function OffersSubmissionForm({
const { const {
handleSubmit, handleSubmit,
trigger, trigger,
formState: { isSubmitting }, formState: { isSubmitting, isDirty },
} = formMethods; } = formMethods;
const generateAnalysisMutation = trpc.useMutation( const generateAnalysisMutation = trpc.useMutation(
@ -218,7 +218,7 @@ export default function OffersSubmissionForm({
offer.monthYearReceived.year, offer.monthYearReceived.year,
offer.monthYearReceived.month - 1, // Convert month to monthIndex offer.monthYearReceived.month - 1, // Convert month to monthIndex
), ),
})); })) as Array<OfferPostData>;
if (params.profileId && params.token) { if (params.profileId && params.token) {
createOrUpdateMutation.mutate({ createOrUpdateMutation.mutate({
@ -254,11 +254,14 @@ export default function OffersSubmissionForm({
const warningText = const warningText =
'Leave this page? Changes that you made will not be saved.'; 'Leave this page? Changes that you made will not be saved.';
const handleWindowClose = (e: BeforeUnloadEvent) => { const handleWindowClose = (e: BeforeUnloadEvent) => {
if (!isDirty) {
return;
}
e.preventDefault(); e.preventDefault();
return (e.returnValue = warningText); return (e.returnValue = warningText);
}; };
const handleRouteChange = (url: string) => { const handleRouteChange = (url: string) => {
if (url.includes('/offers/submit/result')) { if (url.includes('/offers/submit/result') || !isDirty) {
return; return;
} }
if (window.confirm(warningText)) { if (window.confirm(warningText)) {
@ -274,7 +277,7 @@ export default function OffersSubmissionForm({
router.events.off('routeChangeStart', handleRouteChange); router.events.off('routeChangeStart', handleRouteChange);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [isDirty]);
return generateAnalysisMutation.isLoading ? ( return generateAnalysisMutation.isLoading ? (
<Spinner className="m-10" display="block" size="lg" /> <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 { FieldError } from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types'; 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 { import {
Currency, Currency,
@ -17,6 +12,9 @@ import {
import { EducationFieldOptions } from '../../EducationFields'; import { EducationFieldOptions } from '../../EducationFields';
import { EducationLevelOptions } from '../../EducationLevels'; 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 FormRadioList from '../../forms/FormRadioList';
import FormSection from '../../forms/FormSection'; import FormSection from '../../forms/FormSection';
import FormSelect from '../../forms/FormSelect'; import FormSelect from '../../forms/FormSelect';
@ -85,56 +83,19 @@ function YoeSection() {
} }
function FullTimeJobFields() { function FullTimeJobFields() {
const { register, setValue, formState } = useFormContext<{ const { register, formState } = useFormContext<{
background: BackgroundPostData; background: BackgroundPostData;
}>(); }>();
const experiencesField = formState.errors.background?.experiences?.[0]; 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 ( return (
<> <>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6"> <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead <FormJobTitlesTypeahead name="background.experiences.0.title" />
value={{ <FormCompaniesTypeahead
id: watchJobTitle, names={{
label: getLabelForJobTitleType(watchJobTitle as JobTitleType), label: 'background.experiences.0.companyName',
value: watchJobTitle, value: 'background.experiences.0.companyId',
}}
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', '');
}
}} }}
/> />
</div> </div>
@ -172,21 +133,10 @@ function FullTimeJobFields() {
placeholder="e.g. L4, Junior" placeholder="e.g. L4, Junior"
{...register(`background.experiences.0.level`)} {...register(`background.experiences.0.level`)}
/> />
<CitiesTypeahead <FormCitiesTypeahead
label="Location" names={{
value={{ label: 'background.experiences.0.cityName',
id: watchCityId, value: 'background.experiences.0.cityId',
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', '');
}
}} }}
/> />
<FormTextInput <FormTextInput
@ -205,53 +155,19 @@ function FullTimeJobFields() {
} }
function InternshipJobFields() { function InternshipJobFields() {
const { register, setValue, formState } = useFormContext<{ const { register, formState } = useFormContext<{
background: BackgroundPostData; background: BackgroundPostData;
}>(); }>();
const experiencesField = formState.errors.background?.experiences?.[0]; 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 ( return (
<> <>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6"> <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead <FormJobTitlesTypeahead name="background.experiences.0.title" />
value={{ <FormCompaniesTypeahead
id: watchJobTitle, names={{
label: getLabelForJobTitleType(watchJobTitle as JobTitleType), label: 'background.experiences.0.companyName',
value: watchJobTitle, value: 'background.experiences.0.companyId',
}}
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);
}
}} }}
/> />
</div> </div>
@ -280,21 +196,10 @@ function InternshipJobFields() {
/> />
<Collapsible label="Add more details"> <Collapsible label="Add more details">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<CitiesTypeahead <FormCitiesTypeahead
label="Location" names={{
value={{ label: 'background.experiences.0.cityName',
id: watchCityId, value: 'background.experiences.0.cityId',
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', '');
}
}} }}
/> />
<FormTextInput <FormTextInput

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

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

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

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

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

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

@ -3,22 +3,52 @@ import type { PropsWithChildren } from 'react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Fragment, useRef, useState } from 'react'; import { Fragment, useRef, useState } from 'react';
import { Menu, Transition } from '@headlessui/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 { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import CreateListDialog from './CreateListDialog';
export type AddToListDropdownProps = { export type AddToListDropdownProps = {
questionId: string; 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({ export default function AddToListDropdown({
questionId, questionId,
}: AddToListDropdownProps) { }: AddToListDropdownProps) {
const [menuOpened, setMenuOpened] = useState(false); const [menuOpened, setMenuOpened] = useState(false);
const ref = useRef<HTMLDivElement>(null); 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 { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const listsWithQuestionData = useMemo(() => { const listsWithQuestionData = useMemo(() => {
@ -30,25 +60,8 @@ export default function AddToListDropdown({
})); }));
}, [lists, questionId]); }, [lists, questionId]);
const { mutateAsync: addQuestionToList } = trpc.useMutation( const addQuestionToList = useAddQuestionToListAsync();
'questions.lists.createQuestionEntry', const removeQuestionFromList = useRemoveQuestionFromListAsync();
{
// 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 addClickOutsideListener = () => { const addClickOutsideListener = () => {
document.addEventListener('click', handleClickOutside, true); document.addEventListener('click', handleClickOutside, true);
@ -101,14 +114,14 @@ export default function AddToListDropdown({
); );
return ( return (
<div>
<Menu ref={ref} as="div" className="relative inline-block text-left"> <Menu ref={ref} as="div" className="relative inline-block text-left">
<div> <div>
<Menu.Button as={CustomMenuButton}> <Menu.Button as={CustomMenuButton}>
<HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" /> <HeartIcon aria-hidden="true" className="-ml-1 mr-2 h-5 w-5" />
Add to List Add to list
</Menu.Button> </Menu.Button>
</div> </div>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-100" enter="transition ease-out duration-100"
@ -125,16 +138,7 @@ export default function AddToListDropdown({
<> <>
{(listsWithQuestionData ?? []).map((list) => ( {(listsWithQuestionData ?? []).map((list) => (
<div key={list.id} className="py-1"> <div key={list.id} className="py-1">
<Menu.Item> <DropdownButton
{({ 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={() => { onClick={() => {
if (list.hasQuestion) { if (list.hasQuestion) {
handleDeleteFromList(list.id); handleDeleteFromList(list.id);
@ -142,22 +146,47 @@ export default function AddToListDropdown({
handleAddToList(list.id); 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 && ( {list.hasQuestion && (
<CheckIcon <CheckIcon
aria-hidden="true" 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} </div>
</button> </DropdownButton>
)}
</Menu.Item>
</div> </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> </Menu.Items>
</Transition> </Transition>
</Menu> </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 { useState } from 'react';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import { TextInput } from '@tih/ui'; import { TextInput } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
@ -32,9 +27,10 @@ export default function ContributeQuestionCard({
return ( return (
<div className="w-full"> <div className="w-full">
<button <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" type="button"
onClick={handleOpenContribute}> onClick={handleOpenContribute}>
<div className="w-full">
<TextInput <TextInput
disabled={true} disabled={true}
isLabelHidden={true} isLabelHidden={true}
@ -42,34 +38,8 @@ export default function ContributeQuestionCard({
placeholder="Contribute a question" placeholder="Contribute a question"
onChange={handleOpenContribute} onChange={handleOpenContribute}
/> />
<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> </div>
<div className="flex flex-wrap items-end justify-start gap-2">
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white"> <h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute Contribute
</h1> </h1>

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

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

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

@ -5,7 +5,7 @@ import { ArrowPathIcon } from '@heroicons/react/20/solid';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { CheckboxInput } 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 { QUESTION_TYPES } from '~/utils/questions/constants';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates'; import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
@ -187,11 +187,9 @@ export default function ContributeQuestionForm({
/> />
</div> </div>
</div> </div>
<div className="w-full">
<HorizontalDivider />
</div>
<h2 <h2
className="text-primary-900 mb-3 className="text-primary-900
text-lg font-semibold text-lg font-semibold
"> ">
Are these questions the same as yours? Are these questions the same as yours?
@ -243,7 +241,9 @@ export default function ContributeQuestionForm({
/> />
); );
})} })}
{similarQuestions?.length === 0 && ( {similarQuestions?.length === 0 &&
contentToCheck?.length !== 0 &&
questionContent === contentToCheck && (
<p className="font-semibold text-slate-900"> <p className="font-semibold text-slate-900">
No similar questions found. No similar questions found.
</p> </p>

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

@ -35,10 +35,12 @@ export default function ResumePdf({ url }: Props) {
}, [pageWidth]); }, [pageWidth]);
return ( return (
<div className="w-full" id="pdfView"> <div
<div className="group relative"> 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 <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} file={url}
loading={<Spinner display="block" size="lg" />} loading={<Spinner display="block" size="lg" />}
noData="" noData=""
@ -79,7 +81,7 @@ export default function ResumePdf({ url }: Props) {
</div> </div>
</Document> </Document>
</div> </div>
<div className="flex justify-center p-4"> <div className="flex justify-center border-t border-slate-200 bg-white py-4">
<Pagination <Pagination
current={pageNumber} current={pageNumber}
end={numPages} end={numPages}

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

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

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

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

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

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

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

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

@ -1,26 +1,29 @@
import { Button } from '@tih/ui';
import { Container } from './Container'; import { Container } from './Container';
export function CallToAction() { export function CallToAction() {
return ( return (
<section className="relative overflow-hidden py-32" id="get-started-today"> <section className="relative overflow-hidden py-32" id="get-started-today">
<Container className="relative"> <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"> <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> </h2>
<p className="mt-4 text-lg tracking-tight text-slate-600"> <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 Check out how Alwyn from Open Government Products uses the platform
engineers in the field. to provide actionable feedback on a student's resume:
</p> </p>
<Button <div className="mt-10 flex justify-center">
className="mt-4" <iframe
href="/resumes" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
label="Start browsing now" allowFullScreen={true}
variant="primary" frameBorder="0"
height="480"
src="https://www.youtube.com/embed/wVi5dhjDT8Y"
title="Resume Review with Alwyn from OGP"
width="853"
/> />
</div> </div>
</div>
</Container> </Container>
</section> </section>
); );

@ -1,4 +1,3 @@
import Link from 'next/link';
import { Button } from '@tih/ui'; import { Button } from '@tih/ui';
import { Container } from './Container'; import { Container } from './Container';
@ -26,19 +25,18 @@ export function Hero() {
</p> </p>
<div className="mt-10 flex justify-center gap-x-4"> <div className="mt-10 flex justify-center gap-x-4">
<Button href="/resumes" label="Start browsing now" variant="primary" /> <Button href="/resumes" label="Start browsing now" variant="primary" />
{/* TODO: Update video */} </div>
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
<button <div className="mt-10 flex justify-center">
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" <iframe
type="button"> allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
<svg allowFullScreen={true}
aria-hidden="true" frameBorder="0"
className="fill-primary-600 h-3 w-3 flex-none group-active:fill-current"> height="480"
<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" /> src="https://www.youtube.com/embed/7jNiW4extlI"
</svg> title="Resume Review Walkthrough"
<span className="ml-3">Watch video</span> width="853"
</button> />
</Link>
</div> </div>
</Container> </Container>
); );

@ -84,7 +84,7 @@ export function PrimaryFeatures() {
: 'text-blue-100 hover:text-white lg:text-white', : 'text-blue-100 hover:text-white lg:text-white',
)}> )}>
<span className="absolute inset-0 rounded-full lg:rounded-r-none lg:rounded-l-xl" /> <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> </Tab>
</h3> </h3>
<p <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 ( return (
<div> <div className="space-y-1">
<span <span
ref={ref} ref={ref}
className={clsx( 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' : '', isExpanded ? 'line-clamp-none' : '',
)}> )}>
{text} {text}
</span> </span>
{descriptionOverflow && ( {descriptionOverflow && (
<p <button
className="text-primary-500 hover:text-primary-300 mt-1 cursor-pointer text-xs" className="text-primary-500 hover:text-primary-600 text-xs font-medium"
type="button"
onClick={onSeeActionClicked}> onClick={onSeeActionClicked}>
{isExpanded ? 'See Less' : 'See More'} {isExpanded ? 'See Less' : 'See More'}
</p> </button>
)} )}
</div> </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 = { export const JobTitleLabels = {
'ai-ml-engineer': 'AI/ML Engineer', 'ai-engineer': 'Artificial Intelligence (AI) Engineer',
'algorithms-engineer': 'Algorithms Engineer', 'algorithms-engineer': 'Algorithms Engineer',
'android-engineer': 'Android Software Engineer', 'android-engineer': 'Android Software Engineer',
'applications-engineer': 'Applications Engineer', 'applications-engineer': 'Applications Engineer',
'back-end-engineer': 'Back End Engineer', 'back-end-engineer': 'Back End Engineer',
'business-analyst': 'Business Analyst',
'business-engineer': 'Business Engineer', 'business-engineer': 'Business Engineer',
'capacity-engineer': 'Capacity Engineer',
'customer-engineer': 'Customer Engineer',
'data-analyst': 'Data Analyst',
'data-engineer': 'Data Engineer', 'data-engineer': 'Data Engineer',
'data-scientist': 'Data Scientist',
'devops-engineer': 'DevOps Engineer', 'devops-engineer': 'DevOps Engineer',
'engineering-director': 'Engineering Director',
'engineering-manager': 'Engineering Manager',
'enterprise-engineer': 'Enterprise Engineer', 'enterprise-engineer': 'Enterprise Engineer',
'forward-deployed-engineer': 'Forward Deployed Engineer',
'front-end-engineer': 'Front End Engineer', 'front-end-engineer': 'Front End Engineer',
'full-stack-engineer': 'Full Stack Engineer',
'gameplay-engineer': 'Gameplay Engineer',
'hardware-engineer': 'Hardware Engineer', 'hardware-engineer': 'Hardware Engineer',
'infrastructure-engineer': 'Infrastructure Engineer',
'ios-engineer': 'iOS Software 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)', 'mobile-engineer': 'Mobile Software Engineer (iOS + Android)',
'networks-engineer': 'Networks Engineer', 'networks-engineer': 'Networks Engineer',
'partner-engineer': 'Partner Engineer', 'partner-engineer': 'Partner Engineer',
'product-engineer': 'Product Engineer',
'product-manager': 'Product Manager',
'production-engineer': 'Production Engineer', 'production-engineer': 'Production Engineer',
'project-manager': 'Project Manager',
'release-engineer': 'Release Engineer',
'research-engineer': 'Research Engineer', 'research-engineer': 'Research Engineer',
'research-scientist': 'Research Scientist',
'rotational-engineer': 'Rotational Engineer',
'sales-engineer': 'Sales Engineer', 'sales-engineer': 'Sales Engineer',
'security-engineer': 'Security Engineer', 'security-engineer': 'Security Engineer',
'site-reliability-engineer': 'Site Reliability Engineer (SRE)', 'site-reliability-engineer': 'Site Reliability Engineer (SRE)',
'software-engineer': 'Software Engineer', 'software-engineer': 'Software Engineer',
'solutions-architect': 'Solutions Architect',
'solutions-engineer': 'Solutions Engineer',
'systems-analyst': 'Systems Analyst',
'systems-engineer': 'Systems Engineer', 'systems-engineer': 'Systems Engineer',
'tech-ops-engineer': 'Tech Ops Engineer',
'technical-program-manager': 'Technical Program Manager',
'test-engineer': 'QA/Test Engineer (SDET)', 'test-engineer': 'QA/Test Engineer (SDET)',
'ux-engineer': 'User Experience (UX) Engineer',
}; };
export type JobTitleType = keyof typeof JobTitleLabels; export type JobTitleType = keyof typeof JobTitleLabels;

@ -8,7 +8,7 @@ import {
UsersIcon, UsersIcon,
} from '@heroicons/react/24/outline'; } 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 offersAnalysis from '~/components/offers/features/images/offers-analysis.png';
import offersBrowse from '~/components/offers/features/images/offers-browse.png'; import offersBrowse from '~/components/offers/features/images/offers-browse.png';
import offersProfile from '~/components/offers/features/images/offers-profile.png'; import offersProfile from '~/components/offers/features/images/offers-profile.png';
@ -126,6 +126,7 @@ export default function LandingPage() {
/> />
<div className="relative"> <div className="relative">
<LeftTextCard <LeftTextCard
buttonLabel="View offers"
description="Filter relevant offers by job title, company, submission date, salary and more." description="Filter relevant offers by job title, company, submission date, salary and more."
icon={ icon={
<TableCellsIcon <TableCellsIcon
@ -133,27 +134,31 @@ export default function LandingPage() {
className="h-6 w-6 text-white" className="h-6 w-6 text-white"
/> />
} }
imageAlt="Offer table page" imageAlt="Browse page"
imageSrc={offersBrowse} imageSrc={offersBrowse}
title="Stay informed of recent offers" title="Stay informed of recent offers"
url={HOME_URL}
/> />
</div> </div>
<div className="mt-36"> <div className="mt-36">
<RightTextCard <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={ icon={
<ChartBarSquareIcon <ChartBarSquareIcon
aria-hidden="true" aria-hidden="true"
className="h-6 w-6 text-white" className="h-6 w-6 text-white"
/> />
} }
imageAlt="Customer profile user interface" imageAlt="Offers analysis page"
imageSrc={offersAnalysis} imageSrc={offersAnalysis}
title="Better understand your offers" title="Better understand your offers"
url={OFFERS_SUBMIT_URL}
/> />
</div> </div>
<div className="mt-36"> <div className="mt-36">
<LeftTextCard <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." 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={ icon={
<InformationCircleIcon <InformationCircleIcon
@ -161,9 +166,10 @@ export default function LandingPage() {
className="h-6 w-6 text-white" className="h-6 w-6 text-white"
/> />
} }
imageAlt="Offer table page" imageAlt="Offer profile page"
imageSrc={offersProfile} imageSrc={offersProfile}
title="Choosing an offer needs context" title="Choosing an offer needs context"
url={HOME_URL}
/> />
</div> </div>
</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"> <div className="mt-6 space-y-4 sm:flex sm:space-y-0 sm:space-x-5">
<a <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" 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 Get Started
</a> </a>
</div> </div>

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

@ -139,7 +139,7 @@ export default function QuestionPage() {
}, },
); );
const { mutate: addEncounter } = trpc.useMutation( const { mutateAsync: addEncounterAsync } = trpc.useMutation(
'questions.questions.encounters.user.create', 'questions.questions.encounters.user.create',
{ {
onSuccess: () => { onSuccess: () => {
@ -208,8 +208,8 @@ export default function QuestionPage() {
year: 'numeric', year: 'numeric',
})} })}
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
onReceivedSubmit={(data) => { onReceivedSubmit={async (data) => {
addEncounter({ await addEncounterAsync({
cityId: data.cityId, cityId: data.cityId,
companyId: data.company, companyId: data.company,
countryId: data.countryId, countryId: data.countryId,
@ -221,7 +221,7 @@ export default function QuestionPage() {
}} }}
/> />
<div className="mx-2"> <div className="mx-2">
<Collapsible label={`${question.numComments} comment(s)`}> <Collapsible label={`View ${question.numComments} comment(s)`}>
<div className="mt-4 px-4"> <div className="mt-4 px-4">
<form <form
className="mb-2" className="mb-2"
@ -246,7 +246,7 @@ export default function QuestionPage() {
</div> </div>
</form> </form>
{/* TODO: Add button to load more */} {/* 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"> <div className="flex items-center justify-between gap-2">
<p className="text-lg">Comments</p> <p className="text-lg">Comments</p>
<div className="flex items-end gap-2"> <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 { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { useToast } from '@tih/ui';
import { Button, SlideOut } from '@tih/ui'; import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard'; import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
@ -19,6 +20,7 @@ import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
import { JobTitleLabels } from '~/components/shared/JobTitles'; import { JobTitleLabels } from '~/components/shared/JobTitles';
import type { QuestionAge } from '~/utils/questions/constants'; import type { QuestionAge } from '~/utils/questions/constants';
import { QUESTION_SORT_TYPES } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants'; import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
@ -34,6 +36,30 @@ import type { Location } from '~/types/questions.d';
import { SortType } from '~/types/questions.d'; import { SortType } from '~/types/questions.d';
import { SortOrder } 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() { export default function QuestionsBrowsePage() {
const router = useRouter(); const router = useRouter();
@ -88,15 +114,7 @@ export default function QuestionsBrowsePage() {
const [sortOrder, setSortOrder, isSortOrderInitialized] = const [sortOrder, setSortOrder, isSortOrderInitialized] =
useSearchParamSingle<SortOrder>('sortOrder', { useSearchParamSingle<SortOrder>('sortOrder', {
defaultValue: SortOrder.DESC, defaultValue: SortOrder.DESC,
paramToString: (value) => { paramToString: sortOrderToString,
if (value === SortOrder.ASC) {
return 'ASC';
}
if (value === SortOrder.DESC) {
return 'DESC';
}
return null;
},
stringToParam: (param) => { stringToParam: (param) => {
const uppercaseParam = param.toUpperCase(); const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'ASC') { if (uppercaseParam === 'ASC') {
@ -112,15 +130,7 @@ export default function QuestionsBrowsePage() {
const [sortType, setSortType, isSortTypeInitialized] = const [sortType, setSortType, isSortTypeInitialized] =
useSearchParamSingle<SortType>('sortType', { useSearchParamSingle<SortType>('sortType', {
defaultValue: SortType.TOP, defaultValue: SortType.TOP,
paramToString: (value) => { paramToString: sortTypeToString,
if (value === SortType.NEW) {
return 'NEW';
}
if (value === SortType.TOP) {
return 'TOP';
}
return null;
},
stringToParam: (param) => { stringToParam: (param) => {
const uppercaseParam = param.toUpperCase(); const uppercaseParam = param.toUpperCase();
if (uppercaseParam === 'NEW') { if (uppercaseParam === 'NEW') {
@ -129,6 +139,9 @@ export default function QuestionsBrowsePage() {
if (uppercaseParam === 'TOP') { if (uppercaseParam === 'TOP') {
return SortType.TOP; return SortType.TOP;
} }
if (uppercaseParam === 'ENCOUNTERS') {
return SortType.ENCOUNTERS;
}
return null; return null;
}, },
}); });
@ -205,6 +218,11 @@ export default function QuestionsBrowsePage() {
{ {
onSuccess: () => { onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter'); 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, questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes, questionTypes: selectedQuestionTypes,
roles: selectedRoles, roles: selectedRoles,
sortOrder: sortOrder === SortOrder.ASC ? 'ASC' : 'DESC', sortOrder: sortOrderToString(sortOrder),
sortType: sortType === SortType.TOP ? 'TOP' : 'NEW', sortType: sortTypeToString(sortType),
}, },
}); });
@ -280,6 +298,8 @@ export default function QuestionsBrowsePage() {
sortType, sortType,
]); ]);
const { showToast } = useToast();
const selectedCompanyOptions = useMemo(() => { const selectedCompanyOptions = useMemo(() => {
return selectedCompanySlugs.map((company) => { return selectedCompanySlugs.map((company) => {
const [id, label] = company.split('_'); const [id, label] = company.split('_');
@ -473,7 +493,7 @@ export default function QuestionsBrowsePage() {
<Head> <Head>
<title>Home - {APP_TITLE}</title> <title>Home - {APP_TITLE}</title>
</Head> </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"> <div className="flex h-full flex-1">
<section className="min-h-0 flex-1 overflow-auto"> <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"> <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 <QuestionSearchBar
query={query} query={query}
sortOrderValue={sortOrder} sortOrderValue={sortOrder}
sortTypeOptions={QUESTION_SORT_TYPES}
sortTypeValue={sortType} sortTypeValue={sortType}
onFilterOptionsToggle={() => { onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen); setFilterDrawerOpen(!filterDrawerOpen);

@ -5,16 +5,21 @@ import {
EllipsisVerticalIcon, EllipsisVerticalIcon,
NoSymbolIcon, NoSymbolIcon,
PlusIcon, PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Button, Select } from '@tih/ui';
import QuestionListCard from '~/components/questions/card/question/QuestionListCard'; import QuestionListCard from '~/components/questions/card/question/QuestionListCard';
import type { CreateListFormData } from '~/components/questions/CreateListDialog'; import type { CreateListFormData } from '~/components/questions/CreateListDialog';
import CreateListDialog from '~/components/questions/CreateListDialog'; import CreateListDialog from '~/components/questions/CreateListDialog';
import DeleteListDialog from '~/components/questions/DeleteListDialog'; import DeleteListDialog from '~/components/questions/DeleteListDialog';
import { Button } from '~/../../../packages/ui/dist';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import {
useCreateListAsync,
useDeleteListAsync,
} from '~/utils/questions/mutations';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates'; import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback'; import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -22,24 +27,10 @@ import { trpc } from '~/utils/trpc';
export default function ListPage() { export default function ListPage() {
const utils = trpc.useContext(); const utils = trpc.useContext();
const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']); const { data: lists } = trpc.useQuery(['questions.lists.getListsByUser']);
const { mutateAsync: createList } = trpc.useMutation(
'questions.lists.create', const createListAsync = useCreateListAsync();
{ const deleteListAsync = useDeleteListAsync();
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 { mutateAsync: deleteQuestionEntry } = trpc.useMutation( const { mutateAsync: deleteQuestionEntry } = trpc.useMutation(
'questions.lists.deleteQuestionEntry', 'questions.lists.deleteQuestionEntry',
{ {
@ -57,7 +48,7 @@ export default function ListPage() {
const [listIdToDelete, setListIdToDelete] = useState(''); const [listIdToDelete, setListIdToDelete] = useState('');
const handleDeleteList = async (listId: string) => { const handleDeleteList = async (listId: string) => {
await deleteList({ await deleteListAsync({
id: listId, id: listId,
}); });
setShowDeleteListDialog(false); setShowDeleteListDialog(false);
@ -68,7 +59,7 @@ export default function ListPage() {
}; };
const handleCreateList = async (data: CreateListFormData) => { const handleCreateList = async (data: CreateListFormData) => {
await createList({ await createListAsync({
name: data.name, name: data.name,
}); });
setShowCreateListDialog(false); setShowCreateListDialog(false);
@ -145,17 +136,7 @@ export default function ListPage() {
</> </>
); );
return ( const createButton = (
<>
<Head>
<title>My Lists - {APP_TITLE}</title>
</Head>
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<aside className="w-[300px] overflow-y-auto border-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 <Button
icon={PlusIcon} icon={PlusIcon}
isLabelHidden={true} isLabelHidden={true}
@ -168,13 +149,56 @@ export default function ListPage() {
handleAddClick(); handleAddClick();
}} }}
/> />
</div> );
return (
<>
<Head>
<title>My Lists - {APP_TITLE}</title>
</Head>
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<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">{createButton}</div>
</div> </div>
{listOptions} {listOptions}
</aside> </aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto"> <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 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 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] && ( {lists?.[selectedListIndex] && (
<div className="flex flex-col gap-4 pb-4"> <div className="flex flex-col gap-4 pb-4">
{lists[selectedListIndex].questionEntries.map( {lists[selectedListIndex].questionEntries.map(

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

@ -9,6 +9,7 @@ import {
NewspaperIcon, NewspaperIcon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import type { TypeaheadOption } from '@tih/ui';
import { import {
Button, Button,
CheckboxInput, CheckboxInput,
@ -23,23 +24,18 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill'; import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems'; 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 ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import loginPageHref from '~/components/shared/loginPageHref'; import loginPageHref from '~/components/shared/loginPageHref';
import type { import type { Filter, FilterId, Shortcut } from '~/utils/resumes/resumeFilters';
Filter, import type { SortOrder } from '~/utils/resumes/resumeFilters';
FilterId,
FilterLabel,
Shortcut,
} from '~/utils/resumes/resumeFilters';
import { import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
EXPERIENCES,
getFilterLabel, getFilterLabel,
INITIAL_FILTER_STATE, INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATIONS,
ROLES,
SHORTCUTS, SHORTCUTS,
SORT_OPTIONS, SORT_OPTIONS,
} from '~/utils/resumes/resumeFilters'; } from '~/utils/resumes/resumeFilters';
@ -47,8 +43,6 @@ import useDebounceValue from '~/utils/resumes/useDebounceValue';
import useSearchParams from '~/utils/resumes/useSearchParams'; import useSearchParams from '~/utils/resumes/useSearchParams';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { FilterState, SortOrder } from '../../utils/resumes/resumeFilters';
const STALE_TIME = 5 * 60 * 1000; const STALE_TIME = 5 * 60 * 1000;
const DEBOUNCE_DELAY = 800; const DEBOUNCE_DELAY = 800;
const PAGE_LIMIT = 10; const PAGE_LIMIT = 10;
@ -56,17 +50,14 @@ const filters: Array<Filter> = [
{ {
id: 'role', id: 'role',
label: 'Role', label: 'Role',
options: ROLES,
}, },
{ {
id: 'experience', id: 'experience',
label: 'Experience', label: 'Experience',
options: EXPERIENCES,
}, },
{ {
id: 'location', id: 'location',
label: 'Location', label: 'Location',
options: LOCATIONS,
}, },
]; ];
@ -81,20 +72,14 @@ const getLoggedOutText = (tabsValue: string) => {
} }
}; };
const getEmptyDataText = ( const getEmptyDataText = (tabsValue: string, searchValue: string) => {
tabsValue: string,
searchValue: string,
userFilters: FilterState,
) => {
if (searchValue.length > 0) { if (searchValue.length > 0) {
return 'Try tweaking your search text to see more resumes.'; return 'Try tweaking your search text to see more resumes.';
} }
if (!isInitialFilterState(userFilters)) {
return 'Try tweaking your filters to see more resumes.';
}
switch (tabsValue) { switch (tabsValue) {
case BROWSE_TABS_VALUES.ALL: 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: case BROWSE_TABS_VALUES.STARRED:
return 'You have not starred any resumes. Star one to see it here!'; return 'You have not starred any resumes. Star one to see it here!';
case BROWSE_TABS_VALUES.MY: case BROWSE_TABS_VALUES.MY:
@ -200,10 +185,10 @@ export default function ResumeHomePage() {
[ [
'resumes.resume.findAll', 'resumes.resume.findAll',
{ {
experienceFilters: userFilters.experience, experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed, isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location, locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role, roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
@ -219,10 +204,10 @@ export default function ResumeHomePage() {
[ [
'resumes.resume.user.findUserStarred', 'resumes.resume.user.findUserStarred',
{ {
experienceFilters: userFilters.experience, experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed, isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location, locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role, roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
@ -239,10 +224,10 @@ export default function ResumeHomePage() {
[ [
'resumes.resume.user.findUserCreated', 'resumes.resume.user.findUserCreated',
{ {
experienceFilters: userFilters.experience, experienceFilters: userFilters.experience.map(({ value }) => value),
isUnreviewed: userFilters.isUnreviewed, isUnreviewed: userFilters.isUnreviewed,
locationFilters: userFilters.location, locationFilters: userFilters.location.map(({ value }) => value),
roleFilters: userFilters.role, roleFilters: userFilters.role.map(({ value }) => value),
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, 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) => { const onClearFilterClick = (filterSection: FilterId) => {
setUserFilters({ setUserFilters({
...userFilters, ...userFilters,
@ -354,12 +314,71 @@ export default function ResumeHomePage() {
return getTabQueryData()?.filterCounts; 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(); const filterCountsData = getTabFilterCounts();
if (!filterCountsData) { if (
filterCountsData === undefined ||
filterCountsData[filterId] === undefined ||
filterCountsData[filterId][value] === undefined
) {
return 0; return 0;
} }
return filterCountsData[filter][value]; return filterCountsData[filterId][value];
}; };
return ( return (
@ -461,29 +480,28 @@ export default function ResumeHomePage() {
</h3> </h3>
<Disclosure.Panel className="space-y-4 pt-6"> <Disclosure.Panel className="space-y-4 pt-6">
<div className="space-y-3"> <div className="space-y-3">
{filter.options.map((option) => ( {getFilterTypeahead(filter.id)}
{userFilters[filter.id].map((option) => (
<div <div
key={option.value} 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 <CheckboxInput
label={option.label} label={option.label}
value={userFilters[filter.id].includes( value={true}
option.value, onChange={() =>
)} setUserFilters({
onChange={(isChecked) => ...userFilters,
onFilterCheckboxChange( [filter.id]: userFilters[
isChecked, filter.id
filter.id, ].filter(
option.value, ({ value }) =>
) value !== option.value,
),
})
} }
/> />
<span className="ml-1 text-slate-500"> <span className="ml-1 text-slate-500">
( ({getFilterCount(filter.id, option.value)}
{getFilterCount(
filter.label,
option.label,
)}
) )
</span> </span>
</div> </div>
@ -570,32 +588,32 @@ export default function ResumeHomePage() {
</Disclosure.Button> </Disclosure.Button>
</h3> </h3>
<Disclosure.Panel className="space-y-4 pt-4"> <Disclosure.Panel className="space-y-4 pt-4">
{getFilterTypeahead(filter.id)}
<CheckboxList <CheckboxList
description="" description=""
isLabelHidden={true} isLabelHidden={true}
label="" label=""
orientation="vertical"> orientation="vertical">
{filter.options.map((option) => ( {userFilters[filter.id].map((option) => (
<div <div
key={option.value} 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 <CheckboxInput
label={option.label} label={option.label}
value={userFilters[filter.id].includes( value={true}
option.value, onChange={() =>
)} setUserFilters({
onChange={(isChecked) => ...userFilters,
onFilterCheckboxChange( [filter.id]: userFilters[
isChecked, filter.id
filter.id, ].filter(
option.value, ({ value }) => value !== option.value,
) ),
})
} }
/> />
<span className="ml-1 text-slate-500"> <span className="ml-1 text-slate-500">
( ({getFilterCount(filter.id, option.value)})
{getFilterCount(filter.label, option.label)}
)
</span> </span>
</div> </div>
))} ))}
@ -614,7 +632,7 @@ export default function ResumeHomePage() {
</div> </div>
</div> </div>
<div className="relative lg:left-64 lg:w-[calc(100%-16rem)]"> <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 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> <div>
<Tabs <Tabs
@ -660,7 +678,7 @@ export default function ResumeHomePage() {
</div> </div>
<DropdownMenu <DropdownMenu
align="end" align="end"
label={getFilterLabel(SORT_OPTIONS, sortOrder)}> label={getFilterLabel('sort', sortOrder)}>
{SORT_OPTIONS.map(({ label, value }) => ( {SORT_OPTIONS.map(({ label, value }) => (
<DropdownMenu.Item <DropdownMenu.Item
key={value} key={value}
@ -702,7 +720,7 @@ export default function ResumeHomePage() {
height={196} height={196}
width={196} width={196}
/> />
{getEmptyDataText(tabsValue, searchValue, userFilters)} {getEmptyDataText(tabsValue, searchValue)}
</div> </div>
) : ( ) : (
<div> <div>

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

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

@ -1,8 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { Vote } from '@prisma/client'; import { Vote } from '@prisma/client';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
import { createRouter } from '../context'; import { createRouter } from '../context';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
@ -35,7 +33,7 @@ export const resumesRouter = createRouter()
where: { where: {
experience: { in: experienceFilters }, experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
location: { in: locationFilters }, locationId: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' }, title: { contains: searchValue, mode: 'insensitive' },
}, },
@ -49,6 +47,11 @@ export const resumesRouter = createRouter()
}, },
}, },
comments: true, comments: true,
location: {
select: {
name: true,
},
},
stars: { stars: {
where: { where: {
OR: { OR: {
@ -79,7 +82,7 @@ export const resumesRouter = createRouter()
where: { where: {
experience: { in: experienceFilters }, experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
location: { in: locationFilters }, locationId: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' }, title: { contains: searchValue, mode: 'insensitive' },
}, },
@ -92,7 +95,8 @@ export const resumesRouter = createRouter()
id: r.id, id: r.id,
isResolved: r.isResolved, isResolved: r.isResolved,
isStarredByUser: r.stars.length > 0, isStarredByUser: r.stars.length > 0,
location: r.location, location: r.location.name,
locationId: r.locationId,
numComments: r._count.comments, numComments: r._count.comments,
numStars: r._count.stars, numStars: r._count.stars,
role: r.role, role: r.role,
@ -103,7 +107,7 @@ export const resumesRouter = createRouter()
return resume; 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({ const roleCounts = await ctx.prisma.resumesResume.groupBy({
_count: { _count: {
_all: true, _all: true,
@ -112,7 +116,7 @@ export const resumesRouter = createRouter()
where: { where: {
experience: { in: experienceFilters }, experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
location: { in: locationFilters }, locationId: { in: locationFilters },
title: { contains: searchValue, mode: 'insensitive' }, title: { contains: searchValue, mode: 'insensitive' },
}, },
}); });
@ -122,20 +126,6 @@ export const resumesRouter = createRouter()
roleCounts.map((rc) => [rc.role, rc._count._all]), 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({ const experienceCounts = await ctx.prisma.resumesResume.groupBy({
_count: { _count: {
_all: true, _all: true,
@ -143,7 +133,7 @@ export const resumesRouter = createRouter()
by: ['experience'], by: ['experience'],
where: { where: {
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
location: { in: locationFilters }, locationId: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' }, title: { contains: searchValue, mode: 'insensitive' },
}, },
@ -151,21 +141,12 @@ export const resumesRouter = createRouter()
const mappedExperienceCounts = Object.fromEntries( const mappedExperienceCounts = Object.fromEntries(
experienceCounts.map((ec) => [ec.experience, ec._count._all]), 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({ const locationCounts = await ctx.prisma.resumesResume.groupBy({
_count: { _count: {
_all: true, _all: true,
}, },
by: ['location'], by: ['locationId'],
where: { where: {
experience: { in: experienceFilters }, experience: { in: experienceFilters },
isResolved: isUnreviewed ? false : {}, isResolved: isUnreviewed ? false : {},
@ -174,23 +155,13 @@ export const resumesRouter = createRouter()
}, },
}); });
const mappedLocationCounts = Object.fromEntries( 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 = { const filterCounts = {
Experience: processedExperienceCounts, experience: mappedExperienceCounts,
Location: processedLocationCounts, location: mappedLocationCounts,
Role: processedRoleCounts, role: mappedRoleCounts,
}; };
return { return {
@ -217,6 +188,11 @@ export const resumesRouter = createRouter()
stars: true, stars: true,
}, },
}, },
location: {
select: {
name: true,
},
},
stars: { stars: {
where: { where: {
OR: { OR: {

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

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

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

@ -1,4 +1,5 @@
// API from https://github.com/fawazahmed0/currency-api#readme // API from https://github.com/fawazahmed0/currency-api#readme
import fetch from 'cross-fetch';
export const convert = async ( export const convert = async (
value: number, 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'; 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) { export function formatDate(value: Date | number | string) {
const date = new Date(value); const date = new Date(value);
const month = date.toLocaleString('default', { month: 'short' }); const month = date.toLocaleString('default', { month: 'short' });

@ -28,7 +28,7 @@ export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
}, },
{ {
id: 'SYSTEM_DESIGN', id: 'SYSTEM_DESIGN',
label: 'Design', label: 'System Design',
value: '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 = { export const SAMPLE_QUESTION = {
answerCount: 10, answerCount: 10,
commentCount: 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 */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback } from 'react'; 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 { trpc } from '../trpc';
import type { Question } from '~/types/questions';
type UseVoteOptions = { type UseVoteOptions = {
setDownVote: () => void; setDownVote: () => void;
setNoVote: () => void; setNoVote: () => void;
@ -46,12 +49,78 @@ type MutationKey = Parameters<typeof trpc.useMutation>[0];
type QueryKey = Parameters<typeof trpc.useQuery>[0][0]; type QueryKey = Parameters<typeof trpc.useQuery>[0][0];
export const useQuestionVote = (id: string) => { export const useQuestionVote = (id: string) => {
const utils = trpc.useContext();
return useVote(id, { return useVote(id, {
idKey: 'questionId', idKey: 'questionId',
invalidateKeys: [ 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', query: 'questions.questions.user.getVote',
setDownVoteKey: 'questions.questions.user.setDownVote', setDownVoteKey: 'questions.questions.user.setDownVote',
setNoVoteKey: 'questions.questions.user.setNoVote', setNoVoteKey: 'questions.questions.user.setNoVote',
@ -63,8 +132,8 @@ export const useAnswerVote = (id: string) => {
return useVote(id, { return useVote(id, {
idKey: 'answerId', idKey: 'answerId',
invalidateKeys: [ invalidateKeys: [
'questions.answers.getAnswers',
'questions.answers.getAnswerById', 'questions.answers.getAnswerById',
'questions.answers.getAnswers',
], ],
query: 'questions.answers.user.getVote', query: 'questions.answers.user.getVote',
setDownVoteKey: 'questions.answers.user.setDownVote', 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> = { type VoteProps<VoteQueryKey extends QueryKey = QueryKey> = {
idKey: string; idKey: string;
invalidateKeys: Array<VoteQueryKey>; invalidateKeys: Array<QueryKey>;
onMutate?: InvalidateFunction;
// Invalidate: Partial<Record<QueryKey, InvalidateFunction | null>>;
query: VoteQueryKey; query: VoteQueryKey;
setDownVoteKey: MutationKey; setDownVoteKey: MutationKey;
setNoVoteKey: MutationKey; setNoVoteKey: MutationKey;
@ -116,6 +193,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { const {
idKey, idKey,
invalidateKeys, invalidateKeys,
onMutate,
query, query,
setDownVoteKey, setDownVoteKey,
setNoVoteKey, setNoVoteKey,
@ -125,11 +203,16 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const onVoteUpdate = useCallback(() => { const onVoteUpdate = useCallback(() => {
// TODO: Optimise query invalidation // TODO: Optimise query invalidation
utils.invalidateQueries([query, { [idKey]: id } as any]); // utils.invalidateQueries([query, { [idKey]: id } as any]);
for (const invalidateKey of invalidateKeys) { 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([ const { data } = trpc.useQuery([
query, query,
@ -143,7 +226,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setUpVote } = trpc.useMutation<any, UseVoteMutationContext>(
setUpVoteKey, setUpVoteKey,
{ {
onError: (err, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
@ -154,6 +237,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any], [query, { [idKey]: id } as any],
); );
const currentData = {
...(vote as any),
vote: Vote.UPVOTE,
} as BackendVote;
utils.setQueryData( utils.setQueryData(
[ [
query, query,
@ -161,9 +249,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id, [idKey]: id,
} as any, } 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, onSettled: onVoteUpdate,
}, },
@ -171,7 +261,7 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setDownVote } = trpc.useMutation<any, UseVoteMutationContext>(
setDownVoteKey, setDownVoteKey,
{ {
onError: (error, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
@ -182,6 +272,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[query, { [idKey]: id } as any], [query, { [idKey]: id } as any],
); );
const currentData = {
...vote,
vote: Vote.DOWNVOTE,
} as BackendVote;
utils.setQueryData( utils.setQueryData(
[ [
query, query,
@ -189,9 +284,11 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
[idKey]: id, [idKey]: id,
} as any, } as any,
], ],
vote, currentData as any,
); );
return { currentData: vote, previousData };
await onMutate?.(previousData?.vote ?? null, currentData?.vote ?? null);
return { currentData, previousData };
}, },
onSettled: onVoteUpdate, onSettled: onVoteUpdate,
}, },
@ -200,23 +297,31 @@ export const useVote = <VoteQueryKey extends QueryKey = QueryKey>(
const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>( const { mutate: setNoVote } = trpc.useMutation<any, UseVoteMutationContext>(
setNoVoteKey, setNoVoteKey,
{ {
onError: (err, variables, context) => { onError: (_error, _variables, context) => {
if (context !== undefined) { if (context !== undefined) {
utils.setQueryData([query], context.previousData); utils.setQueryData([query], context.previousData);
} }
}, },
onMutate: async (vote) => { onMutate: async () => {
await utils.queryClient.cancelQueries([query, { [idKey]: id } as any]); 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, query,
{ {
[idKey]: id, [idKey]: id,
} as any, } as any,
], ],
null as any, currentData,
); );
return { currentData: null, previousData: vote };
await onMutate?.(previousData?.vote ?? null, null);
return { currentData, previousData };
}, },
onSettled: onVoteUpdate, 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 FilterId = 'experience' | 'location' | 'role';
export type FilterLabel = 'Experience' | 'Location' | 'Role';
export type CustomFilter = { export type CustomFilter = {
isUnreviewed: boolean; 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> = { export type FilterOption<T> = {
label: string; label: string;
value: T; value: T;
@ -30,11 +16,11 @@ export type FilterOption<T> = {
export type Filter = { export type Filter = {
id: FilterId; id: FilterId;
label: FilterLabel; label: string;
options: Array<FilterOption<FilterValue>>;
}; };
export type FilterState = CustomFilter & Record<FilterId, Array<FilterValue>>; export type FilterState = CustomFilter &
Record<FilterId, Array<TypeaheadOption>>;
export type SortOrder = 'latest' | 'mostComments' | 'popular'; export type SortOrder = 'latest' | 'mostComments' | 'popular';
@ -45,6 +31,31 @@ export type Shortcut = {
sortOrder: SortOrder; 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 = { export const BROWSE_TABS_VALUES = {
ALL: 'all', ALL: 'all',
MY: 'my', MY: 'my',
@ -57,45 +68,85 @@ export const SORT_OPTIONS: Array<FilterOption<SortOrder>> = [
{ label: 'Most Comments', value: 'mostComments' }, { label: 'Most Comments', value: 'mostComments' },
]; ];
export const ROLES: Array<FilterOption<RoleFilter>> = [ const INITIAL_ROLES_VALUES: Array<JobTitleType> = [
{ 'software-engineer',
label: 'Full-Stack Engineer', 'back-end-engineer',
value: 'Full-Stack Engineer', 'front-end-engineer',
}, 'full-stack-engineer',
{ label: 'Frontend Engineer', value: 'Frontend Engineer' }, 'ios-engineer',
{ label: 'Backend Engineer', value: 'Backend Engineer' }, 'android-engineer',
{ label: 'DevOps Engineer', value: 'DevOps Engineer' }, 'data-engineer',
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android 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>> = [ export const EXPERIENCES: Array<TypeaheadOption> = [
{ label: 'Internship', value: 'Internship' },
{ {
id: 'internship',
label: 'Internship',
value: 'internship',
},
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)', label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)', value: 'entry-level',
}, },
{ {
id: 'mid-level',
label: 'Mid Level (3 - 5 years)', label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)', value: 'mid-level',
}, },
{ {
id: 'senior-level',
label: 'Senior Level (5+ years)', label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)', value: 'senior-level',
}, },
]; ];
export const LOCATIONS: Array<FilterOption<LocationFilter>> = [ export const INITIAL_LOCATIONS: Array<TypeaheadOption> = [
{ label: 'Singapore', value: 'Singapore' }, {
{ label: 'United States', value: 'United States' }, id: '196',
{ label: 'India', value: 'India' }, 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 = { export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCES).map(({ value }) => value), experience: EXPERIENCES,
isUnreviewed: true, isUnreviewed: true,
location: Object.values(LOCATIONS).map(({ value }) => value), location: INITIAL_LOCATIONS,
role: Object.values(ROLES).map(({ value }) => value), role: INITIAL_ROLES,
}; };
export const SHORTCUTS: Array<Shortcut> = [ export const SHORTCUTS: Array<Shortcut> = [
@ -104,7 +155,7 @@ export const SHORTCUTS: Array<Shortcut> = [
...INITIAL_FILTER_STATE, ...INITIAL_FILTER_STATE,
isUnreviewed: false, isUnreviewed: false,
}, },
name: 'All', name: 'General',
sortOrder: 'latest', sortOrder: 'latest',
}, },
{ {
@ -118,7 +169,13 @@ export const SHORTCUTS: Array<Shortcut> = [
{ {
filters: { filters: {
...INITIAL_FILTER_STATE, ...INITIAL_FILTER_STATE,
experience: ['Entry Level (0 - 2 years)'], experience: [
{
id: 'entry-level',
label: 'Entry Level (0 - 2 years)',
value: 'entry-level',
},
],
isUnreviewed: false, isUnreviewed: false,
}, },
name: 'Fresh Grad', name: 'Fresh Grad',
@ -136,26 +193,46 @@ export const SHORTCUTS: Array<Shortcut> = [
filters: { filters: {
...INITIAL_FILTER_STATE, ...INITIAL_FILTER_STATE,
isUnreviewed: false, isUnreviewed: false,
location: ['United States'], location: [
{
id: '231',
label: 'United States',
value: '231',
},
],
}, },
name: 'US Only', name: 'US Only',
sortOrder: 'latest', sortOrder: 'latest',
}, },
]; ];
export const isInitialFilterState = (filters: FilterState) => // We omit 'location' as its label should be fetched from the Country table.
Object.keys(filters).every((filter) => { export const getFilterLabel = (
if (!['experience', 'location', 'role'].includes(filter)) { filterId: Omit<FilterId | 'sort', 'location'>,
return true; 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 INITIAL_FILTER_STATE[filter as FilterId].every((value) =>
filters[filter as FilterId].includes(value),
);
});
export const getFilterLabel = ( return filters.find(({ value }) => value === filterValue)?.label;
filters: Array< };
FilterOption<ExperienceFilter | LocationFilter | RoleFilter | SortOrder>
>,
filterValue: ExperienceFilter | LocationFilter | RoleFilter | SortOrder,
) => filters.find(({ value }) => value === filterValue)?.label ?? filterValue;

@ -1,5 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import Link from 'next/link'; import Link from 'next/link';
import type { HTMLAttributeAnchorTarget } from 'react';
import type { UrlObject } from 'url'; import type { UrlObject } from 'url';
import { Spinner } from '../'; import { Spinner } from '../';
@ -30,7 +31,9 @@ type Props = Readonly<{
isLoading?: boolean; isLoading?: boolean;
label: string; label: string;
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
rel?: string;
size?: ButtonSize; size?: ButtonSize;
target?: HTMLAttributeAnchorTarget;
type?: ButtonType; type?: ButtonType;
variant: ButtonVariant; variant: ButtonVariant;
}>; }>;
@ -115,6 +118,8 @@ export default function Button({
type = 'button', type = 'button',
variant, variant,
onClick, onClick,
rel,
target,
}: Props) { }: Props) {
const iconSpacingClass = (() => { const iconSpacingClass = (() => {
if (!isLabelHidden && addonPosition === 'start') { if (!isLabelHidden && addonPosition === 'start') {
@ -166,6 +171,6 @@ export default function Button({
return ( return (
// TODO: Allow passing in of Link component. // 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" "@webassemblyjs/wast-parser" "1.9.0"
"@xtuc/long" "4.2.2" "@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": "@xtuc/ieee754@^1.2.0":
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz" 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" resolved "https://registry.npmjs.org/address/-/address-1.2.1.tgz"
integrity sha512-B+6bi5D34+fDYENiH5qOlA0cV2rAGKuWZ9LeyUUehbXy8e0VS9e498yO0Jeeh+iM+6KbfudHTFjXw2MmJD4QRA== 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: aggregate-error@^3.0.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz" resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz"
@ -4816,7 +4826,7 @@ better-opn@^2.1.1:
dependencies: dependencies:
open "^7.0.3" 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" version "1.6.51"
resolved "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz" resolved "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz"
integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg== 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" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== 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: bindings@^1.5.0:
version "1.5.0" version "1.5.0"
resolved "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz" 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" resolved "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== 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: bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.11.9:
version "4.12.0" version "4.12.0"
resolved "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz" 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" resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz"
integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== 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: buffer-xor@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz" 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" ieee754 "^1.1.4"
isarray "^1.0.0" 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: bufferutil@^4.0.1:
version "4.0.6" version "4.0.6"
resolved "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.6.tgz" 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" resolved "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz"
integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== 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: chalk@2.4.1:
version "2.4.1" version "2.4.1"
resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz" 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" resolved "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== 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: collapse-white-space@^1.0.2:
version "1.0.6" version "1.0.6"
resolved "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-1.0.6.tgz" 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-filter "^2.1.0"
p-map "^3.0.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: create-ecdh@^4.0.0:
version "4.0.4" version "4.0.4"
resolved "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz" 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" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz"
integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== 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: date-fns@^2.29.1, date-fns@^2.29.3:
version "2.29.3" version "2.29.3"
resolved "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz" 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" resolved "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz"
integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== 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: duplexer3@^0.1.4:
version "0.1.5" version "0.1.5"
resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz" resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz"
@ -7725,11 +7795,24 @@ feed@^4.2.2:
dependencies: dependencies:
xml-js "^1.6.11" 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: fetch-retry@^5.0.2:
version "5.0.3" version "5.0.3"
resolved "https://registry.npmjs.org/fetch-retry/-/fetch-retry-5.0.3.tgz" resolved "https://registry.npmjs.org/fetch-retry/-/fetch-retry-5.0.3.tgz"
integrity sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw== 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: figgy-pudding@^3.5.1:
version "3.5.2" version "3.5.2"
resolved "https://registry.npmjs.org/figgy-pudding/-/figgy-pudding-3.5.2.tgz" 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" combined-stream "^1.0.8"
mime-types "^2.1.12" 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: formidable@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/formidable/-/formidable-2.0.1.tgz" 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" resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz"
integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== 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: fraction.js@^4.2.0:
version "4.2.0" version "4.2.0"
resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.2.0.tgz" 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" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== 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: function-bind@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" 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" to-readable-stream "^1.0.0"
url-parse-lax "^3.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" version "4.2.10"
resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz"
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
@ -8885,7 +8990,7 @@ inflight@^1.0.4:
once "^1.3.0" once "^1.3.0"
wrappy "1" 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" version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 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" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== 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: load-json-file@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz" 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" for-in "^1.0.2"
is-extendable "^1.0.1" 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" version "0.5.6"
resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz"
integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
@ -10568,6 +10678,11 @@ node-dir@^0.1.10:
dependencies: dependencies:
minimatch "^3.0.2" 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: node-emoji@^1.10.0:
version "1.11.0" version "1.11.0"
resolved "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz" 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: dependencies:
whatwg-url "^5.0.0" 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: node-forge@^1:
version "1.3.1" version "1.3.1"
resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz" resolved "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz"
@ -12387,6 +12511,15 @@ read-cache@^1.0.0:
dependencies: dependencies:
pify "^2.3.0" 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: read-pkg-up@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz" 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" resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== 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: rimraf@3.0.2, rimraf@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz"
@ -12810,13 +12950,6 @@ rimraf@3.0.2, rimraf@^3.0.2:
dependencies: dependencies:
glob "^7.1.3" 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: ripemd160@^2.0.0, ripemd160@^2.0.1:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz" 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" is-plain-object "^2.0.3"
split-string "^3.0.1" 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" version "1.0.5"
resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz" resolved "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz"
integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== 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" resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== 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: ssri@^6.0.1:
version "6.0.2" version "6.0.2"
resolved "https://registry.npmjs.org/ssri/-/ssri-6.0.2.tgz" 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" resolved "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz"
integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== 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: tree-kill@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz" resolved "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz"
@ -14515,6 +14660,22 @@ untildify@^2.0.0:
dependencies: dependencies:
os-homedir "^1.0.0" 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: upath@^1.1.1:
version "1.2.0" version "1.2.0"
resolved "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz" 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" resolved "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz"
integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== 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" version "3.2.1"
resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz" resolved "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz"
integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q== integrity sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==
@ -15123,11 +15284,21 @@ wildcard@^2.0.0:
resolved "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz" resolved "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz"
integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== 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: word-wrap@^1.2.3, word-wrap@~1.2.3:
version "1.2.3" version "1.2.3"
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz" resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== 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: wordwrap@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz" 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" resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz"
integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== 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: xml-js@^1.6.11:
version "1.6.11" version "1.6.11"
resolved "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz" resolved "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz"

Loading…
Cancel
Save