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

pull/384/head
hpkoh 3 years ago
commit 471ff0fa70

@ -30,6 +30,7 @@
"next-auth": "~4.10.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.36.1",
"react-pdf": "^5.7.2",
"react-query": "^3.39.2",

@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `company` on the `QuestionsQuestionEncounter` table. All the data in the column will be lost.
- Added the required column `companyId` to the `QuestionsQuestionEncounter` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "QuestionsQuestionEncounter" DROP COLUMN "company",
ADD COLUMN "companyId" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "QuestionsQuestionEncounter" ADD CONSTRAINT "QuestionsQuestionEncounter_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;

@ -0,0 +1,60 @@
-- CreateTable
CREATE TABLE "OffersAnalysis" (
"id" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
"offerId" TEXT NOT NULL,
"overallPercentile" INTEGER NOT NULL,
"noOfSimilarOffers" INTEGER NOT NULL,
"companyPercentile" INTEGER NOT NULL,
"noOfSimilarCompanyOffers" INTEGER NOT NULL,
CONSTRAINT "OffersAnalysis_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_TopOverallOffers" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "_TopCompanyOffers" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "OffersAnalysis_profileId_key" ON "OffersAnalysis"("profileId");
-- CreateIndex
CREATE UNIQUE INDEX "OffersAnalysis_offerId_key" ON "OffersAnalysis"("offerId");
-- CreateIndex
CREATE UNIQUE INDEX "_TopOverallOffers_AB_unique" ON "_TopOverallOffers"("A", "B");
-- CreateIndex
CREATE INDEX "_TopOverallOffers_B_index" ON "_TopOverallOffers"("B");
-- CreateIndex
CREATE UNIQUE INDEX "_TopCompanyOffers_AB_unique" ON "_TopCompanyOffers"("A", "B");
-- CreateIndex
CREATE INDEX "_TopCompanyOffers_B_index" ON "_TopCompanyOffers"("B");
-- AddForeignKey
ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "OffersProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "OffersOffer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TopOverallOffers" ADD CONSTRAINT "_TopOverallOffers_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TopOverallOffers" ADD CONSTRAINT "_TopOverallOffers_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TopCompanyOffers" ADD CONSTRAINT "_TopCompanyOffers_A_fkey" FOREIGN KEY ("A") REFERENCES "OffersAnalysis"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_TopCompanyOffers" ADD CONSTRAINT "_TopCompanyOffers_B_fkey" FOREIGN KEY ("B") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "OffersAnalysis" ALTER COLUMN "overallPercentile" SET DATA TYPE DOUBLE PRECISION,
ALTER COLUMN "companyPercentile" SET DATA TYPE DOUBLE PRECISION;

@ -0,0 +1,11 @@
-- DropForeignKey
ALTER TABLE "OffersAnalysis" DROP CONSTRAINT "OffersAnalysis_offerId_fkey";
-- DropForeignKey
ALTER TABLE "OffersAnalysis" DROP CONSTRAINT "OffersAnalysis_profileId_fkey";
-- AddForeignKey
ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "OffersProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersAnalysis" ADD CONSTRAINT "OffersAnalysis_offerId_fkey" FOREIGN KEY ("offerId") REFERENCES "OffersOffer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -0,0 +1,14 @@
/*
Warnings:
- Made the column `totalYoe` on table `OffersBackground` required. This step will fail if there are existing NULL values in that column.
- Made the column `negotiationStrategy` on table `OffersOffer` required. This step will fail if there are existing NULL values in that column.
- Made the column `comments` on table `OffersOffer` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "OffersBackground" ALTER COLUMN "totalYoe" SET NOT NULL;
-- AlterTable
ALTER TABLE "OffersOffer" ALTER COLUMN "negotiationStrategy" SET NOT NULL,
ALTER COLUMN "comments" SET NOT NULL;

@ -1,105 +1,107 @@
// Refer to the Prisma schema docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
provider = "postgresql"
url = env("DATABASE_URL")
}
// Necessary for NextAuth.
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
todos Todo[]
resumesResumes ResumesResume[]
resumesStars ResumesStar[]
resumesComments ResumesComment[]
resumesCommentVotes ResumesCommentVote[]
questionsQuestions QuestionsQuestion[]
questionsQuestionEncounters QuestionsQuestionEncounter[]
questionsQuestionVotes QuestionsQuestionVote[]
questionsQuestionComments QuestionsQuestionComment[]
questionsQuestionCommentVotes QuestionsQuestionCommentVote[]
questionsAnswers QuestionsAnswer[]
questionsAnswerVotes QuestionsAnswerVote[]
questionsAnswerComments QuestionsAnswerComment[]
questionsAnswerCommentVotes QuestionsAnswerCommentVote[]
OffersProfile OffersProfile[]
offersDiscussion OffersReply[]
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
todos Todo[]
resumesResumes ResumesResume[]
resumesStars ResumesStar[]
resumesComments ResumesComment[]
resumesCommentVotes ResumesCommentVote[]
questionsQuestions QuestionsQuestion[]
questionsQuestionEncounters QuestionsQuestionEncounter[]
questionsQuestionVotes QuestionsQuestionVote[]
questionsQuestionComments QuestionsQuestionComment[]
questionsQuestionCommentVotes QuestionsQuestionCommentVote[]
questionsAnswers QuestionsAnswer[]
questionsAnswerVotes QuestionsAnswerVote[]
questionsAnswerComments QuestionsAnswerComment[]
questionsAnswerCommentVotes QuestionsAnswerCommentVote[]
OffersProfile OffersProfile[]
offersDiscussion OffersReply[]
}
enum Vote {
UPVOTE
DOWNVOTE
UPVOTE
DOWNVOTE
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
@@unique([identifier, token])
}
model Todo {
id String @id @default(cuid())
userId String
text String @db.Text
status TodoStatus @default(INCOMPLETE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String
text String @db.Text
status TodoStatus @default(INCOMPLETE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum TodoStatus {
INCOMPLETE
COMPLETE
INCOMPLETE
COMPLETE
}
model Company {
id String @id @default(cuid())
name String @db.Text
slug String @unique
description String? @db.Text
logoUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
OffersExperience OffersExperience[]
OffersOffer OffersOffer[]
id String @id @default(cuid())
name String @db.Text
slug String @unique
description String? @db.Text
logoUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
questionsQuestionEncounter QuestionsQuestionEncounter[]
OffersExperience OffersExperience[]
OffersOffer OffersOffer[]
}
// Start of Resumes project models.
@ -107,65 +109,65 @@ model Company {
// use camelCase for field names, and try to name them consistently
// across all models in this file.
model ResumesResume {
id String @id @default(cuid())
userId String
title String @db.Text
// TODO: Update role, experience, location to use Enums
role String @db.Text
experience String @db.Text
location String @db.Text
url String
additionalInfo String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stars ResumesStar[]
comments ResumesComment[]
id String @id @default(cuid())
userId String
title String @db.Text
// TODO: Update role, experience, location to use Enums
role String @db.Text
experience String @db.Text
location String @db.Text
url String
additionalInfo String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stars ResumesStar[]
comments ResumesComment[]
}
model ResumesStar {
id String @id @default(cuid())
userId String
resumeId String
createdAt DateTime @default(now())
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String
resumeId String
createdAt DateTime @default(now())
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, resumeId])
@@unique([userId, resumeId])
}
model ResumesComment {
id String @id @default(cuid())
userId String
resumeId String
description String @db.Text
section ResumesSection
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
votes ResumesCommentVote[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String
resumeId String
description String @db.Text
section ResumesSection
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
votes ResumesCommentVote[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum ResumesSection {
GENERAL
EDUCATION
EXPERIENCE
PROJECTS
SKILLS
GENERAL
EDUCATION
EXPERIENCE
PROJECTS
SKILLS
}
model ResumesCommentVote {
id String @id @default(cuid())
userId String
commentId String
value Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String
commentId String
value Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
comment ResumesComment @relation(fields: [commentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, commentId])
@@unique([userId, commentId])
}
// End of Resumes project models.
@ -176,176 +178,202 @@ model ResumesCommentVote {
// across all models in this file.
model OffersProfile {
id String @id @default(cuid())
profileName String @unique
createdAt DateTime @default(now())
id String @id @default(cuid())
profileName String @unique
createdAt DateTime @default(now())
background OffersBackground?
background OffersBackground?
editToken String
editToken String
discussion OffersReply[]
discussion OffersReply[]
offers OffersOffer[]
offers OffersOffer[]
user User? @relation(fields: [userId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
userId String?
analysis OffersAnalysis?
}
model OffersBackground {
id String @id @default(cuid())
id String @id @default(cuid())
totalYoe Int?
specificYoes OffersSpecificYoe[]
totalYoe Int
specificYoes OffersSpecificYoe[]
experiences OffersExperience[] // For extensibility in the future
experiences OffersExperience[] // For extensibility in the future
educations OffersEducation[] // For extensibility in the future
educations OffersEducation[] // For extensibility in the future
profile OffersProfile @relation(fields: [offersProfileId], references: [id], onDelete: Cascade)
offersProfileId String @unique
profile OffersProfile @relation(fields: [offersProfileId], references: [id], onDelete: Cascade)
offersProfileId String @unique
}
model OffersSpecificYoe {
id String @id @default(cuid())
id String @id @default(cuid())
yoe Int
domain String
yoe Int
domain String
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
}
model OffersExperience {
id String @id @default(cuid())
id String @id @default(cuid())
company Company? @relation(fields: [companyId], references: [id])
companyId String?
company Company? @relation(fields: [companyId], references: [id])
companyId String?
jobType JobType?
title String?
jobType JobType?
title String?
// Add more fields
durationInMonths Int?
specialization String?
// Add more fields
durationInMonths Int?
specialization String?
// FULLTIME fields
level String?
totalCompensation OffersCurrency? @relation("ExperienceTotalCompensation", fields: [totalCompensationId], references: [id])
totalCompensationId String? @unique
// FULLTIME fields
level String?
totalCompensation OffersCurrency? @relation("ExperienceTotalCompensation", fields: [totalCompensationId], references: [id])
totalCompensationId String? @unique
// INTERN fields
monthlySalary OffersCurrency? @relation("ExperienceMonthlySalary", fields: [monthlySalaryId], references: [id])
monthlySalaryId String? @unique
// INTERN fields
monthlySalary OffersCurrency? @relation("ExperienceMonthlySalary", fields: [monthlySalaryId], references: [id])
monthlySalaryId String? @unique
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
}
model OffersCurrency {
id String @id @default(cuid())
value Int
currency String
id String @id @default(cuid())
value Int
currency String
// Experience
OffersExperienceTotalCompensation OffersExperience? @relation("ExperienceTotalCompensation")
OffersExperienceMonthlySalary OffersExperience? @relation("ExperienceMonthlySalary")
// Experience
OffersExperienceTotalCompensation OffersExperience? @relation("ExperienceTotalCompensation")
OffersExperienceMonthlySalary OffersExperience? @relation("ExperienceMonthlySalary")
// Full Time
OffersTotalCompensation OffersFullTime? @relation("OfferTotalCompensation")
OffersBaseSalary OffersFullTime? @relation("OfferBaseSalary")
OffersBonus OffersFullTime? @relation("OfferBonus")
OffersStocks OffersFullTime? @relation("OfferStocks")
// Full Time
OffersTotalCompensation OffersFullTime? @relation("OfferTotalCompensation")
OffersBaseSalary OffersFullTime? @relation("OfferBaseSalary")
OffersBonus OffersFullTime? @relation("OfferBonus")
OffersStocks OffersFullTime? @relation("OfferStocks")
// Intern
OffersMonthlySalary OffersIntern?
// Intern
OffersMonthlySalary OffersIntern?
}
enum JobType {
INTERN
FULLTIME
INTERN
FULLTIME
}
model OffersEducation {
id String @id @default(cuid())
type String?
field String?
id String @id @default(cuid())
type String?
field String?
school String?
startDate DateTime?
endDate DateTime?
school String?
startDate DateTime?
endDate DateTime?
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
}
model OffersReply {
id String @id @default(cuid())
createdAt DateTime @default(now())
message String
id String @id @default(cuid())
createdAt DateTime @default(now())
message String
replyingToId String?
replyingTo OffersReply? @relation("ReplyThread", fields: [replyingToId], references: [id])
replies OffersReply[] @relation("ReplyThread")
replyingToId String?
replyingTo OffersReply? @relation("ReplyThread", fields: [replyingToId], references: [id])
replies OffersReply[] @relation("ReplyThread")
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String
user User? @relation(fields: [userId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
userId String?
}
model OffersOffer {
id String @id @default(cuid())
id String @id @default(cuid())
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String
company Company @relation(fields: [companyId], references: [id])
companyId String
company Company @relation(fields: [companyId], references: [id])
companyId String
monthYearReceived DateTime
location String
negotiationStrategy String
comments String
monthYearReceived DateTime
location String
negotiationStrategy String?
comments String?
jobType JobType
jobType JobType
offersIntern OffersIntern? @relation(fields: [offersInternId], references: [id], onDelete: Cascade)
offersInternId String? @unique
OffersIntern OffersIntern? @relation(fields: [offersInternId], references: [id], onDelete: Cascade)
offersInternId String? @unique
offersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade)
offersFullTimeId String? @unique
OffersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id], onDelete: Cascade)
offersFullTimeId String? @unique
OffersAnalysis OffersAnalysis? @relation("HighestOverallOffer")
OffersAnalysisTopOverallOffers OffersAnalysis[] @relation("TopOverallOffers")
OffersAnalysisTopCompanyOffers OffersAnalysis[] @relation("TopCompanyOffers")
}
model OffersIntern {
id String @id @default(cuid())
id String @id @default(cuid())
title String
specialization String
internshipCycle String
startYear Int
monthlySalary OffersCurrency @relation(fields: [monthlySalaryId], references: [id], onDelete: Cascade)
monthlySalaryId String @unique
title String
specialization String
internshipCycle String
startYear Int
monthlySalary OffersCurrency @relation(fields: [monthlySalaryId], references: [id], onDelete: Cascade)
monthlySalaryId String @unique
OffersOffer OffersOffer?
OffersOffer OffersOffer?
}
model OffersFullTime {
id String @id @default(cuid())
title String
specialization String
level String
totalCompensation OffersCurrency @relation("OfferTotalCompensation", fields: [totalCompensationId], references: [id], onDelete: Cascade)
totalCompensationId String @unique
baseSalary OffersCurrency @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id], onDelete: Cascade)
baseSalaryId String @unique
bonus OffersCurrency @relation("OfferBonus", fields: [bonusId], references: [id], onDelete: Cascade)
bonusId String @unique
stocks OffersCurrency @relation("OfferStocks", fields: [stocksId], references: [id], onDelete: Cascade)
stocksId String @unique
OffersOffer OffersOffer?
id String @id @default(cuid())
title String
specialization String
level String
totalCompensation OffersCurrency @relation("OfferTotalCompensation", fields: [totalCompensationId], references: [id], onDelete: Cascade)
totalCompensationId String @unique
baseSalary OffersCurrency @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id], onDelete: Cascade)
baseSalaryId String @unique
bonus OffersCurrency @relation("OfferBonus", fields: [bonusId], references: [id], onDelete: Cascade)
bonusId String @unique
stocks OffersCurrency @relation("OfferStocks", fields: [stocksId], references: [id], onDelete: Cascade)
stocksId String @unique
OffersOffer OffersOffer?
}
model OffersAnalysis {
id String @id @default(cuid())
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String @unique
overallHighestOffer OffersOffer @relation("HighestOverallOffer", fields: [offerId], references: [id], onDelete: Cascade)
offerId String @unique
// OVERALL
overallPercentile Float
noOfSimilarOffers Int
topOverallOffers OffersOffer[] @relation("TopOverallOffers")
// Company
companyPercentile Float
noOfSimilarCompanyOffers Int
topCompanyOffers OffersOffer[] @relation("TopCompanyOffers")
}
// End of Offers project models.
@ -356,136 +384,140 @@ model OffersFullTime {
// across all models in this file.
enum QuestionsQuestionType {
CODING
SYSTEM_DESIGN
BEHAVIORAL
CODING
SYSTEM_DESIGN
BEHAVIORAL
}
model QuestionsQuestion {
id String @id @default(cuid())
userId String?
content String @db.Text
questionType QuestionsQuestionType
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
encounters QuestionsQuestionEncounter[]
votes QuestionsQuestionVote[]
comments QuestionsQuestionComment[]
answers QuestionsAnswer[]
id String @id @default(cuid())
userId String?
content String @db.Text
questionType QuestionsQuestionType
lastSeenAt DateTime @default(now())
upvotes Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
encounters QuestionsQuestionEncounter[]
votes QuestionsQuestionVote[]
comments QuestionsQuestionComment[]
answers QuestionsAnswer[]
}
model QuestionsQuestionEncounter {
id String @id @default(cuid())
questionId String
userId String?
// TODO: sync with models
company String @db.Text
location String @db.Text
role String @db.Text
seenAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
questionId String
userId String?
// TODO: sync with models (location, role)
companyId String
location String @db.Text
role String @db.Text
netVotes Int @default(0)
seenAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
}
model QuestionsQuestionVote {
id String @id @default(cuid())
questionId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
questionId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
@@unique([questionId, userId])
@@unique([questionId, userId])
}
model QuestionsQuestionComment {
id String @id @default(cuid())
questionId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
questionId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsQuestionCommentVote[]
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsQuestionCommentVote[]
}
model QuestionsQuestionCommentVote {
id String @id @default(cuid())
questionCommentId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
questionCommentId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
comment QuestionsQuestionComment @relation(fields: [questionCommentId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
comment QuestionsQuestionComment @relation(fields: [questionCommentId], references: [id], onDelete: Cascade)
@@unique([questionCommentId, userId])
@@unique([questionCommentId, userId])
}
model QuestionsAnswer {
id String @id @default(cuid())
questionId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
questionId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsAnswerVote[]
comments QuestionsAnswerComment[]
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsAnswerVote[]
comments QuestionsAnswerComment[]
}
model QuestionsAnswerVote {
id String @id @default(cuid())
answerId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
answerId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
@@unique([answerId, userId])
@@unique([answerId, userId])
}
model QuestionsAnswerComment {
id String @id @default(cuid())
answerId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
answerId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
votes QuestionsAnswerCommentVote[]
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
votes QuestionsAnswerCommentVote[]
}
model QuestionsAnswerCommentVote {
id String @id @default(cuid())
answerCommentId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
answerCommentId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
comment QuestionsAnswerComment @relation(fields: [answerCommentId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
comment QuestionsAnswerComment @relation(fields: [answerCommentId], references: [id], onDelete: Cascade)
@@unique([answerCommentId, userId])
@@unique([answerCommentId, userId])
}
// End of Questions project models.

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

@ -0,0 +1,61 @@
import { UserCircleIcon } from '@heroicons/react/24/outline';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { formatDate } from '~/utils/offers/time';
import { JobType } from '../types';
import type { AnalysisOffer } from '~/types/offers';
type OfferProfileCardProps = Readonly<{
offerProfile: AnalysisOffer;
}>;
export default function OfferProfileCard({
offerProfile: {
company,
income,
profileName,
totalYoe,
level,
monthYearReceived,
jobType,
location,
title,
previousCompanies,
},
}: OfferProfileCardProps) {
return (
<div className="my-5 block rounded-lg border p-4">
<div className="grid grid-flow-col grid-cols-12 gap-x-10">
<div className="col-span-1">
<UserCircleIcon width={50} />
</div>
<div className="col-span-10">
<p className="text-sm font-semibold">{profileName}</p>
<p className="text-xs ">Previous company: {previousCompanies[0]}</p>
<p className="text-xs ">YOE: {totalYoe} year(s)</p>
</div>
</div>
<HorizontalDivider />
<div className="grid grid-flow-col grid-cols-2 gap-x-10">
<div className="col-span-1 row-span-3">
<p className="text-sm font-semibold">{title}</p>
<p className="text-xs ">
Company: {company.name}, {location}
</p>
<p className="text-xs ">Level: {level}</p>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end text-sm">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl">
{jobType === JobType.FullTime
? `$${income} / year`
: `$${income} / month`}
</p>
</div>
</div>
</div>
);
}

@ -22,29 +22,6 @@ export const titleOptions = [
},
];
export const companyOptions = [
{
label: 'Amazon',
value: 'cl93patjt0000txewdi601mub',
},
{
label: 'Microsoft',
value: 'cl93patjt0001txewkglfjsro',
},
{
label: 'Apple',
value: 'cl93patjt0002txewf3ug54m8',
},
{
label: 'Google',
value: 'cl93patjt0003txewyiaky7xx',
},
{
label: 'Meta',
value: 'cl93patjt0004txew88wkcqpu',
},
];
export const locationOptions = [
{
label: 'Singapore, Singapore',
@ -86,26 +63,26 @@ export const internshipCycleOptions = [
export const yearOptions = [
{
label: '2021',
value: '2021',
value: 2021,
},
{
label: '2022',
value: '2022',
value: 2022,
},
{
label: '2023',
value: '2023',
value: 2023,
},
{
label: '2024',
value: '2024',
value: 2024,
},
];
export const educationLevelOptions = Object.entries(
EducationBackgroundType,
).map(([key, value]) => ({
label: key,
).map(([, value]) => ({
label: value,
value,
}));
@ -129,3 +106,5 @@ export enum FieldError {
Number = 'Please fill in a number in this field.',
Required = 'Please fill in this field.',
}
export const OVERALL_TAB = 'Overall';

@ -4,7 +4,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
import { getCurrentMonth, getCurrentYear } from '../../../../utils/offers/time';
import { getCurrentMonth, getCurrentYear } from '../../../utils/offers/time';
type MonthYearPickerProps = ComponentProps<typeof MonthYearPicker>;

@ -1,100 +0,0 @@
import { useState } from 'react';
import { UserCircleIcon } from '@heroicons/react/20/solid';
import { HorizontalDivider, Tabs } from '@tih/ui';
const tabs = [
{
label: 'Overall',
value: 'overall',
},
{
label: 'Shopee',
value: 'company-id',
},
];
function OfferPercentileAnalysis() {
const result = {
company: 'Shopee',
numberOfOffers: 105,
percentile: 56,
};
return (
<p>
Your highest offer is from {result.company}, which is {result.percentile}{' '}
percentile out of {result.numberOfOffers} offers received in Singapore for
the same job type, same level, and same YOE in the last year.
</p>
);
}
function OfferProfileCard() {
return (
<div className="my-5 block rounded-lg border p-4">
<div className="grid grid-flow-col grid-cols-12 gap-x-10">
<div className="col-span-1">
<UserCircleIcon width={50} />
</div>
<div className="col-span-10">
<p className="text-sm font-semibold">profile-name</p>
<p className="text-xs ">Previous company: Meta, Singapore</p>
<p className="text-xs ">YOE: 4 years</p>
</div>
</div>
<HorizontalDivider />
<div className="grid grid-flow-col grid-cols-2 gap-x-10">
<div className="col-span-1 row-span-3">
<p className="text-sm font-semibold">Software engineer</p>
<p className="text-xs ">Company: Google, Singapore</p>
<p className="text-xs ">Level: G4</p>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end text-sm">Sept 2022</p>
<p className="text-end text-xl">$125,000 / year</p>
</div>
</div>
</div>
);
}
function TopOfferProfileList() {
return (
<>
<OfferProfileCard />
<OfferProfileCard />
</>
);
}
function OfferAnalysisContent() {
return (
<>
<OfferPercentileAnalysis />
<TopOfferProfileList />
</>
);
}
export default function OfferAnalysis() {
const [tab, setTab] = useState('Overall');
return (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
<div>
<Tabs
label="Result Navigation"
tabs={tabs}
value={tab}
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent />
</div>
</div>
);
}

@ -2,21 +2,28 @@ import { useFormContext, useWatch } from 'react-hook-form';
import { Collapsible, RadioList } from '@tih/ui';
import {
companyOptions,
educationFieldOptions,
educationLevelOptions,
emptyOption,
FieldError,
locationOptions,
titleOptions,
} from '~/components/offers/constants';
import FormRadioList from '~/components/offers/forms/components/FormRadioList';
import FormSelect from '~/components/offers/forms/components/FormSelect';
import FormTextInput from '~/components/offers/forms/components/FormTextInput';
import type { BackgroundPostData } from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
import FormRadioList from '../forms/FormRadioList';
import FormSelect from '../forms/FormSelect';
import FormTextInput from '../forms/FormTextInput';
function YoeSection() {
const { register } = useFormContext();
const { register, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const backgroundFields = formState.errors.background;
return (
<>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400">
@ -26,53 +33,62 @@ function YoeSection() {
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-2 grid grid-cols-3 space-x-3">
<FormTextInput
errorMessage={backgroundFields?.totalYoe?.message}
label="Total YOE"
placeholder="0"
required={true}
type="number"
{...register(`background.totalYoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
/>
</div>
<div className="grid grid-cols-1 space-x-3">
<Collapsible label="Add specific YOEs by domain">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
label="Specific YOE 1"
type="number"
{...register(`background.specificYoes.0.yoe`, {
valueAsNumber: true,
})}
/>
<FormTextInput
label="Specific Domain 1"
placeholder="e.g. Frontend"
{...register(`background.specificYoes.0.domain`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
label="Specific YOE 2"
type="number"
{...register(`background.specificYoes.1.yoe`, {
valueAsNumber: true,
})}
/>
<FormTextInput
label="Specific Domain 2"
placeholder="e.g. Backend"
{...register(`background.specificYoes.1.domain`)}
/>
</div>
</Collapsible>
</div>
<Collapsible label="Add specific YOEs by domain">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
errorMessage={backgroundFields?.specificYoes?.[0]?.yoe?.message}
label="Specific YOE 1"
type="number"
{...register(`background.specificYoes.0.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
<FormTextInput
label="Specific Domain 1"
placeholder="e.g. Frontend"
{...register(`background.specificYoes.0.domain`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
errorMessage={backgroundFields?.specificYoes?.[1]?.yoe?.message}
label="Specific YOE 2"
type="number"
{...register(`background.specificYoes.1.yoe`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
<FormTextInput
label="Specific Domain 2"
placeholder="e.g. Backend"
{...register(`background.specificYoes.1.domain`)}
/>
</div>
</Collapsible>
</div>
</>
);
}
function FullTimeJobFields() {
const { register } = useFormContext();
const { register, setValue, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -80,14 +96,16 @@ function FullTimeJobFields() {
display="block"
label="Title"
options={titleOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.title`)}
/>
<FormSelect
display="block"
label="Company"
options={companyOptions}
{...register(`background.experiences.0.companyId`)}
/>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.companyId`, value)
}
/>
</div>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
<FormTextInput
@ -103,12 +121,14 @@ function FullTimeJobFields() {
/>
}
endAddOnType="element"
errorMessage={experiencesField?.totalCompensation?.value?.message}
label="Total Compensation (Annual)"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experiences.0.totalCompensation.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
@ -134,9 +154,11 @@ function FullTimeJobFields() {
{...register(`background.experiences.0.location`)}
/>
<FormTextInput
errorMessage={experiencesField?.durationInMonths?.message}
label="Duration (months)"
type="number"
{...register(`background.experiences.0.durationInMonths`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
@ -147,7 +169,11 @@ function FullTimeJobFields() {
}
function InternshipJobFields() {
const { register } = useFormContext();
const { register, setValue, formState } = useFormContext<{
background: BackgroundPostData;
}>();
const experiencesField = formState.errors.background?.experiences?.[0];
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -155,14 +181,16 @@ function InternshipJobFields() {
display="block"
label="Title"
options={titleOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.title`)}
/>
<FormSelect
display="block"
label="Company"
options={companyOptions}
{...register(`background.experiences.0.company`)}
/>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.companyId`, value)
}
/>
</div>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
<FormTextInput
@ -176,12 +204,16 @@ function InternshipJobFields() {
/>
}
endAddOnType="element"
errorMessage={experiencesField?.monthlySalary?.value?.message}
label="Salary (Monthly)"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experiences.0.monthlySalary.value`)}
{...register(`background.experiences.0.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
valueAsNumber: true,
})}
/>
</div>
<Collapsible label="Add more details">
@ -195,6 +227,7 @@ function InternshipJobFields() {
display="block"
label="Location"
options={locationOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.location`)}
/>
</div>
@ -231,7 +264,7 @@ function CurrentJobSection() {
<RadioList.Item
key="Internship"
label="Internship"
value={JobType.Internship}
value={JobType.Intern}
/>
</FormRadioList>
</div>
@ -258,12 +291,14 @@ function EducationSection() {
display="block"
label="Education Level"
options={educationLevelOptions}
placeholder={emptyOption}
{...register(`background.educations.0.type`)}
/>
<FormSelect
display="block"
label="Field"
options={educationFieldOptions}
placeholder={emptyOption}
{...register(`background.educations.0.field`)}
/>
</div>
@ -287,9 +322,9 @@ export default function BackgroundForm() {
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Help us better gauge your offers
</h5>
<h6 className="mx-10 mb-8 text-center text-lg font-light text-gray-600">
This section is optional, but your background information helps us
benchmark your offers.
<h6 className="text-md mx-10 mb-8 text-center font-light text-gray-600">
This section is mostly optional, but your background information helps
us benchmark your offers.
</h6>
<div>
<YoeSection />

@ -0,0 +1,138 @@
import Error from 'next/error';
import { useEffect } from 'react';
import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import OfferPercentileAnalysis from '../analysis/OfferPercentileAnalysis';
import OfferProfileCard from '../analysis/OfferProfileCard';
import { OVERALL_TAB } from '../constants';
import type {
Analysis,
AnalysisHighestOffer,
ProfileAnalysis,
} from '~/types/offers';
type OfferAnalysisData = {
offer?: AnalysisHighestOffer;
offerAnalysis?: Analysis;
};
type OfferAnalysisContentProps = Readonly<{
analysis: OfferAnalysisData;
tab: string;
}>;
function OfferAnalysisContent({
analysis: { offer, offerAnalysis },
tab,
}: OfferAnalysisContentProps) {
if (!offerAnalysis || !offer || offerAnalysis.noOfOffers === 0) {
return (
<p className="m-10">
You are the first to submit an offer for these companies! Check back
later when there are more submissions.
</p>
);
}
return (
<>
<OfferPercentileAnalysis
companyName={offer.company.name}
offerAnalysis={offerAnalysis}
tab={tab}
/>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard
key={topPercentileOffer.id}
offerProfile={topPercentileOffer}
/>
))}
</>
);
}
type OfferAnalysisProps = Readonly<{
profileId?: string;
}>;
export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB);
const [allAnalysis, setAllAnalysis] = useState<ProfileAnalysis | null>(null);
const [analysis, setAnalysis] = useState<OfferAnalysisData | null>(null);
useEffect(() => {
if (tab === OVERALL_TAB) {
setAnalysis({
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.overallAnalysis,
});
} else {
setAnalysis({
offer: allAnalysis?.overallHighestOffer,
offerAnalysis: allAnalysis?.companyAnalysis[0],
});
}
}, [tab, allAnalysis]);
if (!profileId) {
return null;
}
const getAnalysisResult = trpc.useQuery(
['offers.analysis.get', { profileId }],
{
onError(error) {
console.error(error.message);
},
onSuccess(data) {
setAllAnalysis(data);
},
},
);
const tabOptions = [
{
label: OVERALL_TAB,
value: OVERALL_TAB,
},
{
label: allAnalysis?.overallHighestOffer.company.name || '',
value: allAnalysis?.overallHighestOffer.company.id || '',
},
];
return (
<>
{getAnalysisResult.isError && (
<Error
statusCode={404}
title="An error occurred while generating profile analysis."
/>
)}
{!getAnalysisResult.isError && analysis && (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
{getAnalysisResult.isLoading ? (
<Spinner className="m-10" display="block" size="lg" />
) : (
<div>
<Tabs
label="Result Navigation"
tabs={tabOptions}
value={tab}
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent analysis={analysis} tab={tab} />
</div>
)}
</div>
)}
</>
);
}

@ -1,5 +1,9 @@
import { useEffect, useState } from 'react';
import type { FieldValues, UseFieldArrayReturn } from 'react-hook-form';
import type {
FieldValues,
UseFieldArrayRemove,
UseFieldArrayReturn,
} from 'react-hook-form';
import { useWatch } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
@ -7,17 +11,14 @@ import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline';
import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import {
defaultFullTimeOfferValues,
defaultInternshipOfferValues,
} from '~/pages/offers/submit';
import FormMonthYearPicker from './components/FormMonthYearPicker';
import FormSelect from './components/FormSelect';
import FormTextArea from './components/FormTextArea';
import FormTextInput from './components/FormTextInput';
import {
companyOptions,
emptyOption,
FieldError,
internshipCycleOptions,
@ -25,36 +26,40 @@ import {
titleOptions,
yearOptions,
} from '../constants';
import type {
FullTimeOfferDetailsFormData,
InternshipOfferDetailsFormData,
} from '../types';
import FormMonthYearPicker from '../forms/FormMonthYearPicker';
import FormSelect from '../forms/FormSelect';
import FormTextArea from '../forms/FormTextArea';
import FormTextInput from '../forms/FormTextInput';
import type { OfferFormData } from '../types';
import { JobTypeLabel } from '../types';
import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{
index: number;
setDialogOpen: (isOpen: boolean) => void;
remove: UseFieldArrayRemove;
}>;
function FullTimeOfferDetailsForm({
index,
setDialogOpen,
remove,
}: FullTimeOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{
offers: Array<FullTimeOfferDetailsFormData>;
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
const watchCurrency = useWatch({
name: `offers.${index}.job.totalCompensation.currency`,
name: `offers.${index}.offersFullTime.totalCompensation.currency`,
});
useEffect(() => {
setValue(`offers.${index}.job.base.currency`, watchCurrency);
setValue(`offers.${index}.job.bonus.currency`, watchCurrency);
setValue(`offers.${index}.job.stocks.currency`, watchCurrency);
setValue(
`offers.${index}.offersFullTime.baseSalary.currency`,
watchCurrency,
);
setValue(`offers.${index}.offersFullTime.bonus.currency`, watchCurrency);
setValue(`offers.${index}.offersFullTime.stocks.currency`, watchCurrency);
}, [watchCurrency, index, setValue]);
return (
@ -62,48 +67,44 @@ function FullTimeOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
errorMessage={offerFields?.offersFullTime?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
{...register(`offers.${index}.offersFullTime.title`, {
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
errorMessage={offerFields?.offersFullTime?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
{...register(`offers.${index}.offersFullTime.specialization`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<div className="mb-5 flex grid grid-cols-2 space-x-3">
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
}
/>
</div>
<FormTextInput
errorMessage={offerFields?.job?.level?.message}
errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.job.level`, {
{...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
@ -132,24 +133,32 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.totalCompensation.currency`, {
required: FieldError.Required,
})}
{...register(
`offers.${index}.offersFullTime.totalCompensation.currency`,
{
required: FieldError.Required,
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.totalCompensation?.value?.message}
errorMessage={
offerFields?.offersFullTime?.totalCompensation?.value?.message
}
label="Total Compensation (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.totalCompensation.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
{...register(
`offers.${index}.offersFullTime.totalCompensation.value`,
{
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
},
)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -160,20 +169,23 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.base.currency`, {
required: FieldError.Required,
})}
{...register(
`offers.${index}.offersFullTime.baseSalary.currency`,
{
required: FieldError.Required,
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.base?.value?.message}
errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message}
label="Base Salary (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.base.value`, {
{...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
@ -186,20 +198,20 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.bonus.currency`, {
{...register(`offers.${index}.offersFullTime.bonus.currency`, {
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.bonus?.value?.message}
errorMessage={offerFields?.offersFullTime?.bonus?.value?.message}
label="Bonus (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.bonus.value`, {
{...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
@ -214,20 +226,20 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.stocks.currency`, {
{...register(`offers.${index}.offersFullTime.stocks.currency`, {
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.stocks?.value?.message}
errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
label="Stocks (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.stocks.value`, {
{...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
@ -254,7 +266,7 @@ function FullTimeOfferDetailsForm({
icon={TrashIcon}
label="Delete"
variant="secondary"
onClick={() => setDialogOpen(true)}
onClick={() => remove(index)}
/>
)}
</div>
@ -264,15 +276,15 @@ function FullTimeOfferDetailsForm({
type InternshipOfferDetailsFormProps = Readonly<{
index: number;
setDialogOpen: (isOpen: boolean) => void;
remove: UseFieldArrayRemove;
}>;
function InternshipOfferDetailsForm({
index,
setDialogOpen,
remove,
}: InternshipOfferDetailsFormProps) {
const { register, formState } = useFormContext<{
offers: Array<InternshipOfferDetailsFormData>;
const { register, formState, setValue } = useFormContext<{
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
@ -282,39 +294,35 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
errorMessage={offerFields?.offersIntern?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
{...register(`offers.${index}.offersIntern.title`, {
minLength: 1,
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
errorMessage={offerFields?.offersIntern?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
{...register(`offers.${index}.offersIntern.specialization`, {
minLength: 1,
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
}
/>
</div>
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
@ -330,24 +338,25 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.internshipCycle?.message}
errorMessage={offerFields?.offersIntern?.internshipCycle?.message}
label="Internship Cycle"
options={internshipCycleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.internshipCycle`, {
{...register(`offers.${index}.offersIntern.internshipCycle`, {
required: FieldError.Required,
})}
/>
<FormSelect
display="block"
errorMessage={offerFields?.job?.startYear?.message}
errorMessage={offerFields?.offersIntern?.startYear?.message}
label="Internship Year"
options={yearOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.startYear`, {
{...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.Required,
valueAsNumber: true,
})}
/>
</div>
@ -369,20 +378,25 @@ function InternshipOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.monthlySalary.currency`, {
required: FieldError.Required,
})}
{...register(
`offers.${index}.offersIntern.monthlySalary.currency`,
{
required: FieldError.Required,
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.monthlySalary?.value?.message}
errorMessage={
offerFields?.offersIntern?.monthlySalary?.value?.message
}
label="Salary (Monthly)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.monthlySalary.value`, {
{...register(`offers.${index}.offersIntern.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
@ -410,7 +424,7 @@ function InternshipOfferDetailsForm({
label="Delete"
variant="secondary"
onClick={() => {
setDialogOpen(true);
remove(index);
}}
/>
)}
@ -429,7 +443,6 @@ function OfferDetailsFormArray({
jobType,
}: OfferDetailsFormArrayProps) {
const { append, remove, fields } = fieldArrayValues;
const [isDialogOpen, setDialogOpen] = useState(false);
return (
<div>
@ -437,44 +450,10 @@ function OfferDetailsFormArray({
return (
<div key={item.id}>
{jobType === JobType.FullTime ? (
<FullTimeOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
<FullTimeOfferDetailsForm index={index} remove={remove} />
) : (
<InternshipOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
<InternshipOfferDetailsForm index={index} remove={remove} />
)}
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="OK"
variant="primary"
onClick={() => {
remove(index);
setDialogOpen(false);
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setDialogOpen(false)}
/>
}
title="Remove this offer"
onClose={() => setDialogOpen(false)}>
<p>
Are you sure you want to remove this offer? This action cannot
be reversed.
</p>
</Dialog>
</div>
);
})}
@ -501,22 +480,21 @@ export default function OfferDetailsForm() {
const [isDialogOpen, setDialogOpen] = useState(false);
const { control } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' });
const { append, remove } = fieldArrayValues;
const toggleJobType = () => {
fieldArrayValues.remove();
remove();
if (jobType === JobType.FullTime) {
setJobType(JobType.Internship);
fieldArrayValues.append(defaultInternshipOfferValues);
setJobType(JobType.Intern);
append(defaultInternshipOfferValues);
} else {
setJobType(JobType.FullTime);
fieldArrayValues.append(defaultFullTimeOfferValues);
append(defaultFullTimeOfferValues);
}
};
const switchJobTypeLabel = () =>
jobType === JobType.FullTime
? JobTypeLabel.INTERNSHIP
: JobTypeLabel.FULLTIME;
jobType === JobType.FullTime ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
return (
<div className="mb-5">
@ -541,11 +519,11 @@ export default function OfferDetailsForm() {
<div className="mx-5 w-1/3">
<Button
display="block"
label={JobTypeLabel.INTERNSHIP}
label={JobTypeLabel.INTERN}
size="md"
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'}
variant={jobType === JobType.Intern ? 'secondary' : 'tertiary'}
onClick={() => {
if (jobType === JobType.Internship) {
if (jobType === JobType.Intern) {
return;
}
setDialogOpen(true);

@ -52,7 +52,8 @@ export default function OfferProfileSave() {
<p className="mb-5 text-gray-900">
If you do not want to keep the edit link, you can opt to save this
profile under your user accont. It will still only be editable by you.
profile under your user account. It will still only be editable by
you.
</p>
<div className="mb-20">
<Button

@ -1,21 +1,102 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
import { Button, HorizontalDivider, Spinner, TextArea } from '@tih/ui';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import { trpc } from '~/utils/trpc';
import type { OffersDiscussion, Reply } from '~/types/offers';
type ProfileHeaderProps = Readonly<{
handleCopyEditLink: () => void;
handleCopyPublicLink: () => void;
isDisabled: boolean;
isEditable: boolean;
isLoading: boolean;
profileId: string;
profileName?: string;
token?: string;
}>;
export default function ProfileComments({
handleCopyEditLink,
handleCopyPublicLink,
isDisabled,
isEditable,
isLoading,
profileId,
profileName,
token,
}: ProfileHeaderProps) {
const { data: session, status } = useSession();
const [currentReply, setCurrentReply] = useState<string>('');
const [replies, setReplies] = useState<Array<Reply>>();
const commentsQuery = trpc.useQuery(
['offers.comments.getComments', { profileId }],
{
onSuccess(response: OffersDiscussion) {
setReplies(response.data);
},
},
);
const trpcContext = trpc.useContext();
const createCommentMutation = trpc.useMutation(['offers.comments.create'], {
onSuccess() {
trpcContext.invalidateQueries([
'offers.comments.getComments',
{ profileId },
]);
},
});
function handleComment(message: string) {
if (isEditable) {
// If it is with edit permission, send comment to API with username = null
createCommentMutation.mutate(
{
message,
profileId,
token,
},
{
onSuccess: () => {
setCurrentReply('');
},
},
);
} else if (status === 'authenticated') {
// If not the OP and logged in, send comment to API
createCommentMutation.mutate(
{
message,
profileId,
userId: session.user?.id,
},
{
onSuccess: () => {
setCurrentReply('');
},
},
);
} else {
// If not the OP and not logged in, direct users to log in
signIn();
}
}
function handleCopyEditLink() {
// TODO: Add notification
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}?token=${token}`,
);
}
function handleCopyPublicLink() {
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}`,
);
}
if (isLoading) {
return (
<div className="col-span-10 pt-4">
@ -24,7 +105,7 @@ export default function ProfileComments({
);
}
return (
<div className="m-4">
<div className="m-4 h-full">
<div className="flex-end flex justify-end space-x-4">
{isEditable && (
<Button
@ -49,10 +130,44 @@ export default function ProfileComments({
onClick={handleCopyPublicLink}
/>
</div>
<h2 className="mt-2 text-2xl font-bold">
Discussions feature coming soon
</h2>
{/* <TextArea label="Comment" placeholder="Type your comment here" /> */}
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
<div>
<TextArea
label={`Comment as ${
isEditable ? profileName : session?.user?.name ?? 'anonymous'
}`}
placeholder="Type your comment here"
value={currentReply}
onChange={(value) => setCurrentReply(value)}
/>
<div className="mt-2 flex w-full justify-end">
<div className="w-fit">
<Button
disabled={commentsQuery.isLoading}
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Comment"
size="sm"
variant="primary"
onClick={() => handleComment(currentReply)}
/>
</div>
</div>
<HorizontalDivider />
</div>
<div className="h-full overflow-y-scroll">
<div className="h-content mb-96 w-full">
{replies?.map((reply: Reply) => (
<ExpandableCommentCard
key={reply.id}
comment={reply}
profileId={profileId}
token={isEditable ? token : undefined}
/>
))}
</div>
</div>
</div>
);
}

@ -0,0 +1,152 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import { ChatBubbleBottomCenterIcon } from '@heroicons/react/24/outline';
import { Button, HorizontalDivider, TextArea } from '@tih/ui';
import { timeSinceNow } from '~/utils/offers/time';
import { trpc } from '../../../../utils/trpc';
import type { Reply } from '~/types/offers';
type Props = Readonly<{
comment: Reply;
disableReply?: boolean;
handleExpanded?: () => void;
isExpanded?: boolean;
profileId: string;
replyLength?: number;
token?: string;
}>;
export default function CommentCard({
comment: { createdAt, id, message, user },
disableReply,
handleExpanded,
isExpanded,
profileId,
token = '',
replyLength = 0,
}: Props) {
const { data: session, status } = useSession();
const [isReplying, setIsReplying] = useState(false);
const [currentReply, setCurrentReply] = useState<string>('');
const trpcContext = trpc.useContext();
const createCommentMutation = trpc.useMutation(['offers.comments.create'], {
onSuccess() {
trpcContext.invalidateQueries([
'offers.comments.getComments',
{ profileId },
]);
},
});
function handleReply() {
if (token && token.length > 0) {
// If it is with edit permission, send comment to API with username = null
createCommentMutation.mutate(
{
message: currentReply,
profileId,
replyingToId: id,
token,
},
{
onSuccess: () => {
setCurrentReply('');
setIsReplying(false);
if (!isExpanded) {
handleExpanded?.();
}
},
},
);
} else if (status === 'authenticated') {
// If not the OP and logged in, send comment to API
createCommentMutation.mutate(
{
message: currentReply,
profileId,
replyingToId: id,
userId: session.user?.id,
},
{
onSuccess: () => {
setCurrentReply('');
setIsReplying(false);
if (!isExpanded) {
handleExpanded?.();
}
},
},
);
} else {
// If not the OP and not logged in, direct users to log in
signIn();
}
}
return (
<>
<div className="flex pl-2">
<div className="flex w-full flex-col">
<div className="flex flex-row font-bold">
{user?.name ?? 'unknown user'}
</div>
<div className="mt-2 mb-2 flex flex-row ">{message}</div>
<div className="flex flex-row items-center justify-start space-x-4 ">
<div className="flex flex-col text-sm font-light text-gray-400">{`${timeSinceNow(
createdAt,
)} ago`}</div>
{replyLength > 0 && (
<div
className="flex cursor-pointer flex-col text-sm text-purple-600 hover:underline"
onClick={handleExpanded}>
{isExpanded ? `Hide replies` : `View replies (${replyLength})`}
</div>
)}
{!disableReply && (
<div className="flex flex-col">
<Button
icon={ChatBubbleBottomCenterIcon}
isLabelHidden={true}
label="Reply"
size="sm"
variant="tertiary"
onClick={() => setIsReplying(!isReplying)}
/>
</div>
)}
</div>
{!disableReply && isReplying && (
<div className="mt-2 mr-2">
<TextArea
isLabelHidden={true}
label="Comment"
placeholder="Type your comment here"
resize="none"
value={currentReply}
onChange={(value) => setCurrentReply(value)}
/>
<div className="mt-2 flex w-full justify-end">
<div className="w-fit">
<Button
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Reply"
size="sm"
variant="primary"
onClick={handleReply}
/>
</div>
</div>
</div>
)}
</div>
</div>
<HorizontalDivider />
</>
);
}

@ -0,0 +1,44 @@
import { useState } from 'react';
import CommentCard from '~/components/offers/profile/comments/CommentCard';
import type { Reply } from '~/types/offers';
type Props = Readonly<{
comment: Reply;
profileId: string;
token?: string;
}>;
export default function ExpandableCommentCard({
comment,
profileId,
token = '',
}: Props) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div>
<CommentCard
comment={comment}
handleExpanded={() => setIsExpanded(!isExpanded)}
isExpanded={isExpanded}
profileId={profileId}
replyLength={comment.replies?.length ?? 0}
token={token}
/>
{comment.replies && (
<div className="pl-8">
{isExpanded &&
comment.replies.map((reply) => (
<CommentCard
key={reply.id}
comment={reply}
disableReply={true}
profileId={profileId}
/>
))}
</div>
)}
</div>
);
}

@ -1,11 +1,14 @@
import Link from 'next/link';
import type { OfferTableRowData } from '~/components/offers/table/types';
import { convertCurrencyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
export type OfferTableRowProps = Readonly<{ row: OfferTableRowData }>;
import type { DashboardOffer } from '~/types/offers';
export type OfferTableRowProps = Readonly<{ row: DashboardOffer }>;
export default function OfferTableRow({
row: { company, date, id, profileId, salary, title, yoe },
row: { company, id, income, monthYearReceived, profileId, title, totalYoe },
}: OfferTableRowProps) {
return (
<tr
@ -14,12 +17,12 @@ export default function OfferTableRow({
<th
className="whitespace-nowrap py-4 px-6 font-medium text-gray-900 dark:text-white"
scope="row">
{company}
{company.name}
</th>
<td className="py-4 px-6">{title}</td>
<td className="py-4 px-6">{yoe}</td>
<td className="py-4 px-6">{salary}</td>
<td className="py-4 px-6">{date}</td>
<td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertCurrencyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="space-x-4 py-4 px-6">
<Link
className="font-medium text-indigo-600 hover:underline dark:text-indigo-500"

@ -2,18 +2,15 @@ import { useEffect, useState } from 'react';
import { HorizontalDivider, Select, Spinner, Tabs } from '@tih/ui';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import type {
OfferTableRowData,
PaginationType,
} from '~/components/offers/table/types';
import { YOE_CATEGORY } from '~/components/offers/table/types';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import OffersRow from './OffersRow';
import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_IN_PAGE = 10;
export type OffersTableProps = Readonly<{
companyFilter: string;
@ -25,18 +22,18 @@ export default function OffersTable({
}: OffersTableProps) {
const [currency, setCurrency] = useState('SGD'); // TODO: Detect location
const [selectedTab, setSelectedTab] = useState(YOE_CATEGORY.ENTRY);
const [pagination, setPagination] = useState<PaginationType>({
currentPage: 1,
numOfItems: 1,
const [pagination, setPagination] = useState<Paging>({
currentPage: 0,
numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
const [offers, setOffers] = useState<Array<OfferTableRowData>>([]);
const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
useEffect(() => {
setPagination({
currentPage: 1,
numOfItems: 1,
currentPage: 0,
numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
@ -48,36 +45,16 @@ export default function OffersTable({
companyId: companyFilter,
limit: NUMBER_OF_OFFERS_IN_PAGE,
location: 'Singapore, Singapore', // TODO: Geolocation
offset: pagination.currentPage - 1,
offset: 0,
sortBy: '-monthYearReceived',
title: jobTitleFilter,
yoeCategory: selectedTab,
},
],
{
onSuccess: (response) => {
const filteredData = response.data.map((res) => {
return {
company: res.company.name,
date: formatDate(res.monthYearReceived),
id: res.OffersFullTime
? res.OffersFullTime!.id
: res.OffersIntern!.id,
profileId: res.profileId,
salary: res.OffersFullTime
? res.OffersFullTime?.totalCompensation.value
: res.OffersIntern?.monthlySalary.value,
title: res.OffersFullTime ? res.OffersFullTime?.level : '',
yoe: 100,
};
});
setOffers(filteredData);
setPagination({
currentPage: (response.paging.currPage as number) + 1,
numOfItems: response.paging.numOfItemsInPage,
numOfPages: response.paging.numOfPages,
totalItems: response.paging.totalNumberOfOffers,
});
onSuccess: (response: GetOffersResponse) => {
setOffers(response.data);
setPagination(response.paging);
},
},
);
@ -90,15 +67,15 @@ export default function OffersTable({
label="Table Navigation"
tabs={[
{
label: 'Fresh Grad (0-3 YOE)',
label: 'Fresh Grad (0-2 YOE)',
value: YOE_CATEGORY.ENTRY,
},
{
label: 'Mid (4-7 YOE)',
label: 'Mid (3-5 YOE)',
value: YOE_CATEGORY.MID,
},
{
label: 'Senior (8+ YOE)',
label: 'Senior (6+ YOE)',
value: YOE_CATEGORY.SENIOR,
},
{
@ -187,14 +164,11 @@ export default function OffersTable({
)}
<OffersTablePagination
endNumber={
(pagination.currentPage - 1) * NUMBER_OF_OFFERS_IN_PAGE +
offers.length
pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + offers.length
}
handlePageChange={handlePageChange}
pagination={pagination}
startNumber={
(pagination.currentPage - 1) * NUMBER_OF_OFFERS_IN_PAGE + 1
}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + 1}
/>
</div>
</div>

@ -1,11 +1,11 @@
import { Pagination } from '@tih/ui';
import type { PaginationType } from '~/components/offers/table/types';
import type { Paging } from '~/types/offers';
type OffersTablePaginationProps = Readonly<{
endNumber: number;
handlePageChange: (page: number) => void;
pagination: PaginationType;
pagination: Paging;
startNumber: number;
}>;
@ -30,13 +30,13 @@ export default function OffersTablePagination({
</span>
</span>
<Pagination
current={pagination.currentPage}
current={pagination.currentPage + 1}
end={pagination.numOfPages}
label="Pagination"
pagePadding={1}
start={1}
onSelect={(currPage) => {
handlePageChange(currPage);
handlePageChange(currPage - 1);
}}
/>
</nav>

@ -1,13 +1,3 @@
export type OfferTableRowData = {
company: string;
date: string;
id: string;
profileId: string;
salary: number | undefined;
title: string;
yoe: number;
};
// eslint-disable-next-line no-shadow
export enum YOE_CATEGORY {
INTERN = 0,
@ -15,10 +5,3 @@ export enum YOE_CATEGORY {
MID = 2,
SENIOR = 3,
}
export type PaginationType = {
currentPage: number;
numOfItems: number;
numOfPages: number;
totalItems: number;
};

@ -6,12 +6,12 @@ import type { MonthYear } from '~/components/shared/MonthYearPicker';
export enum JobType {
FullTime = 'FULLTIME',
Internship = 'INTERNSHIP',
Intern = 'INTERN',
}
export const JobTypeLabel = {
FULLTIME: 'Full-time',
INTERNSHIP: 'Internship',
INTERN: 'Internship',
};
export enum EducationBackgroundType {
@ -20,110 +20,91 @@ export enum EducationBackgroundType {
Masters = 'Masters',
PhD = 'PhD',
Professional = 'Professional',
Seconday = 'Secondary',
Secondary = 'Secondary',
SelfTaught = 'Self-taught',
}
export type Money = {
currency: string;
value: number;
export type OffersProfilePostData = {
background: BackgroundPostData;
offers: Array<OfferPostData>;
};
type FullTimeJobData = {
base: Money;
bonus: Money;
level: string;
specialization: string;
stocks: Money;
title: string;
totalCompensation: Money;
export type OffersProfileFormData = {
background: BackgroundPostData;
offers: Array<OfferFormData>;
};
type InternshipJobData = {
internshipCycle: string;
monthlySalary: Money;
specialization: string;
startYear: number;
title: string;
export type BackgroundPostData = {
educations: Array<EducationPostData>;
experiences: Array<ExperiencePostData>;
specificYoes: Array<SpecificYoePostData>;
totalYoe: number;
};
type OfferDetailsGeneralData = {
comments: string;
companyId: string;
jobType: string;
location: string;
monthYearReceived: MonthYear;
negotiationStrategy: string;
type ExperiencePostData = {
companyId?: string | null;
durationInMonths?: number | null;
jobType?: string | null;
level?: string | null;
location?: string | null;
monthlySalary?: Money | null;
specialization?: string | null;
title?: string | null;
totalCompensation?: Money | null;
totalCompensationId?: string | null;
};
export type FullTimeOfferDetailsFormData = OfferDetailsGeneralData & {
job: FullTimeJobData;
type EducationPostData = {
endDate?: Date | null;
field?: string | null;
school?: string | null;
startDate?: Date | null;
type?: string | null;
};
export type InternshipOfferDetailsFormData = OfferDetailsGeneralData & {
job: InternshipJobData;
};
export type OfferDetailsFormData =
| FullTimeOfferDetailsFormData
| InternshipOfferDetailsFormData;
export type OfferDetailsPostData = Omit<
OfferDetailsFormData,
'monthYearReceived'
> & {
monthYearReceived: Date;
};
type SpecificYoe = {
type SpecificYoePostData = {
domain: string;
yoe: number;
};
type FullTimeExperience = {
level?: string;
totalCompensation?: Money;
};
type InternshipExperience = {
monthlySalary?: Money;
};
type SpecificYoe = SpecificYoePostData;
type GeneralExperience = {
companyId?: string;
durationInMonths?: number;
jobType?: string;
specialization?: string;
title?: string;
export type OfferPostData = {
comments: string;
companyId: string;
jobType: string;
location: string;
monthYearReceived: Date;
negotiationStrategy: string;
offersFullTime?: OfferFullTimePostData | null;
offersIntern?: OfferInternPostData | null;
};
export type Experience =
| (FullTimeExperience & GeneralExperience)
| (GeneralExperience & InternshipExperience);
type Education = {
endDate?: Date;
field?: string;
school?: string;
startDate?: Date;
type?: string;
export type OfferFormData = Omit<OfferPostData, 'monthYearReceived'> & {
monthYearReceived: MonthYear;
};
type BackgroundFormData = {
educations: Array<Education>;
experiences: Array<Experience>;
specificYoes: Array<SpecificYoe>;
totalYoe?: number;
export type OfferFullTimePostData = {
baseSalary: Money;
bonus: Money;
level: string;
specialization: string;
stocks: Money;
title: string;
totalCompensation: Money;
};
export type OfferProfileFormData = {
background: BackgroundFormData;
offers: Array<OfferDetailsFormData>;
export type OfferInternPostData = {
internshipCycle: string;
monthlySalary: Money;
specialization: string;
startYear: number;
title: string;
};
export type OfferProfilePostData = {
background: BackgroundFormData;
offers: Array<OfferDetailsPostData>;
export type Money = {
currency: string;
value: number;
};
type EducationDisplay = {
@ -158,3 +139,14 @@ export type BackgroundCard = {
specificYoes: Array<SpecificYoe>;
totalYoe: string;
};
export type CommentEntity = {
createdAt: Date;
id: string;
message: string;
profileId: string;
replies?: Array<CommentEntity>;
replyingToId: string;
userId: string;
username: string;
};

@ -84,9 +84,8 @@ export default function ContributeQuestionForm({
name="company"
render={({ field }) => (
<CompaniesTypeahead
onSelect={({ label }) => {
// TODO: To change from using company name to company id (i.e., value)
field.onChange(label);
onSelect={({ id }) => {
field.onChange(id);
}}
/>
)}

@ -1,4 +1,5 @@
import { useState } from 'react';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import {
@ -18,14 +19,30 @@ type Props = Readonly<{
export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1);
const [pageWidth, setPageWidth] = useState(750);
const [componentWidth, setComponentWidth] = useState(780);
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages);
};
useEffect(() => {
const onPageResize = () => {
setComponentWidth(
document.querySelector('#pdfView')?.getBoundingClientRect().width ??
780,
);
};
window.addEventListener('resize', onPageResize);
return () => {
window.removeEventListener('resize', onPageResize);
};
}, []);
return (
<div>
<div id="pdfView">
<div className="group relative">
<Document
className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-auto"
@ -33,25 +50,38 @@ export default function ResumePdf({ url }: Props) {
loading={<Spinner display="block" size="lg" />}
noData=""
onLoadSuccess={onPdfLoadSuccess}>
<Page pageNumber={pageNumber} scale={scale} width={750} />
<div
style={{
paddingLeft: clsx(
pageWidth > componentWidth
? `${pageWidth - componentWidth}px`
: '',
),
}}>
<Page
pageNumber={pageNumber}
renderTextLayer={false}
width={pageWidth}
/>
</div>
<div className="absolute top-2 right-5 hidden hover:block group-hover:block">
<Button
className="rounded-r-none focus:ring-0 focus:ring-offset-0"
disabled={scale === 0.5}
disabled={pageWidth === 450}
icon={MagnifyingGlassMinusIcon}
isLabelHidden={true}
label="Zoom Out"
variant="tertiary"
onClick={() => setScale(scale - 0.25)}
onClick={() => setPageWidth(pageWidth - 150)}
/>
<Button
className="rounded-l-none focus:ring-0 focus:ring-offset-0"
disabled={scale === 1.5}
disabled={pageWidth === 1050}
icon={MagnifyingGlassPlusIcon}
isLabelHidden={true}
label="Zoom In"
variant="tertiary"
onClick={() => setScale(scale + 0.25)}
onClick={() => setPageWidth(pageWidth + 150)}
/>
</div>
</Document>

@ -0,0 +1,24 @@
export default function BronzeReviewerBadgeIcon() {
return (
<svg
aria-hidden="true"
height="36px"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 36 36"
width="36px"
xmlns="http://www.w3.org/2000/svg">
<path
d="M7.042 26c.33 0 .651.121.963.331c1.368-8.106 20.362-8.248 21.755-.29c1.666.412 3.08 4.378 3.748 9.959h-31c.793-5.899 2.522-10 4.534-10z"
fill="#292F33"></path>
<path
d="M7.043 23.688C10.966 12.533 6.508 3 17.508 3s8.736 8.173 13.193 19.125c1.119 2.75-1.443 5.908-1.443 5.908s-2.612-4.756-4.75-5.846c-.591 3.277-1.75 6.938-1.75 6.938s-2.581-2.965-5.587-5.587c-.879 1.009-2.065 2.183-3.663 3.462c-.349-1.048-.943-2.339-1.568-3.576c-1.468 2.238-3.182 4.951-3.182 4.951s-2.507-2.435-1.715-4.687z"
fill="#E1E8ED"></path>
<path
d="M11.507 5c-4.36 3.059-5.542 2.16-7.812 3.562c-2.125 1.312-2 4.938-.125 8.062c.579-2.661-.5-3.149 6.938-3.149c5 0 7.928.289 7-1c-.927-1.289-10.027.459-6.001-7.475z"
fill="#FFCC4D"></path>
<path
d="M16.535 7.517a1.483 1.483 0 1 1-2.967 0c0-.157.031-.305.076-.446h2.816c.044.141.075.289.075.446z"
fill="#292F33"></path>
</svg>
);
}

@ -0,0 +1,49 @@
export default function GoldReviewerBadgeIcon() {
return (
<svg
aria-hidden="true"
height="36px"
preserveAspectRatio="xMidYMid meet"
role="img"
viewBox="0 0 36 36"
width="36px"
xmlns="http://www.w3.org/2000/svg">
<path
d="M33.035 28.055c-3.843-2.612-14.989 2.92-15.037 2.944c-.047-.024-11.193-5.556-15.037-2.944C-.021 30.082 0 36 0 36h35.996s.021-5.918-2.961-7.945z"
fill="#A0041E"></path>
<path
d="M32 29c-2.155-1.085-4 0-4 0l-10-1l-10 1s-1.845-1.085-4 0c-3.995 2.011-2 7-2 7h32s1.995-4.989-2-7z"
fill="#55ACEE"></path>
<path
d="M24.056 36c-1.211-1.194-3.466-2-6.056-2s-4.845.806-6.056 2h12.112z"
fill="#DD2E44"></path>
<path
d="M13.64 28.537C15.384 29.805 16.487 30.5 18 30.5c1.512 0 2.615-.696 4.359-1.963V24.29h-8.72v4.247z"
fill="#D4AB88"></path>
<path
d="M30.453 27c-1.953-.266-3.594.547-3.594.547s-.845-.594-1.845-.614c-1.469-.03-2.442.935-3.014 1.755C21.281 29.719 19 30 18 30s-3.281-.281-4-1.312c-.572-.82-1.545-1.784-3.014-1.755c-1 .02-1.845.614-1.845.614S7.5 26.734 5.547 27c-1.305.177-2.357.764-2.846 1.248c2.83-1.685 4.757-.229 6.065.643C10.074 29.763 11 32 11 32c2-1 7-1 7-1s5 0 7 1c0 0 .926-2.237 2.234-3.109c1.308-.872 3.234-2.328 6.065-.643c-.489-.484-1.541-1.071-2.846-1.248z"
fill="#DD2E44"></path>
<path
d="M13.632 25.5c.368 2.027 2.724 2.219 4.364 2.219c1.639 0 4.004-.191 4.363-2.219v-3.019h-8.728V25.5z"
fill="#CC9B7A"></path>
<path
d="M11.444 15.936c0 1.448-.734 2.622-1.639 2.622s-1.639-1.174-1.639-2.622s.734-2.623 1.639-2.623c.905-.001 1.639 1.174 1.639 2.623m16.389 0c0 1.448-.733 2.622-1.639 2.622c-.905 0-1.639-1.174-1.639-2.622s.733-2.623 1.639-2.623c.906-.001 1.639 1.174 1.639 2.623"
fill="#D4AB88"></path>
<path
d="M18 7c-5 0-8 2-8 5s0 9 2 12s4 3 6 3s4 0 6-3s2-9 2-12s-3-5-8-5z"
fill="#D4AB88"></path>
<path
d="M18.821 3.118c6.004.49 8.356 4.246 8.356 7.851c0 3.604-.706 5.047-1.412 3.604c-.706-1.441-1.356-3.368-1.356-3.368s-4.292.485-5.704-.957c0 0 2.118 4.326-2.118 0c0 0 .706 2.884-3.53-.72c0 0-2.118 1.442-2.824 5.046c-.196 1.001-1.412 0-1.412-3.604c.001-2.677.179-6.652 4.908-6.17c1.028-1.639 3.018-1.851 5.092-1.682z"
fill="#963B22"></path>
<path
d="M25 12c-3 0-5 1-7 1s-4-1-7-1s-1 5.72 0 6.72s5-1 7-1s6 2 7 1S28 12 25 12z"
fill="#269"></path>
<path
d="M14 17c-.55 0-1-.45-1-1v-1c0-.55.45-1 1-1s1 .45 1 1v1c0 .55-.45 1-1 1m8 0c-.55 0-1-.45-1-1v-1c0-.55.45-1 1-1s1 .45 1 1v1c0 .55-.45 1-1 1"
fill="#88C9F9"></path>
<path
d="M18.75 19.75h-1.5c-.413 0-.75-.337-.75-.75s.337-.75.75-.75h1.5c.413 0 .75.337.75.75s-.337.75-.75.75m-.75 3.5c-2.058 0-3.594-.504-3.658-.525a.5.5 0 0 1 .316-.949c.014.004 1.455.474 3.342.474s3.328-.47 3.343-.475a.5.5 0 0 1 .316.949c-.065.022-1.601.526-3.659.526z"
fill="#C1694F"></path>
</svg>
);
}

@ -0,0 +1,54 @@
export default function SilverReviewerBadgeIcon() {
return (
<svg
aria-hidden="true"
height="36px"
preserveAspectRatio="xMidYMid meet"
role="img"
viewBox="0 0 36 36"
width="36px"
xmlns="http://www.w3.org/2000/svg">
<path
d="M33 36v-1a6 6 0 0 0-6-6H9a6 6 0 0 0-6 6v1h30zm-6.25-15.565c1.188.208 2.619.129 2.416.917c-.479 1.854-2.604 1.167-2.979 1.188c-.375.02.563-2.105.563-2.105z"
fill="#66757F"></path>
<path
d="M27.062 20.645c1.875.25 2.541.416 1.166.958c-.772.305-2.243 4.803-3.331 4.118c-1.087-.685 2.165-5.076 2.165-5.076z"
fill="#292F33"></path>
<path
d="M9.255 20.435c-1.188.208-2.619.129-2.416.917c.479 1.854 2.604 1.167 2.979 1.188c.375.02-.563-2.105-.563-2.105z"
fill="#66757F"></path>
<path
d="M8.943 20.645c-1.875.25-2.541.416-1.166.958c.772.305 2.243 4.803 3.331 4.118c1.088-.685-2.165-5.076-2.165-5.076z"
fill="#292F33"></path>
<path
d="M21.771 4.017c-1.958-.634-6.566-.461-7.718 1.037c-2.995.058-6.508 2.764-6.969 6.335c-.456 3.534.56 5.175.922 7.833c.409 3.011 2.102 3.974 3.456 4.377c1.947 2.572 4.017 2.462 7.492 2.462c6.787 0 10.019-4.541 10.305-12.253c.172-4.665-2.565-8.198-7.488-9.791z"
fill="#FFAC33"></path>
<path
d="M25.652 14.137c-.657-.909-1.497-1.641-3.34-1.901c.691.317 1.353 1.411 1.44 2.016c.086.605.173 1.094-.374.49c-2.192-2.423-4.579-1.469-6.944-2.949c-1.652-1.034-2.155-2.177-2.155-2.177s-.202 1.526-2.707 3.081c-.726.451-1.593 1.455-2.073 2.937c-.346 1.066-.238 2.016-.238 3.64c0 4.74 3.906 8.726 8.726 8.726s8.726-4.02 8.726-8.726c-.004-2.948-.312-4.1-1.061-5.137z"
fill="#FFDC5D"></path>
<path
d="M18.934 21.565h-1.922a.481.481 0 0 1-.481-.481v-.174c0-.265.215-.482.481-.482h1.922c.265 0 .482.216.482.482v.174a.481.481 0 0 1-.482.481"
fill="#C1694F"></path>
<path
clip-rule="evenodd"
d="M7.657 14.788c.148.147.888.591 1.036 1.034c.148.443.445 2.954 1.333 3.693c.916.762 4.37.478 5.032.149c1.48-.738 1.662-2.798 1.924-3.842c.148-.591 1.036-.591 1.036-.591s.888 0 1.036.591c.262 1.044.444 3.104 1.924 3.841c.662.33 4.116.614 5.034-.147c.887-.739 1.183-3.25 1.331-3.694c.146-.443.888-.886 1.035-1.034c.148-.148.148-.739 0-.887c-.296-.295-3.788-.559-7.548-.148c-.75.082-1.035.295-2.812.295c-1.776 0-2.062-.214-2.812-.295c-3.759-.411-7.252-.148-7.548.148c-.149.148-.149.74-.001.887z"
fill="#292F33"
fill-rule="evenodd"></path>
<path
d="M7.858 8.395S9.217-.506 13.79.023c3.512.406 4.89.825 7.833.097c1.947-.482 4.065 1.136 5.342 4.379a27.72 27.72 0 0 1 1.224 4.041s3.938-.385 4.165 1.732c.228 2.117-4.354 4.716-15.889 4.716C10 14.987 3.33 12.63 3.013 10.657c-.317-1.973 4.845-2.262 4.845-2.262z"
fill="#66757F"></path>
<path
d="M8.125 7.15s-.27 1.104-.406 1.871c-.136.768.226 1.296 2.705 1.824c3.287.7 10.679.692 15.058-.383c1.759-.432 2.886-.72 2.751-1.583c-.167-1.068-.196-1.066-.541-2.208c0 0-1.477.502-3.427.96c-2.66.624-9.964.911-13.481.144c-1.874-.41-2.659-.625-2.659-.625zm-.136 13.953c-.354.145 2.921 1.378 7.48 1.458c4.771.084 6.234.39 5.146 1.459c-1.146 1.125-.852 2.894-.771 3.418c.081.524 2.047 1.916 2.208 2.56c.161.645-1.229 5.961-1.229 5.961l-8.729-.252c-2.565-8.844-2.883-8.501-4.105-13.604c-.241-1.008 0-1 0-1z"
fill="#292F33"></path>
<path
d="M6.989 21.144c-.354.146 2.921 1.378 7.48 1.458c4.771.084 6.234.39 5.146 1.459c-1.146 1.125-.664 2.894-.583 3.418c.081.524 1.859 1.916 2.021 2.561c.16.644-1.231 5.96-1.231 5.96l-8.729-.252c-2.565-8.844-2.883-8.501-4.105-13.604c-.24-1.008.001-1 .001-1z"
fill="#66757F"></path>
<path
d="M28.052 21.103c.354.145-2.921 1.378-7.479 1.458c-4.771.084-6.234.39-5.146 1.459c1.146 1.125 2.976 2.892 2.896 3.416c-.081.524-4.172 1.918-4.333 2.562c-.161.645 1.229 5.961 1.229 5.961l8.729-.252c2.565-8.844 2.883-8.501 4.104-13.604c.241-1.008 0-1 0-1z"
fill="#292F33"></path>
<path
d="M28.958 21.103c.354.145-2.921 1.378-7.479 1.458c-4.771.084-6.234.39-5.146 1.459c1.146 1.125 2.977 2.892 2.896 3.416c-.081.524-4.172 1.918-4.333 2.562c-.161.645 1.229 5.961 1.229 5.961l8.657.01c2.565-8.844 2.955-8.763 4.177-13.866c.24-1.008-.001-1-.001-1z"
fill="#66757F"></path>
</svg>
);
}

@ -1,12 +1,22 @@
import clsx from 'clsx';
type Props = Readonly<{
isSelected: boolean;
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
title: string;
}>;
export default function ResumeFilterPill({ title, onClick }: Props) {
export default function ResumeFilterPill({
title,
onClick,
isSelected,
}: Props) {
return (
<button
className="rounded-xl border border-indigo-500 border-transparent bg-white px-2 py-1 text-xs font-medium text-indigo-500 focus:bg-indigo-500 focus:text-white"
className={clsx(
'rounded-xl border border-indigo-500 border-transparent px-2 py-1 text-xs font-medium focus:bg-indigo-500 focus:text-white',
isSelected ? 'bg-indigo-500 text-white' : 'bg-white text-indigo-500',
)}
type="button"
onClick={onClick}>
{title}

@ -1,17 +1,14 @@
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Link from 'next/link';
import { useSession } from 'next-auth/react';
import type { UrlObject } from 'url';
import { ChevronRightIcon } from '@heroicons/react/20/solid';
import {
AcademicCapIcon,
BriefcaseIcon,
ChevronRightIcon,
StarIcon as ColouredStarIcon,
} from '@heroicons/react/20/solid';
import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import { trpc } from '~/utils/trpc';
import type { Resume } from '~/types/resume';
type Props = Readonly<{
@ -19,16 +16,7 @@ type Props = Readonly<{
resumeInfo: Resume;
}>;
export default function BrowseListItem({ href, resumeInfo }: Props) {
const { data: sessionData } = useSession();
// Find out if user has starred this particular resume
const resumeId = resumeInfo.id;
const isStarredQuery = trpc.useQuery([
'resumes.resume.user.isResumeStarred',
{ resumeId },
]);
export default function ResumeListItem({ href, resumeInfo }: Props) {
return (
<Link href={href}>
<div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100">
@ -56,7 +44,7 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
{resumeInfo.numComments} comments
</div>
<div className="flex gap-2">
{isStarredQuery.data && sessionData?.user ? (
{resumeInfo.isStarredByUser ? (
<ColouredStarIcon className="w-4 text-yellow-400" />
) : (
<StarIcon className="w-4" />
@ -67,8 +55,9 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
</div>
<div className="col-span-3 self-center text-sm text-slate-500">
<div>
Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '}
{resumeInfo.user}
{`Uploaded ${formatDistanceToNow(resumeInfo.createdAt, {
addSuffix: true,
})} by ${resumeInfo.user}`}
</div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div>

@ -1,6 +1,6 @@
import { Spinner } from '@tih/ui';
import ResumseListItem from './ResumeListItem';
import ResumeListItem from './ResumeListItem';
import type { Resume } from '~/types/resume';
@ -22,7 +22,7 @@ export default function ResumeListItems({ isLoading, resumes }: Props) {
<ul role="list">
{resumes.map((resumeObj: Resume) => (
<li key={resumeObj.id}>
<ResumseListItem
<ResumeListItem
href={`/resumes/${resumeObj.id}`}
resumeInfo={resumeObj}
/>

@ -1,96 +0,0 @@
export const BROWSE_TABS_VALUES = {
ALL: 'all',
MY: 'my',
STARRED: 'starred',
};
export type SortOrder = 'latest' | 'popular' | 'topComments';
type SortOption = {
name: string;
value: SortOrder;
};
export const SORT_OPTIONS: Array<SortOption> = [
{ name: 'Latest', value: 'latest' },
{ name: 'Popular', value: 'popular' },
{ name: 'Top Comments', value: 'topComments' },
];
export const TOP_HITS = [
{ href: '#', name: 'Unreviewed' },
{ href: '#', name: 'Fresh Grad' },
{ href: '#', name: 'GOATs' },
{ href: '#', name: 'US Only' },
];
export type FilterOption = {
label: string;
value: string;
};
export const ROLE: Array<FilterOption> = [
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{ label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ label: 'Backend Engineer', value: 'Backend Engineer' },
{ label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android Engineer' },
];
export const EXPERIENCE: Array<FilterOption> = [
{ label: 'Freshman', value: 'Freshman' },
{ label: 'Sophomore', value: 'Sophomore' },
{ label: 'Junior', value: 'Junior' },
{ label: 'Senior', value: 'Senior' },
{
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
},
{
label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)',
},
{
label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)',
},
];
export const LOCATION: Array<FilterOption> = [
{ label: 'Singapore', value: 'Singapore' },
{ label: 'United States', value: 'United States' },
{ label: 'India', value: 'India' },
];
export const TEST_RESUMES = [
{
createdAt: new Date(),
experience: 'Fresh Grad (0-1 years)',
numComments: 9,
numStars: 1,
role: 'Backend Engineer',
title: 'Rejected from multiple companies, please help...:(',
user: 'Git Ji Ra',
},
{
createdAt: new Date(),
experience: 'Fresh Grad (0-1 years)',
numComments: 9,
numStars: 1,
role: 'Backend Engineer',
title: 'Rejected from multiple companies, please help...:(',
user: 'Git Ji Ra',
},
{
createdAt: new Date(),
experience: 'Fresh Grad (0-1 years)',
numComments: 9,
numStars: 1,
role: 'Backend Engineer',
title: 'Rejected from multiple companies, please help...:(',
user: 'Git Ji Ra',
},
];

@ -0,0 +1,141 @@
export type FilterId = 'experience' | 'location' | 'role';
export type CustomFilter = {
numComments: number;
};
type RoleFilter =
| 'Android Engineer'
| 'Backend Engineer'
| 'DevOps Engineer'
| 'Frontend Engineer'
| 'Full-Stack Engineer'
| 'iOS Engineer';
type ExperienceFilter =
| 'Entry Level (0 - 2 years)'
| 'Freshman'
| 'Junior'
| 'Mid Level (3 - 5 years)'
| 'Senior Level (5+ years)'
| 'Senior'
| 'Sophomore';
type LocationFilter = 'India' | 'Singapore' | 'United States';
export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
export type FilterOption<T> = {
label: string;
value: T;
};
export type Filter = {
id: FilterId;
label: string;
options: Array<FilterOption<FilterValue>>;
};
export type FilterState = Partial<CustomFilter> &
Record<FilterId, Array<FilterValue>>;
export type SortOrder = 'latest' | 'popular' | 'topComments';
export type Shortcut = {
customFilters?: CustomFilter;
filters: FilterState;
name: string;
sortOrder: SortOrder;
};
export const BROWSE_TABS_VALUES = {
ALL: 'all',
MY: 'my',
STARRED: 'starred',
};
export const SORT_OPTIONS: Record<string, string> = {
latest: 'Latest',
popular: 'Popular',
topComments: 'Top Comments',
};
export const ROLE: Array<FilterOption<RoleFilter>> = [
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{ label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ label: 'Backend Engineer', value: 'Backend Engineer' },
{ label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android Engineer' },
];
export const EXPERIENCE: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Freshman', value: 'Freshman' },
{ label: 'Sophomore', value: 'Sophomore' },
{ label: 'Junior', value: 'Junior' },
{ label: 'Senior', value: 'Senior' },
{
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
},
{
label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)',
},
{
label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)',
},
];
export const LOCATION: Array<FilterOption<LocationFilter>> = [
{ label: 'Singapore', value: 'Singapore' },
{ label: 'United States', value: 'United States' },
{ label: 'India', value: 'India' },
];
export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCE).map(({ value }) => value),
location: Object.values(LOCATION).map(({ value }) => value),
role: Object.values(ROLE).map(({ value }) => value),
};
export const SHORTCUTS: Array<Shortcut> = [
{
filters: INITIAL_FILTER_STATE,
name: 'All',
sortOrder: 'latest',
},
{
filters: {
...INITIAL_FILTER_STATE,
numComments: 0,
},
name: 'Unreviewed',
sortOrder: 'latest',
},
{
filters: {
...INITIAL_FILTER_STATE,
experience: ['Entry Level (0 - 2 years)'],
},
name: 'Fresh Grad',
sortOrder: 'latest',
},
{
filters: INITIAL_FILTER_STATE,
name: 'GOATs',
sortOrder: 'popular',
},
{
filters: {
...INITIAL_FILTER_STATE,
location: ['United States'],
},
name: 'US Only',
sortOrder: 'latest',
},
];

@ -1,8 +1,17 @@
import clsx from 'clsx';
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import {
ArrowDownCircleIcon,
ArrowUpCircleIcon,
} from '@heroicons/react/20/solid';
import { FaceSmileIcon } from '@heroicons/react/24/outline';
import { Vote } from '@prisma/client';
import { Button, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import ResumeExpandableText from '../shared/ResumeExpandableText';
@ -10,7 +19,11 @@ import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentListItemProps = {
comment: ResumeComment;
userId?: string;
userId: string | undefined;
};
type ICommentInput = {
description: string;
};
export default function ResumeCommentListItem({
@ -18,10 +31,113 @@ export default function ResumeCommentListItem({
userId,
}: ResumeCommentListItemProps) {
const isCommentOwner = userId === comment.user.userId;
const [isEditingComment, setIsEditingComment] = useState(false);
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
const {
register,
handleSubmit,
setValue,
formState: { errors, isDirty },
reset,
} = useForm<ICommentInput>({
defaultValues: {
description: comment.description,
},
});
const trpcContext = trpc.useContext();
const commentUpdateMutation = trpc.useMutation(
'resumes.comments.user.update',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
},
},
);
// COMMENT VOTES
const commentVotesQuery = trpc.useQuery([
'resumes.comments.votes.list',
{ commentId: comment.id },
]);
const commentVotesUpsertMutation = trpc.useMutation(
'resumes.comments.votes.user.upsert',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
},
},
);
const commentVotesDeleteMutation = trpc.useMutation(
'resumes.comments.votes.user.delete',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
},
},
);
// FORM ACTIONS
const onCancel = () => {
reset({ description: comment.description });
setIsEditingComment(false);
};
const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
const { id } = comment;
return commentUpdateMutation.mutate(
{
id,
...data,
},
{
onSuccess: () => {
setIsEditingComment(false);
},
},
);
};
const setFormValue = (value: string) => {
setValue('description', value.trim(), { shouldDirty: true });
};
const onVote = async (
value: Vote,
setAnimation: Dispatch<SetStateAction<boolean>>,
) => {
setAnimation(true);
if (commentVotesQuery.data?.userVote?.value === value) {
return commentVotesDeleteMutation.mutate(
{
commentId: comment.id,
},
{
onSettled: async () => setAnimation(false),
},
);
}
return commentVotesUpsertMutation.mutate(
{
commentId: comment.id,
value,
},
{
onSettled: async () => setAnimation(false),
},
);
};
return (
<div className="border-primary-300 w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
<div className="flex w-full flex-row space-x-2 p-1 align-top">
<div className="border-primary-300 w-11/12 min-w-fit rounded-md border-2 bg-white p-2 drop-shadow-md">
<div className="flex flex-row space-x-2 p-1 align-top">
{comment.user.image ? (
<img
alt={comment.user.name ?? 'Reviewer'}
@ -45,7 +161,7 @@ export default function ResumeCommentListItem({
</div>
</div>
<div className="text-xs text-gray-600">
<div className="px-2 text-xs text-gray-600">
{comment.createdAt.toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
@ -54,21 +170,112 @@ export default function ResumeCommentListItem({
</div>
{/* Description */}
<ResumeExpandableText>{comment.description}</ResumeExpandableText>
{isEditingComment ? (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex-column mt-1 space-y-2">
<TextArea
{...(register('description', {
required: 'Comments cannot be empty!',
}),
{})}
defaultValue={comment.description}
disabled={commentUpdateMutation.isLoading}
errorMessage={errors.description?.message}
label=""
placeholder="Leave your comment here"
onChange={setFormValue}
/>
<div className="flex-row space-x-2">
<Button
disabled={commentUpdateMutation.isLoading}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentUpdateMutation.isLoading}
isLoading={commentUpdateMutation.isLoading}
label="Confirm"
size="sm"
type="submit"
variant="primary"
/>
</div>
</div>
</form>
) : (
<ResumeExpandableText text={comment.description} />
)}
{/* Upvote and edit */}
<div className="flex flex-row space-x-1 pt-1 align-middle">
{/* TODO: Implement upvote */}
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
<div className="text-xs">{comment.numVotes}</div>
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
{/* TODO: Implement edit */}
{isCommentOwner ? (
<div className="text-primary-800 hover:text-primary-400 px-1 text-xs">
<button
disabled={
!userId ||
commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading
}
type="button"
onClick={() => onVote(Vote.UPVOTE, setUpvoteAnimation)}>
<ArrowUpCircleIcon
className={clsx(
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
upvoteAnimation
? 'fill-indigo-500'
: 'fill-gray-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-indigo-500',
upvoteAnimation &&
'animate-[bounce_0.5s_infinite] cursor-default',
)}
/>
</button>
<div className="text-xs">
{commentVotesQuery.data?.numVotes ?? 0}
</div>
<button
disabled={
!userId ||
commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading
}
type="button"
onClick={() => onVote(Vote.DOWNVOTE, setDownvoteAnimation)}>
<ArrowDownCircleIcon
className={clsx(
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
downvoteAnimation
? 'fill-red-500'
: 'fill-gray-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-red-500',
downvoteAnimation &&
'animate-[bounce_0.5s_infinite] cursor-default',
)}
/>
</button>
{isCommentOwner && !isEditingComment && (
<button
className="text-primary-800 hover:text-primary-400 px-1 text-xs"
type="button"
onClick={() => setIsEditingComment(true)}>
Edit
</div>
) : null}
</button>
)}
</div>
</div>
</div>

@ -1,6 +1,14 @@
import { useSession } from 'next-auth/react';
import { useState } from 'react';
import { Spinner, Tabs } from '@tih/ui';
import {
BookOpenIcon,
BriefcaseIcon,
CodeBracketSquareIcon,
FaceSmileIcon,
IdentificationIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import { ResumesSection } from '@prisma/client';
import { Spinner } from '@tih/ui';
import { Button } from '@tih/ui';
import { trpc } from '~/utils/trpc';
@ -21,23 +29,26 @@ export default function ResumeCommentsList({
setShowCommentsForm,
}: ResumeCommentsListProps) {
const { data: sessionData } = useSession();
const [tab, setTab] = useState(RESUME_COMMENTS_SECTIONS[0].value);
const [tabs, setTabs] = useState(RESUME_COMMENTS_SECTIONS);
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }], {
onSuccess: (data: Array<ResumeComment>) => {
const updatedTabs = RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const count = data.filter(({ section }) => section === value).length;
const updatedLabel = count > 0 ? `${label} (${count})` : label;
return {
label: updatedLabel,
value,
};
});
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }]);
setTabs(updatedTabs);
},
});
const renderIcon = (section: ResumesSection) => {
const className = 'h-8 w-8';
switch (section) {
case ResumesSection.GENERAL:
return <IdentificationIcon className={className} />;
case ResumesSection.EDUCATION:
return <BookOpenIcon className={className} />;
case ResumesSection.EXPERIENCE:
return <BriefcaseIcon className={className} />;
case ResumesSection.PROJECTS:
return <CodeBracketSquareIcon className={className} />;
case ResumesSection.SKILLS:
return <SparklesIcon className={className} />;
default:
return <FaceSmileIcon className={className} />;
}
};
const renderButton = () => {
if (sessionData === null) {
@ -57,28 +68,44 @@ export default function ResumeCommentsList({
<div className="space-y-3">
{renderButton()}
<Tabs
label="comments"
tabs={tabs}
value={tab}
onChange={(value) => setTab(value)}
/>
{commentsQuery.isFetching ? (
{commentsQuery.isLoading ? (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
) : (
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-auto">
{(commentsQuery.data?.filter((c) => c.section === tab) ?? []).map(
(comment) => (
<ResumeCommentListItem
key={comment.id}
comment={comment}
userId={sessionData?.user?.id}
/>
),
)}
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-4 overflow-y-auto">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {
return (comment.section as string) === value;
})
: [];
const commentCount = comments.length;
return (
<div key={value} className="mb-4 space-y-3">
<div className="flex flex-row items-center space-x-2 text-indigo-800">
{renderIcon(value)}
<div className="w-fit text-xl font-medium">{label}</div>
</div>
{commentCount > 0 ? (
comments.map((comment) => {
return (
<ResumeCommentListItem
key={comment.id}
comment={comment}
userId={sessionData?.user?.id}
/>
);
})
) : (
<div>There are no comments for this section yet!</div>
)}
</div>
);
})}
</div>
)}
</div>

@ -1,45 +0,0 @@
import clsx from 'clsx';
import Link from 'next/link';
const baseStyles = {
outline:
'group inline-flex ring-1 items-center justify-center rounded-full py-2 px-4 text-sm focus:outline-none',
solid:
'group inline-flex items-center justify-center rounded-full py-2 px-4 text-sm font-semibold focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2',
};
const variantStyles = {
outline: {
slate:
'ring-slate-200 text-slate-700 hover:text-slate-900 hover:ring-slate-300 active:bg-slate-100 active:text-slate-600 focus-visible:outline-blue-600 focus-visible:ring-slate-300',
white:
'ring-slate-700 text-white hover:ring-slate-500 active:ring-slate-700 active:text-slate-400 focus-visible:outline-white',
},
solid: {
blue: 'bg-blue-600 text-white hover:text-slate-100 hover:bg-blue-500 active:bg-blue-800 active:text-blue-100 focus-visible:outline-blue-600',
slate:
'bg-slate-900 text-white hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900',
white:
'bg-white text-slate-900 hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white',
},
};
export function Button({
variant = 'solid',
color = 'slate',
className,
href,
...props
}) {
className = clsx(
baseStyles[variant],
variantStyles[variant][color],
className,
);
return href ? (
<Link className={className} href={href} {...props} />
) : (
<button className={className} type="button" {...props} />
);
}

@ -1,36 +0,0 @@
import Image from 'next/future/image';
import { Button } from './Button';
import { Container } from './Container';
import backgroundImage from './images/background-call-to-action.jpg';
export function CallToAction() {
return (
<section
className="relative overflow-hidden bg-blue-600 py-32"
id="get-started-today">
<Image
alt=""
className="absolute top-1/2 left-1/2 max-w-none -translate-x-1/2 -translate-y-1/2"
height={1244}
src={backgroundImage}
unoptimized={true}
width={2347}
/>
<Container className="relative">
<div className="mx-auto max-w-lg text-center">
<h2 className="font-display text-3xl tracking-tight text-white sm:text-4xl">
Resume review can start right now.
</h2>
<p className="mt-4 text-lg tracking-tight text-white">
It's free! Take charge of your resume game by learning from the top
engineers in the field.
</p>
<Button className="mt-10" color="white" href="/resumes/browse">
Start browsing now
</Button>
</div>
</Container>
</section>
);
}

@ -0,0 +1,28 @@
import Link from 'next/link';
import { Container } from './Container';
export function CallToAction() {
return (
<section className="relative overflow-hidden py-32" id="get-started-today">
<Container className="relative">
<div className="mx-auto max-w-lg text-center">
<h2 className="font-display text-3xl tracking-tight text-gray-900 sm:text-4xl">
Resume review can start right now.
</h2>
<p className="mt-4 text-lg tracking-tight text-gray-600">
It's free! Take charge of your resume game by learning from the top
engineers in the field.
</p>
<Link href="/resumes/browse">
<button
className="mt-4 rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white"
type="button">
Start browsing now
</button>
</Link>
</div>
</Container>
</section>
);
}

@ -1,10 +0,0 @@
import clsx from 'clsx'
export function Container({ className, ...props }) {
return (
<div
className={clsx('mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', className)}
{...props}
/>
)
}

@ -0,0 +1,16 @@
import clsx from 'clsx';
import type { FC } from 'react';
type ContainerProps = {
children: Array<JSX.Element> | JSX.Element;
className?: string;
};
export const Container: FC<ContainerProps> = ({ className, ...props }) => {
return (
<div
className={clsx('mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', className)}
{...props}
/>
);
};

@ -1,50 +0,0 @@
import Link from 'next/link';
import { Container } from './Container';
import { Logo } from './Logo';
export function Footer() {
return (
<footer className="bg-slate-50">
<Container>
<div className="py-16">
<Logo className="mx-auto h-10 w-auto" />
<nav aria-label="quick links" className="mt-10 text-sm">
<div className="-my-1 flex justify-center gap-x-6">
<Link href="#features">Features</Link>
<Link href="#testimonials">Testimonials</Link>
</div>
</nav>
</div>
<div className="flex flex-col items-center border-t border-slate-400/10 py-10 sm:flex-row-reverse sm:justify-between">
<div className="flex gap-x-6">
<Link
aria-label="TaxPal on Twitter"
className="group"
href="https://twitter.com">
<svg
aria-hidden="true"
className="h-6 w-6 fill-slate-500 group-hover:fill-slate-700">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0 0 22 5.92a8.19 8.19 0 0 1-2.357.646 4.118 4.118 0 0 0 1.804-2.27 8.224 8.224 0 0 1-2.605.996 4.107 4.107 0 0 0-6.993 3.743 11.65 11.65 0 0 1-8.457-4.287 4.106 4.106 0 0 0 1.27 5.477A4.073 4.073 0 0 1 2.8 9.713v.052a4.105 4.105 0 0 0 3.292 4.022 4.093 4.093 0 0 1-1.853.07 4.108 4.108 0 0 0 3.834 2.85A8.233 8.233 0 0 1 2 18.407a11.615 11.615 0 0 0 6.29 1.84" />
</svg>
</Link>
<Link
aria-label="TaxPal on GitHub"
className="group"
href="https://github.com">
<svg
aria-hidden="true"
className="h-6 w-6 fill-slate-500 group-hover:fill-slate-700">
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2Z" />
</svg>
</Link>
</div>
<p className="mt-6 text-sm text-slate-500 sm:mt-0">
Copyright &copy; {new Date().getFullYear()} Resume Review. All
rights reserved.
</p>
</div>
</Container>
</footer>
);
}

@ -1,21 +1,13 @@
import Image from 'next/future/image';
import Link from 'next/link';
import { Button } from './Button';
import { Container } from './Container';
import logoLaravel from './images/logos/laravel.svg';
import logoMirage from './images/logos/mirage.svg';
import logoStatamic from './images/logos/statamic.svg';
import logoStaticKit from './images/logos/statickit.svg';
import logoTransistor from './images/logos/transistor.svg';
import logoTuple from './images/logos/tuple.svg';
export function Hero() {
return (
<Container className="pt-20 pb-16 text-center lg:pt-32">
<Container className="pb-36 pt-20 text-center lg:pt-32">
<h1 className="font-display mx-auto max-w-4xl text-5xl font-medium tracking-tight text-slate-900 sm:text-7xl">
Resume review{' '}
<span className="relative whitespace-nowrap text-blue-600">
<span className="relative whitespace-nowrap text-indigo-500">
<svg
aria-hidden="true"
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-blue-300/70"
@ -33,41 +25,25 @@ export function Hero() {
</p>
<div className="mt-10 flex justify-center gap-x-4">
<Link href="/resumes/browse">
<Button>Start browsing now</Button>
<button
className="rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white"
type="button">
Start browsing now
</button>
</Link>
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
<Button variant="outline">
<button
className="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:outline-indigo-600 focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600"
type="button">
<svg
aria-hidden="true"
className="h-3 w-3 flex-none fill-blue-600 group-active:fill-current">
className="h-3 w-3 flex-none fill-indigo-600 group-active:fill-current">
<path d="m9.997 6.91-7.583 3.447A1 1 0 0 1 1 9.447V2.553a1 1 0 0 1 1.414-.91L9.997 5.09c.782.355.782 1.465 0 1.82Z" />
</svg>
<span className="ml-3">Watch video</span>
</Button>
</button>
</Link>
</div>
<div className="mt-36 lg:mt-44">
<p className="font-display text-base text-slate-900">
Resumes reviewed from engineers from these companies so far
</p>
<ul
className="mt-8 flex items-center justify-center gap-x-8 sm:flex-col sm:gap-x-0 sm:gap-y-10 xl:flex-row xl:gap-x-12 xl:gap-y-0"
role="list">
{[
{ logo: logoTransistor, name: 'Apple' },
{ logo: logoTuple, name: 'Meta' },
{ logo: logoStaticKit, name: 'Google' },
{ logo: logoMirage, name: 'Mirage' },
{ logo: logoLaravel, name: 'Laravel' },
{ logo: logoStatamic, name: 'Statamic' },
].map((company) => (
<li key={company.name} className="flex">
<Image alt={company.name} src={company.logo} unoptimized={true} />
</li>
))}
</ul>
</div>
</Container>
);
}

@ -1,4 +1,6 @@
export function Logo(props) {
import type { FC } from 'react';
export const Logo: FC = (props) => {
return (
<svg aria-hidden="true" viewBox="0 0 109 40" {...props}>
<path
@ -29,4 +31,4 @@ export function Logo(props) {
/>
</svg>
);
}
};

@ -4,7 +4,6 @@ import { useEffect, useState } from 'react';
import { Tab } from '@headlessui/react';
import { Container } from './Container';
import backgroundImage from './images/background-features.jpg';
import screenshotExpenses from './images/screenshots/expenses.png';
import screenshotPayroll from './images/screenshots/payroll.png';
import screenshotVatReturns from './images/screenshots/vat-returns.png';
@ -36,7 +35,7 @@ export function PrimaryFeatures() {
useEffect(() => {
const lgMediaQuery = window.matchMedia('(min-width: 1024px)');
function onMediaQueryChange({ matches }) {
function onMediaQueryChange({ matches }: { matches: boolean }) {
setTabOrientation(matches ? 'vertical' : 'horizontal');
}
@ -51,16 +50,8 @@ export function PrimaryFeatures() {
return (
<section
aria-label="Features for running your books"
className="relative overflow-hidden bg-blue-600 pt-20 pb-28 sm:py-32"
className="relative overflow-hidden bg-gradient-to-r from-indigo-400 to-indigo-700 pt-20 pb-28 sm:py-32"
id="features">
<Image
alt=""
className="absolute top-1/2 left-1/2 max-w-none translate-x-[-44%] translate-y-[-42%]"
height={1636}
src={backgroundImage}
unoptimized={true}
width={2245}
/>
<Container className="relative">
<div className="max-w-2xl md:mx-auto md:text-center xl:max-w-none">
<h2 className="font-display text-3xl tracking-tight text-white sm:text-4xl md:text-5xl">

@ -7,6 +7,10 @@ import avatarImage3 from './images/avatars/avatar-3.png';
import avatarImage4 from './images/avatars/avatar-4.png';
import avatarImage5 from './images/avatars/avatar-5.png';
type QuoteProps = {
className: string;
};
const testimonials = [
{
columns: [
@ -79,7 +83,7 @@ const testimonials = [
},
];
function QuoteIcon(props) {
function QuoteIcon(props: QuoteProps) {
return (
<svg aria-hidden="true" height={78} width={105} {...props}>
<path d="M25.086 77.292c-4.821 0-9.115-1.205-12.882-3.616-3.767-2.561-6.78-6.102-9.04-10.622C1.054 58.534 0 53.411 0 47.686c0-5.273.904-10.396 2.712-15.368 1.959-4.972 4.746-9.567 8.362-13.786a59.042 59.042 0 0 1 12.43-11.3C28.325 3.917 33.599 1.507 39.324 0l11.074 13.786c-6.479 2.561-11.677 5.951-15.594 10.17-3.767 4.219-5.65 7.835-5.65 10.848 0 1.356.377 2.863 1.13 4.52.904 1.507 2.637 3.089 5.198 4.746 3.767 2.41 6.328 4.972 7.684 7.684 1.507 2.561 2.26 5.5 2.26 8.814 0 5.123-1.959 9.19-5.876 12.204-3.767 3.013-8.588 4.52-14.464 4.52Zm54.24 0c-4.821 0-9.115-1.205-12.882-3.616-3.767-2.561-6.78-6.102-9.04-10.622-2.11-4.52-3.164-9.643-3.164-15.368 0-5.273.904-10.396 2.712-15.368 1.959-4.972 4.746-9.567 8.362-13.786a59.042 59.042 0 0 1 12.43-11.3C82.565 3.917 87.839 1.507 93.564 0l11.074 13.786c-6.479 2.561-11.677 5.951-15.594 10.17-3.767 4.219-5.65 7.835-5.65 10.848 0 1.356.377 2.863 1.13 4.52.904 1.507 2.637 3.089 5.198 4.746 3.767 2.41 6.328 4.972 7.684 7.684 1.507 2.561 2.26 5.5 2.26 8.814 0 5.123-1.959 9.19-5.876 12.204-3.767 3.013-8.588 4.52-14.464 4.52Z" />
@ -91,14 +95,14 @@ export function Testimonials() {
return (
<section
aria-label="What our customers are saying"
className="bg-slate-50 py-20 sm:py-32"
className="bg-gradient-to-r from-indigo-700 to-indigo-400 py-20 sm:py-32"
id="testimonials">
<Container>
<div className="mx-auto max-w-2xl md: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-white sm:text-4xl">
Loved by software engineers worldwide.
</h2>
<p className="mt-4 text-lg tracking-tight text-slate-700">
<p className="mt-4 text-lg tracking-tight text-white">
We crowdsource ideas and feedback from across the world,
guaranteeing you for success in your job application.
</p>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

@ -1,18 +0,0 @@
<svg width="136" height="48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#a)" fill="#334155">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M38.455 13.018c.004.01.01.02.012.03a.658.658 0 0 1 .022.164v8.586a.627.627 0 0 1-.311.543l-7.138 4.15v8.223a.627.627 0 0 1-.312.543l-14.899 8.66a.533.533 0 0 1-.108.044c-.014.006-.027.014-.042.018a.612.612 0 0 1-.318 0 .164.164 0 0 1-.028-.01l-.02-.01c-.035-.013-.07-.024-.103-.043L.311 35.257A.628.628 0 0 1 0 34.714V8.956a.68.68 0 0 1 .021-.163c.006-.019.016-.035.022-.053l.02-.053a.328.328 0 0 1 .02-.044c.012-.02.029-.037.043-.056.018-.025.033-.05.055-.073.018-.018.04-.03.06-.047.023-.018.044-.038.069-.053l7.45-4.33a.616.616 0 0 1 .62 0l7.45 4.33c.025.015.046.035.069.053l.021.016c.014.01.028.02.04.031a.345.345 0 0 1 .04.051l.015.022c.013.02.03.035.042.056.017.03.028.064.04.097l.01.022.012.03a.644.644 0 0 1 .021.164v16.088l6.208-3.608v-8.224a.64.64 0 0 1 .022-.163c.005-.019.015-.035.021-.053l.007-.02a.279.279 0 0 1 .076-.133c.018-.025.034-.05.055-.073.01-.01.02-.017.03-.025.01-.007.021-.014.03-.022l.036-.03a.26.26 0 0 1 .033-.023l7.45-4.33a.616.616 0 0 1 .62 0l7.45 4.33c.026.015.046.036.069.053l.022.016c.013.01.027.02.038.031a.327.327 0 0 1 .04.052l.016.021.016.02c.01.012.019.023.026.036a.522.522 0 0 1 .034.08l.006.017.01.022ZM9.322 30.453l6.196 3.54 13.652-7.867-6.201-3.605-13.647 7.932Zm20.476-5.409v-7.14l-6.208-3.607v7.14l6.207 3.607h.001Zm6.826-11.83-6.206-3.608-6.205 3.607 6.205 3.606 6.206-3.606Zm-27.933.434v15.726l6.208-3.609V10.04L8.69 13.648h.001Zm5.584-4.692L8.07 5.35 1.864 8.956l6.206 3.607 6.205-3.607ZM7.449 13.65l-6.208-3.61v24.31L14.9 42.29v-7.21l-7.135-4.076h-.002L7.759 31c-.025-.015-.045-.035-.067-.053a.277.277 0 0 1-.059-.045l-.002-.002c-.013-.013-.024-.029-.035-.044a.567.567 0 0 0-.016-.022l-.03-.038a.201.201 0 0 1-.016-.023l-.001-.002a.259.259 0 0 1-.023-.054l-.01-.024-.015-.033a.237.237 0 0 1-.014-.038.374.374 0 0 1-.01-.068l-.003-.025a.48.48 0 0 0-.004-.026c-.002-.014-.005-.029-.005-.044V13.65v.001Zm8.691 21.43v7.21l13.657-7.937V27.21L16.14 35.08v.001Zm14.9-10.037 6.208-3.608v-7.14l-6.208 3.61v7.14-.002Z" />
<path d="M132.739 13.214H136V34.36h-3.261V13.214Zm-84.346 0h3.441V31.25h6.463v3.11h-9.904V13.216Z" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M68.576 20.525c.808.403 1.43.956 1.87 1.66v-1.902h3.261V34.36h-3.261v-1.903c-.439.705-1.062 1.259-1.87 1.66-.808.404-1.62.605-2.44.605-1.057 0-2.024-.196-2.902-.59a6.79 6.79 0 0 1-2.26-1.615 7.525 7.525 0 0 1-1.465-2.356 7.669 7.669 0 0 1-.524-2.84c0-.986.174-1.928.524-2.824a7.496 7.496 0 0 1 1.466-2.371 6.8 6.8 0 0 1 2.26-1.616c.877-.393 1.844-.59 2.902-.59.818 0 1.63.202 2.439.605Zm.733 9.938c.367-.416.651-.898.838-1.42.2-.552.302-1.134.3-1.721 0-.605-.1-1.18-.3-1.722a4.373 4.373 0 0 0-.838-1.42 4.056 4.056 0 0 0-1.302-.967 3.893 3.893 0 0 0-1.69-.362c-.62 0-1.178.12-1.677.362a4.105 4.105 0 0 0-1.286.967c-.36.403-.634.876-.823 1.42a5.182 5.182 0 0 0-.284 1.722c0 .604.094 1.178.284 1.72a4.17 4.17 0 0 0 .823 1.42c.36.404.788.726 1.286.968.524.247 1.097.37 1.676.362.618 0 1.182-.12 1.691-.362.495-.231.938-.56 1.302-.967Zm27.649-8.277c-.44-.705-1.063-1.258-1.87-1.661-.808-.403-1.62-.604-2.44-.604-1.057 0-2.024.196-2.902.589a6.8 6.8 0 0 0-2.26 1.616 7.492 7.492 0 0 0-1.465 2.37c-.35.901-.528 1.86-.524 2.826 0 1.007.174 1.953.524 2.84.338.869.836 1.668 1.466 2.355a6.79 6.79 0 0 0 2.26 1.616c.877.393 1.844.59 2.902.59.818 0 1.63-.202 2.439-.605.808-.402 1.43-.956 1.87-1.66v1.902h3.261V20.283h-3.261v1.903Zm-.3 6.857a4.368 4.368 0 0 1-.838 1.42 4.043 4.043 0 0 1-1.301.967 3.89 3.89 0 0 1-1.69.362c-.619 0-1.178-.12-1.677-.362a4.094 4.094 0 0 1-2.109-2.387 5.182 5.182 0 0 1-.285-1.721c0-.605.095-1.18.285-1.722a4.148 4.148 0 0 1 .823-1.42c.36-.404.798-.733 1.286-.967a3.794 3.794 0 0 1 1.676-.362c.618 0 1.182.12 1.69.362.51.242.943.565 1.302.967.36.403.639.876.839 1.42.198.543.299 1.117.299 1.722 0 .604-.1 1.178-.3 1.72Z" />
<path
d="M76.281 34.36h3.262V23.523h5.596v-3.24H76.28V34.36h.001Zm32.916-3.297 4.099-10.78h3.304l-5.354 14.077h-4.099l-5.353-14.077h3.303l4.1 10.78Z" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M123.714 19.92c-3.994 0-7.156 3.315-7.156 7.4 0 4.52 3.06 7.402 7.574 7.402 2.526 0 4.139-.975 6.109-3.098l-2.203-1.721c-.002 0-1.664 2.204-4.145 2.204-2.884 0-4.099-2.348-4.099-3.562h10.821c.568-4.65-2.46-8.624-6.901-8.624Zm-3.911 6.178c.025-.27.401-3.562 3.885-3.562s3.907 3.29 3.931 3.562h-7.816Z" />
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h136v48H0z" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

@ -1,17 +0,0 @@
<svg width="138" height="48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#a)" fill="#334155">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M30.316 5c3.984 0 7.213 3.256 7.213 7.273a7.272 7.272 0 0 1-4.771 6.845l5.814 10.462h3.98c.613 0 1.11.5 1.11 1.118 0 .618-.497 1.118-1.11 1.118H1.11c-.612 0-1.109-.5-1.109-1.118 0-.618.497-1.118 1.11-1.118h3.98l10.353-18.562a1.106 1.106 0 0 1 1.896-.063l5.948 9.189 1.85-2.809a7.28 7.28 0 0 1-2.035-5.062c0-4.017 3.23-7.273 7.214-7.273h-.001Zm-5.709 17.183 4.788 7.397h6.634l-7.457-13.418-3.965 6.021Zm2.14 7.397L16.48 13.72 7.635 29.58H26.747Zm8.702-17.307a5.172 5.172 0 0 1-3.728 4.98l-2.101-3.781a1.106 1.106 0 0 0-1.892-.072l-1.402 2.13a5.18 5.18 0 0 1-1.144-3.257c0-2.859 2.299-5.176 5.134-5.176 2.835 0 5.133 2.317 5.133 5.176Z" />
<path
d="M9.62 35.173c-.611 0-1.107.5-1.107 1.117s.496 1.116 1.107 1.116h24.42c.612 0 1.108-.5 1.108-1.116 0-.617-.496-1.117-1.107-1.117H9.62Zm8.513 5.59c-.613 0-1.11.5-1.11 1.119 0 .617.497 1.118 1.11 1.118h7.396c.612 0 1.109-.5 1.109-1.118 0-.618-.497-1.12-1.11-1.12h-7.395Z" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M120.027 21.871c-.982-1.25-2.453-1.923-4.347-1.923-2.984 0-6.049 2.528-6.049 6.786 0 4.258 3.065 6.786 6.049 6.786 1.894 0 3.365-.66 4.347-1.923v1.058c0 2.445-1.472 3.93-4.142 3.93-1.594 0-3.107-.524-4.428-1.334l-1.036 2.432c1.376.99 3.515 1.525 5.464 1.525 4.36 0 7.003-2.54 7.003-6.677v-12.24h-2.861v1.58Zm-7.467 4.863c0-2.225 1.444-4.08 3.855-4.08 2.303 0 3.857 1.773 3.857 4.08 0 2.308-1.554 4.08-3.857 4.08-2.411 0-3.855-1.867-3.855-4.08Zm-8.219-4.849c-.899-1.168-2.248-1.937-4.101-1.937-3.65 0-6.526 2.898-6.526 6.923s2.875 6.924 6.526 6.924c1.854 0 3.202-.755 4.101-1.923v1.58h2.848v-13.16h-2.848v1.593Zm-7.698 4.986c0-2.307 1.486-4.217 3.938-4.217 2.357 0 3.938 1.813 3.938 4.217s-1.581 4.218-3.938 4.218c-2.452 0-3.938-1.91-3.938-4.218ZM138 26.858c-.013-4.107-2.52-6.91-6.172-6.91-3.842 0-6.499 2.803-6.499 6.924 0 4.176 2.766 6.924 6.676 6.924 1.976 0 3.774-.48 5.368-1.854l-1.417-2.048c-1.076.865-2.466 1.388-3.774 1.388-1.853 0-3.501-.99-3.883-3.353h9.647c.027-.329.054-.7.054-1.07Zm-9.687-1.113c.3-1.923 1.43-3.242 3.46-3.242 1.813 0 2.998 1.195 3.311 3.242h-6.771Z" />
<path
d="m64.333 27.957-5.546-13.738H54.06v19.233h3.08V17.777L62.71 31.57h3.243l5.573-13.944v15.826h3.08V14.219h-4.729l-5.545 13.738h.001Zm16.871 5.495v-13.16h-2.86v13.16h2.86Zm12.182-13.133c-.654-.261-1.322-.37-2.194-.37-1.594 0-2.93.576-3.788 1.826V20.29h-2.82v13.16h2.848v-7.24c0-2.238 1.294-3.53 3.106-3.53.695 0 1.567.165 2.166.48l.682-2.842Zm-11.61-4.575c0-1.14-.886-2.033-2.017-2.033-1.13 0-1.99.893-1.99 2.033s.86 2.006 1.99 2.006c1.131 0 2.017-.866 2.017-2.006Z" />
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h138v48H0z" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

@ -1,5 +0,0 @@
<svg width="127" height="48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="m3.31 28.903 14.75-15.816L14.749 10 0 25.816l3.31 3.087ZM5.792 39 20.54 23.184l-3.31-3.087L2.482 35.913 5.792 39Zm31.261-18.216a3.87 3.87 0 0 0-1.441-1.115c-.594-.276-1.166-.414-1.718-.414-.284 0-.572.025-.864.075a2.23 2.23 0 0 0-.79.289c-.234.142-.43.326-.59.551-.158.226-.237.514-.237.865 0 .3.062.552.188.752.125.2.309.376.551.527.242.15.53.288.865.413.334.126.71.255 1.128.389.602.2 1.229.422 1.88.664a7.03 7.03 0 0 1 1.78.965 5.07 5.07 0 0 1 1.329 1.492c.35.593.526 1.333.526 2.219 0 1.019-.188 1.9-.564 2.644a5.274 5.274 0 0 1-1.516 1.843 6.499 6.499 0 0 1-2.181 1.078 9.17 9.17 0 0 1-2.532.35c-1.27 0-2.499-.22-3.685-.663-1.187-.443-2.173-1.074-2.959-1.893l2.808-2.858c.435.535 1.007.982 1.717 1.341.71.36 1.417.54 2.119.54.317 0 .626-.034.927-.101.301-.067.564-.176.79-.326.225-.15.405-.351.539-.602.134-.25.2-.551.2-.902 0-.334-.083-.619-.25-.853a2.45 2.45 0 0 0-.715-.639 5.76 5.76 0 0 0-1.153-.526c-.46-.159-.982-.33-1.567-.514a14.963 14.963 0 0 1-1.667-.652 5.58 5.58 0 0 1-1.454-.965 4.471 4.471 0 0 1-1.028-1.43c-.259-.559-.388-1.24-.388-2.042 0-.986.2-1.83.601-2.532.39-.689.93-1.28 1.58-1.73a6.786 6.786 0 0 1 2.206-.99c.81-.208 1.645-.314 2.482-.314 1.002 0 2.026.184 3.07.552 1.045.368 1.96.91 2.746 1.63l-2.733 2.882Zm9.677 3.736v4.763c0 .585.113 1.023.338 1.316.226.292.631.439 1.216.439.2 0 .414-.017.64-.05.194-.025.383-.075.563-.15l.05 3.007c-.283.1-.643.188-1.077.264a7.63 7.63 0 0 1-1.304.112c-.836 0-1.538-.104-2.106-.313-.568-.209-1.023-.51-1.366-.902a3.54 3.54 0 0 1-.74-1.404 6.804 6.804 0 0 1-.225-1.818V24.52h-2.006v-3.084h1.98v-3.284h4.037v3.284h2.933v3.084H46.73Zm12.234 3.96h-.527c-.451 0-.906.021-1.366.063-.46.042-.87.122-1.228.238a2.27 2.27 0 0 0-.89.514c-.234.226-.351.523-.351.89 0 .234.054.435.163.602.108.167.246.3.413.401.167.1.36.171.577.213a3.3 3.3 0 0 0 .627.063c.835 0 1.474-.23 1.917-.69.443-.46.665-1.082.665-1.867v-.427Zm-7.546-5.34a7.2 7.2 0 0 1 2.57-1.579 8.805 8.805 0 0 1 2.995-.526c1.053 0 1.943.13 2.67.389.727.259 1.316.66 1.767 1.203.452.543.782 1.228.99 2.056.21.827.314 1.809.314 2.945v6.293h-3.76v-1.329h-.076c-.317.518-.798.92-1.441 1.203a5.125 5.125 0 0 1-2.093.426 6.403 6.403 0 0 1-1.555-.2 4.554 4.554 0 0 1-1.466-.652 3.53 3.53 0 0 1-1.09-1.203c-.285-.502-.427-1.12-.427-1.855 0-.903.247-1.63.74-2.181.493-.552 1.128-.978 1.905-1.279.777-.3 1.642-.501 2.595-.601.952-.1 1.88-.151 2.782-.151v-.2c0-.619-.217-1.074-.651-1.367-.435-.292-.97-.439-1.605-.439a3.99 3.99 0 0 0-1.692.377 5.4 5.4 0 0 0-1.392.902l-2.08-2.231v-.001Zm18.688 1.38v4.763c0 .585.112 1.023.338 1.316.225.292.63.439 1.216.439.2 0 .413-.017.639-.05.226-.034.414-.084.564-.15l.05 3.007a6.88 6.88 0 0 1-1.078.264c-.43.075-.866.112-1.303.112-.836 0-1.538-.104-2.106-.313-.568-.209-1.024-.51-1.366-.902a3.537 3.537 0 0 1-.74-1.404 6.808 6.808 0 0 1-.226-1.818V24.52H64.09v-3.084h1.98v-3.284h4.037v3.284h2.933v3.084H70.106Zm9.325-7.07c0 .318-.063.614-.188.89-.12.268-.29.51-.501.715a2.44 2.44 0 0 1-1.667.652c-.669 0-1.229-.222-1.68-.665a2.15 2.15 0 0 1-.677-1.592c0-.3.059-.589.176-.865.117-.275.284-.514.501-.714.217-.2.468-.364.752-.489s.593-.188.928-.188a2.445 2.445 0 0 1 1.667.652c.209.2.376.439.501.714.126.276.188.573.188.89ZM75.02 33.92V21.437h4.111v12.485H75.02v-.002Zm15.273-8.448a2.496 2.496 0 0 0-.953-.727 2.92 2.92 0 0 0-1.228-.275c-.435 0-.828.087-1.179.263a2.86 2.86 0 0 0-.902.702c-.25.292-.447.63-.59 1.015-.143.393-.215.81-.212 1.228 0 .435.067.844.2 1.229a3 3 0 0 0 .59 1.015c.258.293.568.522.927.69.36.167.765.25 1.216.25.418 0 .831-.08 1.24-.238.41-.159.74-.389.99-.69l2.282 2.783c-.518.502-1.186.894-2.005 1.178-.84.288-1.72.432-2.608.427a8.229 8.229 0 0 1-2.757-.452 6.361 6.361 0 0 1-2.219-1.316 6.18 6.18 0 0 1-1.479-2.093c-.36-.819-.539-1.746-.539-2.783 0-1.02.18-1.938.54-2.757a6.181 6.181 0 0 1 1.478-2.093 6.519 6.519 0 0 1 2.219-1.33 7.951 7.951 0 0 1 5.352.001c.41.142.786.317 1.128.526.343.21.64.439.89.69l-2.381 2.757Zm15.091 8.449-6.593-8.173h-.05v8.173h-4.212V16.17h4.212v7.22h.075l6.343-7.22h5.364l-7.646 8.173 8.098 9.577h-5.591v.001Zm11.206-16.47c0 .317-.062.613-.188.89-.12.268-.29.51-.501.714a2.445 2.445 0 0 1-1.667.652c-.669 0-1.229-.222-1.68-.665a2.152 2.152 0 0 1-.677-1.592c0-.3.059-.589.176-.865.117-.275.284-.514.501-.714.217-.2.468-.364.752-.489s.593-.188.928-.188a2.445 2.445 0 0 1 1.667.652c.209.2.376.439.501.714.126.276.188.573.188.89v.001Zm-4.412 16.47V21.436h4.111v12.485h-4.111Zm11.833-9.401v4.763c0 .585.112 1.023.338 1.316.226.292.631.439 1.216.439.2 0 .414-.017.639-.05.194-.024.384-.075.564-.15l.05 3.007a6.88 6.88 0 0 1-1.078.264c-.43.075-.866.112-1.303.112-.836 0-1.538-.104-2.106-.313-.568-.209-1.024-.51-1.366-.902a3.535 3.535 0 0 1-.74-1.404 6.84 6.84 0 0 1-.225-1.818V24.52h-2.006v-3.084h1.981v-3.284h4.036v3.284h2.933v3.084h-2.933Z"
fill="#334155" />
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

@ -1,13 +0,0 @@
<svg width="158" height="48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#a)" fill="#334155">
<path
d="M55.423 18.178v-2.491H66.38v2.491h-4.076v13.875H59.47V18.178h-4.047Zm18.098 4.577a7.526 7.526 0 0 0-1.33-.116c-1.82 0-2.89 1.188-2.89 3.592v5.793h-2.69v-11.47h2.631v1.622h.058c.52-.985 1.677-2 3.21-2 .433 0 .722.03 1.011.059v2.52Zm12.866 9.269h-2.602v-1.448h-.058c-.78.985-2.024 1.883-3.932 1.883-2.573 0-5.493-2.057-5.493-6.17 0-3.65 2.573-6.083 5.695-6.083 1.908 0 3.065 1.013 3.76 1.911h.057v-1.564h2.602v11.471h-.03.001Zm-5.898-1.94c1.763 0 3.411-1.536 3.411-3.738 0-2.317-1.503-3.852-3.382-3.852-2.37 0-3.499 1.912-3.499 3.795 0 1.911 1.1 3.794 3.47 3.794v.001Zm9.021-9.531h2.66v1.449h.057c.896-1.304 2.226-1.825 3.498-1.825 2.371 0 4.453 1.564 4.453 5.243v6.604h-2.69v-6.46c0-1.97-.924-3.012-2.457-3.012-1.677 0-2.833 1.188-2.833 3.418v6.083H89.51v-11.5h.001Zm18.792 2.955c-.116-.84-.752-1.39-1.533-1.39-.925 0-1.445.579-1.445 1.216 0 .695.347 1.188 2.341 1.854 2.458.782 3.325 2.057 3.325 3.679 0 2.114-1.59 3.592-4.221 3.592-2.746 0-4.105-1.507-4.308-3.65h2.487c.115.956.694 1.68 1.879 1.68 1.012 0 1.59-.637 1.59-1.42 0-.868-.491-1.419-2.399-2.056-2.14-.695-3.239-1.767-3.239-3.563 0-1.883 1.475-3.273 3.903-3.273 2.458 0 3.759 1.448 4.048 3.33h-2.428v.001Zm5.03-8.227h2.978v2.723h-2.978v-2.723Zm.145 5.272h2.688v11.5h-2.688v-11.5Zm10.986 2.955c-.116-.84-.752-1.39-1.533-1.39-.925 0-1.445.579-1.445 1.216 0 .695.347 1.188 2.342 1.854 2.457.782 3.324 2.057 3.324 3.679 0 2.114-1.59 3.592-4.221 3.592-2.746 0-4.105-1.507-4.307-3.65h2.486c.116.956.694 1.68 1.879 1.68 1.012 0 1.59-.637 1.59-1.42 0-.868-.491-1.419-2.399-2.056-2.14-.695-3.238-1.767-3.238-3.563 0-1.883 1.474-3.273 3.903-3.273 2.457 0 3.758 1.448 4.047 3.33h-2.428v.001Zm3.845-2.955h1.445v-3.678h2.689v3.678h2.862v2.26h-2.891v5.127c0 1.564.492 1.999 1.59 1.999.463 0 .983-.087 1.388-.203v2.172c-.607.174-1.359.261-2.024.261-2.862 0-3.614-1.738-3.614-4.084v-5.272h-1.445v-2.26Zm14.311-.376c3.585 0 6.129 2.636 6.129 6.112 0 3.389-2.573 6.17-6.129 6.17-3.498 0-6.129-2.694-6.129-6.17 0-3.563 2.66-6.112 6.129-6.112Zm0 9.877c2.024 0 3.411-1.622 3.411-3.765 0-2.028-1.301-3.737-3.411-3.737-2.053 0-3.412 1.593-3.412 3.737 0 2.201 1.562 3.765 3.412 3.765Zm14.052-7.415c-1.822 0-2.891 1.188-2.891 3.592v5.793h-2.689v-11.47h2.631v1.622h.058c.52-.985 1.676-2 3.209-2 .433 0 .722.03 1.012.059v2.52a7.525 7.525 0 0 0-1.33-.116ZM20.816 37.731a1.39 1.39 0 0 1-1.388-1.39V11.37a1.389 1.389 0 0 1 2.369-.982c.26.26.406.614.406.982v24.97c0 .753-.636 1.39-1.387 1.39v.001Zm-5.783-12.484h-6.65a1.39 1.39 0 0 1-1.387-1.39c0-.783.607-1.391 1.388-1.391h6.65a1.39 1.39 0 1 1 0 2.78v.001Zm18.243 0h-6.678a1.39 1.39 0 0 1-1.388-1.39c0-.783.607-1.391 1.388-1.391h6.65a1.39 1.39 0 0 1 1.387 1.39c0 .782-.607 1.39-1.359 1.39v.001Z" />
<path
d="M20.816 44.712C9.338 44.712 0 35.356 0 23.856 0 12.356 9.338 3 20.816 3s20.816 9.356 20.816 20.856c0 11.5-9.338 20.856-20.816 20.856Zm0-38.931c-9.945 0-18.04 8.11-18.04 18.075s8.095 18.075 18.04 18.075c9.946 0 18.04-8.11 18.04-18.075S30.763 5.781 20.817 5.781h-.001Z" />
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 0h158v48H0z" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

@ -1,13 +0,0 @@
<svg width="105" height="48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M18 4 0 10v19.5l6 2V37l18 6V11.5l-6 2V4ZM8 32.167 18 35.5V15.608l4-1.333v25.95L8 35.56v-3.393Z" fill="#334155" />
<path
d="M42.9 20.45V31h4.446V20.45h3.53v-3.392H39.39v3.393h3.51Zm10.205 4.798c0 3.978 2.3 6.006 6.376 6.006 3.9 0 6.396-1.853 6.396-6.045v-8.15H61.43v7.994c0 1.833-.39 2.73-1.95 2.73-1.58 0-1.97-.897-1.97-2.71v-8.015h-4.406v8.19Z"
fill="#334155" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M68.965 31V17.058h5.558c4.017 0 5.733 1.794 5.733 4.777v.078c0 2.906-1.93 4.544-5.538 4.544h-1.346V31h-4.407Zm5.323-7.507h-.916v-3.14h.936c1.15 0 1.755.43 1.755 1.502v.078c0 1.033-.605 1.56-1.775 1.56Z"
fill="#334155" />
<path
d="M82.563 31V17.058h4.427v10.53h5.07V31h-9.497Zm11.999-13.942V31h10.218v-3.393h-5.811v-2.086h4.368v-3.1h-4.368v-1.97h5.499v-3.393h-9.906Z"
fill="#334155" />
</svg>

Before

Width:  |  Height:  |  Size: 967 B

@ -1,48 +1,45 @@
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
type ResumeExpandableTextProps = Readonly<{
children: ReactNode;
text: string;
}>;
export default function ResumeExpandableText({
children,
text,
}: ResumeExpandableTextProps) {
const ref = useRef<HTMLSpanElement>(null);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [descriptionOverflow, setDescriptionOverflow] = useState(false);
useLayoutEffect(() => {
if (ref.current && ref.current.clientHeight < ref.current.scrollHeight) {
useEffect(() => {
const lines = text.split(/\r\n|\r|\n/);
if (lines.length > 3) {
setDescriptionOverflow(true);
} else {
setDescriptionOverflow(false);
}
}, [ref]);
}, [text]);
const onSeeActionClicked = () => {
setDescriptionExpanded(!descriptionExpanded);
setIsExpanded((prevExpanded) => !prevExpanded);
};
return (
<>
<div>
<span
ref={ref}
className={clsx(
'whitespace-pre-wrap text-sm',
'line-clamp-3',
descriptionExpanded ? 'line-clamp-none' : '',
'line-clamp-3 whitespace-pre-wrap text-sm',
isExpanded ? 'line-clamp-none' : '',
)}>
{children}
{text}
</span>
{descriptionOverflow && (
<div className="flex flex-row">
<div
className="text-xs text-indigo-500 hover:text-indigo-300"
onClick={onSeeActionClicked}>
{descriptionExpanded ? 'See Less' : 'See More'}
</div>
</div>
<p
className="mt-1 cursor-pointer text-xs text-indigo-500 hover:text-indigo-300"
onClick={onSeeActionClicked}>
{isExpanded ? 'See Less' : 'See More'}
</p>
)}
</>
</div>
);
}

@ -0,0 +1,30 @@
export default function SubmissionGuidelines() {
return (
<div className="mb-4 text-left text-sm text-slate-700">
<h2 className="mb-2 text-xl font-medium">Submission Guidelines</h2>
<p>
Before you submit, please review and acknolwedge our
<span className="font-bold"> submission guidelines </span>
stated below.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any of your
<span className="font-bold"> personal particulars</span>.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any
<span className="font-bold">
{' '}
company's proprietary and confidential information
</span>
.
</p>
<p>
<span className="text-lg font-bold"> </span>
Proof-read your resumes to look for grammatical/spelling errors.
</p>
</div>
);
}

@ -0,0 +1,574 @@
import type {
Company,
OffersAnalysis,
OffersBackground,
OffersCurrency,
OffersEducation,
OffersExperience,
OffersFullTime,
OffersIntern,
OffersOffer,
OffersProfile,
OffersReply,
OffersSpecificYoe,
User,
} from '@prisma/client';
import { JobType } from '@prisma/client';
import type {
AddToProfileResponse,
Analysis,
AnalysisHighestOffer,
AnalysisOffer,
Background,
CreateOfferProfileResponse,
DashboardOffer,
Education,
Experience,
GetOffersResponse,
OffersCompany,
Paging,
Profile,
ProfileAnalysis,
ProfileOffer,
SpecificYoe,
Valuation,
} from '~/types/offers';
const analysisOfferDtoMapper = (
offer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
},
) => {
const { background, profileName } = offer.profile;
const analysisOfferDto: AnalysisOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
income: -1,
jobType: offer.jobType,
level: offer.offersFullTime?.level ?? '',
location: offer.location,
monthYearReceived: offer.monthYearReceived,
negotiationStrategy: offer.negotiationStrategy,
previousCompanies: [],
profileName,
specialization:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.specialization ?? ''
: offer.offersIntern?.specialization ?? '',
title:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.title ?? ''
: offer.offersIntern?.title ?? '',
totalYoe: background?.totalYoe ?? -1,
};
if (offer.offersFullTime?.totalCompensation) {
analysisOfferDto.income = offer.offersFullTime.totalCompensation.value;
} else if (offer.offersIntern?.monthlySalary) {
analysisOfferDto.income = offer.offersIntern.monthlySalary.value;
}
return analysisOfferDto;
};
const analysisDtoMapper = (
noOfOffers: number,
percentile: number,
topPercentileOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
}
>,
) => {
const analysisDto: Analysis = {
noOfOffers,
percentile,
topPercentileOffers: topPercentileOffers.map((offer) =>
analysisOfferDtoMapper(offer),
),
};
return analysisDto;
};
const analysisHighestOfferDtoMapper = (
offer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
},
) => {
const analysisHighestOfferDto: AnalysisHighestOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
level: offer.offersFullTime?.level ?? '',
location: offer.location,
specialization:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.specialization ?? ''
: offer.offersIntern?.specialization ?? '',
totalYoe: offer.profile.background?.totalYoe ?? -1,
};
return analysisHighestOfferDto;
};
export const profileAnalysisDtoMapper = (
analysis:
| (OffersAnalysis & {
overallHighestOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topCompanyOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
topOverallOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
})
| null,
) => {
if (!analysis) {
return null;
}
const profileAnalysisDto: ProfileAnalysis = {
companyAnalysis: [
analysisDtoMapper(
analysis.noOfSimilarCompanyOffers,
analysis.companyPercentile,
analysis.topCompanyOffers,
),
],
id: analysis.id,
overallAnalysis: analysisDtoMapper(
analysis.noOfSimilarOffers,
analysis.overallPercentile,
analysis.topOverallOffers,
),
overallHighestOffer: analysisHighestOfferDtoMapper(
analysis.overallHighestOffer,
),
profileId: analysis.profileId,
};
return profileAnalysisDto;
};
export const valuationDtoMapper = (currency: {
currency: string;
id?: string;
value: number;
}) => {
const valuationDto: Valuation = {
currency: currency.currency,
value: currency.value,
};
return valuationDto;
};
export const offersCompanyDtoMapper = (company: Company) => {
const companyDto: OffersCompany = {
createdAt: company.createdAt,
description: company?.description ?? '',
id: company.id,
logoUrl: company.logoUrl ?? '',
name: company.name,
slug: company.slug,
updatedAt: company.updatedAt,
};
return companyDto;
};
export const educationDtoMapper = (education: {
backgroundId?: string;
endDate: Date | null;
field: string | null;
id: string;
school: string | null;
startDate: Date | null;
type: string | null;
}) => {
const educationDto: Education = {
endDate: education.endDate,
field: education.field,
id: education.id,
school: education.school,
startDate: education.startDate,
type: education.type,
};
return educationDto;
};
export const experienceDtoMapper = (
experience: OffersExperience & {
company: Company | null;
monthlySalary: OffersCurrency | null;
totalCompensation: OffersCurrency | null;
},
) => {
const experienceDto: Experience = {
company: experience.company
? offersCompanyDtoMapper(experience.company)
: null,
durationInMonths: experience.durationInMonths,
id: experience.id,
jobType: experience.jobType,
level: experience.level,
monthlySalary: experience.monthlySalary
? valuationDtoMapper(experience.monthlySalary)
: experience.monthlySalary,
specialization: experience.specialization,
title: experience.title,
totalCompensation: experience.totalCompensation
? valuationDtoMapper(experience.totalCompensation)
: experience.totalCompensation,
};
return experienceDto;
};
export const specificYoeDtoMapper = (specificYoe: {
backgroundId?: string;
domain: string;
id: string;
yoe: number;
}) => {
const specificYoeDto: SpecificYoe = {
domain: specificYoe.domain,
id: specificYoe.id,
yoe: specificYoe.yoe,
};
return specificYoeDto;
};
export const backgroundDtoMapper = (
background:
| (OffersBackground & {
educations: Array<OffersEducation>;
experiences: Array<
OffersExperience & {
company: Company | null;
monthlySalary: OffersCurrency | null;
totalCompensation: OffersCurrency | null;
}
>;
specificYoes: Array<OffersSpecificYoe>;
})
| null,
) => {
if (!background) {
return null;
}
const educations = background.educations.map((education) =>
educationDtoMapper(education),
);
const experiences = background.experiences.map((experience) =>
experienceDtoMapper(experience),
);
const specificYoes = background.specificYoes.map((specificYoe) =>
specificYoeDtoMapper(specificYoe),
);
const backgroundDto: Background = {
educations,
experiences,
id: background.id,
specificYoes,
totalYoe: background.totalYoe,
};
return backgroundDto;
};
export const profileOfferDtoMapper = (
offer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency;
bonus: OffersCurrency;
stocks: OffersCurrency;
totalCompensation: OffersCurrency;
})
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
},
) => {
const profileOfferDto: ProfileOffer = {
comments: offer.comments,
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
jobType: offer.jobType,
location: offer.location,
monthYearReceived: offer.monthYearReceived,
negotiationStrategy: offer.negotiationStrategy,
offersFullTime: offer.offersFullTime,
offersIntern: offer.offersIntern,
};
if (offer.offersFullTime) {
profileOfferDto.offersFullTime = {
baseSalary: valuationDtoMapper(offer.offersFullTime.baseSalary),
bonus: valuationDtoMapper(offer.offersFullTime.bonus),
id: offer.offersFullTime.id,
level: offer.offersFullTime.level,
specialization: offer.offersFullTime.specialization,
stocks: valuationDtoMapper(offer.offersFullTime.stocks),
title: offer.offersFullTime.title,
totalCompensation: valuationDtoMapper(
offer.offersFullTime.totalCompensation,
),
};
} else if (offer.offersIntern) {
profileOfferDto.offersIntern = {
id: offer.offersIntern.id,
internshipCycle: offer.offersIntern.internshipCycle,
monthlySalary: valuationDtoMapper(offer.offersIntern.monthlySalary),
specialization: offer.offersIntern.specialization,
startYear: offer.offersIntern.startYear,
title: offer.offersIntern.title,
};
}
return profileOfferDto;
};
export const profileDtoMapper = (
profile: OffersProfile & {
analysis:
| (OffersAnalysis & {
overallHighestOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topCompanyOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
topOverallOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & { company: Company | null }
>;
})
| null;
};
}
>;
})
| null;
background:
| (OffersBackground & {
educations: Array<OffersEducation>;
experiences: Array<
OffersExperience & {
company: Company | null;
monthlySalary: OffersCurrency | null;
totalCompensation: OffersCurrency | null;
}
>;
specificYoes: Array<OffersSpecificYoe>;
})
| null;
discussion: Array<
OffersReply & {
replies: Array<OffersReply>;
replyingTo: OffersReply | null;
user: User | null;
}
>;
offers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency;
bonus: OffersCurrency;
stocks: OffersCurrency;
totalCompensation: OffersCurrency;
})
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
}
>;
},
inputToken: string | undefined,
) => {
const profileDto: Profile = {
analysis: profileAnalysisDtoMapper(profile.analysis),
background: backgroundDtoMapper(profile.background),
editToken: null,
id: profile.id,
isEditable: false,
offers: profile.offers.map((offer) => profileOfferDtoMapper(offer)),
profileName: profile.profileName,
};
if (inputToken === profile.editToken) {
profileDto.editToken = profile.editToken;
profileDto.isEditable = true;
}
return profileDto;
};
export const createOfferProfileResponseMapper = (
profile: { id: string },
token: string,
) => {
const res: CreateOfferProfileResponse = {
id: profile.id,
token,
};
return res;
};
export const addToProfileResponseMapper = (updatedProfile: {
id: string;
profileName: string;
userId?: string | null;
}) => {
const addToProfileResponse: AddToProfileResponse = {
id: updatedProfile.id,
profileName: updatedProfile.profileName,
userId: updatedProfile.userId ?? '',
};
return addToProfileResponse;
};
export const dashboardOfferDtoMapper = (
offer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency;
bonus: OffersCurrency;
stocks: OffersCurrency;
totalCompensation: OffersCurrency;
})
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
},
) => {
const dashboardOfferDto: DashboardOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
income: valuationDtoMapper({ currency: '', value: -1 }),
monthYearReceived: offer.monthYearReceived,
profileId: offer.profileId,
title: offer.offersFullTime?.title ?? '',
totalYoe: offer.profile.background?.totalYoe ?? -1,
};
if (offer.offersFullTime) {
dashboardOfferDto.income = valuationDtoMapper(
offer.offersFullTime.totalCompensation,
);
} else if (offer.offersIntern) {
dashboardOfferDto.income = valuationDtoMapper(
offer.offersIntern.monthlySalary,
);
}
return dashboardOfferDto;
};
export const getOffersResponseMapper = (
data: Array<DashboardOffer>,
paging: Paging,
) => {
const getOffersResponse: GetOffersResponse = {
data,
paging,
};
return getOffersResponse;
};

@ -5,12 +5,14 @@ import { useState } from 'react';
import ProfileComments from '~/components/offers/profile/ProfileComments';
import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader';
import type { OfferEntity } from '~/components/offers/types';
import type { BackgroundCard } from '~/components/offers/types';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
import { convertCurrencyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { Profile, ProfileOffer } from '~/types/offers';
export default function OfferProfile() {
const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." />
@ -30,7 +32,7 @@ export default function OfferProfile() {
],
{
enabled: typeof offerProfileId === 'string',
onSuccess: (data) => {
onSuccess: (data: Profile) => {
if (!data) {
router.push('/offers');
}
@ -43,26 +45,24 @@ export default function OfferProfile() {
if (data?.offers) {
const filteredOffers: Array<OfferEntity> = data
? data?.offers.map((res) => {
if (res.OffersFullTime) {
? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) {
const filteredOffer: OfferEntity = {
base: convertCurrencyToString(
res.OffersFullTime.baseSalary.value,
),
bonus: convertCurrencyToString(
res.OffersFullTime.bonus.value,
res.offersFullTime.baseSalary,
),
bonus: convertCurrencyToString(res.offersFullTime.bonus),
companyName: res.company.name,
id: res.OffersFullTime.id,
jobLevel: res.OffersFullTime.level,
jobTitle: res.OffersFullTime.title,
id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title,
location: res.location,
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived),
stocks: convertCurrencyToString(res.OffersFullTime.stocks),
stocks: convertCurrencyToString(res.offersFullTime.stocks),
totalCompensation: convertCurrencyToString(
res.OffersFullTime.totalCompensation,
res.offersFullTime.totalCompensation,
),
};
@ -70,11 +70,11 @@ export default function OfferProfile() {
}
const filteredOffer: OfferEntity = {
companyName: res.company.name,
id: res.OffersIntern!.id,
jobTitle: res.OffersIntern!.title,
id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title,
location: res.location,
monthlySalary: convertCurrencyToString(
res.OffersIntern!.monthlySalary,
res.offersIntern!.monthlySalary,
),
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
@ -156,19 +156,6 @@ export default function OfferProfile() {
}
}
function handleCopyEditLink() {
// TODO: Add notification
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${offerProfileId}?token=${token}`,
);
}
function handleCopyPublicLink() {
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${offerProfileId}`,
);
}
return (
<>
{getProfileQuery.isError && ErrorPage}
@ -194,11 +181,12 @@ export default function OfferProfile() {
</div>
<div className="h-full w-1/3 bg-white">
<ProfileComments
handleCopyEditLink={handleCopyEditLink}
handleCopyPublicLink={handleCopyPublicLink}
isDisabled={deleteMutation.isLoading}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
profileId={offerProfileId as string}
profileName={background?.profileName}
token={token as string}
/>
</div>
</div>

@ -5,13 +5,13 @@ import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import BackgroundForm from '~/components/offers/forms/BackgroundForm';
import OfferAnalysis from '~/components/offers/forms/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/forms/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/forms/OfferProfileSave';
import BackgroundForm from '~/components/offers/offers-submission/BackgroundForm';
import OfferAnalysis from '~/components/offers/offers-submission/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/offers-submission/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/offers-submission/OfferProfileSave';
import type {
OfferDetailsFormData,
OfferProfileFormData,
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
@ -20,10 +20,11 @@ import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
import type { CreateOfferProfileResponse } from '~/types/offers';
const defaultOfferValues = {
comments: '',
companyId: '',
job: {},
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
@ -40,7 +41,7 @@ export const defaultFullTimeOfferValues = {
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Internship,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
@ -61,10 +62,13 @@ type FormStep = {
export default function OffersSubmissionPage() {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>();
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OfferProfileFormData>({
const formMethods = useForm<OffersProfileFormData>({
defaultValues: defaultOfferProfileValues,
mode: 'all',
});
@ -84,7 +88,9 @@ export default function OffersSubmissionPage() {
label: 'Background',
},
{
component: <OfferAnalysis key={2} />,
component: (
<OfferAnalysis key={2} profileId={createProfileResponse?.id} />
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
@ -115,18 +121,30 @@ export default function OffersSubmissionPage() {
scrollToTop();
};
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const createMutation = trpc.useMutation(['offers.profile.create'], {
onError(error) {
console.error(error.message);
},
onSuccess() {
alert('offer profile submit success!');
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OfferProfileFormData> = async (data) => {
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
@ -142,7 +160,7 @@ export default function OffersSubmissionPage() {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferDetailsFormData) => ({
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,

@ -4,10 +4,10 @@ import { trpc } from '~/utils/trpc';
function Test() {
const [createdData, setCreatedData] = useState('');
const [error, setError] = useState("");
const [error, setError] = useState('');
const createMutation = trpc.useMutation(['offers.profile.create'], {
onError(err: any) {
onError(err) {
alert(err);
},
onSuccess(data) {
@ -15,17 +15,20 @@ function Test() {
},
});
const addToUserProfileMutation = trpc.useMutation(['offers.profile.addToUserProfile'], {
onError(err: any) {
alert(err);
},
onSuccess(data) {
setCreatedData(JSON.stringify(data));
const addToUserProfileMutation = trpc.useMutation(
['offers.profile.addToUserProfile'],
{
onError(err) {
alert(err);
},
onSuccess(data) {
setCreatedData(JSON.stringify(data));
},
},
})
);
const deleteCommentMutation = trpc.useMutation(['offers.comments.delete'], {
onError(err: any) {
onError(err) {
alert(err);
},
onSuccess(data) {
@ -38,12 +41,12 @@ function Test() {
id: 'cl97fprun001j7iyg6ev9x983',
profileId: 'cl96stky5002ew32gx2kale2x',
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1',
userId: 'cl97dl51k001e7iygd5v5gt58'
})
}
userId: 'cl97dl51k001e7iygd5v5gt58',
});
};
const updateCommentMutation = trpc.useMutation(['offers.comments.update'], {
onError(err: any) {
onError(err) {
alert(err);
},
onSuccess(data) {
@ -56,12 +59,12 @@ function Test() {
id: 'cl97fxb0y001l7iyg14sdobt2',
message: 'hello hello',
profileId: 'cl96stky5002ew32gx2kale2x',
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba'
})
}
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
});
};
const createCommentMutation = trpc.useMutation(['offers.comments.create'], {
onError(err: any) {
onError(err) {
alert(err);
},
onSuccess(data) {
@ -71,19 +74,20 @@ function Test() {
const handleCreate = () => {
createCommentMutation.mutate({
message: 'hello',
profileId: 'cl96stky5002ew32gx2kale2x',
// UserId: 'cl97dl51k001e7iygd5v5gt58'
})
}
message: 'wassup bro',
profileId: 'cl9efyn9p004ww3u42mjgl1vn',
replyingToId: 'cl9el4xj10001w3w21o3p2iny',
userId: 'cl9ehvpng0000w3ec2mpx0bdd'
});
};
const handleLink = () => {
addToUserProfileMutation.mutate({
profileId: 'cl96stky5002ew32gx2kale2x',
profileId: 'cl9efyn9p004ww3u42mjgl1vn',
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
userId: 'cl97dl51k001e7iygd5v5gt58'
})
}
userId: 'cl9ehvpng0000w3ec2mpx0bdd',
});
};
const handleClick = () => {
createMutation.mutate({
@ -99,7 +103,7 @@ function Test() {
],
experiences: [
{
companyId: 'cl95u79f000007im531ysjg79',
companyId: 'cl9ec1mgg0000w33hg1a3612r',
durationInMonths: 24,
jobType: 'FULLTIME',
level: 'Junior',
@ -126,8 +130,14 @@ function Test() {
},
offers: [
{
OffersFullTime: {
comments: 'I am a Raffles Institution almumni',
// Comments: '',
companyId: 'cl98yuqk80007txhgjtjp8fk4',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Leveraged having multiple offers',
offersFullTime: {
baseSalary: {
currency: 'SGD',
value: 84000,
@ -148,15 +158,15 @@ function Test() {
value: 104100,
},
},
// Comments: '',
companyId: 'cl95u79f000007im531ysjg79',
},
{
comments: '',
companyId: 'cl98yuqk80007txhgjtjp8fk4',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Leveraged having multiple offers',
},
{
OffersFullTime: {
offersFullTime: {
baseSalary: {
currency: 'SGD',
value: 84000,
@ -177,47 +187,49 @@ function Test() {
value: 104100,
},
},
comments: undefined,
companyId: 'cl95u79f000007im531ysjg79',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
// NegotiationStrategy: 'Leveraged having multiple offers',
},
],
});
};
const profileId = 'cl96stky5002ew32gx2kale2x'; // Remember to change this filed after testing deleting
const data = trpc.useQuery([
`offers.profile.listOne`,
const profileId = 'cl9efyn9p004ww3u42mjgl1vn'; // Remember to change this filed after testing deleting
const data = trpc.useQuery(
[
`offers.profile.listOne`,
{
profileId,
token:
'd14666ff76e267c9e99445844b41410e83874936d0c07e664db73ff0ea76919e',
},
],
{
profileId,
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
onError(err) {
setError(err.shape?.message || '');
},
},
], {
onError(err) {
setError(err.shape?.message || "")
}
});
);
const replies = trpc.useQuery(['offers.comments.getComments', {profileId: 'cl96stky5002ew32gx2kale2x'}], {
onError(err) {
setError(err.shape?.message || "")
const replies = trpc.useQuery(
['offers.comments.getComments', { profileId }],
{
onError(err) {
setError(err.shape?.message || '');
},
},
});
);
// Console.log(replies.data?.data)
const deleteMutation = trpc.useMutation(['offers.profile.delete']);
const handleDelete = (id: string) => {
deleteMutation.mutate({
profileId: id,
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
token: 'e7effd2a40adba2deb1ddea4fb9f1e6c3c98ab0a85a88ed1567fc2a107fdb445',
});
};
const updateMutation = trpc.useMutation(['offers.profile.update'], {
onError(err: any) {
onError(err) {
alert(err);
},
onSuccess(response) {
@ -230,362 +242,368 @@ function Test() {
background: {
educations: [
{
backgroundId: "cl96stky6002fw32g6vj4meyr",
endDate: new Date("2018-09-30T07:58:54.000Z"),
field: "Computer Science",
id: "cl96stky6002gw32gey2ffawd",
school: "National University of Singapore",
startDate: new Date("2014-09-30T07:58:54.000Z"),
type: "Bachelors"
}
backgroundId: 'cl96stky6002fw32g6vj4meyr',
endDate: new Date('2018-09-30T07:58:54.000Z'),
field: 'Computer Science',
id: 'cl96stky6002gw32gey2ffawd',
school: 'National University of Singapore',
startDate: new Date('2014-09-30T07:58:54.000Z'),
type: 'Bachelors',
},
],
experiences: [
{
backgroundId: "cl96stky6002fw32g6vj4meyr",
backgroundId: 'cl96stky6002fw32g6vj4meyr',
company: {
createdAt: new Date("2022-10-12T16:19:05.196Z"),
description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
id: "cl95u79f000007im531ysjg79",
logoUrl: "https://logo.clearbit.com/meta.com",
name: "Meta",
slug: "meta",
updatedAt: new Date("2022-10-12T16:19:05.196Z")
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: "cl95u79f000007im531ysjg79",
companyId: 'cl9ec1mgg0000w33hg1a3612r',
durationInMonths: 24,
id: "cl96stky6002iw32gpt6t87s2",
jobType: "FULLTIME",
level: "Junior",
id: 'cl96stky6002iw32gpt6t87s2',
jobType: 'FULLTIME',
level: 'Junior',
monthlySalary: null,
monthlySalaryId: null,
specialization: "Front End",
title: "Software Engineer",
specialization: 'Front End',
title: 'Software Engineer',
totalCompensation: {
currency: "SGD",
id: "cl96stky6002jw32g73svfacr",
value: 104100
currency: 'SGD',
id: 'cl96stky6002jw32g73svfacr',
value: 104100,
},
totalCompensationId: "cl96stky6002jw32g73svfacr"
}
totalCompensationId: 'cl96stky6002jw32g73svfacr',
},
],
id: "cl96stky6002fw32g6vj4meyr",
offersProfileId: "cl96stky5002ew32gx2kale2x",
id: 'cl96stky6002fw32g6vj4meyr',
offersProfileId: 'cl96stky5002ew32gx2kale2x',
specificYoes: [
{
backgroundId: "cl96stky6002fw32g6vj4meyr",
domain: "Backend",
id: "cl96t7890004tw32g5in3px5j",
yoe: 2
backgroundId: 'cl96stky6002fw32g6vj4meyr',
domain: 'Backend',
id: 'cl96t7890004tw32g5in3px5j',
yoe: 2,
},
{
backgroundId: "cl96stky6002fw32g6vj4meyr",
domain: "Backend",
id: "cl96tb87x004xw32gnu17jbzv",
yoe: 2
backgroundId: 'cl96stky6002fw32g6vj4meyr',
domain: 'Backend',
id: 'cl96tb87x004xw32gnu17jbzv',
yoe: 2,
},
{
backgroundId: "cl96stky6002fw32g6vj4meyr",
domain: "Backend",
id: "cl976t39z00007iygt3np3cgo",
yoe: 2
backgroundId: 'cl96stky6002fw32g6vj4meyr',
domain: 'Backend',
id: 'cl976t39z00007iygt3np3cgo',
yoe: 2,
},
{
backgroundId: "cl96stky6002fw32g6vj4meyr",
domain: "Front End",
id: "cl96stky7002mw32gn4jc7uml",
yoe: 2
backgroundId: 'cl96stky6002fw32g6vj4meyr',
domain: 'Front End',
id: 'cl96stky7002mw32gn4jc7uml',
yoe: 2,
},
{
backgroundId: "cl96stky6002fw32g6vj4meyr",
domain: "Full Stack",
id: "cl96stky7002nw32gpprghtxr",
yoe: 2
backgroundId: 'cl96stky6002fw32g6vj4meyr',
domain: 'Full Stack',
id: 'cl96stky7002nw32gpprghtxr',
yoe: 2,
},
{
backgroundId: "cl96stky6002fw32g6vj4meyr",
domain: "Backend",
id: "cl976we5h000p7iygiomdo9fh",
yoe: 2
}
backgroundId: 'cl96stky6002fw32g6vj4meyr',
domain: 'Backend',
id: 'cl976we5h000p7iygiomdo9fh',
yoe: 2,
},
],
totalYoe: 6
totalYoe: 6,
},
createdAt: "2022-10-13T08:28:13.518Z",
createdAt: '2022-10-13T08:28:13.518Z',
discussion: [],
id: "cl96stky5002ew32gx2kale2x",
id: 'cl96stky5002ew32gx2kale2x',
isEditable: true,
offers: [
{
OffersFullTime: {
comments: 'this IS SO IEUHDAEUIGDI',
company: {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
id: 'cl976t4de00047iygl0zbce11',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Charmed the guy with my face',
offersFullTime: {
baseSalary: {
currency: "SGD",
id: "cl976t4de00067iyg3pjir7k9",
value: 1999999999
currency: 'SGD',
id: 'cl976t4de00067iyg3pjir7k9',
value: 1999999999,
},
baseSalaryId: "cl976t4de00067iyg3pjir7k9",
baseSalaryId: 'cl976t4de00067iyg3pjir7k9',
bonus: {
currency: "SGD",
id: "cl976t4de00087iygcnlmh8aw",
value: 1410065407
currency: 'SGD',
id: 'cl976t4de00087iygcnlmh8aw',
value: 1410065407,
},
bonusId: "cl976t4de00087iygcnlmh8aw",
id: "cl976t4de00057iygq3ktce3v",
level: "EXPERT",
specialization: "FRONTEND",
bonusId: 'cl976t4de00087iygcnlmh8aw',
id: 'cl976t4de00057iygq3ktce3v',
level: 'EXPERT',
specialization: 'FRONTEND',
stocks: {
currency: "SGD",
id: "cl976t4df000a7iygkrsgr1xh",
value: -558038585
currency: 'SGD',
id: 'cl976t4df000a7iygkrsgr1xh',
value: -558038585,
},
stocksId: "cl976t4df000a7iygkrsgr1xh",
title: "Software Engineer",
stocksId: 'cl976t4df000a7iygkrsgr1xh',
title: 'Software Engineer',
totalCompensation: {
currency: "SGD",
id: "cl976t4df000c7iyg73ryf5uw",
value: 55555555
currency: 'SGD',
id: 'cl976t4df000c7iyg73ryf5uw',
value: 55555555,
},
totalCompensationId: "cl976t4df000c7iyg73ryf5uw"
},
OffersIntern: null,
comments: "this IS SO IEUHDAEUIGDI",
company: {
createdAt: new Date("2022-10-12T16:19:05.196Z"),
description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
id: "cl95u79f000007im531ysjg79",
logoUrl: "https://logo.clearbit.com/meta.com",
name: "Meta",
slug: "meta",
updatedAt: new Date("2022-10-12T16:19:05.196Z")
totalCompensationId: 'cl976t4df000c7iyg73ryf5uw',
},
companyId: "cl95u79f000007im531ysjg79",
id: "cl976t4de00047iygl0zbce11",
jobType: "FULLTIME",
location: "Singapore, Singapore",
monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
negotiationStrategy: "Charmed the guy with my face",
offersFullTimeId: "cl976t4de00057iygq3ktce3v",
offersFullTimeId: 'cl976t4de00057iygq3ktce3v',
offersIntern: null,
offersInternId: null,
profileId: "cl96stky5002ew32gx2kale2x"
profileId: 'cl96stky5002ew32gx2kale2x',
},
{
OffersFullTime: {
comments: '',
company: {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
id: 'cl96stky80031w32gau9mu1gs',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Leveraged having million offers',
offersFullTime: {
baseSalary: {
currency: "SGD",
id: "cl96stky80033w32gxw5goc4z",
value: 84000
currency: 'SGD',
id: 'cl96stky80033w32gxw5goc4z',
value: 84000,
},
baseSalaryId: "cl96stky80033w32gxw5goc4z",
baseSalaryId: 'cl96stky80033w32gxw5goc4z',
bonus: {
currency: "SGD",
id: "cl96stky80035w32gajjwdo1p",
value: 123456789
currency: 'SGD',
id: 'cl96stky80035w32gajjwdo1p',
value: 123456789,
},
bonusId: "cl96stky80035w32gajjwdo1p",
id: "cl96stky80032w32gep9ovgj3",
level: "Junior",
specialization: "Front End",
bonusId: 'cl96stky80035w32gajjwdo1p',
id: 'cl96stky80032w32gep9ovgj3',
level: 'Junior',
specialization: 'Front End',
stocks: {
currency: "SGD",
id: "cl96stky90037w32gu04t6ybh",
value: 100
currency: 'SGD',
id: 'cl96stky90037w32gu04t6ybh',
value: 100,
},
stocksId: "cl96stky90037w32gu04t6ybh",
title: "Software Engineer",
stocksId: 'cl96stky90037w32gu04t6ybh',
title: 'Software Engineer',
totalCompensation: {
currency: "SGD",
id: "cl96stky90039w32glbpktd0o",
value: 104100
currency: 'SGD',
id: 'cl96stky90039w32glbpktd0o',
value: 104100,
},
totalCompensationId: "cl96stky90039w32glbpktd0o"
},
OffersIntern: null,
comments: null,
company: {
createdAt: new Date("2022-10-12T16:19:05.196Z"),
description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
id: "cl95u79f000007im531ysjg79",
logoUrl: "https://logo.clearbit.com/meta.com",
name: "Meta",
slug: "meta",
updatedAt: new Date("2022-10-12T16:19:05.196Z")
totalCompensationId: 'cl96stky90039w32glbpktd0o',
},
companyId: "cl95u79f000007im531ysjg79",
id: "cl96stky80031w32gau9mu1gs",
jobType: "FULLTIME",
location: "Singapore, Singapore",
monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
negotiationStrategy: "Leveraged having million offers",
offersFullTimeId: "cl96stky80032w32gep9ovgj3",
offersFullTimeId: 'cl96stky80032w32gep9ovgj3',
offersIntern: null,
offersInternId: null,
profileId: "cl96stky5002ew32gx2kale2x"
profileId: 'cl96stky5002ew32gx2kale2x',
},
{
OffersFullTime: {
comments: '',
company: {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
id: 'cl96stky9003bw32gc3l955vr',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'LOst out having multiple offers',
offersFullTime: {
baseSalary: {
currency: "SGD",
id: "cl96stky9003dw32gcvqbijlo",
value: 1
currency: 'SGD',
id: 'cl96stky9003dw32gcvqbijlo',
value: 1,
},
baseSalaryId: "cl96stky9003dw32gcvqbijlo",
baseSalaryId: 'cl96stky9003dw32gcvqbijlo',
bonus: {
currency: "SGD",
id: "cl96stky9003fw32goc3zqxwr",
value: 0
currency: 'SGD',
id: 'cl96stky9003fw32goc3zqxwr',
value: 0,
},
bonusId: "cl96stky9003fw32goc3zqxwr",
id: "cl96stky9003cw32g5v10izfu",
level: "Senior",
specialization: "Front End",
bonusId: 'cl96stky9003fw32goc3zqxwr',
id: 'cl96stky9003cw32g5v10izfu',
level: 'Senior',
specialization: 'Front End',
stocks: {
currency: "SGD",
id: "cl96stky9003hw32g1lbbkqqr",
value: 999999
currency: 'SGD',
id: 'cl96stky9003hw32g1lbbkqqr',
value: 999999,
},
stocksId: "cl96stky9003hw32g1lbbkqqr",
title: "Software Engineer DOG",
stocksId: 'cl96stky9003hw32g1lbbkqqr',
title: 'Software Engineer DOG',
totalCompensation: {
currency: "SGD",
id: "cl96stky9003jw32gzumcoi7v",
value: 999999
currency: 'SGD',
id: 'cl96stky9003jw32gzumcoi7v',
value: 999999,
},
totalCompensationId: "cl96stky9003jw32gzumcoi7v"
},
OffersIntern: null,
comments: null,
company: {
createdAt: new Date("2022-10-12T16:19:05.196Z"),
description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
id: "cl95u79f000007im531ysjg79",
logoUrl: "https://logo.clearbit.com/meta.com",
name: "Meta",
slug: "meta",
updatedAt: new Date("2022-10-12T16:19:05.196Z")
totalCompensationId: 'cl96stky9003jw32gzumcoi7v',
},
companyId: "cl95u79f000007im531ysjg79",
id: "cl96stky9003bw32gc3l955vr",
jobType: "FULLTIME",
location: "Singapore, Singapore",
monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
negotiationStrategy: "LOst out having multiple offers",
offersFullTimeId: "cl96stky9003cw32g5v10izfu",
offersFullTimeId: 'cl96stky9003cw32g5v10izfu',
offersIntern: null,
offersInternId: null,
profileId: "cl96stky5002ew32gx2kale2x"
profileId: 'cl96stky5002ew32gx2kale2x',
},
{
OffersFullTime: {
comments: 'this IS SO COOL',
company: {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
id: 'cl976wf28000t7iyga4noyz7s',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Charmed the guy with my face',
offersFullTime: {
baseSalary: {
currency: "SGD",
id: "cl976wf28000v7iygmk1b7qaq",
value: 1999999999
currency: 'SGD',
id: 'cl976wf28000v7iygmk1b7qaq',
value: 1999999999,
},
baseSalaryId: "cl976wf28000v7iygmk1b7qaq",
baseSalaryId: 'cl976wf28000v7iygmk1b7qaq',
bonus: {
currency: "SGD",
id: "cl976wf28000x7iyg63w7kcli",
value: 1410065407
currency: 'SGD',
id: 'cl976wf28000x7iyg63w7kcli',
value: 1410065407,
},
bonusId: "cl976wf28000x7iyg63w7kcli",
id: "cl976wf28000u7iyg6euei8e9",
level: "EXPERT",
specialization: "FRONTEND",
bonusId: 'cl976wf28000x7iyg63w7kcli',
id: 'cl976wf28000u7iyg6euei8e9',
level: 'EXPERT',
specialization: 'FRONTEND',
stocks: {
currency: "SGD",
id: "cl976wf28000z7iyg9ivun6ap",
value: 111222333
currency: 'SGD',
id: 'cl976wf28000z7iyg9ivun6ap',
value: 111222333,
},
stocksId: "cl976wf28000z7iyg9ivun6ap",
title: "Software Engineer",
stocksId: 'cl976wf28000z7iyg9ivun6ap',
title: 'Software Engineer',
totalCompensation: {
currency: "SGD",
id: "cl976wf2800117iygmzsc0xit",
value: 55555555
currency: 'SGD',
id: 'cl976wf2800117iygmzsc0xit',
value: 55555555,
},
totalCompensationId: "cl976wf2800117iygmzsc0xit"
totalCompensationId: 'cl976wf2800117iygmzsc0xit',
},
OffersIntern: null,
comments: "this IS SO COOL",
company: {
createdAt: new Date("2022-10-12T16:19:05.196Z"),
description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
id: "cl95u79f000007im531ysjg79",
logoUrl: "https://logo.clearbit.com/meta.com",
name: "Meta",
slug: "meta",
updatedAt: new Date("2022-10-12T16:19:05.196Z")
},
companyId: "cl95u79f000007im531ysjg79",
id: "cl976wf28000t7iyga4noyz7s",
jobType: "FULLTIME",
location: "Singapore, Singapore",
monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
negotiationStrategy: "Charmed the guy with my face",
offersFullTimeId: "cl976wf28000u7iyg6euei8e9",
offersFullTimeId: 'cl976wf28000u7iyg6euei8e9',
offersIntern: null,
offersInternId: null,
profileId: "cl96stky5002ew32gx2kale2x"
profileId: 'cl96stky5002ew32gx2kale2x',
},
{
OffersFullTime: {
comments: 'this rocks',
company: {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
id: 'cl96tbb3o0051w32gjrpaiiit',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Charmed the guy with my face',
offersFullTime: {
baseSalary: {
currency: "SGD",
id: "cl96tbb3o0053w32gz11paaxu",
value: 1999999999
currency: 'SGD',
id: 'cl96tbb3o0053w32gz11paaxu',
value: 1999999999,
},
baseSalaryId: "cl96tbb3o0053w32gz11paaxu",
baseSalaryId: 'cl96tbb3o0053w32gz11paaxu',
bonus: {
currency: "SGD",
id: "cl96tbb3o0055w32gpyqgz5hx",
value: 1410065407
currency: 'SGD',
id: 'cl96tbb3o0055w32gpyqgz5hx',
value: 1410065407,
},
bonusId: "cl96tbb3o0055w32gpyqgz5hx",
id: "cl96tbb3o0052w32guguajzin",
level: "EXPERT",
specialization: "FRONTEND",
bonusId: 'cl96tbb3o0055w32gpyqgz5hx',
id: 'cl96tbb3o0052w32guguajzin',
level: 'EXPERT',
specialization: 'FRONTEND',
stocks: {
currency: "SGD",
id: "cl96tbb3o0057w32gu4nyxguf",
value: 500
currency: 'SGD',
id: 'cl96tbb3o0057w32gu4nyxguf',
value: 500,
},
stocksId: "cl96tbb3o0057w32gu4nyxguf",
title: "Software Engineer",
stocksId: 'cl96tbb3o0057w32gu4nyxguf',
title: 'Software Engineer',
totalCompensation: {
currency: "SGD",
id: "cl96tbb3o0059w32gm3iy1zk4",
value: 55555555
currency: 'SGD',
id: 'cl96tbb3o0059w32gm3iy1zk4',
value: 55555555,
},
totalCompensationId: "cl96tbb3o0059w32gm3iy1zk4"
},
OffersIntern: null,
comments: "this rocks",
company: {
createdAt: new Date("2022-10-12T16:19:05.196Z"),
description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
id: "cl95u79f000007im531ysjg79",
logoUrl: "https://logo.clearbit.com/meta.com",
name: "Meta",
slug: "meta",
updatedAt: new Date("2022-10-12T16:19:05.196Z")
totalCompensationId: 'cl96tbb3o0059w32gm3iy1zk4',
},
companyId: "cl95u79f000007im531ysjg79",
id: "cl96tbb3o0051w32gjrpaiiit",
jobType: "FULLTIME",
location: "Singapore, Singapore",
monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
negotiationStrategy: "Charmed the guy with my face",
offersFullTimeId: "cl96tbb3o0052w32guguajzin",
offersFullTimeId: 'cl96tbb3o0052w32guguajzin',
offersIntern: null,
offersInternId: null,
profileId: "cl96stky5002ew32gx2kale2x"
}
profileId: 'cl96stky5002ew32gx2kale2x',
},
],
profileName: "ailing bryann stuart ziqing",
token: "afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba",
userId: null
profileName: 'ailing bryann stuart ziqing',
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
userId: null,
});
}
};
return (
<>
<div>{createdData}</div>
<div>{JSON.stringify(replies.data)}</div>
<div>{JSON.stringify(replies.data?.data)}</div>
<button type="button" onClick={handleClick}>
Click Me!
</button>

@ -0,0 +1,17 @@
import React from 'react';
import { trpc } from '~/utils/trpc';
function GenerateAnalysis() {
const analysisMutation = trpc.useMutation(['offers.analysis.generate']);
return (
<div>
{JSON.stringify(
analysisMutation.mutate({ profileId: 'cl98ywtbv0000tx1s4p18eol1' }),
)}
</div>
);
}
export default GenerateAnalysis;

@ -0,0 +1,14 @@
import React from 'react';
import { trpc } from '~/utils/trpc';
function GetAnalysis() {
const analysis = trpc.useQuery([
'offers.analysis.get',
{ profileId: 'cl98ywtbv0000tx1s4p18eol1' },
]);
return <div>{JSON.stringify(analysis.data)}</div>;
}
export default GetAnalysis;

@ -6,12 +6,11 @@ function Test() {
const data = trpc.useQuery([
'offers.list',
{
companyId: 'cl95u79f000007im531ysjg79',
limit: 20,
limit: 100,
location: 'Singapore, Singapore',
offset: 0,
sortBy: '-monthYearReceived',
yoeCategory: 1,
sortBy: '-totalYoe',
yoeCategory: 2,
},
]);

@ -67,7 +67,7 @@ export default function QuestionsHomePage() {
[
'questions.questions.getQuestionsByFilter',
{
companies: selectedCompanies,
companyNames: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
@ -257,7 +257,7 @@ export default function QuestionsHomePage() {
<ContributeQuestionCard
onSubmit={(data) => {
createQuestion({
company: data.company,
companyId: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,

@ -4,21 +4,26 @@ import Error from 'next/error';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useState } from 'react';
import {
AcademicCapIcon,
BriefcaseIcon,
CalendarIcon,
InformationCircleIcon,
MapPinIcon,
PencilSquareIcon,
StarIcon,
} from '@heroicons/react/20/solid';
import { Spinner } from '@tih/ui';
import ResumeCommentsSection from '~/components/resumes/comments/ResumeCommentsSection';
import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import { trpc } from '~/utils/trpc';
import SubmitResumeForm from './submit';
export default function ResumeReviewPage() {
const ErrorPage = (
<Error statusCode={404} title="Requested resume does not exist" />
@ -44,6 +49,10 @@ export default function ResumeReviewPage() {
utils.invalidateQueries(['resumes.resume.findOne']);
},
});
const userIsOwner =
session?.user?.id != null && session.user.id === detailsQuery.data?.userId;
const [isEditMode, setIsEditMode] = useState(false);
const onStarButtonClick = () => {
if (session?.user?.id == null) {
@ -51,8 +60,6 @@ export default function ResumeReviewPage() {
return;
}
// Star button only rendered if resume exists
// Star button only clickable if user exists
if (detailsQuery.data?.stars.length) {
unstarMutation.mutate({
resumeId: resumeId as string,
@ -64,6 +71,30 @@ export default function ResumeReviewPage() {
}
};
const onEditButtonClick = () => {
setIsEditMode(true);
};
if (isEditMode && detailsQuery.data != null) {
return (
<SubmitResumeForm
initFormDetails={{
additionalInfo: detailsQuery.data.additionalInfo ?? '',
experience: detailsQuery.data.experience,
location: detailsQuery.data.location,
resumeId: resumeId as string,
role: detailsQuery.data.role,
title: detailsQuery.data.title,
url: detailsQuery.data.url,
}}
onClose={() => {
utils.invalidateQueries(['resumes.resume.findOne']);
setIsEditMode(false);
}}
/>
);
}
return (
<>
{(detailsQuery.isError || detailsQuery.data === null) && ErrorPage}
@ -79,45 +110,46 @@ export default function ResumeReviewPage() {
<title>{detailsQuery.data.title}</title>
</Head>
<main className="h-[calc(100vh-2rem)] flex-1 overflow-y-auto p-4">
<div className="flex flex-row space-x-8">
<div className="flex space-x-8">
<h1 className="text-2xl font-bold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title}
</h1>
<button
className={clsx(
detailsQuery.data?.stars.length
? 'z-10 border-indigo-500 outline-none ring-1 ring-indigo-500'
: '',
'isolate inline-flex max-h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:hover:bg-white',
<div className="flex gap-4">
<button
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:hover:bg-white"
disabled={starMutation.isLoading || unstarMutation.isLoading}
type="button"
onClick={onStarButtonClick}>
<span className="relative inline-flex">
<div className="-ml-1 mr-2 h-5 w-5">
{starMutation.isLoading || unstarMutation.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<StarIcon
aria-hidden="true"
className={clsx(
detailsQuery.data?.stars.length
? 'text-orange-400'
: 'text-gray-400',
)}
/>
)}
</div>
Star
</span>
<span className="relative -ml-px inline-flex">
{detailsQuery.data?._count.stars}
</span>
</button>
{userIsOwner && (
<button
className="p h-10 rounded-md border border-gray-300 bg-white py-1 px-2 text-center"
type="button"
onClick={onEditButtonClick}>
<PencilSquareIcon className="h-6 w-6 text-indigo-500 hover:text-indigo-300" />
</button>
)}
disabled={
session?.user === undefined ||
starMutation.isLoading ||
unstarMutation.isLoading
}
type="button"
onClick={onStarButtonClick}>
<span className="relative inline-flex">
<div className="-ml-1 mr-2 h-5 w-5">
{starMutation.isLoading || unstarMutation.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<StarIcon
aria-hidden="true"
className={clsx(
detailsQuery.data?.stars.length
? 'text-orange-400'
: 'text-gray-400',
)}
/>
)}
</div>
Star
</span>
<span className="relative -ml-px inline-flex">
{detailsQuery.data?._count.stars}
</span>
</button>
</div>
</div>
<div className="flex flex-col pt-1 lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8">
<div className="mt-2 flex items-center text-sm text-gray-500">
@ -146,10 +178,9 @@ export default function ResumeReviewPage() {
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{`Uploaded ${formatDistanceToNow(
new Date(detailsQuery.data.createdAt),
{ addSuffix: true },
)} by ${detailsQuery.data.user.name}`}
{`Uploaded ${formatDistanceToNow(detailsQuery.data.createdAt, {
addSuffix: true,
})} by ${detailsQuery.data.user.name}`}
</div>
</div>
{detailsQuery.data.additionalInfo && (
@ -158,7 +189,7 @@ export default function ResumeReviewPage() {
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{detailsQuery.data.additionalInfo}
<ResumeExpandableText text={detailsQuery.data.additionalInfo} />
</div>
)}
<div className="flex w-full flex-col py-4 lg:flex-row">

@ -1,8 +1,7 @@
import compareAsc from 'date-fns/compareAsc';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Disclosure } from '@headlessui/react';
import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
@ -10,23 +9,26 @@ import {
CheckboxInput,
CheckboxList,
DropdownMenu,
Pagination,
Tabs,
TextInput,
} from '@tih/ui';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import type {
FilterOption,
SortOrder,
} from '~/components/resumes/browse/resumeConstants';
Filter,
FilterId,
Shortcut,
} from '~/components/resumes/browse/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCE,
INITIAL_FILTER_STATE,
LOCATION,
ROLE,
SHORTCUTS,
SORT_OPTIONS,
TOP_HITS,
} from '~/components/resumes/browse/resumeConstants';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
} from '~/components/resumes/browse/resumeFilters';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
@ -35,86 +37,82 @@ import { trpc } from '~/utils/trpc';
import type { Resume } from '~/types/resume';
type FilterId = 'experience' | 'location' | 'role';
type Filter = {
id: FilterId;
name: string;
options: Array<FilterOption>;
};
type FilterState = Record<FilterId, Array<string>>;
const filters: Array<Filter> = [
{
id: 'role',
name: 'Role',
label: 'Role',
options: ROLE,
},
{
id: 'experience',
name: 'Experience',
label: 'Experience',
options: EXPERIENCE,
},
{
id: 'location',
name: 'Location',
label: 'Location',
options: LOCATION,
},
];
const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCE).map(({ value }) => value),
location: Object.values(LOCATION).map(({ value }) => value),
role: Object.values(ROLE).map(({ value }) => value),
};
const filterResumes = (
resumes: Array<Resume>,
searchValue: string,
userFilters: FilterState,
) =>
resumes
.filter((resume) =>
resume.title.toLowerCase().includes(searchValue.toLocaleLowerCase()),
)
.filter(
({ experience, location, role }) =>
userFilters.role.includes(role) &&
userFilters.experience.includes(experience) &&
userFilters.location.includes(location),
);
const sortComparators: Record<
SortOrder,
(resume1: Resume, resume2: Resume) => number
> = {
latest: (resume1, resume2) =>
compareAsc(resume2.createdAt, resume1.createdAt),
popular: (resume1, resume2) => resume2.numStars - resume1.numStars,
topComments: (resume1, resume2) => resume2.numComments - resume1.numComments,
};
const sortResumes = (resumes: Array<Resume>, sortOrder: SortOrder) =>
resumes.sort(sortComparators[sortOrder]);
export default function ResumeHomePage() {
const { data: sessionData } = useSession();
const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
const [sortOrder, setSortOrder] = useState(SORT_OPTIONS[0].value);
const [sortOrder, setSortOrder] = useState('latest');
const [searchValue, setSearchValue] = useState('');
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
const [shortcutSelected, setShortcutSelected] = useState('All');
const [resumes, setResumes] = useState<Array<Resume>>([]);
const [renderSignInButton, setRenderSignInButton] = useState(false);
const [signInButtonText, setSignInButtonText] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const allResumesQuery = trpc.useQuery(['resumes.resume.findAll'], {
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
onSuccess: (data) => {
setResumes(data);
setRenderSignInButton(false);
const PAGE_LIMIT = 10;
const skip = (currentPage - 1) * PAGE_LIMIT;
useEffect(() => {
setCurrentPage(1);
}, [userFilters, sortOrder]);
const allResumesQuery = trpc.useQuery(
[
'resumes.resume.findAll',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
skip,
sortOrder,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
onSuccess: (data) => {
setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
setRenderSignInButton(false);
},
},
});
);
const starredResumesQuery = trpc.useQuery(
['resumes.resume.user.findUserStarred'],
[
'resumes.resume.user.findUserStarred',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
skip,
sortOrder,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
onError: () => {
@ -123,13 +121,28 @@ export default function ResumeHomePage() {
setSignInButtonText('to view starred resumes');
},
onSuccess: (data) => {
setResumes(data);
setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
},
retry: false,
},
);
const myResumesQuery = trpc.useQuery(
['resumes.resume.user.findUserCreated'],
[
'resumes.resume.user.findUserCreated',
{
experienceFilters: userFilters.experience,
locationFilters: userFilters.location,
numComments: userFilters.numComments,
roleFilters: userFilters.role,
skip,
sortOrder,
},
],
{
enabled: tabsValue === BROWSE_TABS_VALUES.MY,
onError: () => {
@ -138,7 +151,12 @@ export default function ResumeHomePage() {
setSignInButtonText('to view your submitted resumes');
},
onSuccess: (data) => {
setResumes(data);
setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
},
retry: false,
},
@ -172,6 +190,21 @@ export default function ResumeHomePage() {
}
};
const onShortcutChange = ({
sortOrder: shortcutSortOrder,
filters: shortcutFilters,
name: shortcutName,
}: Shortcut) => {
setShortcutSelected(shortcutName);
setSortOrder(shortcutSortOrder);
setUserFilters(shortcutFilters);
};
const onTabChange = (tab: string) => {
setTabsValue(tab);
setCurrentPage(1);
};
return (
<>
<Head>
@ -191,7 +224,7 @@ export default function ResumeHomePage() {
</div>
<div className="col-span-10">
<div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2">
<div className="col-span-7">
<div className="col-span-5">
<Tabs
label="Resume Browse Tabs"
tabs={[
@ -209,42 +242,44 @@ export default function ResumeHomePage() {
},
]}
value={tabsValue}
onChange={setTabsValue}
onChange={onTabChange}
/>
</div>
<div className="col-span-3 self-end">
<form>
<TextInput
label=""
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
type="text"
value={searchValue}
onChange={setSearchValue}
/>
</form>
</div>
<div className="col-span-1 justify-self-center">
<DropdownMenu align="end" label="Sort">
{SORT_OPTIONS.map((option) => (
<DropdownMenu.Item
key={option.name}
isSelected={sortOrder === option.value}
label={option.name}
onClick={() =>
setSortOrder(option.value)
}></DropdownMenu.Item>
))}
</DropdownMenu>
</div>
<div className="col-span-1">
<button
className="rounded-md bg-indigo-500 py-1 px-3 text-sm text-white"
type="button"
onClick={onSubmitResume}>
Submit
</button>
<div className="col-span-7 flex items-center justify-evenly">
<div className="w-64">
<form>
<TextInput
label=""
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
type="text"
value={searchValue}
onChange={setSearchValue}
/>
</form>
</div>
<div>
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
{Object.entries(SORT_OPTIONS).map(([key, value]) => (
<DropdownMenu.Item
key={key}
isSelected={sortOrder === key}
label={value}
onClick={() =>
setSortOrder(key)
}></DropdownMenu.Item>
))}
</DropdownMenu>
</div>
<div>
<button
className="rounded-md bg-indigo-500 py-1 px-3 text-sm font-medium text-white"
type="button"
onClick={onSubmitResume}>
Submit Resume
</button>
</div>
</div>
</div>
</div>
@ -258,12 +293,12 @@ export default function ResumeHomePage() {
<ul
className="flex flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900"
role="list">
{TOP_HITS.map((category) => (
<li key={category.name}>
{/* TODO: Replace onClick with filtering function */}
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
<ResumeFilterPill
title={category.name}
onClick={() => true}
isSelected={shortcutSelected === shortcut.name}
title={shortcut.name}
onClick={() => onShortcutChange(shortcut)}
/>
</li>
))}
@ -271,9 +306,9 @@ export default function ResumeHomePage() {
<h3 className="text-md my-4 font-medium tracking-tight text-gray-900">
Explore these filters:
</h3>
{filters.map((section) => (
{filters.map((filter) => (
<Disclosure
key={section.id}
key={filter.id}
as="div"
className="border-b border-gray-200 py-6">
{({ open }) => (
@ -281,7 +316,7 @@ export default function ResumeHomePage() {
<h3 className="-my-3 flow-root">
<Disclosure.Button className="flex w-full items-center justify-between py-3 text-sm text-gray-400 hover:text-gray-500">
<span className="font-medium text-gray-900">
{section.name}
{filter.label}
</span>
<span className="ml-6 flex items-center">
{open ? (
@ -304,19 +339,19 @@ export default function ResumeHomePage() {
isLabelHidden={true}
label=""
orientation="vertical">
{section.options.map((option) => (
{filter.options.map((option) => (
<div
key={option.value}
className="[&>div>div:nth-child(2)>label]:font-normal [&>div>div:nth-child(1)>input]:text-indigo-600 [&>div>div:nth-child(1)>input]:ring-indigo-500">
<CheckboxInput
label={option.label}
value={userFilters[section.id].includes(
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
section.id,
filter.id,
option.value,
)
}
@ -336,17 +371,26 @@ export default function ResumeHomePage() {
{renderSignInButton && (
<ResumeSignInButton text={signInButtonText} />
)}
{totalPages === 0 && (
<div className="mt-4">Nothing to see here.</div>
)}
<ResumeListItems
isLoading={
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching
}
resumes={sortResumes(
filterResumes(resumes, searchValue, userFilters),
sortOrder,
)}
resumes={resumes}
/>
<div className="my-4 flex justify-center">
<Pagination
current={currentPage}
end={totalPages}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
</div>
</div>
</div>
</div>

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

@ -3,10 +3,12 @@ import clsx from 'clsx';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FileRejection } from 'react-dropzone';
import { useDropzone } from 'react-dropzone';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { PaperClipIcon } from '@heroicons/react/24/outline';
import { ArrowUpCircleIcon } from '@heroicons/react/24/outline';
import {
Button,
CheckboxInput,
@ -16,11 +18,13 @@ import {
TextInput,
} from '@tih/ui';
import type { Filter } from '~/components/resumes/browse/resumeFilters';
import {
EXPERIENCE,
LOCATION,
ROLE,
} from '~/components/resumes/browse/resumeConstants';
} from '~/components/resumes/browse/resumeFilters';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { trpc } from '~/utils/trpc';
@ -43,122 +47,171 @@ type IFormInput = {
title: string;
};
export default function SubmitResumeForm() {
const { data: session, status } = useSession();
const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create');
const router = useRouter();
const selectors: Array<Filter> = [
{ id: 'role', label: 'Role', options: ROLE },
{ id: 'experience', label: 'Experience Level', options: EXPERIENCE },
{ id: 'location', label: 'Location', options: LOCATION },
];
type InitFormDetails = {
additionalInfo?: string;
experience: string;
location: string;
resumeId: string;
role: string;
title: string;
url: string;
};
const [resumeFile, setResumeFile] = useState<File | null>();
type Props = Readonly<{
initFormDetails?: InitFormDetails | null;
onClose: () => void;
}>;
export default function SubmitResumeForm({
initFormDetails,
onClose = () => undefined,
}: Props) {
const [isLoading, setIsLoading] = useState(false);
const [invalidFileUploadError, setInvalidFileUploadError] = useState<
string | null
>(null);
const [isDialogShown, setIsDialogShown] = useState(false);
useEffect(() => {
if (status !== 'loading') {
if (session?.user?.id == null) {
router.push('/api/auth/signin');
}
}
}, [router, session, status]);
const { data: session, status } = useSession();
const router = useRouter();
const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert');
const isNewForm = initFormDetails == null;
const {
register,
handleSubmit,
setValue,
reset,
formState: { errors, isDirty },
watch,
formState: { errors, isDirty, dirtyFields },
} = useForm<IFormInput>({
defaultValues: {
isChecked: false,
...initFormDetails,
},
});
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
if (resumeFile == null) {
console.error('Resume file is empty');
return;
const resumeFile = watch('file');
const onFileDrop = useCallback(
(acceptedFiles: Array<File>, fileRejections: Array<FileRejection>) => {
if (fileRejections.length === 0) {
setInvalidFileUploadError('');
setValue('file', acceptedFiles[0], {
shouldDirty: true,
});
} else {
setInvalidFileUploadError(FILE_UPLOAD_ERROR);
}
},
[setValue],
);
const { getRootProps, getInputProps } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
maxFiles: 1,
maxSize: FILE_SIZE_LIMIT_BYTES,
noClick: isLoading,
noDrag: isLoading,
onDrop: onFileDrop,
});
// Route user to sign in if not logged in
useEffect(() => {
if (status !== 'loading') {
if (session?.user?.id == null) {
router.push('/api/auth/signin');
}
}
}, [router, session, status]);
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
setIsLoading(true);
let fileUrl = initFormDetails?.url ?? '';
const formData = new FormData();
formData.append('key', RESUME_STORAGE_KEY);
formData.append('file', resumeFile);
// Only update file in fs when it changes
if (dirtyFields.file) {
const formData = new FormData();
formData.append('key', RESUME_STORAGE_KEY);
formData.append('file', resumeFile);
const res = await axios.post('/api/file-storage', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
const { url } = res.data;
const res = await axios.post('/api/file-storage', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
fileUrl = res.data.url;
}
resumeCreateMutation.mutate(
resumeUpsertMutation.mutate(
{
additionalInfo: data.additionalInfo,
experience: data.experience,
id: initFormDetails?.resumeId,
location: data.location,
role: data.role,
title: data.title,
url,
url: fileUrl,
},
{
onError: (error) => {
onError(error) {
console.error(error);
},
onSettled: () => {
onSettled() {
setIsLoading(false);
},
onSuccess: () => {
router.push('/resumes');
onSuccess() {
if (isNewForm) {
router.push('/resumes/browse');
} else {
onClose();
}
},
},
);
};
const onUploadFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.item(0);
if (file == null) {
return;
}
if (file.type !== 'application/pdf' || file.size > FILE_SIZE_LIMIT_BYTES) {
setInvalidFileUploadError(FILE_UPLOAD_ERROR);
return;
}
setInvalidFileUploadError('');
setResumeFile(file);
};
const onClickReset = () => {
if (isDirty || resumeFile != null) {
const onClickClear = () => {
if (isDirty) {
setIsDialogShown(true);
} else {
onClose();
}
};
const onClickProceedDialog = () => {
const onClickResetDialog = () => {
onClose();
setIsDialogShown(false);
reset();
setResumeFile(null);
setInvalidFileUploadError(null);
};
const onClickDownload = async () => {
if (resumeFile == null) {
return;
}
const onClickDownload = async (
event: React.MouseEvent<HTMLParagraphElement, MouseEvent>,
) => {
// Prevent click event from propagating up to dropzone
event.stopPropagation();
const url = window.URL.createObjectURL(resumeFile);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', resumeFile.name);
// Append to html link element page
document.body.appendChild(link);
// Start download
link.click();
// Clean up and remove the link
// Clean up and remove the link and object URL
link.remove();
URL.revokeObjectURL(url);
};
const fileUploadError = useMemo(() => {
@ -179,6 +232,7 @@ export default function SubmitResumeForm() {
<section
aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col lg:order-last">
{/* Reset Dialog component */}
<Dialog
isShown={isDialogShown}
primaryButton={
@ -186,7 +240,7 @@ export default function SubmitResumeForm() {
display="block"
label="OK"
variant="primary"
onClick={onClickProceedDialog}
onClick={onClickResetDialog}
/>
}
secondaryButton={
@ -197,13 +251,18 @@ export default function SubmitResumeForm() {
onClick={() => setIsDialogShown(false)}
/>
}
title="Are you sure you want to clear?"
title={
isNewForm
? 'Are you sure you want to clear?'
: 'Are you sure you want to leave?'
}
onClose={() => setIsDialogShown(false)}>
Note that your current input will not be saved!
</Dialog>
<div className="mx-20 space-y-4 py-8">
<form onSubmit={handleSubmit(onSubmit)}>
<h1 className="mb-4 text-2xl font-bold">Upload a resume</h1>
{/* Title Section */}
<div className="mb-4">
<TextInput
{...register('title', { required: true })}
@ -214,94 +273,87 @@ export default function SubmitResumeForm() {
onChange={(val) => setValue('title', val)}
/>
</div>
<div className="mb-4">
<Select
{...register('role', { required: true })}
disabled={isLoading}
label="Role"
options={ROLE}
required={true}
onChange={(val) => setValue('role', val)}
/>
</div>
<div className="mb-4">
<Select
{...register('experience', { required: true })}
disabled={isLoading}
label="Experience Level"
options={EXPERIENCE}
required={true}
onChange={(val) => setValue('experience', val)}
/>
</div>
<div className="mb-4">
<Select
{...register('location', { required: true })}
disabled={isLoading}
label="Location"
name="location"
options={LOCATION}
required={true}
onChange={(val) => setValue('location', val)}
/>
</div>
<p className="text-sm font-medium text-slate-700">
Upload resume (PDF format)
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
</p>
<div className="mb-4">
<div
className={clsx(
fileUploadError ? 'border-danger-600' : 'border-gray-300',
'mt-2 flex justify-center rounded-md border-2 border-dashed px-6 pt-5 pb-6',
)}>
<div className="space-y-1 text-center">
<div className="flex gap-2">
{resumeFile == null ? (
<PaperClipIcon className="m-auto h-8 w-8 text-gray-600" />
) : (
<div className="flex gap-2">
{/* Selectors */}
{selectors.map((item) => (
<div key={item.id} className="mb-4">
<Select
{...register(item.id, { required: true })}
disabled={isLoading}
label={item.label}
options={item.options}
required={true}
onChange={(val) => setValue(item.id, val)}
/>
</div>
))}
{/* Upload resume form */}
{isNewForm && (
<>
<p className="text-sm font-medium text-slate-700">
Upload resume (PDF format)
<span aria-hidden="true" className="text-danger-500">
{' '}
*
</span>
</p>
<div className="mb-4">
<div
{...getRootProps()}
className={clsx(
fileUploadError
? 'border-danger-600'
: 'border-gray-300',
'mt-2 flex cursor-pointer justify-center rounded-md border-2 border-dashed bg-gray-100 px-6 pt-5 pb-6',
)}>
<div className="space-y-1 text-center">
{resumeFile == null ? (
<ArrowUpCircleIcon className="m-auto h-10 w-10 text-indigo-500" />
) : (
<p
className="cursor-pointer underline underline-offset-1 hover:text-indigo-600"
className="cursor-pointer underline underline-offset-1 hover:text-indigo-600"
onClick={onClickDownload}>
{resumeFile.name}
</p>
)}
<div className="flex items-center text-sm">
<label
className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2"
htmlFor="file-upload">
<span className="mt-2 font-medium">
Drop file here
</span>
<span className="mr-1 ml-1 font-light">or</span>
<span className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-400">
{resumeFile == null
? 'Select file'
: 'Replace file'}
</span>
<input
{...register('file', { required: true })}
{...getInputProps()}
accept="application/pdf"
className="sr-only"
disabled={isLoading}
id="file-upload"
name="file-upload"
type="file"
/>
</label>
</div>
)}
</div>
<div className="flex justify-center text-sm">
<label
className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2"
htmlFor="file-upload">
<p className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-500">
{resumeFile == null
? 'Upload a file'
: 'Replace file'}
<p className="text-xs text-gray-500">
PDF up to {FILE_SIZE_LIMIT_MB}MB
</p>
<input
{...register('file', { required: true })}
accept="application/pdf"
className="sr-only"
disabled={isLoading}
id="file-upload"
name="file-upload"
type="file"
onChange={onUploadFile}
/>
</label>
</div>
</div>
<p className="text-xs text-gray-500">
PDF up to {FILE_SIZE_LIMIT_MB}MB
</p>
{fileUploadError && (
<p className="text-danger-600 text-sm">
{fileUploadError}
</p>
)}
</div>
</div>
{fileUploadError && (
<p className="text-danger-600 text-sm">{fileUploadError}</p>
)}
</div>
</>
)}
{/* Additional Info Section */}
<div className="mb-8">
<TextArea
{...register('additionalInfo')}
@ -311,58 +363,28 @@ export default function SubmitResumeForm() {
onChange={(val) => setValue('additionalInfo', val)}
/>
</div>
<div className="mb-4 text-left text-sm text-slate-700">
<h2 className="mb-2 text-xl font-medium">
Submission Guidelines
</h2>
<p>
Before you submit, please review and acknolwedge our
<span className="font-bold"> submission guidelines </span>
stated below.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any of your
<span className="font-bold"> personal particulars</span>.
</p>
<p>
<span className="text-lg font-bold"> </span>
Ensure that you do not divulge any
<span className="font-bold">
{' '}
company's proprietary and confidential information
</span>
.
</p>
<p>
<span className="text-lg font-bold"> </span>
Proof-read your resumes to look for grammatical/spelling
errors.
</p>
</div>
{/* Submission Guidelines */}
<SubmissionGuidelines />
<CheckboxInput
{...register('isChecked', { required: true })}
disabled={isLoading}
label="I have read and will follow the guidelines stated."
onChange={(val) => setValue('isChecked', val)}
/>
{/* Clear and Submit Buttons */}
<div className="mt-4 flex justify-end gap-4">
<Button
addonPosition="start"
disabled={isLoading}
display="inline"
label="Clear"
size="md"
label={isNewForm ? 'Clear' : 'Cancel'}
variant="tertiary"
onClick={onClickReset}
onClick={onClickClear}
/>
<Button
addonPosition="start"
disabled={isLoading}
display="inline"
isLoading={isLoading}
label="Submit"
size="md"
type="submit"
variant="primary"
/>

@ -3,6 +3,7 @@ import superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context';
import { offersRouter } from './offers/offers';
import { offersAnalysisRouter } from './offers/offers-analysis-router';
import { offersCommentsRouter } from './offers/offers-comments-router';
import { offersProfileRouter } from './offers/offers-profile-router';
import { protectedExampleRouter } from './protected-example-router';
@ -12,6 +13,8 @@ import { questionsQuestionCommentRouter } from './questions-question-comment-rou
import { questionsQuestionRouter } from './questions-question-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router';
import { resumesCommentsVotesUserRouter } from './resumes/resumes-comments-votes-user-router';
import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
@ -32,12 +35,15 @@ export const appRouter = createRouter()
.merge('resumes.resume.', resumesStarUserRouter)
.merge('resumes.comments.', resumeCommentsRouter)
.merge('resumes.comments.user.', resumesCommentsUserRouter)
.merge('resumes.comments.votes.', resumesCommentsVotesRouter)
.merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.', questionsQuestionRouter)
.merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter)
.merge('offers.analysis.', offersAnalysisRouter)
.merge('offers.comments.', offersCommentsRouter);
// Export type definition of API

@ -0,0 +1,470 @@
import { z } from 'zod';
import type {
Company,
OffersBackground,
OffersCurrency,
OffersFullTime,
OffersIntern,
OffersOffer,
OffersProfile,
} from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { profileAnalysisDtoMapper } from '~/mappers/offers-mappers';
import { createRouter } from '../context';
const searchOfferPercentile = (
offer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency;
bonus: OffersCurrency;
stocks: OffersCurrency;
totalCompensation: OffersCurrency;
})
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
},
similarOffers: Array<
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & {
totalCompensation: OffersCurrency;
})
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
}
>,
) => {
for (let i = 0; i < similarOffers.length; i++) {
if (similarOffers[i].id === offer.id) {
return i;
}
}
return -1;
};
export const offersAnalysisRouter = createRouter()
.query('get', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const analysis = await ctx.prisma.offersAnalysis.findFirst({
include: {
overallHighestOffer: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
topCompanyOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
topOverallOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
where: {
profileId: input.profileId,
},
});
if (!analysis) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No analysis found on this profile',
});
}
return profileAnalysisDtoMapper(analysis);
},
})
.mutation('generate', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
await ctx.prisma.offersAnalysis.deleteMany({
where: {
profileId: input.profileId,
},
});
const offers = await ctx.prisma.offersOffer.findMany({
include: {
company: true,
offersFullTime: {
include: {
baseSalary: true,
bonus: true,
stocks: true,
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
orderBy: [
{
offersFullTime: {
totalCompensation: {
value: 'desc',
},
},
},
{
offersIntern: {
monthlySalary: {
value: 'desc',
},
},
},
],
where: {
profileId: input.profileId,
},
});
if (!offers || offers.length === 0) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'No offers found on this profile',
});
}
const overallHighestOffer = offers[0];
// TODO: Shift yoe out of background to make it mandatory
if (
!overallHighestOffer.profile.background ||
!overallHighestOffer.profile.background.totalYoe
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot analyse without YOE',
});
}
const yoe = overallHighestOffer.profile.background.totalYoe as number;
let similarOffers = await ctx.prisma.offersOffer.findMany({
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
orderBy: [
{
offersFullTime: {
totalCompensation: {
value: 'desc',
},
},
},
{
offersIntern: {
monthlySalary: {
value: 'desc',
},
},
},
],
where: {
AND: [
{
location: overallHighestOffer.location,
},
{
OR: [
{
offersFullTime: {
level: overallHighestOffer.offersFullTime?.level,
specialization:
overallHighestOffer.offersFullTime?.specialization,
},
offersIntern: {
specialization:
overallHighestOffer.offersIntern?.specialization,
},
},
],
},
{
profile: {
background: {
AND: [
{
totalYoe: {
gte: Math.max(yoe - 1, 0),
lte: yoe + 1,
},
},
],
},
},
},
],
},
});
let similarCompanyOffers = similarOffers.filter(
(offer) => offer.companyId === overallHighestOffer.companyId,
);
// CALCULATE PERCENTILES
const overallIndex = searchOfferPercentile(
overallHighestOffer,
similarOffers,
);
const overallPercentile =
similarOffers.length === 0 ? 0 : overallIndex / similarOffers.length;
const companyIndex = searchOfferPercentile(
overallHighestOffer,
similarCompanyOffers,
);
const companyPercentile =
similarCompanyOffers.length === 0
? 0
: companyIndex / similarCompanyOffers.length;
// FIND TOP >=90 PERCENTILE OFFERS
similarOffers = similarOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
);
similarCompanyOffers = similarCompanyOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
);
const noOfSimilarOffers = similarOffers.length;
const similarOffers90PercentileIndex =
Math.floor(noOfSimilarOffers * 0.9) - 1;
const topPercentileOffers =
noOfSimilarOffers > 1
? similarOffers.slice(
similarOffers90PercentileIndex,
similarOffers90PercentileIndex + 2,
)
: similarOffers;
const noOfSimilarCompanyOffers = similarCompanyOffers.length;
const similarCompanyOffers90PercentileIndex =
Math.floor(noOfSimilarCompanyOffers * 0.9) - 1;
const topPercentileCompanyOffers =
noOfSimilarCompanyOffers > 1
? similarCompanyOffers.slice(
similarCompanyOffers90PercentileIndex,
similarCompanyOffers90PercentileIndex + 2,
)
: similarCompanyOffers;
const analysis = await ctx.prisma.offersAnalysis.create({
data: {
companyPercentile,
noOfSimilarCompanyOffers,
noOfSimilarOffers,
overallHighestOffer: {
connect: {
id: overallHighestOffer.id,
},
},
overallPercentile,
profile: {
connect: {
id: input.profileId,
},
},
topCompanyOffers: {
connect: topPercentileCompanyOffers.map((offer) => {
return { id: offer.id };
}),
},
topOverallOffers: {
connect: topPercentileOffers.map((offer) => {
return { id: offer.id };
}),
},
},
include: {
overallHighestOffer: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
topCompanyOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
topOverallOffers: {
include: {
company: true,
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: {
experiences: {
include: {
company: true,
},
},
},
},
},
},
},
},
},
});
return profileAnalysisDtoMapper(analysis);
},
});

@ -1,225 +1,335 @@
import { z } from 'zod';
import * as trpc from '@trpc/server';
import { createProtectedRouter } from '../context';
export const offersCommentsRouter = createProtectedRouter()
.query('getComments', {
input: z.object({
profileId: z.string()
}),
async resolve({ ctx, input }) {
const result = await ctx.prisma.offersProfile.findFirst({
import { createRouter } from '../context';
import type { OffersDiscussion, Reply } from '~/types/offers';
export const offersCommentsRouter = createRouter()
.query('getComments', {
input: z.object({
profileId: z.string(),
}),
async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
id: input.profileId,
},
});
const result = await ctx.prisma.offersProfile.findFirst({
include: {
discussion: {
include: {
replies: {
include: {
discussion: {
include: {
replies: true,
replyingTo: true,
user: true
}
}
user: true,
},
where: {
id: input.profileId
orderBy: {
createdAt: 'desc'
}
})
if (result) {
return result.discussion.filter((x) => x.replyingToId === null)
},
replyingTo: true,
user: true,
},
orderBy: {
createdAt: 'desc'
}
return result
},
},
where: {
id: input.profileId,
}
})
.mutation("create", {
input: z.object({
message: z.string(),
profileId: z.string(),
replyingToId: z.string().optional(),
userId: z.string().optional()
}),
async resolve({ ctx, input }) {
const createdReply = await ctx.prisma.offersReply.create({
data: {
message: input.message,
profile: {
connect: {
id: input.profileId
}
}
}
})
});
if (input.replyingToId) {
await ctx.prisma.offersReply.update({
data: {
replyingTo: {
connect: {
id: input.replyingToId
}
}
},
where: {
id: createdReply.id
}
})
}
const discussions: OffersDiscussion = {
data: result?.discussion
.filter((x) => {
return x.replyingToId === null
})
.map((x) => {
if (x.user == null) {
x.user = {
email: '',
emailVerified: null,
id: '',
image: '',
name: profile?.profileName ?? '<missing name>',
};
}
if (input.userId) {
await ctx.prisma.offersReply.update({
data: {
user: {
connect: {
id: input.userId
}
}
},
where: {
id: createdReply.id
}
})
}
// Get replies
const result = await ctx.prisma.offersProfile.findFirst({
include: {
discussion: {
include: {
replies: true,
replyingTo: true,
user: true
}
}
},
where: {
id: input.profileId
x.replies?.map((y) => {
if (y.user == null) {
y.user = {
email: '',
emailVerified: null,
id: '',
image: '',
name: profile?.profileName ?? '<missing name>',
};
}
})
});
if (result) {
return result.discussion.filter((x) => x.replyingToId === null)
}
const replyType: Reply = {
createdAt: x.createdAt,
id: x.id,
message: x.message,
replies: x.replies.map((reply) => {
return {
createdAt: reply.createdAt,
id: reply.id,
message: reply.message,
replies: [],
replyingToId: reply.replyingToId,
user: reply.user
}
}),
replyingToId: x.replyingToId,
user: x.user
}
return result
}
})
.mutation("update", {
input: z.object({
id: z.string(),
message: z.string(),
profileId: z.string(),
// Have to pass in either userID or token for validation
token: z.string().optional(),
userId: z.string().optional(),
}),
async resolve({ ctx, input }) {
const messageToUpdate = await ctx.prisma.offersReply.findFirst({
where: {
id: input.id
}
})
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
return replyType
}) ?? []
}
return discussions
},
})
.mutation('create', {
input: z.object({
message: z.string(),
profileId: z.string(),
replyingToId: z.string().optional(),
token: z.string().optional(),
userId: z.string().optional()
}),
async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
id: input.profileId,
},
});
const profileEditToken = profile?.editToken;
if (input.token === profileEditToken || input.userId) {
const createdReply = await ctx.prisma.offersReply.create({
data: {
message: input.message,
profile: {
connect: {
id: input.profileId,
},
},
},
});
if (input.replyingToId) {
await ctx.prisma.offersReply.update({
data: {
replyingTo: {
connect: {
id: input.replyingToId,
},
});
const profileEditToken = profile?.editToken;
// To validate user editing, OP or correct user
// TODO: improve validation process
if (profileEditToken === input.token || messageToUpdate?.userId === input.userId) {
await ctx.prisma.offersReply.update({
data: {
message: input.message
},
where: {
id: input.id
}
})
const result = await ctx.prisma.offersProfile.findFirst({
include: {
discussion: {
include: {
replies: true,
replyingTo: true,
user: true
}
}
},
where: {
id: input.profileId
}
})
if (result) {
return result.discussion.filter((x) => x.replyingToId === null)
}
},
},
where: {
id: createdReply.id,
},
});
}
return result
}
if (input.userId) {
await ctx.prisma.offersReply.update({
data: {
user: {
connect: {
id: input.userId,
},
},
},
where: {
id: createdReply.id,
},
});
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Wrong userId or token.'
})
const created = await ctx.prisma.offersReply.findFirst({
include: {
user: true
},
where: {
id: createdReply.id,
},
});
const result: Reply = {
createdAt: created!.createdAt,
id: created!.id,
message: created!.message,
replies: [], // New message should have no replies
replyingToId: created!.replyingToId,
user: created!.user ?? {
email: '',
emailVerified: null,
id: '',
image: '',
name: profile?.profileName ?? '<missing name>',
}
}
})
.mutation("delete", {
input: z.object({
id: z.string(),
profileId: z.string(),
// Have to pass in either userID or token for validation
token: z.string().optional(),
userId: z.string().optional(),
}),
async resolve({ ctx, input }) {
const messageToDelete = await ctx.prisma.offersReply.findFirst({
where: {
id: input.id
}
})
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
id: input.profileId,
},
});
const profileEditToken = profile?.editToken;
// To validate user editing, OP or correct user
// TODO: improve validation process
if (profileEditToken === input.token || messageToDelete?.userId === input.userId) {
await ctx.prisma.offersReply.delete({
where: {
id: input.id
}
})
const result = await ctx.prisma.offersProfile.findFirst({
include: {
discussion: {
include: {
replies: true,
replyingTo: true,
user: true
}
}
},
where: {
id: input.profileId
}
})
if (result) {
return result.discussion.filter((x) => x.replyingToId === null)
}
return result
}
return result
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Wrong userId or token.'
})
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Missing userId or wrong token.',
});
},
})
.mutation('update', {
input: z.object({
id: z.string(),
message: z.string(),
profileId: z.string(),
// Have to pass in either userID or token for validation
token: z.string().optional(),
userId: z.string().optional(),
}),
async resolve({ ctx, input }) {
const messageToUpdate = await ctx.prisma.offersReply.findFirst({
where: {
id: input.id,
},
});
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
id: input.profileId,
},
});
const profileEditToken = profile?.editToken;
// To validate user editing, OP or correct user
// TODO: improve validation process
if (
profileEditToken === input.token ||
messageToUpdate?.userId === input.userId
) {
const updated = await ctx.prisma.offersReply.update({
data: {
message: input.message,
},
include: {
replies: {
include: {
user: true
}
},
user: true
},
where: {
id: input.id,
},
});
const result: Reply = {
createdAt: updated!.createdAt,
id: updated!.id,
message: updated!.message,
replies: updated!.replies.map((x) => {
return {
createdAt: x.createdAt,
id: x.id,
message: x.message,
replies: [],
replyingToId: x.replyingToId,
user: x.user ?? {
email: '',
emailVerified: null,
id: '',
image: '',
name: profile?.profileName ?? '<missing name>',
}
}
}),
replyingToId: updated!.replyingToId,
user: updated!.user ?? {
email: '',
emailVerified: null,
id: '',
image: '',
name: profile?.profileName ?? '<missing name>',
}
}
})
return result
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Wrong userId or token.',
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
profileId: z.string(),
// Have to pass in either userID or token for validation
token: z.string().optional(),
userId: z.string().optional(),
}),
async resolve({ ctx, input }) {
const messageToDelete = await ctx.prisma.offersReply.findFirst({
where: {
id: input.id,
},
});
const profile = await ctx.prisma.offersProfile.findFirst({
where: {
id: input.profileId,
},
});
const profileEditToken = profile?.editToken;
// To validate user editing, OP or correct user
// TODO: improve validation process
if (
profileEditToken === input.token ||
messageToDelete?.userId === input.userId
) {
await ctx.prisma.offersReply.delete({
where: {
id: input.id,
},
});
await ctx.prisma.offersProfile.findFirst({
include: {
discussion: {
include: {
replies: true,
replyingTo: true,
user: true,
},
},
},
where: {
id: input.profileId,
},
});
// If (result) {
// return result.discussion.filter((x) => x.replyingToId === null);
// }
// return result;
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Wrong userId or token.',
});
},
});

@ -1,6 +1,11 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import {
dashboardOfferDtoMapper,
getOffersResponseMapper,
} from '~/mappers/offers-mappers';
import { createRouter } from '../context';
const yoeCategoryMap: Record<number, string> = {
@ -16,8 +21,8 @@ const getYoeRange = (yoeCategory: number) => {
: yoeCategoryMap[yoeCategory] === 'Mid'
? { maxYoe: 7, minYoe: 4 }
: yoeCategoryMap[yoeCategory] === 'Senior'
? { maxYoe: null, minYoe: 8 }
: null;
? { maxYoe: 100, minYoe: 8 }
: null; // Internship
};
const ascOrder = '+';
@ -35,7 +40,7 @@ export const offersRouter = createRouter().query('list', {
companyId: z.string().nullish(),
dateEnd: z.date().nullish(),
dateStart: z.date().nullish(),
limit: z.number().nonnegative(),
limit: z.number().positive(),
location: z.string(),
offset: z.number().nonnegative(),
salaryMax: z.number().nullish(),
@ -43,57 +48,20 @@ export const offersRouter = createRouter().query('list', {
sortBy: z.string().regex(createSortByValidationRegex()).nullish(),
title: z.string().nullish(),
yoeCategory: z.number().min(0).max(3),
yoeMax: z.number().max(100).nullish(),
yoeMin: z.number().min(0).nullish(),
}),
async resolve({ ctx, input }) {
const yoeRange = getYoeRange(input.yoeCategory);
const yoeMin = input.yoeMin ? input.yoeMin : yoeRange?.minYoe;
const yoeMax = input.yoeMax ? input.yoeMax : yoeRange?.maxYoe;
let data = !yoeRange
? await ctx.prisma.offersOffer.findMany({
// Internship
include: {
OffersFullTime: {
include: {
baseSalary: true,
bonus: true,
stocks: true,
totalCompensation: true,
},
},
OffersIntern: {
include: {
monthlySalary: true,
},
},
company: true,
profile: {
include: {
background: true,
},
},
},
where: {
AND: [
{
location: input.location,
},
{
OffersIntern: {
isNot: null,
},
},
{
OffersFullTime: {
is: null,
},
},
],
},
})
: yoeRange.maxYoe
? await ctx.prisma.offersOffer.findMany({
// Junior, Mid
include: {
OffersFullTime: {
offersFullTime: {
include: {
baseSalary: true,
bonus: true,
@ -101,12 +69,11 @@ export const offersRouter = createRouter().query('list', {
totalCompensation: true,
},
},
OffersIntern: {
offersIntern: {
include: {
monthlySalary: true,
},
},
company: true,
profile: {
include: {
background: true,
@ -119,40 +86,23 @@ export const offersRouter = createRouter().query('list', {
location: input.location,
},
{
OffersIntern: {
is: null,
},
},
{
OffersFullTime: {
offersIntern: {
isNot: null,
},
},
{
profile: {
background: {
totalYoe: {
gte: yoeRange.minYoe,
},
},
},
},
{
profile: {
background: {
totalYoe: {
gte: yoeRange.maxYoe,
},
},
offersFullTime: {
is: null,
},
},
],
},
})
: await ctx.prisma.offersOffer.findMany({
// Senior
// Junior, Mid, Senior
include: {
OffersFullTime: {
company: true,
offersFullTime: {
include: {
baseSalary: true,
bonus: true,
@ -160,12 +110,11 @@ export const offersRouter = createRouter().query('list', {
totalCompensation: true,
},
},
OffersIntern: {
offersIntern: {
include: {
monthlySalary: true,
},
},
company: true,
profile: {
include: {
background: true,
@ -178,12 +127,12 @@ export const offersRouter = createRouter().query('list', {
location: input.location,
},
{
OffersIntern: {
offersIntern: {
is: null,
},
},
{
OffersFullTime: {
offersFullTime: {
isNot: null,
},
},
@ -191,7 +140,8 @@ export const offersRouter = createRouter().query('list', {
profile: {
background: {
totalYoe: {
gte: yoeRange.minYoe,
gte: yoeMin,
lte: yoeMax,
},
},
},
@ -211,8 +161,8 @@ export const offersRouter = createRouter().query('list', {
if (input.title) {
validRecord =
validRecord &&
(offer.OffersFullTime?.title === input.title ||
offer.OffersIntern?.title === input.title);
(offer.offersFullTime?.title === input.title ||
offer.offersIntern?.title === input.title);
}
if (input.dateStart && input.dateEnd) {
@ -223,9 +173,9 @@ export const offersRouter = createRouter().query('list', {
}
if (input.salaryMin && input.salaryMax) {
const salary = offer.OffersFullTime?.totalCompensation.value
? offer.OffersFullTime?.totalCompensation.value
: offer.OffersIntern?.monthlySalary.value;
const salary = offer.offersFullTime?.totalCompensation.value
? offer.offersFullTime?.totalCompensation.value
: offer.offersIntern?.monthlySalary.value;
if (!salary) {
throw new TRPCError({
@ -263,13 +213,13 @@ export const offersRouter = createRouter().query('list', {
}
if (sortingKey === 'totalCompensation') {
const salary1 = offer1.OffersFullTime?.totalCompensation.value
? offer1.OffersFullTime?.totalCompensation.value
: offer1.OffersIntern?.monthlySalary.value;
const salary1 = offer1.offersFullTime?.totalCompensation.value
? offer1.offersFullTime?.totalCompensation.value
: offer1.offersIntern?.monthlySalary.value;
const salary2 = offer2.OffersFullTime?.totalCompensation.value
? offer2.OffersFullTime?.totalCompensation.value
: offer2.OffersIntern?.monthlySalary.value;
const salary2 = offer2.offersFullTime?.totalCompensation.value
? offer2.offersFullTime?.totalCompensation.value
: offer2.offersIntern?.monthlySalary.value;
if (!salary1 || !salary2) {
throw new TRPCError({
@ -309,13 +259,13 @@ export const offersRouter = createRouter().query('list', {
}
if (sortingKey === 'totalCompensation') {
const salary1 = offer1.OffersFullTime?.totalCompensation.value
? offer1.OffersFullTime?.totalCompensation.value
: offer1.OffersIntern?.monthlySalary.value;
const salary1 = offer1.offersFullTime?.totalCompensation.value
? offer1.offersFullTime?.totalCompensation.value
: offer1.offersIntern?.monthlySalary.value;
const salary2 = offer2.OffersFullTime?.totalCompensation.value
? offer2.OffersFullTime?.totalCompensation.value
: offer2.OffersIntern?.monthlySalary.value;
const salary2 = offer2.offersFullTime?.totalCompensation.value
? offer2.offersFullTime?.totalCompensation.value
: offer2.offersIntern?.monthlySalary.value;
if (!salary1 || !salary2) {
throw new TRPCError({
@ -354,14 +304,14 @@ export const offersRouter = createRouter().query('list', {
: data.length;
const paginatedData = data.slice(startRecordIndex, endRecordIndex);
return {
data: paginatedData,
paging: {
currPage: input.offset,
numOfItemsInPage: paginatedData.length,
return getOffersResponseMapper(
paginatedData.map((offer) => dashboardOfferDtoMapper(offer)),
{
currentPage: input.offset,
numOfItems: paginatedData.length,
numOfPages: Math.ceil(data.length / input.limit),
totalNumberOfOffers: data.length,
totalItems: data.length,
},
};
);
},
});

@ -0,0 +1,135 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import type { AggregatedQuestionEncounter } from '~/types/questions';
export const questionsQuestionEncounterRouter = createProtectedRouter()
.query('getAggregatedEncounters', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const questionEncountersData = await ctx.prisma.questionsQuestionEncounter.findMany({
include: {
company : true,
},
where: {
...input,
},
});
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts:Record<string, number> = {};
for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i];
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const questionEncounter:AggregatedQuestionEncounter = {
companyCounts,
locationCounts,
roleCounts,
}
return questionEncounter;
}
})
.mutation('create', {
input: z.object({
companyId: z.string(),
location: z.string(),
questionId: z.string(),
role: z.string(),
seenAt: z.date()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestionEncounter.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
//
input: z.object({
companyId: z.string().optional(),
id: z.string(),
location: z.string().optional(),
role: z.string().optional(),
seenAt: z.date().optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToUpdate = await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionEncounter.update({
data: {
...input,
},
where: {
id: input.id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionEncounterToDelete = await ctx.prisma.questionsQuestionEncounter.findUnique({
where: {
id: input.id,
},
});
if (questionEncounterToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionEncounter.delete({
where: {
id: input.id,
},
});
},
});

@ -7,18 +7,21 @@ import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
import { SortOrder, SortType } from '~/types/questions';
const TWO_WEEK_IN_MS = 12096e5;
export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', {
input: z.object({
companies: z.string().array(),
endDate: z.date(),
companyNames: z.string().array(),
endDate: z.date().default(new Date()),
locations: z.string().array(),
pageSize: z.number().default(50),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
startDate: z.date().optional(),
startDate: z.date().default(new Date(Date.now() - TWO_WEEK_IN_MS)),
}),
async resolve({ ctx, input }) {
const questionsData = await ctx.prisma.questionsQuestion.findMany({
@ -57,10 +60,16 @@ export const questionsQuestionRouter = createProtectedRouter()
: {}),
encounters: {
some: {
...(input.companies.length > 0
seenAt: {
gte: input.startDate,
lte: input.endDate,
},
...(input.companyNames.length > 0
? {
company: {
in: input.companies,
name: {
in: input.companyNames,
},
},
}
: {}),
@ -101,7 +110,7 @@ export const questionsQuestionRouter = createProtectedRouter()
);
const question: Question = {
company: data.encounters[0].company,
company: data.encounters[0].company!.name ?? 'Unknown company',
content: data.content,
id: data.id,
location: data.encounters[0].location ?? 'Unknown location',
@ -174,7 +183,7 @@ export const questionsQuestionRouter = createProtectedRouter()
);
const question: Question = {
company: questionData.encounters[0].company,
company: questionData.encounters[0].company!.name ?? 'Unknown company',
content: questionData.content,
id: questionData.id,
location: questionData.encounters[0].location ?? 'Unknown location',
@ -192,7 +201,7 @@ export const questionsQuestionRouter = createProtectedRouter()
})
.mutation('create', {
input: z.object({
company: z.string(),
companyId: z.string(),
content: z.string(),
location: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType),
@ -202,38 +211,31 @@ export const questionsQuestionRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const question = await ctx.prisma.questionsQuestion.create({
return await ctx.prisma.questionsQuestion.create({
data: {
content: input.content,
lastSeenAt: input.seenAt,
encounters: {
create: [
{
company: input.company,
location: input.location,
role: input.role,
seenAt: input.seenAt,
userId,
create: {
company: {
connect: {
id: input.companyId,
},
},
],
location: input.location,
role: input.role,
seenAt: input.seenAt,
user: {
connect: {
id: userId,
},
},
},
},
questionType: input.questionType,
userId,
},
});
// Create question encounter
await ctx.prisma.questionsQuestionEncounter.create({
data: {
company: input.company,
location: input.location,
questionId: question.id,
role: input.role,
seenAt: input.seenAt,
userId,
},
});
return question;
},
})
.mutation('update', {
@ -325,6 +327,11 @@ export const questionsQuestionRouter = createProtectedRouter()
const { questionId, vote } = input;
return await ctx.prisma.questionsQuestionVote.create({
question: {
update :{
}
}
data: {
questionId,
userId,

@ -9,7 +9,6 @@ export const resumeCommentsRouter = createRouter().query('list', {
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { resumeId } = input;
// For this resume, we retrieve every comment's information, along with:
@ -17,23 +16,12 @@ export const resumeCommentsRouter = createRouter().query('list', {
// Number of votes, and whether the user (if-any) has voted
const comments = await ctx.prisma.resumesComment.findMany({
include: {
_count: {
select: {
votes: true,
},
},
user: {
select: {
image: true,
name: true,
},
},
votes: {
take: 1,
where: {
userId,
},
},
},
orderBy: {
createdAt: 'desc',
@ -44,15 +32,10 @@ export const resumeCommentsRouter = createRouter().query('list', {
});
return comments.map((data) => {
const hasVoted = data.votes.length > 0;
const numVotes = data._count.votes;
const comment: ResumeComment = {
createdAt: data.createdAt,
description: data.description,
hasVoted,
id: data.id,
numVotes,
resumeId: data.resumeId,
section: data.section,
updatedAt: data.updatedAt,

@ -10,9 +10,8 @@ type ResumeCommentInput = Readonly<{
userId: string;
}>;
export const resumesCommentsUserRouter = createProtectedRouter().mutation(
'create',
{
export const resumesCommentsUserRouter = createProtectedRouter()
.mutation('create', {
input: z.object({
education: z.string(),
experience: z.string(),
@ -22,7 +21,7 @@ export const resumesCommentsUserRouter = createProtectedRouter().mutation(
skills: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const userId = ctx.session.user.id;
const { resumeId, education, experience, general, projects, skills } =
input;
@ -50,5 +49,22 @@ export const resumesCommentsUserRouter = createProtectedRouter().mutation(
data: comments,
});
},
},
);
})
.mutation('update', {
input: z.object({
description: z.string(),
id: z.string(),
}),
async resolve({ ctx, input }) {
const { id, description } = input;
return await ctx.prisma.resumesComment.update({
data: {
description,
},
where: {
id,
},
});
},
});

@ -0,0 +1,38 @@
import { z } from 'zod';
import type { ResumesCommentVote } from '@prisma/client';
import { Vote } from '@prisma/client';
import { createRouter } from '../context';
import type { ResumeCommentVote } from '~/types/resume-comments';
export const resumesCommentsVotesRouter = createRouter().query('list', {
input: z.object({
commentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { commentId } = input;
const votes = await ctx.prisma.resumesCommentVote.findMany({
where: {
commentId,
},
});
let userVote: ResumesCommentVote | null = null;
let numVotes = 0;
votes.forEach((vote) => {
numVotes += vote.value === Vote.UPVOTE ? 1 : -1;
userVote = vote.userId === userId ? vote : null;
});
const resumeCommentVote: ResumeCommentVote = {
numVotes,
userVote,
};
return resumeCommentVote;
},
});

@ -0,0 +1,45 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { createProtectedRouter } from '../context';
export const resumesCommentsVotesUserRouter = createProtectedRouter()
.mutation('upsert', {
input: z.object({
commentId: z.string(),
value: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
const { commentId, value } = input;
await ctx.prisma.resumesCommentVote.upsert({
create: {
commentId,
userId,
value,
},
update: {
value,
},
where: {
userId_commentId: { commentId, userId },
},
});
},
})
.mutation('delete', {
input: z.object({
commentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
const { commentId } = input;
await ctx.prisma.resumesCommentVote.delete({
where: {
userId_commentId: { commentId, userId },
},
});
},
});

@ -6,7 +6,36 @@ import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter()
.query('findAll', {
async resolve({ ctx }) {
input: z.object({
experienceFilters: z.string().array(),
locationFilters: z.string().array(),
numComments: z.number().optional(),
roleFilters: z.string().array(),
skip: z.number(),
sortOrder: z.string(),
}),
async resolve({ ctx, input }) {
const {
roleFilters,
locationFilters,
experienceFilters,
sortOrder,
numComments,
skip,
} = input;
const userId = ctx.session?.user?.id;
const totalRecords = await ctx.prisma.resumesResume.count({
where: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
},
});
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
@ -15,22 +44,52 @@ export const resumesRouter = createRouter()
stars: true,
},
},
comments: true,
stars: {
where: {
OR: {
userId,
},
},
},
user: {
select: {
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
orderBy:
sortOrder === 'latest'
? {
createdAt: 'desc',
}
: sortOrder === 'popular'
? {
stars: {
_count: 'desc',
},
}
: { comments: { _count: 'desc' } },
skip,
take: 10,
where: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
},
});
return resumesData.map((r) => {
const mappedResumeData = resumesData.map((r) => {
const resume: Resume = {
additionalInfo: r.additionalInfo,
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
numStars: r._count.stars,
@ -41,6 +100,7 @@ export const resumesRouter = createRouter()
};
return resume;
});
return { mappedResumeData, totalRecords };
},
})
.query('findOne', {

@ -5,29 +5,79 @@ import { createProtectedRouter } from '../context';
import type { Resume } from '~/types/resume';
export const resumesResumeUserRouter = createProtectedRouter()
.mutation('create', {
.mutation('upsert', {
// TODO: Use enums for experience, location, role
input: z.object({
additionalInfo: z.string().optional(),
experience: z.string(),
id: z.string().optional(),
location: z.string(),
role: z.string(),
title: z.string(),
url: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user.id;
return await ctx.prisma.resumesResume.create({
data: {
...input,
const userId = ctx.session.user.id;
return await ctx.prisma.resumesResume.upsert({
create: {
additionalInfo: input.additionalInfo,
experience: input.experience,
location: input.location,
role: input.role,
title: input.title,
url: input.url,
userId,
},
update: {
additionalInfo: input.additionalInfo,
experience: input.experience,
location: input.location,
role: input.role,
title: input.title,
url: input.url,
userId,
},
where: {
id: input.id ?? '',
},
});
},
})
.query('findUserStarred', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
input: z.object({
experienceFilters: z.string().array(),
locationFilters: z.string().array(),
numComments: z.number().optional(),
roleFilters: z.string().array(),
skip: z.number(),
sortOrder: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
const {
roleFilters,
locationFilters,
experienceFilters,
sortOrder,
numComments,
skip,
} = input;
const totalRecords = await ctx.prisma.resumesStar.count({
where: {
resume: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
},
userId,
},
});
const resumeStarsData = await ctx.prisma.resumesStar.findMany({
include: {
resume: {
@ -46,19 +96,52 @@ export const resumesResumeUserRouter = createProtectedRouter()
},
},
},
orderBy: {
createdAt: 'desc',
},
orderBy:
sortOrder === 'latest'
? {
resume: {
createdAt: 'desc',
},
}
: sortOrder === 'popular'
? {
resume: {
stars: {
_count: 'desc',
},
},
}
: {
resume: {
comments: {
_count: 'desc',
},
},
},
skip,
take: 10,
where: {
resume: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
},
userId,
},
});
return resumeStarsData.map((rs) => {
const mappedResumeData = resumeStarsData.map((rs) => {
const resume: Resume = {
additionalInfo: rs.resume.additionalInfo,
createdAt: rs.resume.createdAt,
experience: rs.resume.experience,
id: rs.resume.id,
isStarredByUser: true,
location: rs.resume.location,
numComments: rs.resume._count.comments,
numStars: rs.resume._count.stars,
@ -69,11 +152,41 @@ export const resumesResumeUserRouter = createProtectedRouter()
};
return resume;
});
return { mappedResumeData, totalRecords };
},
})
.query('findUserCreated', {
async resolve({ ctx }) {
const userId = ctx.session?.user?.id;
input: z.object({
experienceFilters: z.string().array(),
locationFilters: z.string().array(),
numComments: z.number().optional(),
roleFilters: z.string().array(),
skip: z.number(),
sortOrder: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session.user.id;
const {
roleFilters,
locationFilters,
experienceFilters,
sortOrder,
numComments,
skip,
} = input;
const totalRecords = await ctx.prisma.resumesResume.count({
where: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
userId,
},
});
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
@ -82,25 +195,50 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
stars: {
where: {
userId,
},
},
user: {
select: {
name: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
orderBy:
sortOrder === 'latest'
? {
createdAt: 'desc',
}
: sortOrder === 'popular'
? {
stars: {
_count: 'desc',
},
}
: { comments: { _count: 'desc' } },
skip,
take: 10,
where: {
...(numComments === 0 && {
comments: {
none: {},
},
}),
experience: { in: experienceFilters },
location: { in: locationFilters },
role: { in: roleFilters },
userId,
},
});
return resumesData.map((r) => {
const mappedResumeData = resumesData.map((r) => {
const resume: Resume = {
additionalInfo: r.additionalInfo,
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
numStars: r._count.stars,
@ -111,19 +249,6 @@ export const resumesResumeUserRouter = createProtectedRouter()
};
return resume;
});
},
})
.query('isResumeStarred', {
input: z.object({
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { resumeId } = input;
return await ctx.prisma.resumesStar.findUnique({
where: {
userId_resumeId: { resumeId, userId },
},
});
return { mappedResumeData, totalRecords };
},
});

@ -1,113 +0,0 @@
export type offersProfile = {
background?: background | null;
createdAt: Date;
// Discussions: Array<discussion>;
editToken: string;
id: string;
offers: Array<offer>;
profileName: string;
userId?: string | null;
};
export type background = {
educations: Array<education>;
experiences: Array<experience>;
id: string;
offersProfileId: string;
specificYoes: Array<specificYoe>;
totalYoe?: number | null;
}
export type experience = {
backgroundId: string;
company?: company | null;
companyId?: string | null;
durationInMonths?: number | null;
id: string;
jobType?: string | null;
level?: string | null;
monthlySalary?: valuation | null;
monthlySalaryId?: string | null;
specialization?: string | null;
title?: string | null;
totalCompensation?: valuation | null;
totalCompensationId?: string | null;
}
export type company = {
createdAt: Date;
description: string | null;
id: string;
logoUrl: string | null;
name: string;
slug: string;
updatedAt: Date
}
export type valuation = {
currency: string;
id: string;
value: number;
}
export type education = {
backgroundId: string;
endDate?: Date | null;
field?: string | null;
id: string;
school?: string | null;
startDate?: Date | null;
type?: string | null;
}
export type specificYoe = {
backgroundId: string;
domain: string;
id: string;
yoe: number;
}
export type offers = {
OffersFullTime?: offersFullTime | null;
OffersIntern?: offersIntern | null;
comments?: string | null;
company: company;
companyId: string;
id: string;
jobType: string;
location: string;
monthYearReceived: string;
negotiationStrategy?: string | null;
offersFullTimeId?: string | null;
offersInternId?: string | null;
profileId: string;
}
export type offersFullTime = {
baseSalary: valuation;
baseSalaryId: string;
bonus: valuation;
bonusId: string;
id: string;
level: string;
specialization: string;
stocks: valuation;
stocksId: string;
title?: string | null;
totalCompensation: valuation;
totalCompensationId: string;
}
export type offersIntern = {
id: string;
internshipCycle: string;
monthlySalary: valuation;
monthlySalaryId: string;
specialization: string;
startYear: number;
}
// TODO: fill in next time
export type discussion = {
id: string;
}

@ -0,0 +1,186 @@
import type { JobType } from '@prisma/client';
export type Profile = {
analysis: ProfileAnalysis?;
background: Background?;
editToken: string?;
id: string;
isEditable: boolean;
offers: Array<ProfileOffer>;
profileName: string;
};
export type Background = {
educations: Array<Education>;
experiences: Array<Experience>;
id: string;
specificYoes: Array<SpecificYoe>;
totalYoe: number;
};
export type Experience = {
company: OffersCompany?;
durationInMonths: number?;
id: string;
jobType: JobType?;
level: string?;
monthlySalary: Valuation?;
specialization: string?;
title: string?;
totalCompensation: Valuation?;
};
export type OffersCompany = {
createdAt: Date;
description: string;
id: string;
logoUrl: string;
name: string;
slug: string;
updatedAt: Date;
};
export type Valuation = {
currency: string;
value: number;
};
export type Education = {
endDate: Date?;
field: string?;
id: string;
school: string?;
startDate: Date?;
type: string?;
};
export type SpecificYoe = {
domain: string;
id: string;
yoe: number;
};
export type DashboardOffer = {
company: OffersCompany;
id: string;
income: Valuation;
monthYearReceived: Date;
profileId: string;
title: string;
totalYoe: number;
};
export type ProfileOffer = {
comments: string;
company: OffersCompany;
id: string;
jobType: JobType;
location: string;
monthYearReceived: Date;
negotiationStrategy: string;
offersFullTime: FullTime?;
offersIntern: Intern?;
};
export type FullTime = {
baseSalary: Valuation;
bonus: Valuation;
id: string;
level: string;
specialization: string;
stocks: Valuation;
title: string;
totalCompensation: Valuation;
};
export type Intern = {
id: string;
internshipCycle: string;
monthlySalary: Valuation;
specialization: string;
startYear: number;
title: string;
};
export type Reply = {
createdAt: Date;
id: string;
message: string;
replies: Array<Reply>?;
replyingToId: string?;
user: User?;
};
export type User = {
email: string?;
emailVerified: Date?;
id: string;
image: string?;
name: string?;
};
export type GetOffersResponse = {
data: Array<DashboardOffer>;
paging: Paging;
};
export type Paging = {
currentPage: number;
numOfItems: number;
numOfPages: number;
totalItems: number;
};
export type CreateOfferProfileResponse = {
id: string;
token: string;
};
export type OffersDiscussion = {
data: Array<Reply>;
};
export type ProfileAnalysis = {
companyAnalysis: Array<Analysis>;
id: string;
overallAnalysis: Analysis;
overallHighestOffer: AnalysisHighestOffer;
profileId: string;
};
export type Analysis = {
noOfOffers: number;
percentile: number;
topPercentileOffers: Array<AnalysisOffer>;
};
export type AnalysisHighestOffer = {
company: OffersCompany;
id: string;
level: string;
location: string;
specialization: string;
totalYoe: number;
};
export type AnalysisOffer = {
company: OffersCompany;
id: string;
income: number;
jobType: JobType;
level: string;
location: string;
monthYearReceived: Date;
negotiationStrategy: string;
previousCompanies: Array<string>;
profileName: string;
specialization: string;
title: string;
totalYoe: number;
};
export type AddToProfileResponse = {
id: string;
profileName: string;
userId: string;
};

@ -1,3 +1,5 @@
import type { QuestionsQuestionType } from '@prisma/client';
export type Question = {
// TODO: company, location, role maps
company: string;
@ -9,11 +11,17 @@ export type Question = {
numVotes: number;
role: string;
seenAt: Date;
type: stringl;
type: QuestionsQuestionType;
updatedAt: Date;
user: string;
};
export type AggregatedQuestionEncounter = {
companyCounts: Record<string, number>;
locationCounts: Record<string, number>;
roleCounts: Record<string, number>;
}
export type AnswerComment = {
content: string;
createdAt: Date;
@ -49,7 +57,6 @@ export enum SortOrder {
};
export enum SortType {
BEST,
TOP,
NEW,
};

@ -1,4 +1,4 @@
import type { ResumesSection } from '@prisma/client';
import type { ResumesCommentVote, ResumesSection } from '@prisma/client';
/**
* Returned by `resumeCommentsRouter` (query for 'resumes.comments.list') and received as prop by `Comment` in `CommentsList`
@ -7,9 +7,7 @@ import type { ResumesSection } from '@prisma/client';
export type ResumeComment = Readonly<{
createdAt: Date;
description: string;
hasVoted: boolean;
id: string;
numVotes: number;
resumeId: string;
section: ResumesSection;
updatedAt: Date;
@ -19,3 +17,8 @@ export type ResumeComment = Readonly<{
userId: string;
};
}>;
export type ResumeCommentVote = Readonly<{
numVotes: number;
userVote: ResumesCommentVote?;
}>;

@ -3,6 +3,7 @@ export type Resume = {
createdAt: Date;
experience: string;
id: string;
isStarredByUser: boolean;
location: string;
numComments: number;
numStars: number;

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

@ -20,6 +20,9 @@ export default {
placeholder: {
control: 'text',
},
required: {
control: 'boolean',
},
},
component: Typeahead,
parameters: {
@ -80,3 +83,39 @@ Basic.args = {
isLabelHidden: false,
label: 'Author',
};
export function Required() {
const people = [
{ id: '1', label: 'Wade Cooper', value: '1' },
{ id: '2', label: 'Arlene Mccoy', value: '2' },
{ id: '3', label: 'Devon Webb', value: '3' },
{ id: '4', label: 'Tom Cook', value: '4' },
{ id: '5', label: 'Tanya Fox', value: '5' },
{ id: '6', label: 'Hellen Schmidt', value: '6' },
];
const [selectedEntry, setSelectedEntry] = useState<TypeaheadOption>(
people[0],
);
const [query, setQuery] = useState('');
const filteredPeople =
query === ''
? people
: people.filter((person) =>
person.label
.toLowerCase()
.replace(/\s+/g, '')
.includes(query.toLowerCase().replace(/\s+/g, '')),
);
return (
<Typeahead
label="Author"
options={filteredPeople}
required={true}
value={selectedEntry}
onQueryChange={setQuery}
onSelect={setSelectedEntry}
/>
);
}

@ -15,7 +15,7 @@ First and foremost, compensation. Most technical roles at tech companies would r
Not all stock grants are equal as well. Some companies have linear vesting cycles (you vest the same amount every year), some companies like Amazon and Snap have backloaded schemes (you vest less in the earlier years, more later), and there are pay attention to cliffs as well. [Stripe and Lyft](https://www.theinformation.com/articles/stripe-and-lyft-speed-up-equity-payouts-to-first-year) recently changed their stock structure and announced that they will speed up equity payouts to the first year. This sounds good initially, [but in reality there are some nuances](https://tanay.substack.com/p/employee-compensation-and-one-year).
Regardless of company, **always negotiate** your offer, especially if you have multiple offers to choose from! Having multiple offers in hand is the best bargaining chip you can have for negotiation and you should leverage it. We go into this more in the [Negotiation](./negotiation.md) section. Use [Moonchaser](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_choosing_between_companies) for risk-free negotiation services.
Regardless of company, **always negotiate** your offer, especially if you have multiple offers to choose from! Having multiple offers in hand is the best bargaining chip you can have for negotiation and you should leverage it. We go into this more in the [Negotiation](./negotiation.md) section. Use [Rora](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_choosing_between_companies) for risk-free negotiation services.
## Products

@ -110,6 +110,6 @@ Don't waste their time or play games for your own purposes. Even if the company
:::tip Expert tip
Get paid more. Receive salary negotiation help from [**Moonchaser**](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
Get paid more. Receive salary negotiation help from [**Rora**](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
:::

@ -32,11 +32,11 @@ If you've received an offer (or even better, offers), congratulations! You may h
If you haven't been negotiating your past offers, or are new to the negotiation game, worry not! There are multiple negotiation services that can help you out. Typically, they'd be well-worth the cost. Had I know about negotiation services in the past, I'd have leveraged them!
### Moonchaser
### Rora
How Moonchaser works is that you will be guided by their experienced team of professionals throughout the entire salary negotiation process. It's also risk-free because you don't have to pay anything unless you have an increased offer. It's a **no-brainer decision** to get the help of Moonchaser during the offer process - some increase is better than no increase. Don't leave money on the table! Check out [Moonchaser](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation).
How Rora works is that you will be guided by their experienced team of professionals throughout the entire salary negotiation process. It's also risk-free because you don't have to pay anything unless you have an increased offer. It's a **no-brainer decision** to get the help of Rora during the offer process - some increase is better than no increase. Don't leave money on the table! Check out [Rora](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation).
Things Moonchaser can do for you:
Things Rora can do for you:
- Help you to negotiate increases even without competing offers
- Provide tailored advice through their knowledge of compensation ranges at many companies
@ -45,18 +45,18 @@ Things Moonchaser can do for you:
- Provide you with live guidance during the recruiter call through chat
- Introduce you to recruiters at other companies
<a className="button button--primary" href="https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation">Book a free consultation with Moonchaser &nbsp;</a>
<a className="button button--primary" href="https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation">Book a free consultation with Rora &nbsp;</a>
<br/>
<br/>
### Levels.fyi
[Levels.fyi](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) is most famously known for being a salary database but they also offer complementary services such as salary negotiation where you will be put in-touch with experienced recruiters to help you in the process. How Levels.fyi differs from Moonchaser is that Levels.fyi charges a flat fee whereas Moonchaser takes a percentage of the negotiated difference.
[Levels.fyi](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) is most famously known for being a salary database but they also offer complementary services such as salary negotiation where you will be put in-touch with experienced recruiters to help you in the process. How Levels.fyi differs from Rora is that Levels.fyi charges a flat fee whereas Rora takes a percentage of the negotiated difference.
:::tip Expert tip
Get paid more. Receive salary negotiation advice from [**Moonchaser**](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
Get paid more. Receive salary negotiation advice from [**Rora**](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
:::

@ -69,23 +69,23 @@ function AlgoMonster({ position }) {
);
}
function Moonchaser({ position }) {
function Rora({ position }) {
return (
<a
className={clsx(styles.container, styles.backgroundMoonchaser)}
href={`https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=${position}`}
className={clsx(styles.container, styles.backgroundRora)}
href={`https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=${position}`}
key={Math.random()}
target="_blank"
rel="noopener"
onClick={() => {
window.gtag('event', `moonchaser.${position}.click`);
window.gtag('event', `rora.${position}.click`);
}}>
<p className={styles.tagline}>
<strong className={styles.title}>
Risk-free salary negotiation help
</strong>{' '}
Receive risk-free salary negotiation advice from <u>Moonchaser</u>. You
pay nothing unless your offer is increased.{' '}
Receive risk-free salary negotiation advice from <u>Rora</u>. You pay
nothing unless your offer is increased.{' '}
<u>Book your free consultation today!</u>
</p>
</a>
@ -210,7 +210,7 @@ export default React.memo(function SidebarAd({ position }) {
}
if (path.includes('negotiation') || path.includes('compensation')) {
return <Moonchaser key={Math.random()} position={position} />;
return <Rora key={Math.random()} position={position} />;
}
if (path.includes('system-design')) {

@ -37,7 +37,7 @@
background-color: #58527b;
}
.backgroundMoonchaser {
.backgroundRora {
background-color: #1574f9;
}

@ -222,7 +222,7 @@ function WhatIsThisSection() {
);
}
function MoonchaserSection() {
function RoraSection() {
// Because the SSR and client output can differ and hydration doesn't patch attribute differences,
// we'll render this on the browser only.
return (
@ -237,18 +237,18 @@ function MoonchaserSection() {
<h2 className={styles.sectionSponsorTitle}>
<strong>
Get paid more. Receive risk-free salary negotiation
advice from Moonchaser. You pay nothing unless your
offer is increased.
advice from Rora. You pay nothing unless your offer is
increased.
</strong>
</h2>
<div className="margin-vert--lg">
<a
className="button button--secondary button--lg"
href="https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_homepage"
href="https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_homepage"
rel="noopener"
target="_blank"
onClick={() => {
window.gtag('event', 'moonchaser.homepage.click');
window.gtag('event', 'rora.homepage.click');
}}>
Get risk-free negotiation advice&nbsp;&nbsp;
</a>
@ -504,7 +504,7 @@ function GreatFrontEndSection() {
return (
<div
className={clsx('padding-vert--lg')}
style={{backgroundColor: 'rgb(79, 70, 229)'}}>
style={{ backgroundColor: 'rgb(79, 70, 229)' }}>
<div className="container">
<div className="row">
<div className="col col--8 col--offset-2">
@ -517,13 +517,13 @@ function GreatFrontEndSection() {
</h2>
<h3
className={styles.sectionSponsorTitle}
style={{fontSize: 'var(--ifm-h2-font-size)'}}>
style={{ fontSize: 'var(--ifm-h2-font-size)' }}>
<strong>
Spend less time but prepare better for your Front End
Interviews with{' '}
<a
href="https://www.greatfrontend.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=homepage&fpr=techinterviewhandbook"
style={{color: '#fff', textDecoration: 'underline'}}>
style={{ color: '#fff', textDecoration: 'underline' }}>
Great Front End's
</a>{' '}
large pool of high quality practice questions and solutions.

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save