Merge branch 'main' into hongpo/add-question-text-match

pull/412/head
hpkoh 3 years ago
commit 23a59143f4

@ -30,6 +30,7 @@
"next-auth": "~4.10.3", "next-auth": "~4.10.3",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.36.1", "react-hook-form": "^7.36.1",
"react-pdf": "^5.7.2", "react-pdf": "^5.7.2",
"react-query": "^3.39.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;

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "ResumesComment" ADD COLUMN "parentId" TEXT;
-- AddForeignKey
ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "ResumesComment"("id") ON DELETE SET NULL ON UPDATE CASCADE;

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OffersExperience" ADD COLUMN "location" TEXT;

@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `upvotes` to the `QuestionsQuestion` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "QuestionsQuestion" ADD COLUMN "lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "upvotes" INTEGER NOT NULL;
-- AlterTable
ALTER TABLE "QuestionsQuestionEncounter" ADD COLUMN "netVotes" INTEGER NOT NULL DEFAULT 0;

@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `netVotes` on the `QuestionsQuestionEncounter` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "QuestionsQuestion" ALTER COLUMN "lastSeenAt" DROP DEFAULT,
ALTER COLUMN "upvotes" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "QuestionsQuestionEncounter" DROP COLUMN "netVotes";

@ -0,0 +1,5 @@
-- CreateIndex
CREATE INDEX "QuestionsQuestion_lastSeenAt_id_idx" ON "QuestionsQuestion"("lastSeenAt", "id");
-- CreateIndex
CREATE INDEX "QuestionsQuestion_upvotes_id_idx" ON "QuestionsQuestion"("upvotes", "id");

@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `baseValue` to the `OffersCurrency` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `OffersCurrency` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "OffersCurrency" ADD COLUMN "baseCurrency" TEXT NOT NULL DEFAULT 'USD',
ADD COLUMN "baseValue" INTEGER NOT NULL,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "OffersCurrency" ALTER COLUMN "value" SET DATA TYPE DOUBLE PRECISION,
ALTER COLUMN "baseValue" SET DATA TYPE DOUBLE PRECISION;

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

@ -35,34 +35,6 @@ const COMPANIES = [
}, },
]; ];
const OFFER_PROFILES = [
{
id: 'cl91v97ex000109mt7fka5rto',
profileName: 'battery-horse-stable-cow',
editToken: 'cl91ulmhg000009l86o45aspt',
},
{
id: 'cl91v9iw2000209mtautgdnxq',
profileName: 'house-zebra-fast-giraffe',
editToken: 'cl91umigc000109l80f1tcqe8',
},
{
id: 'cl91v9m3y000309mt1ctw55wi',
profileName: 'keyboard-mouse-lazy-cat',
editToken: 'cl91ummoa000209l87q2b8hl7',
},
{
id: 'cl91v9p09000409mt5rvoasf1',
profileName: 'router-hen-bright-pig',
editToken: 'cl91umqa3000309l87jyefe9k',
},
{
id: 'cl91v9uda000509mt5i5fez3v',
profileName: 'screen-ant-dirty-bird',
editToken: 'cl91umuj9000409l87ez85vmg',
},
];
async function main() { async function main() {
console.log('Seeding started...'); console.log('Seeding started...');
await Promise.all([ await Promise.all([
@ -73,13 +45,6 @@ async function main() {
create: company, create: company,
}); });
}), }),
OFFER_PROFILES.map(async (offerProfile) => {
await prisma.offersProfile.upsert({
where: { profileName: offerProfile.profileName },
update: offerProfile,
create: offerProfile,
});
}),
]); ]);
console.log('Seeding completed.'); console.log('Seeding completed.');
} }

@ -5,43 +5,20 @@ export const emptyOption = '----';
// TODO: use enums // TODO: use enums
export const titleOptions = [ export const titleOptions = [
{ {
label: 'Software engineer', label: 'Software Engineer',
value: 'Software engineer', value: 'Software Engineer',
}, },
{ {
label: 'Frontend engineer', label: 'Frontend Engineer',
value: 'Frontend engineer', value: 'Frontend Engineer',
}, },
{ {
label: 'Backend engineer', label: 'Backend Engineer',
value: 'Backend engineer', value: 'Backend Engineer',
}, },
{ {
label: 'Full-stack engineer', label: 'Full-stack Engineer',
value: 'Full-stack engineer', value: 'Full-stack Engineer',
},
];
export const companyOptions = [
{
label: 'Amazon',
value: 'cl93patjt0000txewdi601mub',
},
{
label: 'Microsoft',
value: 'cl93patjt0001txewkglfjsro',
},
{
label: 'Apple',
value: 'cl93patjt0002txewf3ug54m8',
},
{
label: 'Google',
value: 'cl93patjt0003txewyiaky7xx',
},
{
label: 'Meta',
value: 'cl93patjt0004txew88wkcqpu',
}, },
]; ];
@ -86,26 +63,26 @@ export const internshipCycleOptions = [
export const yearOptions = [ export const yearOptions = [
{ {
label: '2021', label: '2021',
value: '2021', value: 2021,
}, },
{ {
label: '2022', label: '2022',
value: '2022', value: 2022,
}, },
{ {
label: '2023', label: '2023',
value: '2023', value: 2023,
}, },
{ {
label: '2024', label: '2024',
value: '2024', value: 2024,
}, },
]; ];
export const educationLevelOptions = Object.entries( export const educationLevelOptions = Object.entries(
EducationBackgroundType, EducationBackgroundType,
).map(([key, value]) => ({ ).map(([, value]) => ({
label: key, label: value,
value, value,
})); }));
@ -118,10 +95,18 @@ export const educationFieldOptions = [
label: 'Information Security', label: 'Information Security',
value: 'Information Security', value: 'Information Security',
}, },
{
label: 'Information Systems',
value: 'Information Systems',
},
{ {
label: 'Business Analytics', label: 'Business Analytics',
value: 'Business Analytics', value: 'Business Analytics',
}, },
{
label: 'Data Science and Analytics',
value: 'Data Science and Analytics',
},
]; ];
export enum FieldError { export enum FieldError {
@ -129,3 +114,5 @@ export enum FieldError {
Number = 'Please fill in a number in this field.', Number = 'Please fill in a number in this field.',
Required = 'Please fill 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 MonthYearPicker from '~/components/shared/MonthYearPicker';
import { getCurrentMonth, getCurrentYear } from '../../../../utils/offers/time'; import { getCurrentMonth, getCurrentYear } from '../../../utils/offers/time';
type MonthYearPickerProps = ComponentProps<typeof MonthYearPicker>; 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>
);
}

@ -1,13 +1,30 @@
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { setTimeout } from 'timers'; import { setTimeout } from 'timers';
import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid'; import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline'; import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui'; import { Button, TextInput } from '@tih/ui';
export default function OfferProfileSave() { import {
copyProfileLink,
getProfileLink,
getProfilePath,
} from '~/utils/offers/link';
type OfferProfileSaveProps = Readonly<{
profileId: string;
token?: string;
}>;
export default function OfferProfileSave({
profileId,
token,
}: OfferProfileSaveProps) {
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const [isSaving, setSaving] = useState(false); const [isSaving, setSaving] = useState(false);
const [isSaved, setSaved] = useState(false); const [isSaved, setSaved] = useState(false);
const router = useRouter();
const saveProfile = () => { const saveProfile = () => {
setSaving(true); setSaving(true);
setTimeout(() => { setTimeout(() => {
@ -27,13 +44,13 @@ export default function OfferProfileSave() {
To keep you offer profile strictly anonymous, only people who have the To keep you offer profile strictly anonymous, only people who have the
link below can edit it. link below can edit it.
</p> </p>
<div className="mb-20 grid grid-cols-12 gap-4"> <div className="mb-5 grid grid-cols-12 gap-4">
<div className="col-span-11"> <div className="col-span-11">
<TextInput <TextInput
disabled={true} disabled={true}
isLabelHidden={true} isLabelHidden={true}
label="Edit link" label="Edit link"
value="link.myprofile-auto-generate..." value={getProfileLink(profileId, token)}
/> />
</div> </div>
<Button <Button
@ -41,10 +58,12 @@ export default function OfferProfileSave() {
isLabelHidden={true} isLabelHidden={true}
label="Copy" label="Copy"
variant="primary" variant="primary"
onClick={() => setLinkCopied(true)} onClick={() => {
copyProfileLink(profileId, token), setLinkCopied(true);
}}
/> />
</div> </div>
<div className="mb-5"> <div className="mb-20">
{linkCopied && ( {linkCopied && (
<p className="text-purple-700">Link copied to clipboard!</p> <p className="text-purple-700">Link copied to clipboard!</p>
)} )}
@ -52,20 +71,26 @@ export default function OfferProfileSave() {
<p className="mb-5 text-gray-900"> <p className="mb-5 text-gray-900">
If you do not want to keep the edit link, you can opt to save this 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> </p>
<div className="mb-20"> <div className="mb-20">
<Button <Button
disabled={isSaved} disabled={isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon} icon={isSaved ? CheckIcon : BookmarkSquareIcon}
isLoading={isSaving} isLoading={isSaving}
label="Save to user profile" label={isSaved ? 'Saved to user profile' : 'Save to user profile'}
variant="primary" variant="primary"
onClick={saveProfile} onClick={saveProfile}
/> />
</div> </div>
<div className="mb-10"> <div className="mb-10">
<Button icon={EyeIcon} label="View your profile" variant="special" /> <Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div> </div>
</div> </div>
</div> </div>

@ -0,0 +1,243 @@
import { useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OfferAnalysis from '~/components/offers/offersSubmission/analysis/OfferAnalysis';
import OfferProfileSave from '~/components/offers/offersSubmission/OfferProfileSave';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type {
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
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: '',
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
month: getCurrentMonth() as Month,
year: getCurrentYear(),
},
negotiationStrategy: '',
};
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FullTime,
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
specificYoes: [],
totalYoe: 0,
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
type Props = Readonly<{
initialOfferProfileValues?: OffersProfileFormData;
profileId?: string;
token?: string;
}>;
export default function OffersSubmissionForm({
initialOfferProfileValues = defaultOfferProfileValues,
profileId,
token,
}: Props) {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>({
id: profileId || '',
token: token || '',
});
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: initialOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />,
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: (
<OfferProfileSave
key={3}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
/>
),
hasNext: false,
hasPrevious: false,
label: 'Save',
},
];
const formStepsLabels = formSteps.map((step) => step.label);
const nextStep = async (currStep: number) => {
if (currStep === 0) {
const result = await trigger('offers');
if (!result) {
return;
}
}
setFormStep(formStep + 1);
scrollToTop();
};
const previousStep = () => {
setFormStep(formStep - 1);
scrollToTop();
};
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const mutationpath =
profileId && token ? 'offers.profile.update' : 'offers.profile.create';
const createOrUpdateMutation = trpc.useMutation([mutationpath], {
onError(error) {
console.error(error.message);
},
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
}
data = removeInvalidMoneyData(data);
const background = cleanObject(data.background);
background.specificYoes = data.background.specificYoes.filter(
(specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
);
if (Object.entries(background.experiences[0]).length === 1) {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,
offer.monthYearReceived.month - 1, // Convert month to monthIndex
),
}));
if (profileId && token) {
createOrUpdateMutation.mutate({
background,
id: profileId,
offers,
token,
});
} else {
createOrUpdateMutation.mutate({ background, offers });
}
};
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
}

@ -0,0 +1,135 @@
import { useEffect } from 'react';
import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import OfferPercentileAnalysis from './OfferPercentileAnalysis';
import OfferProfileCard from './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 (
analysis && (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
{getAnalysisResult.isError && (
<p className="m-10 text-center">
An error occurred while generating profile analysis.
</p>
)}
{getAnalysisResult.isLoading && (
<Spinner className="m-10" display="block" size="lg" />
)}
{!getAnalysisResult.isError && !getAnalysisResult.isLoading && (
<div>
<Tabs
label="Result Navigation"
tabs={tabOptions}
value={tab}
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent analysis={analysis} tab={tab} />
</div>
)}
</div>
)
);
}

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

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

@ -1,5 +1,9 @@
import { useEffect, useState } from 'react'; 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 { useWatch } from 'react-hook-form';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form'; import { useFieldArray } from 'react-hook-form';
@ -7,54 +11,54 @@ import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline'; import { TrashIcon } from '@heroicons/react/24/outline';
import { Button, Dialog } from '@tih/ui'; import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { import {
defaultFullTimeOfferValues, defaultFullTimeOfferValues,
defaultInternshipOfferValues, defaultInternshipOfferValues,
} from '~/pages/offers/submit'; } from '../OffersSubmissionForm';
import FormMonthYearPicker from './components/FormMonthYearPicker';
import FormSelect from './components/FormSelect';
import FormTextArea from './components/FormTextArea';
import FormTextInput from './components/FormTextInput';
import { import {
companyOptions,
emptyOption, emptyOption,
FieldError, FieldError,
internshipCycleOptions, internshipCycleOptions,
locationOptions, locationOptions,
titleOptions, titleOptions,
yearOptions, yearOptions,
} from '../constants'; } from '../../constants';
import type { import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
FullTimeOfferDetailsFormData, import FormSelect from '../../forms/FormSelect';
InternshipOfferDetailsFormData, import FormTextArea from '../../forms/FormTextArea';
} from '../types'; import FormTextInput from '../../forms/FormTextInput';
import { JobTypeLabel } from '../types'; import type { OfferFormData } from '../../types';
import { JobType } from '../types'; import { JobTypeLabel } from '../../types';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum'; import { JobType } from '../../types';
import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{ type FullTimeOfferDetailsFormProps = Readonly<{
index: number; index: number;
setDialogOpen: (isOpen: boolean) => void; remove: UseFieldArrayRemove;
}>; }>;
function FullTimeOfferDetailsForm({ function FullTimeOfferDetailsForm({
index, index,
setDialogOpen, remove,
}: FullTimeOfferDetailsFormProps) { }: FullTimeOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{ const { register, formState, setValue } = useFormContext<{
offers: Array<FullTimeOfferDetailsFormData>; offers: Array<OfferFormData>;
}>(); }>();
const offerFields = formState.errors.offers?.[index]; const offerFields = formState.errors.offers?.[index];
const watchCurrency = useWatch({ const watchCurrency = useWatch({
name: `offers.${index}.job.totalCompensation.currency`, name: `offers.${index}.offersFullTime.totalCompensation.currency`,
}); });
useEffect(() => { useEffect(() => {
setValue(`offers.${index}.job.base.currency`, watchCurrency); setValue(
setValue(`offers.${index}.job.bonus.currency`, watchCurrency); `offers.${index}.offersFullTime.baseSalary.currency`,
setValue(`offers.${index}.job.stocks.currency`, watchCurrency); watchCurrency,
);
setValue(`offers.${index}.offersFullTime.bonus.currency`, watchCurrency);
setValue(`offers.${index}.offersFullTime.stocks.currency`, watchCurrency);
}, [watchCurrency, index, setValue]); }, [watchCurrency, index, setValue]);
return ( return (
@ -62,48 +66,44 @@ function FullTimeOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.job?.title?.message} errorMessage={offerFields?.offersFullTime?.title?.message}
label="Title" label="Title"
options={titleOptions} options={titleOptions}
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.job.title`, { {...register(`offers.${index}.offersFullTime.title`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
<FormTextInput <FormTextInput
errorMessage={offerFields?.job?.specialization?.message} errorMessage={offerFields?.offersFullTime?.specialization?.message}
label="Focus / Specialization" label="Focus / Specialization"
placeholder="e.g. Front End" placeholder="e.g. Front End"
required={true} required={true}
{...register(`offers.${index}.job.specialization`, { {...register(`offers.${index}.offersFullTime.specialization`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
</div> </div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 flex grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <CompaniesTypeahead
errorMessage={offerFields?.companyId?.message} onSelect={({ value }) =>
label="Company" setValue(`offers.${index}.companyId`, value)
options={companyOptions} }
placeholder={emptyOption} />
required={true} </div>
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<FormTextInput <FormTextInput
errorMessage={offerFields?.job?.level?.message} errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level" label="Level"
placeholder="e.g. L4, Junior" placeholder="e.g. L4, Junior"
required={true} required={true}
{...register(`offers.${index}.job.level`, { {...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
</div> </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 <FormSelect
display="block" display="block"
errorMessage={offerFields?.location?.message} errorMessage={offerFields?.location?.message}
@ -132,24 +132,32 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.totalCompensation.currency`, { {...register(
required: FieldError.Required, `offers.${index}.offersFullTime.totalCompensation.currency`,
})} {
required: FieldError.Required,
},
)}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.totalCompensation?.value?.message} errorMessage={
offerFields?.offersFullTime?.totalCompensation?.value?.message
}
label="Total Compensation (Annual)" label="Total Compensation (Annual)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.totalCompensation.value`, { {...register(
min: { message: FieldError.NonNegativeNumber, value: 0 }, `offers.${index}.offersFullTime.totalCompensation.value`,
required: FieldError.Required, {
valueAsNumber: true, min: { message: FieldError.NonNegativeNumber, value: 0 },
})} required: FieldError.Required,
valueAsNumber: true,
},
)}
/> />
</div> </div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
@ -160,20 +168,23 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.base.currency`, { {...register(
required: FieldError.Required, `offers.${index}.offersFullTime.baseSalary.currency`,
})} {
required: FieldError.Required,
},
)}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.base?.value?.message} errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message}
label="Base Salary (Annual)" label="Base Salary (Annual)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.base.value`, { {...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
@ -186,20 +197,20 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.bonus.currency`, { {...register(`offers.${index}.offersFullTime.bonus.currency`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.bonus?.value?.message} errorMessage={offerFields?.offersFullTime?.bonus?.value?.message}
label="Bonus (Annual)" label="Bonus (Annual)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.bonus.value`, { {...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
@ -214,20 +225,20 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.stocks.currency`, { {...register(`offers.${index}.offersFullTime.stocks.currency`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.stocks?.value?.message} errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
label="Stocks (Annual)" label="Stocks (Annual)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.stocks.value`, { {...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
@ -254,7 +265,7 @@ function FullTimeOfferDetailsForm({
icon={TrashIcon} icon={TrashIcon}
label="Delete" label="Delete"
variant="secondary" variant="secondary"
onClick={() => setDialogOpen(true)} onClick={() => remove(index)}
/> />
)} )}
</div> </div>
@ -264,15 +275,15 @@ function FullTimeOfferDetailsForm({
type InternshipOfferDetailsFormProps = Readonly<{ type InternshipOfferDetailsFormProps = Readonly<{
index: number; index: number;
setDialogOpen: (isOpen: boolean) => void; remove: UseFieldArrayRemove;
}>; }>;
function InternshipOfferDetailsForm({ function InternshipOfferDetailsForm({
index, index,
setDialogOpen, remove,
}: InternshipOfferDetailsFormProps) { }: InternshipOfferDetailsFormProps) {
const { register, formState } = useFormContext<{ const { register, formState, setValue } = useFormContext<{
offers: Array<InternshipOfferDetailsFormData>; offers: Array<OfferFormData>;
}>(); }>();
const offerFields = formState.errors.offers?.[index]; const offerFields = formState.errors.offers?.[index];
@ -282,39 +293,35 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.job?.title?.message} errorMessage={offerFields?.offersIntern?.title?.message}
label="Title" label="Title"
options={titleOptions} options={titleOptions}
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.job.title`, { {...register(`offers.${index}.offersIntern.title`, {
minLength: 1, minLength: 1,
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
<FormTextInput <FormTextInput
errorMessage={offerFields?.job?.specialization?.message} errorMessage={offerFields?.offersIntern?.specialization?.message}
label="Focus / Specialization" label="Focus / Specialization"
placeholder="e.g. Front End" placeholder="e.g. Front End"
required={true} required={true}
{...register(`offers.${index}.job.specialization`, { {...register(`offers.${index}.offersIntern.specialization`, {
minLength: 1, minLength: 1,
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
</div> </div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <CompaniesTypeahead
errorMessage={offerFields?.companyId?.message} onSelect={({ value }) =>
label="Company" setValue(`offers.${index}.companyId`, value)
options={companyOptions} }
placeholder={emptyOption} />
required={true} </div>
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.location?.message} errorMessage={offerFields?.location?.message}
@ -330,24 +337,25 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.job?.internshipCycle?.message} errorMessage={offerFields?.offersIntern?.internshipCycle?.message}
label="Internship Cycle" label="Internship Cycle"
options={internshipCycleOptions} options={internshipCycleOptions}
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.job.internshipCycle`, { {...register(`offers.${index}.offersIntern.internshipCycle`, {
required: FieldError.Required, required: FieldError.Required,
})} })}
/> />
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.job?.startYear?.message} errorMessage={offerFields?.offersIntern?.startYear?.message}
label="Internship Year" label="Internship Year"
options={yearOptions} options={yearOptions}
placeholder={emptyOption} placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.job.startYear`, { {...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true,
})} })}
/> />
</div> </div>
@ -369,20 +377,25 @@ function InternshipOfferDetailsForm({
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.monthlySalary.currency`, { {...register(
required: FieldError.Required, `offers.${index}.offersIntern.monthlySalary.currency`,
})} {
required: FieldError.Required,
},
)}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.job?.monthlySalary?.value?.message} errorMessage={
offerFields?.offersIntern?.monthlySalary?.value?.message
}
label="Salary (Monthly)" label="Salary (Monthly)"
placeholder="0" placeholder="0"
required={true} required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.job.monthlySalary.value`, { {...register(`offers.${index}.offersIntern.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 }, min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required, required: FieldError.Required,
valueAsNumber: true, valueAsNumber: true,
@ -410,7 +423,7 @@ function InternshipOfferDetailsForm({
label="Delete" label="Delete"
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
setDialogOpen(true); remove(index);
}} }}
/> />
)} )}
@ -429,7 +442,6 @@ function OfferDetailsFormArray({
jobType, jobType,
}: OfferDetailsFormArrayProps) { }: OfferDetailsFormArrayProps) {
const { append, remove, fields } = fieldArrayValues; const { append, remove, fields } = fieldArrayValues;
const [isDialogOpen, setDialogOpen] = useState(false);
return ( return (
<div> <div>
@ -437,44 +449,10 @@ function OfferDetailsFormArray({
return ( return (
<div key={item.id}> <div key={item.id}>
{jobType === JobType.FullTime ? ( {jobType === JobType.FullTime ? (
<FullTimeOfferDetailsForm <FullTimeOfferDetailsForm index={index} remove={remove} />
index={index}
setDialogOpen={setDialogOpen}
/>
) : ( ) : (
<InternshipOfferDetailsForm <InternshipOfferDetailsForm index={index} remove={remove} />
index={index}
setDialogOpen={setDialogOpen}
/>
)} )}
<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> </div>
); );
})} })}
@ -501,22 +479,21 @@ export default function OfferDetailsForm() {
const [isDialogOpen, setDialogOpen] = useState(false); const [isDialogOpen, setDialogOpen] = useState(false);
const { control } = useFormContext(); const { control } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' }); const fieldArrayValues = useFieldArray({ control, name: 'offers' });
const { append, remove } = fieldArrayValues;
const toggleJobType = () => { const toggleJobType = () => {
fieldArrayValues.remove(); remove();
if (jobType === JobType.FullTime) { if (jobType === JobType.FullTime) {
setJobType(JobType.Internship); setJobType(JobType.Intern);
fieldArrayValues.append(defaultInternshipOfferValues); append(defaultInternshipOfferValues);
} else { } else {
setJobType(JobType.FullTime); setJobType(JobType.FullTime);
fieldArrayValues.append(defaultFullTimeOfferValues); append(defaultFullTimeOfferValues);
} }
}; };
const switchJobTypeLabel = () => const switchJobTypeLabel = () =>
jobType === JobType.FullTime jobType === JobType.FullTime ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
? JobTypeLabel.INTERNSHIP
: JobTypeLabel.FULLTIME;
return ( return (
<div className="mb-5"> <div className="mb-5">
@ -541,11 +518,11 @@ export default function OfferDetailsForm() {
<div className="mx-5 w-1/3"> <div className="mx-5 w-1/3">
<Button <Button
display="block" display="block"
label={JobTypeLabel.INTERNSHIP} label={JobTypeLabel.INTERN}
size="md" size="md"
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'} variant={jobType === JobType.Intern ? 'secondary' : 'tertiary'}
onClick={() => { onClick={() => {
if (jobType === JobType.Internship) { if (jobType === JobType.Intern) {
return; return;
} }
setDialogOpen(true); setDialogOpen(true);

@ -1,21 +1,90 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline'; 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 { copyProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
import type { OffersDiscussion, Reply } from '~/types/offers';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
handleCopyEditLink: () => void;
handleCopyPublicLink: () => void;
isDisabled: boolean; isDisabled: boolean;
isEditable: boolean; isEditable: boolean;
isLoading: boolean; isLoading: boolean;
profileId: string;
profileName?: string;
token?: string;
}>; }>;
export default function ProfileComments({ export default function ProfileComments({
handleCopyEditLink,
handleCopyPublicLink,
isDisabled, isDisabled,
isEditable, isEditable,
isLoading, isLoading,
profileId,
profileName,
token,
}: ProfileHeaderProps) { }: 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();
}
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
@ -24,7 +93,7 @@ export default function ProfileComments({
); );
} }
return ( return (
<div className="m-4"> <div className="m-4 h-full">
<div className="flex-end flex justify-end space-x-4"> <div className="flex-end flex justify-end space-x-4">
{isEditable && ( {isEditable && (
<Button <Button
@ -35,7 +104,7 @@ export default function ProfileComments({
label="Copy profile edit link" label="Copy profile edit link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={handleCopyEditLink} onClick={() => copyProfileLink(profileId, token)}
/> />
)} )}
<Button <Button
@ -46,13 +115,47 @@ export default function ProfileComments({
label="Copy public link" label="Copy public link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={handleCopyPublicLink} onClick={() => copyProfileLink(profileId)}
/> />
</div> </div>
<h2 className="mt-2 text-2xl font-bold"> <h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
Discussions feature coming soon <div>
</h2> <TextArea
{/* <TextArea label="Comment" placeholder="Type your comment here" /> */} 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> </div>
); );
} }

@ -27,10 +27,10 @@ export default function ProfileDetails({
); );
} }
if (selectedTab === 'offers') { if (selectedTab === 'offers') {
if (offers && offers.length !== 0) { if (offers.length !== 0) {
return ( return (
<> <>
{[...offers].map((offer) => ( {offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} /> <OfferCard key={offer.id} offer={offer} />
))} ))}
</> </>

@ -1,3 +1,4 @@
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
BookmarkSquareIcon, BookmarkSquareIcon,
@ -11,6 +12,8 @@ import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder'; import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundCard } from '~/components/offers/types'; import type { BackgroundCard } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
background?: BackgroundCard; background?: BackgroundCard;
handleDelete: () => void; handleDelete: () => void;
@ -29,6 +32,12 @@ export default function ProfileHeader({
setSelectedTab, setSelectedTab,
}: ProfileHeaderProps) { }: ProfileHeaderProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const router = useRouter();
const { offerProfileId = '', token = '' } = router.query;
const handleEditClick = () => {
router.push(getProfileEditPath(offerProfileId as string, token as string));
};
function renderActionList() { function renderActionList() {
return ( return (
@ -48,6 +57,7 @@ export default function ProfileHeader({
label="Edit" label="Edit"
size="md" size="md"
variant="tertiary" variant="tertiary"
onClick={handleEditClick}
/> />
<Button <Button
disabled={isLoading} disabled={isLoading}
@ -119,9 +129,11 @@ export default function ProfileHeader({
<div className="flex flex-row"> <div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" /> <BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span> <span className="mr-2 font-bold">Current:</span>
<span>{`${background?.experiences[0].companyName ?? '-'} ${ <span>
background?.experiences[0].jobLevel {`${background?.experiences[0]?.companyName ?? '-'} ${
} ${background?.experiences[0].jobTitle}`}</span> background?.experiences[0]?.jobLevel || ''
} ${background?.experiences[0]?.jobTitle || ''}`}
</span>
</div> </div>
<div className="flex flex-row"> <div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" /> <CalendarDaysIcon className="mr-2.5 h-5" />

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

@ -2,18 +2,21 @@ import { useEffect, useState } from 'react';
import { HorizontalDivider, Select, Spinner, Tabs } from '@tih/ui'; import { HorizontalDivider, Select, Spinner, Tabs } from '@tih/ui';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination'; import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import type { import {
OfferTableRowData, OfferTableFilterOptions,
PaginationType, OfferTableSortBy,
OfferTableTabOptions,
YOE_CATEGORY,
} from '~/components/offers/table/types'; } from '~/components/offers/table/types';
import { YOE_CATEGORY } from '~/components/offers/table/types';
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector'; import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import OffersRow from './OffersRow'; import OffersRow from './OffersRow';
import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_IN_PAGE = 10; const NUMBER_OF_OFFERS_IN_PAGE = 10;
export type OffersTableProps = Readonly<{ export type OffersTableProps = Readonly<{
companyFilter: string; companyFilter: string;
@ -23,61 +26,47 @@ export default function OffersTable({
companyFilter, companyFilter,
jobTitleFilter, jobTitleFilter,
}: OffersTableProps) { }: OffersTableProps) {
const [currency, setCurrency] = useState('SGD'); // TODO: Detect location const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
const [selectedTab, setSelectedTab] = useState(YOE_CATEGORY.ENTRY); const [selectedTab, setSelectedTab] = useState(YOE_CATEGORY.ENTRY);
const [pagination, setPagination] = useState<PaginationType>({ const [pagination, setPagination] = useState<Paging>({
currentPage: 1, currentPage: 0,
numOfItems: 1, numOfItems: 0,
numOfPages: 0, numOfPages: 0,
totalItems: 0, totalItems: 0,
}); });
const [offers, setOffers] = useState<Array<OfferTableRowData>>([]); const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
const [selectedFilter, setSelectedFilter] = useState(
OfferTableFilterOptions[0].value,
);
useEffect(() => { useEffect(() => {
setPagination({ setPagination({
currentPage: 1, currentPage: 0,
numOfItems: 1, numOfItems: 0,
numOfPages: 0, numOfPages: 0,
totalItems: 0, totalItems: 0,
}); });
}, [selectedTab]); }, [selectedTab, currency]);
const offersQuery = trpc.useQuery( const offersQuery = trpc.useQuery(
[ [
'offers.list', 'offers.list',
{ {
companyId: companyFilter, companyId: companyFilter,
currency,
limit: NUMBER_OF_OFFERS_IN_PAGE, limit: NUMBER_OF_OFFERS_IN_PAGE,
location: 'Singapore, Singapore', // TODO: Geolocation location: 'Singapore, Singapore', // TODO: Geolocation
offset: pagination.currentPage - 1, offset: pagination.currentPage,
sortBy: '-monthYearReceived', sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
title: jobTitleFilter, title: jobTitleFilter,
yoeCategory: selectedTab, yoeCategory: selectedTab,
}, },
], ],
{ {
onSuccess: (response) => { onError: (err) => {
const filteredData = response.data.map((res) => { alert(err);
return { },
company: res.company.name, onSuccess: (response: GetOffersResponse) => {
date: formatDate(res.monthYearReceived), setOffers(response.data);
id: res.OffersFullTime setPagination(response.paging);
? 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,
});
}, },
}, },
); );
@ -88,24 +77,7 @@ export default function OffersTable({
<div className="w-fit"> <div className="w-fit">
<Tabs <Tabs
label="Table Navigation" label="Table Navigation"
tabs={[ tabs={OfferTableTabOptions}
{
label: 'Fresh Grad (0-3 YOE)',
value: YOE_CATEGORY.ENTRY,
},
{
label: 'Mid (4-7 YOE)',
value: YOE_CATEGORY.MID,
},
{
label: 'Senior (8+ YOE)',
value: YOE_CATEGORY.SENIOR,
},
{
label: 'Internship',
value: YOE_CATEGORY.INTERN,
},
]}
value={selectedTab} value={selectedTab}
onChange={(value) => setSelectedTab(value)} onChange={(value) => setSelectedTab(value)}
/> />
@ -125,16 +97,11 @@ export default function OffersTable({
/> />
</div> </div>
<Select <Select
disabled={true}
isLabelHidden={true} isLabelHidden={true}
label="" label=""
options={[ options={OfferTableFilterOptions}
{ value={selectedFilter}
label: 'Latest Submitted', onChange={(value) => setSelectedFilter(value)}
value: 'latest-submitted',
},
]}
value="latest-submitted"
/> />
</div> </div>
); );
@ -162,7 +129,9 @@ export default function OffersTable({
} }
const handlePageChange = (currPage: number) => { const handlePageChange = (currPage: number) => {
setPagination({ ...pagination, currentPage: currPage }); if (0 < currPage && currPage < pagination.numOfPages) {
setPagination({ ...pagination, currentPage: currPage });
}
}; };
return ( return (
@ -187,14 +156,11 @@ export default function OffersTable({
)} )}
<OffersTablePagination <OffersTablePagination
endNumber={ endNumber={
(pagination.currentPage - 1) * NUMBER_OF_OFFERS_IN_PAGE + pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + offers.length
offers.length
} }
handlePageChange={handlePageChange} handlePageChange={handlePageChange}
pagination={pagination} pagination={pagination}
startNumber={ startNumber={pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + 1}
(pagination.currentPage - 1) * NUMBER_OF_OFFERS_IN_PAGE + 1
}
/> />
</div> </div>
</div> </div>

@ -1,11 +1,11 @@
import { Pagination } from '@tih/ui'; import { Pagination } from '@tih/ui';
import type { PaginationType } from '~/components/offers/table/types'; import type { Paging } from '~/types/offers';
type OffersTablePaginationProps = Readonly<{ type OffersTablePaginationProps = Readonly<{
endNumber: number; endNumber: number;
handlePageChange: (page: number) => void; handlePageChange: (page: number) => void;
pagination: PaginationType; pagination: Paging;
startNumber: number; startNumber: number;
}>; }>;
@ -30,13 +30,13 @@ export default function OffersTablePagination({
</span> </span>
</span> </span>
<Pagination <Pagination
current={pagination.currentPage} current={pagination.currentPage + 1}
end={pagination.numOfPages} end={pagination.numOfPages}
label="Pagination" label="Pagination"
pagePadding={1} pagePadding={2}
start={1} start={1}
onSelect={(currPage) => { onSelect={(currPage) => {
handlePageChange(currPage); handlePageChange(currPage - 1);
}} }}
/> />
</nav> </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 // eslint-disable-next-line no-shadow
export enum YOE_CATEGORY { export enum YOE_CATEGORY {
INTERN = 0, INTERN = 0,
@ -16,9 +6,47 @@ export enum YOE_CATEGORY {
SENIOR = 3, SENIOR = 3,
} }
export type PaginationType = { export const OfferTableTabOptions = [
currentPage: number; {
numOfItems: number; label: 'Fresh Grad (0-2 YOE)',
numOfPages: number; value: YOE_CATEGORY.ENTRY,
totalItems: number; },
{
label: 'Mid (3-5 YOE)',
value: YOE_CATEGORY.MID,
},
{
label: 'Senior (6+ YOE)',
value: YOE_CATEGORY.SENIOR,
},
{
label: 'Internship',
value: YOE_CATEGORY.INTERN,
},
];
export const OfferTableFilterOptions = [
{
label: 'Latest Submitted',
value: 'latest-submitted',
},
{
label: 'Highest Salary',
value: 'highest-salary',
},
{
label: 'Highest YOE first',
value: 'highest-yoe-first',
},
{
label: 'Lowest YOE first',
value: 'lowest-yoe-first',
},
];
export const OfferTableSortBy: Record<string, string> = {
'highest-salary': '-totalCompensation',
'highest-yoe-first': '-totalYoe',
'latest-submitted': '-monthYearReceived',
'lowest-yoe-first': '+totalYoe',
}; };

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

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

@ -0,0 +1,68 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeCoolIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 511.999 511.999"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<circle cx="247.796" cy="255.997" fill="#FFDB6C" r="247.796" />
<path
d="M300.895,467.216c-136.853,0-247.794-110.941-247.794-247.794c0-73.116,31.673-138.825,82.04-184.181
C54.919,76.258,0,159.716,0,256.003c0,136.853,110.941,247.794,247.794,247.794c63.738,0,121.848-24.073,165.754-63.612
C379.75,457.466,341.462,467.216,300.895,467.216z"
fill="#FCC56B"
/>
<g>
<path
d="M141.308,259.555c-18.402,0-33.321,14.918-33.321,33.32h66.641
C174.628,274.473,159.71,259.555,141.308,259.555z"
fill="#F9A880"
/>
<path
d="M431.948,259.555c-18.402,0-33.321,14.918-33.321,33.32h66.641
C465.269,274.473,450.349,259.555,431.948,259.555z"
fill="#F9A880"
/>
</g>
<path
d="M105.165,121.895c64.702-14.849,117.079-9.739,175.098,3.782c8.604,2.004,17.692,4.239,27.29,4.532
c15.985,0.489,33.956-3.489,49.449-7.382c61.168-15.366,108.95-7.374,154.996,2.465l-3.402,27.211
c-7.188,0.159-9.449,3.511-11.503,10.054c-10.747,34.242-1.594,93.16-81.048,86.233c-52.27-4.558-67.239-18.879-92.152-81.847
c-2.12-5.356-3.497-14.207-15.602-13.88c-6.835,0.184-12.948,1.392-15.079,13.267c-3.973,22.126-34.188,82.245-95.535,82.179
c-54.185-0.058-74.855-28.184-77.323-90.159c-0.306-7.695-7.012-9.156-11.035-9.246L105.165,121.895L105.165,121.895z"
fill="#56586F"
/>
<g>
<path
d="M199.128,113.331l-37.84,129.044c9.958,4.097,21.979,6.12,36.392,6.134
c0.254,0,0.504-0.009,0.758-0.011l38.499-131.292C224.347,115.304,211.809,113.972,199.128,113.331z"
fill="#737891"
/>
<path
d="M434.438,114.376c-12.593-0.403-25.665,0-39.395,1.534l-33.781,115.202
c9.238,7.758,20.144,12.263,34.543,15.016L434.438,114.376z"
fill="#737891"
/>
</g>
<path
d="M319.673,395.914c-16.785,0-33.382-5.73-46.784-16.718c-4.305-3.53-4.933-9.882-1.403-14.187
c3.53-4.306,9.882-4.933,14.188-1.403c15.016,12.314,35.551,15.539,53.597,8.423c17.582-6.937,30.535-23.491,33.802-43.202
c0.913-5.492,6.101-9.207,11.594-8.296c5.493,0.911,9.207,6.102,8.297,11.594c-4.422,26.66-22.161,49.137-46.296,58.657
C337.935,394.228,328.776,395.914,319.673,395.914z"
fill="#7F184C"
/>
<ellipse
cx="298.209"
cy="78.261"
fill="#FCEB88"
rx="28.897"
ry="51.747"
transform="matrix(0.2723 -0.9622 0.9622 0.2723 141.702 343.89)"
/>
</svg>
);
}

@ -0,0 +1,78 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeRocketIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 496.158 496.158"
x="36px"
xmlns="http://www.w3.org/2000/svg"
y="36px">
<path
d="M248.082,0.003C111.07,0.003,0,111.063,0,248.085c0,137.001,111.07,248.07,248.082,248.07 c137.006,0,248.076-111.069,248.076-248.07C496.158,111.062,385.088,0.003,248.082,0.003z"
fill="#334D5C"
/>
<g>
<polygon
fill="#DBBB00"
points="130.14,198.865 112.329,198.237 106.733,181.859 101.138,198.237 83.327,198.865 97.68,208.88 92.267,226.381 106.733,215.458 121.199,226.381 115.787,208.88 "
/>
<polygon
fill="#DBBB00"
points="112.416,202.889 115.484,191.248 105.788,198.382 95.265,191.881 99.455,203.294 89.618,211.306 102.168,210.835 106.348,222.679 110.18,210.584 122.334,210.282 "
/>
<polygon
fill="#DBBB00"
points="357.01,69.501 339.199,68.873 333.603,52.496 328.008,68.873 310.197,69.501 324.55,79.516 319.138,97.017 333.603,86.094 348.069,97.017 342.657,79.516 "
/>
<polygon
fill="#DBBB00"
points="339.286,73.525 342.354,61.884 332.658,69.018 322.135,62.517 326.325,73.93 316.488,81.942 329.038,81.472 333.218,93.315 337.05,81.221 349.204,80.918 "
/>
<polygon
fill="#DBBB00"
points="429.005,224.008 411.194,223.38 405.599,207.003 400.003,223.38 382.192,224.008 396.545,234.023 391.133,251.524 405.599,240.601 420.064,251.524 414.652,234.023 "
/>
<polygon
fill="#DBBB00"
points="411.281,228.032 414.35,216.392 404.653,223.526 394.13,217.024 398.32,228.437 388.483,236.449 401.033,235.979 405.213,247.822 409.045,235.728 421.199,235.426 "
/>
</g>
<path
d="M383.34,314.795c-5.941-14.345-21.202-36.571-46.212-55.931 c-19.131-14.808-50.218-32.46-89.678-32.46c-39.018,0-69.746,16.634-88.654,30.588c-25.352,18.71-40.673,40.56-46.559,54.769 c-4.417,10.663-4.502,18.883-0.239,23.145c3.465,3.465,7.585,5.079,12.965,5.079c6.495,0,14.247-2.294,24.975-5.469 c20.098-5.947,50.469-14.936,97.513-14.936c48.545,0,80.322,8.617,101.35,14.318c10.673,2.894,18.384,4.985,24.472,4.986h0.003 c4.713,0,8.172-1.264,10.886-3.979C387.635,331.431,387.35,324.477,383.34,314.795z"
fill="#EA6307"
/>
<path
d="M286.255,121.222c-14.873-40.687-31.176-66.481-38.176-66.481c-6.988,0-23.253,25.596-38.118,66.13 c-15.702,42.815-29.844,102.297-29.844,165.89c0,40.446,6.193,56.536,6.193,56.536s25.869,13.801,62.818,13.801 s60.716-13.801,60.716-13.801s6.101-16.404,6.101-57.03C315.945,223.234,301.891,163.997,286.255,121.222z"
fill="#DFEADC"
/>
<path
d="M248.166,54.741c-8.74,0-24.42,24.539-38.204,66.13c10.715,2.375,24.12,4.325,39.314,4.325 c14.394,0,26.884-1.749,36.92-3.953C272.454,79.654,256.87,54.741,248.166,54.741z"
fill="#CE5800"
/>
<path
d="M248.165,54.741c-8.343,0-23.005,22.365-36.309,60.561c10.384,2.186,23.106,3.916,37.418,3.916 c13.501,0,25.329-1.54,35.026-3.549C271.044,77.446,256.471,54.741,248.165,54.741z"
fill="#EA6307"
/>
<circle cx="248.079" cy="183.889" fill="#DBBB00" r="30.677" />
<circle cx="248.079" cy="183.889" fill="#FFDB29" r="25.486" />
<path
d="M262.936,167.597c-8.602-8.601-22.547-8.602-31.148,0s-8.602,22.547,0,31.149 S271.538,176.199,262.936,167.597z"
fill="#FFE36E"
/>
<path
d="M249.007,368.151c-16.392,0.012-32.76,0.337-32.76,8.403c0,16.16,32.564,81.608,32.564,81.608 s33.101-65.882,33.101-81.608C281.912,368.464,265.447,368.139,249.007,368.151z"
fill="#E17A2D"
/>
<path
d="M249.079,371.948c-11.66,0-23.32-0.845-23.32,4.894c0,11.479,23.131,57.964,23.131,57.964 s23.51-46.794,23.51-57.964C272.399,371.103,260.739,371.948,249.079,371.948z"
fill="#F4E028"
/>
<path
d="M249.079,376.829c-7.005,0-14.011-1.99-14.011,1.458c0,6.896,13.897,34.824,13.897,34.824 s14.124-28.113,14.124-34.824C263.09,374.839,256.084,376.829,249.079,376.829z"
fill="#FFFFFF"
/>
</svg>
);
}

@ -0,0 +1,198 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeTreasureIcon({
className,
}: ResumeBadgeProps) {
return (
<svg
className={className}
viewBox="0 0 511.672 511.672"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<path
d="M473.853,222.264l37.757-57.073c0,0,4.779-141.878-85.273-149.217h-170.47h-0.078H85.32
C-4.731,23.313,0.047,165.19,0.047,165.19l37.929,56.526L0,325.057l42.629,170.579l426.398,0.062l42.645-170.595L473.853,222.264z"
fill="#A85D5D"
/>
<g opacity={0.2}>
<path
d="M0.593,165.987C3.186,126.403,16.771,42.878,85.32,37.273h170.469h0.078h170.469
c68.551,5.606,82.15,89.162,84.728,128.73l0.546-0.812c0,0,4.779-141.878-85.273-149.217h-170.47h-0.078H85.32
C-4.731,23.313,0.047,165.19,0.047,165.19L0.593,165.987z"
fill="#FFFFFF"
/>
</g>
<polygon
fill="#723F3F"
points="511.672,325.104 0,325.057 37.976,221.717 473.853,222.264 "
/>
<polygon
fill="#8C4C4C"
points="473.853,222.264 37.976,221.717 0.047,165.19 511.609,165.19 "
/>
<path
d="M266.485,410.315c0,5.887-4.778,10.665-10.649,10.665c-5.887,0-10.665-4.778-10.665-10.665
c0-5.888,4.778-10.649,10.665-10.649C261.707,399.666,266.485,404.428,266.485,410.315z"
/>
<g>
<path
d="M170.251,295.045c-12.258,0-24.079-2.701-32.448-7.401c-6.387-3.591-10.181-8.042-10.181-11.914
c0-7.87,16.615-19.315,42.629-19.315c12.257,0,24.094,2.701,32.463,7.417c6.371,3.575,10.181,8.026,10.181,11.898
C212.895,283.615,196.28,295.045,170.251,295.045z"
fill="#FFCE54"
/>
<path
d="M255.524,295.045c-12.258,0-24.079-2.701-32.464-7.401c-6.371-3.591-10.165-8.042-10.165-11.914
c0-7.87,16.599-19.315,42.629-19.315c12.257,0,24.078,2.701,32.463,7.417c6.371,3.575,10.165,8.026,10.165,11.898
C298.152,283.615,281.555,295.045,255.524,295.045z"
fill="#FFCE54"
/>
<path
d="M340.797,295.045c-12.258,0-24.094-2.701-32.463-7.401c-6.371-3.591-10.182-8.042-10.182-11.914
c0-7.87,16.615-19.315,42.645-19.315c12.258,0,24.078,2.701,32.448,7.417c6.371,3.575,10.181,8.026,10.181,11.898
C383.426,283.615,366.813,295.045,340.797,295.045z"
fill="#FFCE54"
/>
<path
d="M212.895,265.065c-12.258,0-24.094-2.686-32.464-7.401c-6.371-3.592-10.181-8.026-10.181-11.899
c0-7.885,16.614-19.332,42.645-19.332c12.242,0,24.078,2.717,32.448,7.417c6.371,3.576,10.181,8.042,10.181,11.915
C255.524,253.634,238.909,265.065,212.895,265.065z"
fill="#FFCE54"
/>
<path
d="M298.152,265.065c-12.242,0-24.078-2.686-32.447-7.401c-6.371-3.592-10.181-8.026-10.181-11.899
c0-7.885,16.615-19.332,42.628-19.332c12.258,0,24.094,2.717,32.464,7.417c6.371,3.576,10.181,8.042,10.181,11.915
C340.797,253.634,324.184,265.065,298.152,265.065z"
fill="#FFCE54"
/>
<path
d="M255.524,235.099c-12.258,0-24.079-2.702-32.464-7.417c-6.371-3.575-10.165-8.026-10.165-11.898
c0-7.87,16.599-19.315,42.629-19.315c12.257,0,24.078,2.701,32.463,7.417c6.371,3.576,10.165,8.026,10.165,11.898
C298.152,223.653,281.555,235.099,255.524,235.099z"
fill="#FFCE54"
/>
<path
d="M91.629,325.104h72.516c4.153-3.123,6.417-6.511,6.417-9.415c0-3.873-3.81-8.308-10.181-11.899
c-8.37-4.715-20.206-7.401-32.463-7.401c-26.015,0-42.629,11.431-42.629,19.301C85.289,318.718,87.6,322.074,91.629,325.104z"
fill="#FFCE54"
/>
<path
d="M176.902,325.104h72.516c4.153-3.123,6.402-6.511,6.402-9.415c0-3.873-3.794-8.308-10.165-11.899
c-8.37-4.715-20.206-7.401-32.464-7.401c-26.03,0-42.629,11.431-42.629,19.301C170.563,318.718,172.874,322.074,176.902,325.104z"
fill="#FFCE54"
/>
</g>
<rect
fill="#CCD1D9"
height="98.75"
width="96.27"
x="206.586"
y="325.106"
/>
<g>
<path
d="M262.16,325.104h72.516c4.154-3.123,6.418-6.511,6.418-9.415c0-3.873-3.795-8.308-10.165-11.899
c-8.386-4.715-20.206-7.401-32.464-7.401c-26.029,0-42.645,11.431-42.645,19.301C255.82,318.718,258.147,322.074,262.16,325.104z"
fill="#FFCE54"
/>
<path
d="M347.434,325.104h72.516c4.154-3.123,6.418-6.511,6.418-9.415c0-3.873-3.81-8.308-10.181-11.899
c-8.37-4.715-20.206-7.401-32.448-7.401c-26.029,0-42.645,11.431-42.645,19.301C341.094,318.718,343.404,322.074,347.434,325.104z"
fill="#FFCE54"
/>
</g>
<path
d="M434.331,325.104c1.733-2.951,2.702-6.121,2.702-9.415c0-15.179-20.098-27.732-46.158-29.7
c2.076-3.186,3.217-6.652,3.217-10.259c0-14.507-18.332-26.593-42.66-29.372c0-0.203,0.016-0.406,0.016-0.593
c0-14.522-18.316-26.608-42.66-29.387c0.016-0.188,0.031-0.391,0.031-0.594c0-16.552-23.86-29.98-53.294-29.98
c-29.435,0-53.294,13.429-53.294,29.98c0,0.203,0.016,0.406,0.031,0.594c-24.344,2.779-42.661,14.865-42.661,29.387
c0,0.188,0.016,0.39,0.016,0.593c-24.328,2.779-42.66,14.865-42.66,29.372c0,3.622,1.14,7.104,3.248,10.321
c-25.78,2.093-45.58,14.568-45.58,29.638c0,3.294,0.968,6.464,2.701,9.415H434.331z M236.989,319.998
c-6.605,2.811-15.053,4.356-23.797,4.356s-17.192-1.546-23.782-4.356c-3.654-1.562-5.934-3.154-7.198-4.31
c1.265-1.124,3.544-2.733,7.198-4.278c6.59-2.812,15.038-4.373,23.782-4.373c8.745,0,17.192,1.562,23.797,4.373
c3.638,1.545,5.918,3.154,7.199,4.278C242.907,316.844,240.627,318.437,236.989,319.998z M231.727,280.024
c-3.638-1.546-5.918-3.154-7.199-4.294c0.297-0.266,0.656-0.547,1.062-0.858c9.322-1.281,17.676-3.936,24.344-7.59
c1.843-0.125,3.716-0.203,5.59-0.203s3.748,0.078,5.574,0.203c6.684,3.654,15.038,6.309,24.359,7.59
c0.406,0.312,0.766,0.593,1.062,0.858c-1.28,1.14-3.561,2.748-7.199,4.294c-6.604,2.811-15.053,4.372-23.796,4.372
C246.779,284.396,238.332,282.834,231.727,280.024z M322.246,319.998c-6.589,2.811-15.037,4.356-23.781,4.356
s-17.191-1.546-23.797-4.356c-3.639-1.562-5.918-3.154-7.199-4.31c1.281-1.124,3.561-2.733,7.199-4.278
c6.605-2.812,15.053-4.373,23.797-4.373s17.192,1.562,23.781,4.373c3.654,1.545,5.934,3.154,7.199,4.278
C328.18,316.844,325.9,318.437,322.246,319.998z M407.52,311.41c3.654,1.545,5.935,3.154,7.199,4.278
c-1.265,1.155-3.545,2.748-7.199,4.31c-6.589,2.811-15.053,4.356-23.781,4.356c-8.744,0-17.191-1.546-23.797-4.356
c-3.654-1.562-5.934-3.154-7.199-4.31c1.266-1.124,3.545-2.733,7.199-4.278c6.605-2.812,15.053-4.373,23.797-4.373
C392.467,307.037,400.931,308.599,407.52,311.41z M340.797,267.078c8.744,0,17.192,1.547,23.782,4.357
c3.653,1.562,5.934,3.154,7.198,4.294c-1.265,1.14-3.545,2.748-7.198,4.294c-6.59,2.811-15.038,4.372-23.782,4.372
s-17.191-1.562-23.797-4.372c-3.654-1.546-5.934-3.154-7.198-4.294c0.296-0.266,0.655-0.547,1.062-0.858
c9.322-1.281,17.676-3.936,24.344-7.59C337.05,267.156,338.908,267.078,340.797,267.078z M298.152,237.098
c8.744,0,17.192,1.546,23.798,4.356c3.653,1.562,5.934,3.154,7.198,4.31c-0.297,0.25-0.656,0.546-1.062,0.859
c-9.322,1.28-17.677,3.95-24.345,7.573c-1.842,0.141-3.7,0.219-5.59,0.219c-1.873,0-3.731-0.078-5.574-0.219
c-6.684-3.623-15.037-6.293-24.359-7.573c-0.406-0.312-0.75-0.609-1.047-0.859c0.297-0.281,0.641-0.562,1.047-0.875
c9.322-1.28,17.676-3.935,24.359-7.573C294.405,237.176,296.279,237.098,298.152,237.098z M231.727,211.489
c6.605-2.811,15.053-4.356,23.797-4.356s17.192,1.546,23.796,4.356c3.639,1.546,5.919,3.154,7.199,4.294
c-0.297,0.266-0.656,0.562-1.062,0.875c-9.321,1.281-17.676,3.935-24.359,7.558c-1.826,0.156-3.7,0.218-5.574,0.218
s-3.748-0.062-5.59-0.218c-6.667-3.623-15.021-6.277-24.344-7.558c-0.406-0.312-0.765-0.609-1.062-0.875
C225.809,214.644,228.088,213.035,231.727,211.489z M189.098,241.454c6.605-2.811,15.053-4.356,23.797-4.356
c1.874,0,3.732,0.078,5.574,0.219c6.684,3.639,15.038,6.293,24.359,7.573c0.406,0.312,0.75,0.594,1.046,0.875
c-0.297,0.25-0.64,0.546-1.046,0.859c-9.322,1.28-17.691,3.95-24.359,7.573c-1.842,0.141-3.701,0.219-5.574,0.219
c-1.89,0-3.748-0.078-5.59-0.219c-6.667-3.623-15.021-6.293-24.344-7.573c-0.406-0.312-0.765-0.609-1.062-0.859
C183.164,244.608,185.444,243.016,189.098,241.454z M146.469,271.436c6.589-2.811,15.037-4.357,23.782-4.357
c1.889,0,3.748,0.078,5.59,0.203c6.667,3.654,15.021,6.309,24.344,7.59c0.406,0.312,0.765,0.593,1.062,0.858
c-1.28,1.14-3.544,2.748-7.199,4.294c-6.605,2.811-15.053,4.372-23.797,4.372c-8.745,0-17.192-1.562-23.782-4.372
c-3.654-1.546-5.934-3.154-7.199-4.294C140.535,274.59,142.815,272.997,146.469,271.436z M104.136,311.41
c6.59-2.812,15.053-4.373,23.782-4.373c8.745,0,17.192,1.562,23.797,4.373c3.654,1.545,5.934,3.154,7.198,4.278
c-1.265,1.155-3.544,2.748-7.198,4.31c-6.605,2.811-15.053,4.356-23.797,4.356s-17.192-1.546-23.782-4.356
c-3.654-1.562-5.934-3.154-7.198-4.31C98.203,314.565,100.482,312.955,104.136,311.41z"
fill="#F6BB42"
/>
<rect
fill="#AAB2BC"
height="26.702"
width="21.331"
x="244.856"
y="383.616"
/>
<path
d="M270.936,369.982c0,8.448-6.855,15.287-15.287,15.287c-8.448,0-15.303-6.839-15.303-15.287
c0-8.447,6.855-15.287,15.303-15.287C264.08,354.694,270.936,361.534,270.936,369.982z"
fill="#434A54"
/>
<path
d="M319.467,165.19c0,0,0.016-0.016,0.016-0.031V122.53c0-5.871-4.777-10.649-10.664-10.649H202.23
c-5.887,0-10.665,4.778-10.665,10.649v42.629c0,0.016,0,0.031,0,0.031H319.467z"
fill="#CCD1D9"
/>
<g>
<path
d="M212.895,165.19v-31.995h85.257v31.995h21.314c0-21.798,0.016-42.66,0.016-42.66
c0-5.871-4.777-10.649-10.664-10.649H202.23c-5.887,0-10.665,4.778-10.665,10.649c0,0,0,20.862,0,42.66H212.895z"
fill="#AAB2BC"
/>
<polygon
fill="#AAB2BC"
points="55.152,325.057 92.894,495.651 114.723,495.651 76.998,325.057 "
/>
<polygon
fill="#AAB2BC"
points="434.159,325.088 396.387,495.698 418.217,495.698 455.989,325.088 "
/>
<path
d="M298.152,325.088v95.893h-85.257v-95.908h-21.33v106.573c0,5.871,4.778,10.649,10.665,10.649
h106.588c5.887,0,10.664-4.778,10.664-10.649V325.088H298.152z"
fill="#AAB2BC"
/>
<path
d="M255.836,335.707c-17.661,0-31.979,14.318-31.979,31.979c0,17.66,14.319,31.979,31.979,31.979
c17.645,0,31.964-14.319,31.964-31.979C287.8,350.025,273.481,335.707,255.836,335.707z M255.836,378.336
c-5.887,0-10.665-4.778-10.665-10.649c0-5.872,4.778-10.665,10.665-10.665c5.871,0,10.649,4.793,10.649,10.665
C266.485,373.558,261.707,378.336,255.836,378.336z"
fill="#AAB2BC"
/>
</g>
<polygon
opacity={0.1}
points="0,325.057 7.823,303.727 503.803,303.727 511.672,325.057"
/>
</svg>
);
}

@ -0,0 +1,3 @@
export type ResumeBadgeProps = Readonly<{
className: string;
}>;

@ -0,0 +1,59 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeDetectiveIcon({
className,
}: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
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>
);
}

@ -0,0 +1,27 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeEagleIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
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,54 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeSuperheroIcon({
className,
}: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
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,135 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeBookIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 512 512"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<path
d="M8.17,90.446v350.268H224.7c2.367,0,3.648,1.543,4.089,2.206l9.701,11.315l35.025-0.003l9.699-11.316
c0.439-0.661,1.719-2.202,4.086-2.202H503.83V90.446H8.17z"
fill="#FF7226"
/>
<path
d="M224.699,57.766H40.851v350.268h183.848c13.061,0,24.571,6.669,31.301,16.786l21.787-175.126
L256,74.567C249.271,64.442,237.767,57.766,224.699,57.766z"
fill="#F7EBD4"
/>
<path
d="M287.301,57.766c-13.068,0-24.573,6.677-31.301,16.801v350.252
c6.729-10.119,18.238-16.786,31.301-16.786h183.848V57.766H287.301z"
fill="#D2F0E7"
/>
<rect fill="#F99FB6" height="67.028" width="128" x="84.426" y="297.428" />
<g>
<path
d="M256,148.099c4.513,0,8.17-3.658,8.17-8.17v-32.681c0-4.512-3.657-8.17-8.17-8.17
s-8.17,3.658-8.17,8.17v32.681C247.83,144.441,251.487,148.099,256,148.099z"
fill="#3E0412"
/>
<path
d="M256,390.861c4.513,0,8.17-3.658,8.17-8.17V172.609c0-4.512-3.657-8.17-8.17-8.17
s-8.17,3.658-8.17,8.17v210.081C247.83,387.203,251.487,390.861,256,390.861z"
fill="#3E0412"
/>
<path
d="M503.83,82.276c-4.513,0-8.17,3.658-8.17,8.17v342.098H287.3c-4.182,0-8.077,1.987-10.54,5.346
l-7.004,8.172l-27.511,0.002l-7.007-8.172c-2.467-3.36-6.363-5.348-10.541-5.348H16.34V90.446c0-4.512-3.657-8.17-8.17-8.17
S0,85.934,0,90.446v350.268c0,4.512,3.657,8.17,8.17,8.17h214.971l9.146,10.668c1.552,1.81,3.818,2.852,6.203,2.852l35.025-0.003
c2.385,0,4.652-1.043,6.203-2.854l9.139-10.664H503.83c4.513,0,8.17-3.658,8.17-8.17V90.446
C512,85.934,508.343,82.276,503.83,82.276z"
fill="#3E0412"
/>
<path
d="M40.851,416.204H224.7c9.866,0,19.024,4.912,24.498,13.141c1.515,2.277,4.068,3.645,6.803,3.645
c2.734,0,5.288-1.368,6.802-3.646c5.471-8.228,14.629-13.14,24.496-13.14h183.849c4.513,0,8.17-3.658,8.17-8.17V57.766
c0-4.512-3.657-8.17-8.17-8.17H287.3c-11.783,0-22.915,4.503-31.3,12.389c-8.386-7.885-19.517-12.389-31.3-12.389H40.851
c-4.513,0-8.17,3.658-8.17,8.17v350.268C32.681,412.546,36.338,416.204,40.851,416.204z M49.021,65.936H224.7
c9.865,0,19.022,4.917,24.495,13.154c1.514,2.279,4.068,3.648,6.804,3.648c2.736,0,5.29-1.369,6.805-3.648
c5.472-8.237,14.629-13.153,24.494-13.153h175.679v333.927H287.3c-11.784,0-22.915,4.5-31.301,12.378
c-8.386-7.878-19.517-12.378-31.298-12.378H49.021V65.936z"
fill="#3E0412"
/>
<path
d="M212.426,93.17h-128c-4.513,0-8.17,3.658-8.17,8.17c0,4.512,3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17C220.596,96.828,216.939,93.17,212.426,93.17z"
fill="#3E0412"
/>
<path
d="M212.426,125.851h-128c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17C220.596,129.509,216.939,125.851,212.426,125.851z"
fill="#3E0412"
/>
<path
d="M212.426,158.532h-128c-4.513,0-8.17,3.658-8.17,8.17c0,4.512,3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17C220.596,162.19,216.939,158.532,212.426,158.532z"
fill="#3E0412"
/>
<path
d="M212.426,191.212h-128c-4.513,0-8.17,3.658-8.17,8.17c0,4.512,3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17C220.596,194.87,216.939,191.212,212.426,191.212z"
fill="#3E0412"
/>
<path
d="M212.426,223.893h-128c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17S216.939,223.893,212.426,223.893z"
fill="#3E0412"
/>
<path
d="M84.426,272.914h64c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17h-64
c-4.513,0-8.17,3.658-8.17,8.17C76.255,269.256,79.912,272.914,84.426,272.914z"
fill="#3E0412"
/>
<path
d="M212.426,289.255h-128c-4.513,0-8.17,3.658-8.17,8.17v67.034c0,4.512,3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17v-67.034C220.596,292.913,216.939,289.255,212.426,289.255z M204.255,356.289H92.596v-50.693h111.66
V356.289z"
fill="#3E0412"
/>
<path
d="M299.574,241.906h128c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17h-128
c-4.513,0-8.17,3.658-8.17,8.17C291.404,238.248,295.061,241.906,299.574,241.906z"
fill="#3E0412"
/>
<path
d="M299.574,274.587h128c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17h-128
c-4.513,0-8.17,3.658-8.17,8.17C291.404,270.929,295.061,274.587,299.574,274.587z"
fill="#3E0412"
/>
<path
d="M299.574,307.268h128c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17h-128
c-4.513,0-8.17,3.658-8.17,8.17S295.061,307.268,299.574,307.268z"
fill="#3E0412"
/>
<path
d="M299.574,339.948h128c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17h-128
c-4.513,0-8.17,3.658-8.17,8.17S295.061,339.948,299.574,339.948z"
fill="#3E0412"
/>
<path
d="M299.574,372.629h64c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17h-64
c-4.513,0-8.17,3.658-8.17,8.17C291.404,368.971,295.061,372.629,299.574,372.629z"
fill="#3E0412"
/>
<path
d="M299.574,192.885c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17
c11.714,0,21.543-2.95,30.036-7.774c0.43-0.204,0.84-0.44,1.226-0.712c13.658-8.171,23.803-21.223,32.739-34.574
c8.933,13.346,19.073,26.393,32.723,34.564c0.398,0.282,0.821,0.528,1.268,0.736c8.486,4.814,18.308,7.758,30.01,7.758
c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17c-6.915,0-12.958-1.35-18.383-3.759v-75.856
c5.425-2.41,11.468-3.759,18.383-3.759c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17
c-11.706,0-21.531,2.947-30.021,7.764c-0.439,0.207-0.858,0.449-1.251,0.727c-13.654,8.171-23.795,21.22-32.729,34.568
c-8.938-13.353-19.083-26.406-32.745-34.577c-0.38-0.268-0.784-0.501-1.208-0.702c-8.495-4.827-18.327-7.779-30.047-7.779
c-4.513,0-8.17,3.658-8.17,8.17c0,4.512,3.657,8.17,8.17,8.17c6.915,0,12.958,1.35,18.383,3.759v75.856
C312.532,191.535,306.49,192.885,299.574,192.885z M392.851,125.053v52.288c-7.034-7.18-13.2-16.294-19.562-26.144
C379.651,141.347,385.817,132.232,392.851,125.053z M353.86,151.197c-6.363,9.85-12.528,18.965-19.562,26.144v-52.288
C341.332,132.232,347.498,141.347,353.86,151.197z"
fill="#3E0412"
/>
</g>
</svg>
);
}

@ -0,0 +1,125 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeOwlIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 511.988 511.988"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<g>
<path
d="M366.867,149.324c-5.891,0-10.672-4.766-10.672-10.656s4.781-10.672,10.672-10.672
c16.781,0,23.312-4.469,25.719-7.141c1.562-1.718,2.062-3.515,2.062-3.53c0-5.891,4.781-10.656,10.688-10.656
c5.875,0,10.656,4.766,10.656,10.656c0,1.578-0.375,9.843-7.562,17.812C399.961,144.557,385.961,149.324,366.867,149.324z"
fill="#434A54"
/>
<path
d="M145.122,127.995c-11.554,0-20.382-2.234-24.866-6.281c-2.438-2.202-2.859-4.296-2.93-4.765
c0-0.031,0.008-0.062,0.008-0.078c0-5.891-4.781-10.672-10.672-10.672c-5.89,0-10.664,4.781-10.664,10.672
c0,0.156,0.016,0.297,0.023,0.453h-0.023c0,1.578,0.367,9.843,7.555,17.812c8.484,9.422,22.476,14.188,41.569,14.188
c5.891,0,10.672-4.766,10.672-10.656S151.013,127.995,145.122,127.995z M117.334,117.325h-0.023c0-0.109,0.016-0.234,0.016-0.344
C117.342,117.2,117.334,117.325,117.334,117.325z"
fill="#434A54"
/>
</g>
<path
d="M255.995,63.998c-76.459,0-138.661,62.201-138.661,138.669c0,17.906,5.039,42.093,14.57,69.936
c8.781,25.641,20.773,52.921,34.687,78.936c14.227,26.578,29.188,49.39,43.273,65.969c17.437,20.515,32.522,30.483,46.131,30.483
s28.687-9.969,46.124-30.483c14.094-16.579,29.062-39.391,43.28-65.969c13.905-26.015,25.905-53.295,34.687-78.936
c9.531-27.843,14.562-52.029,14.562-69.936C394.648,126.199,332.462,63.998,255.995,63.998z"
fill="#A85D5D"
/>
<path
d="M501.334,362.663H10.664C4.774,362.663,0,357.882,0,351.991c0-5.89,4.773-10.671,10.664-10.671
h490.669c5.874,0,10.655,4.781,10.655,10.671C511.989,357.882,507.208,362.663,501.334,362.663z"
fill="#FFD2A6"
/>
<g>
<path
d="M213.332,362.663c-5.891,0-10.672-4.781-10.672-10.672V341.32c0-5.891,4.781-10.656,10.672-10.656
c5.89,0,10.664,4.766,10.664,10.656v10.671C223.996,357.882,219.223,362.663,213.332,362.663z"
fill="#F6BB42"
/>
<path
d="M234.66,362.663c-5.891,0-10.664-4.781-10.664-10.672V341.32c0-5.891,4.773-10.656,10.664-10.656
c5.89,0,10.671,4.766,10.671,10.656v10.671C245.331,357.882,240.55,362.663,234.66,362.663z"
fill="#F6BB42"
/>
<path
d="M277.338,362.663c-5.897,0-10.679-4.781-10.679-10.672V341.32c0-5.891,4.781-10.656,10.679-10.656
c5.875,0,10.656,4.766,10.656,10.656v10.671C287.994,357.882,283.213,362.663,277.338,362.663z"
fill="#F6BB42"
/>
<path
d="M298.65,362.663c-5.875,0-10.656-4.781-10.656-10.672V341.32c0-5.891,4.781-10.656,10.656-10.656
c5.906,0,10.688,4.766,10.688,10.656v10.671C309.338,357.882,304.557,362.663,298.65,362.663z"
fill="#F6BB42"
/>
</g>
<path
d="M255.995,234.665c-5.89,0-10.664-4.781-10.664-10.671v-21.328c0-5.891,4.773-10.672,10.664-10.672
c5.891,0,10.664,4.781,10.664,10.672v21.328C266.659,229.885,261.886,234.665,255.995,234.665z"
fill="#FFCE54"
/>
<g>
<path
d="M387.914,159.947c-18.047-55.623-70.357-95.95-131.919-95.95
c-61.295,0-113.419,39.983-131.685,95.231l0.148,0.766c35.375-71.326,131.537-42.67,131.537,21.328
C255.995,117.34,352.523,88.684,387.914,159.947z"
fill="#7F4545"
/>
<path
d="M255.995,383.99c-5.89,0-10.664,4.781-10.664,10.672v51.391c3.656,1.297,7.211,1.938,10.664,1.938
s7.008-0.641,10.664-1.938v-51.391C266.659,388.771,261.886,383.99,255.995,383.99z"
fill="#7F4545"
/>
</g>
<path
d="M255.995,63.998c-1.828,0-3.656,0.031-5.468,0.109
c74.061,2.75,133.465,63.842,133.465,138.56c0,17.906-5.031,42.093-14.578,69.936c-8.766,25.641-20.765,52.921-34.687,78.936
c-14.218,26.578-29.187,49.39-43.265,65.969c-15.188,17.874-28.601,27.733-40.803,29.983c1.805,0.328,3.586,0.5,5.335,0.5
c13.609,0,28.687-9.969,46.124-30.483c14.094-16.579,29.062-39.391,43.28-65.969c13.905-26.015,25.905-53.295,34.687-78.936
c9.531-27.843,14.562-52.029,14.562-69.936C394.648,126.199,332.462,63.998,255.995,63.998z"
fill="#FFFFFF"
opacity={0.1}
/>
<g>
<path
d="M219.98,193.322c0,15.094-13.187,27.344-29.444,27.344c-16.266,0-29.445-12.25-29.445-27.344
s13.179-27.328,29.445-27.328C206.793,165.995,219.98,178.229,219.98,193.322z"
fill="#F6BB42"
/>
<path
d="M344.649,191.214c0,14.672-12.297,26.562-27.452,26.562c-15.156,0-27.453-11.891-27.453-26.562
c0-14.656,12.297-26.547,27.453-26.547C332.352,164.667,344.649,176.557,344.649,191.214z"
fill="#F6BB42"
/>
</g>
<path
d="M191.997,181.322c-5.891,0-10.664,4.781-10.664,10.672s4.773,10.672,10.664,10.672
s10.664-4.781,10.664-10.672S197.888,181.322,191.997,181.322z"
fill="#434A54"
/>
<path
d="M191.997,149.324c-23.523,0-42.664,19.14-42.664,42.671s19.14,42.671,42.664,42.671
c23.523,0,42.663-19.14,42.663-42.671S215.52,149.324,191.997,149.324z M191.997,213.322c-11.766,0-21.336-9.562-21.336-21.328
s9.57-21.328,21.336-21.328c11.765,0,21.335,9.562,21.335,21.328S203.762,213.322,191.997,213.322z"
fill="#FFCE54"
/>
<path
d="M319.994,181.322c-5.891,0-10.656,4.781-10.656,10.672s4.766,10.672,10.656,10.672
s10.656-4.781,10.656-10.672S325.885,181.322,319.994,181.322z"
fill="#434A54"
/>
<path
d="M319.994,149.324c-23.531,0-42.656,19.14-42.656,42.671s19.125,42.671,42.656,42.671
c23.53,0,42.654-19.14,42.654-42.671S343.524,149.324,319.994,149.324z M319.994,213.322c-11.766,0-21.344-9.562-21.344-21.328
s9.578-21.328,21.344-21.328s21.343,9.562,21.343,21.328S331.76,213.322,319.994,213.322z"
fill="#FFCE54"
/>
</svg>
);
}

@ -0,0 +1,103 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeSageIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 512 512"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<g>
<path
d="M252.356,4.59c-2.05-4.1-7.035-5.762-11.14-3.713c-4.1,2.051-5.763,7.037-3.713,11.14l16.605,33.211
l14.852-7.427L252.356,4.59z"
fill="#FFF3D4"
/>
<path
d="M219.145,4.59c-2.051-4.1-7.035-5.762-11.14-3.713c-4.1,2.051-5.763,7.037-3.713,11.14
l18.714,37.429l14.852-7.427L219.145,4.59z"
fill="#FFF3D4"
/>
</g>
<path
d="M106.661,141.146h-0.111c-18.342,0-33.211,14.868-33.211,33.211s14.868,33.211,33.211,33.211h149.448
v-66.421C255.999,141.146,106.661,141.146,106.661,141.146z"
fill="#FFCDC1"
/>
<path
d="M405.448,141.146h-0.111H256v66.421h149.448c18.342,0,33.211-14.868,33.211-33.211
S423.789,141.146,405.448,141.146z"
fill="#FFAB97"
/>
<path
d="M482.94,334.876c0-24.455-19.825-44.281-44.281-44.281l-33.211,94.097l33.211,94.097h44.281V334.876z
"
fill="#7F7774"
/>
<path
d="M73.341,290.595c-24.456,0-44.281,19.826-44.281,44.281v143.913h409.599V290.595H73.341z"
fill="#A99E9B"
/>
<path
d="M386.948,74.725h-44.281L372.237,512c18.342,0,33.211-14.868,33.211-33.211V146.682
C405.448,120.611,398.731,96.084,386.948,74.725z"
fill="#FFEAB2"
/>
<path
d="M342.667,74.725H125.051c-11.783,21.359-18.501,45.886-18.501,71.957v332.107
c0,18.342,14.868,33.211,33.211,33.211c12.507,0,23.396-6.918,29.059-17.132C174.484,505.082,185.373,512,197.88,512
s23.396-6.918,29.059-17.132C232.603,505.082,243.492,512,255.999,512c12.507,0,23.396-6.918,29.059-17.132
C290.722,505.082,301.61,512,314.118,512s23.396-6.918,29.059-17.132C348.84,505.082,359.729,512,372.236,512V146.682
L342.667,74.725z"
fill="#FFF3D4"
/>
<path
d="M256,30.444l83.027,149.448h33.211v-33.211C372.237,82.485,320.195,30.444,256,30.444z"
fill="#FFAB97"
/>
<path
d="M256,30.444c-64.196,0-116.238,52.041-116.238,116.238v33.211h199.264v-33.211
C339.027,82.485,301.854,30.444,256,30.444z"
fill="#FFCDC1"
/>
<g>
<path
d="M172.973,298.897c-4.586,0-8.303-3.716-8.303-8.303v-11.07c0-4.586,3.716-8.303,8.303-8.303
s8.303,3.716,8.303,8.303v11.07C181.276,295.181,177.558,298.897,172.973,298.897z"
fill="#FFD159"
/>
<path
d="M339.027,387.459c-4.586,0-8.303-3.716-8.303-8.303v-11.07c0-4.586,3.716-8.303,8.303-8.303
c4.586,0,8.303,3.716,8.303,8.303v11.07C347.329,383.743,343.612,387.459,339.027,387.459z"
fill="#FFD159"
/>
<path
d="M305.816,420.67c-4.586,0-8.303-3.716-8.303-8.303v-11.07c0-4.586,3.716-8.303,8.303-8.303
c4.586,0,8.303,3.716,8.303,8.303v11.07C314.119,416.954,310.401,420.67,305.816,420.67z"
fill="#FFD159"
/>
</g>
<path
d="M256,234.297c-11.69,0-22.174-7.399-26.088-18.412c-1.537-4.321,0.721-9.069,5.042-10.604
c4.315-1.54,9.068,0.72,10.603,5.041c1.568,4.408,5.764,7.369,10.444,7.369c4.679,0,8.876-2.961,10.444-7.369
c1.535-4.32,6.285-6.58,10.603-5.041c4.321,1.535,6.578,6.283,5.042,10.604C278.174,226.898,267.689,234.297,256,234.297z"
fill="#E26142"
/>
<g>
<path
d="M225.003,154.984c-3.616,0-6.691-2.31-7.83-5.535h-10.989c-4.586,0-8.303-3.716-8.303-8.303
c0-4.586,3.716-8.303,8.303-8.303h18.819c4.586,0,8.303,3.716,8.303,8.303v5.535C233.306,151.268,229.588,154.984,225.003,154.984z
"
fill="#554F4E"
/>
<path
d="M286.997,154.984c-4.586,0-8.303-3.716-8.303-8.303v-5.535c0-4.586,3.716-8.303,8.303-8.303h18.819
c4.586,0,8.303,3.716,8.303,8.303c0,4.586-3.716,8.303-8.303,8.303h-10.989C293.686,152.674,290.611,154.984,286.997,154.984z"
fill="#554F4E"
/>
</g>
</svg>
);
}

@ -0,0 +1,30 @@
import type { BadgeIcon } from './resumeBadgeConstants';
type Props = Readonly<{
description: string;
icon: BadgeIcon;
title: string;
}>;
export default function ResumeUserBadge({
description,
icon: Icon,
title,
}: Props) {
return (
<div className="group relative flex items-center justify-center">
<div
className="absolute -top-3 hidden w-48 -translate-y-full flex-col
justify-center gap-1 rounded-lg bg-white px-2 py-2 text-center drop-shadow-xl
after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2
after:border-8 after:border-x-transparent after:border-b-transparent
after:border-t-white after:drop-shadow-lg after:content-['']
group-hover:flex">
<Icon className="h-12 w-12 self-center" />
<p className="font-medium">{title}</p>
<p className="text-sm">{description}.</p>
</div>
<Icon className="h-4 w-4" />
</div>
);
}

@ -0,0 +1,45 @@
import { trpc } from '~/utils/trpc';
import type { BadgePayload } from './resumeBadgeConstants';
import { RESUME_USER_BADGES } from './resumeBadgeConstants';
import ResumeUserBadge from './ResumeUserBadge';
type Props = Readonly<{
userId: string;
}>;
export default function ResumeUserBadges({ userId }: Props) {
const userReviewedResumeCountQuery = trpc.useQuery([
'resumes.resume.findUserReviewedResumeCount',
{ userId },
]);
const userMaxResumeUpvoteCountQuery = trpc.useQuery([
'resumes.resume.findUserMaxResumeUpvoteCount',
{ userId },
]);
const userTopUpvotedCommentCountQuery = trpc.useQuery([
'resumes.resume.findUserTopUpvotedCommentCount',
{ userId },
]);
const payload: BadgePayload = {
maxResumeUpvoteCount: userMaxResumeUpvoteCountQuery.data ?? 0,
reviewedResumesCount: userReviewedResumeCountQuery.data ?? 0,
topUpvotedCommentCount: userTopUpvotedCommentCountQuery.data ?? 0,
};
return (
<div className="flex items-center justify-center gap-1">
{RESUME_USER_BADGES.filter((badge) => badge.isValid(payload)).map(
(badge) => (
<ResumeUserBadge
key={badge.id}
description={badge.description}
icon={badge.icon}
title={badge.title}
/>
),
)}
</div>
);
}

@ -0,0 +1,113 @@
import ResumeBadgeCoolIcon from '../badgeIcons/popularResumes/ResumeBadgeCoolIcon';
import ResumeBadgeRocketIcon from '../badgeIcons/popularResumes/ResumeBadgeRocketIcon';
import ResumeBadgeTreasureIcon from '../badgeIcons/popularResumes/ResumeBadgeTreasureIcon';
import ResumeBadgeDetectiveIcon from '../badgeIcons/reviewer/ResumeBadgeDetectiveIcon';
import ResumeBadgeEagleIcon from '../badgeIcons/reviewer/ResumeBadgeEagleIcon';
import ResumeBadgeSuperheroIcon from '../badgeIcons/reviewer/ResumeBadgeSuperheroIcon';
import ResumeBadgeBookIcon from '../badgeIcons/topComment/ResumeBadgeBookIcon';
import ResumeBadgeOwlIcon from '../badgeIcons/topComment/ResumeBadgeOwlIcon';
import ResumeBadgeSageIcon from '../badgeIcons/topComment/ResumeBadgeSageIcon';
export type BadgeIcon = (
props: React.ComponentProps<typeof ResumeBadgeDetectiveIcon>,
) => JSX.Element;
export type BadgeInfo = {
description: string;
icon: BadgeIcon;
id: string;
isValid: (payload: BadgePayload) => boolean;
title: string;
};
// TODO: Add other badges in
export type BadgePayload = {
maxResumeUpvoteCount: number;
reviewedResumesCount: number;
topUpvotedCommentCount: number;
};
const TIER_THREE = 20;
const TIER_TWO = 10;
const TIER_ONE = 5;
export const RESUME_USER_BADGES: Array<BadgeInfo> = [
{
description: `Reviewed over ${TIER_THREE} resumes`,
icon: ResumeBadgeSuperheroIcon,
id: 'Superhero',
isValid: (payload: BadgePayload) =>
payload.reviewedResumesCount >= TIER_THREE,
title: 'True saviour of the people',
},
{
description: `Reviewed over ${TIER_TWO} resumes`,
icon: ResumeBadgeDetectiveIcon,
id: 'Detective',
isValid: (payload: BadgePayload) =>
payload.reviewedResumesCount >= TIER_TWO &&
payload.reviewedResumesCount < TIER_THREE,
title: 'Keen eye for details like a private eye',
},
{
description: `Reviewed over ${TIER_ONE} resumes`,
icon: ResumeBadgeEagleIcon,
id: 'Eagle',
isValid: (payload: BadgePayload) =>
payload.reviewedResumesCount >= TIER_ONE &&
payload.reviewedResumesCount < TIER_TWO,
title: 'As sharp as an eagle',
},
{
description: `${TIER_THREE} upvotes on a resume`,
icon: ResumeBadgeRocketIcon,
id: 'Rocket',
isValid: (payload: BadgePayload) =>
payload.maxResumeUpvoteCount >= TIER_THREE,
title: 'To the moon!',
},
{
description: `${TIER_TWO} upvotes on a resume`,
icon: ResumeBadgeTreasureIcon,
id: 'Treasure',
isValid: (payload: BadgePayload) =>
payload.maxResumeUpvoteCount >= TIER_TWO &&
payload.maxResumeUpvoteCount < TIER_THREE,
title: "Can't get enough of this!",
},
{
description: `${TIER_ONE} upvotes on a resume`,
icon: ResumeBadgeCoolIcon,
id: 'Cool',
isValid: (payload: BadgePayload) =>
payload.maxResumeUpvoteCount >= TIER_ONE &&
payload.maxResumeUpvoteCount < TIER_TWO,
title: 'Like the cool kids',
},
{
description: `${TIER_THREE} top upvoted comment`,
icon: ResumeBadgeSageIcon,
id: 'Sage',
isValid: (payload: BadgePayload) =>
payload.topUpvotedCommentCount >= TIER_THREE,
title: 'I am wisdom',
},
{
description: `${TIER_TWO} top upvoted comment`,
icon: ResumeBadgeBookIcon,
id: 'Book',
isValid: (payload: BadgePayload) =>
payload.topUpvotedCommentCount >= TIER_TWO &&
payload.topUpvotedCommentCount < TIER_THREE,
title: 'The walking encyclopaedia',
},
{
description: `${TIER_ONE} top upvoted comment`,
icon: ResumeBadgeOwlIcon,
id: 'Owl',
isValid: (payload: BadgePayload) =>
payload.topUpvotedCommentCount >= TIER_ONE &&
payload.topUpvotedCommentCount < TIER_TWO,
title: 'Wise as an owl',
},
];

@ -1,12 +1,22 @@
import clsx from 'clsx';
type Props = Readonly<{ type Props = Readonly<{
isSelected: boolean;
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
title: string; title: string;
}>; }>;
export default function ResumeFilterPill({ title, onClick }: Props) { export default function ResumeFilterPill({
title,
onClick,
isSelected,
}: Props) {
return ( return (
<button <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" type="button"
onClick={onClick}> onClick={onClick}>
{title} {title}

@ -1,17 +1,14 @@
import formatDistanceToNow from 'date-fns/formatDistanceToNow'; import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Link from 'next/link'; import Link from 'next/link';
import { useSession } from 'next-auth/react';
import type { UrlObject } from 'url'; import type { UrlObject } from 'url';
import { ChevronRightIcon } from '@heroicons/react/20/solid';
import { import {
AcademicCapIcon, AcademicCapIcon,
BriefcaseIcon, BriefcaseIcon,
ChevronRightIcon,
StarIcon as ColouredStarIcon, StarIcon as ColouredStarIcon,
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline'; import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
import { trpc } from '~/utils/trpc';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
type Props = Readonly<{ type Props = Readonly<{
@ -19,16 +16,7 @@ type Props = Readonly<{
resumeInfo: Resume; resumeInfo: Resume;
}>; }>;
export default function BrowseListItem({ href, resumeInfo }: Props) { export default function ResumeListItem({ 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 },
]);
return ( return (
<Link href={href}> <Link href={href}>
<div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100"> <div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100">
@ -53,22 +41,27 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
<div className="mt-4 flex justify-start text-xs text-slate-500"> <div className="mt-4 flex justify-start text-xs text-slate-500">
<div className="flex gap-2 pr-4"> <div className="flex gap-2 pr-4">
<ChatBubbleLeftIcon className="w-4" /> <ChatBubbleLeftIcon className="w-4" />
{resumeInfo.numComments} comments {`${resumeInfo.numComments} comment${
resumeInfo.numComments === 1 ? '' : 's'
}`}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{isStarredQuery.data && sessionData?.user ? ( {resumeInfo.isStarredByUser ? (
<ColouredStarIcon className="w-4 text-yellow-400" /> <ColouredStarIcon className="w-4 text-yellow-400" />
) : ( ) : (
<StarIcon className="w-4" /> <StarIcon className="w-4" />
)} )}
{resumeInfo.numStars} stars {`${resumeInfo.numStars} star${
resumeInfo.numStars === 1 ? '' : 's'
}`}
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-3 self-center text-sm text-slate-500"> <div className="col-span-3 self-center text-sm text-slate-500">
<div> <div>
Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '} {`Uploaded ${formatDistanceToNow(resumeInfo.createdAt, {
{resumeInfo.user} addSuffix: true,
})} by ${resumeInfo.user}`}
</div> </div>
<div className="mt-2 text-slate-400">{resumeInfo.location}</div> <div className="mt-2 text-slate-400">{resumeInfo.location}</div>
</div> </div>

@ -1,6 +1,6 @@
import { Spinner } from '@tih/ui'; import { Spinner } from '@tih/ui';
import ResumseListItem from './ResumeListItem'; import ResumeListItem from './ResumeListItem';
import type { Resume } from '~/types/resume'; import type { Resume } from '~/types/resume';
@ -22,7 +22,7 @@ export default function ResumeListItems({ isLoading, resumes }: Props) {
<ul role="list"> <ul role="list">
{resumes.map((resumeObj: Resume) => ( {resumes.map((resumeObj: Resume) => (
<li key={resumeObj.id}> <li key={resumeObj.id}>
<ResumseListItem <ResumeListItem
href={`/resumes/${resumeObj.id}`} href={`/resumes/${resumeObj.id}`}
resumeInfo={resumeObj} 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,151 @@
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',
},
];
export const isInitialFilterState = (filters: FilterState) =>
Object.keys(filters).every((filter) => {
if (!['experience', 'location', 'role'].includes(filter)) {
return true;
}
return INITIAL_FILTER_STATE[filter as FilterId].every((value) =>
filters[filter as FilterId].includes(value),
);
});

@ -1,16 +1,19 @@
import { import clsx from 'clsx';
ArrowDownCircleIcon, import { useState } from 'react';
ArrowUpCircleIcon, import { ChevronUpIcon } from '@heroicons/react/20/solid';
} from '@heroicons/react/20/solid';
import { FaceSmileIcon } from '@heroicons/react/24/outline'; import { FaceSmileIcon } from '@heroicons/react/24/outline';
import ResumeCommentEditForm from './comment/ResumeCommentEditForm';
import ResumeCommentReplyForm from './comment/ResumeCommentReplyForm';
import ResumeCommentVoteButtons from './comment/ResumeCommentVoteButtons';
import ResumeUserBadges from '../badges/ResumeUserBadges';
import ResumeExpandableText from '../shared/ResumeExpandableText'; import ResumeExpandableText from '../shared/ResumeExpandableText';
import type { ResumeComment } from '~/types/resume-comments'; import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentListItemProps = { type ResumeCommentListItemProps = {
comment: ResumeComment; comment: ResumeComment;
userId?: string; userId: string | undefined;
}; };
export default function ResumeCommentListItem({ export default function ResumeCommentListItem({
@ -18,34 +21,57 @@ export default function ResumeCommentListItem({
userId, userId,
}: ResumeCommentListItemProps) { }: ResumeCommentListItemProps) {
const isCommentOwner = userId === comment.user.userId; const isCommentOwner = userId === comment.user.userId;
const [isEditingComment, setIsEditingComment] = useState(false);
const [isReplyingComment, setIsReplyingComment] = useState(false);
const [showReplies, setShowReplies] = useState(true);
return ( return (
<div className="border-primary-300 w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md"> <div
<div className="flex w-full flex-row space-x-2 p-1 align-top"> className={clsx(
'min-w-fit rounded-md bg-white ',
!comment.parentId &&
'w-11/12 border-2 border-indigo-300 p-2 drop-shadow-md',
)}>
<div className="flex flex-row space-x-2 p-1 align-top">
{/* Image Icon */}
{comment.user.image ? ( {comment.user.image ? (
<img <img
alt={comment.user.name ?? 'Reviewer'} alt={comment.user.name ?? 'Reviewer'}
className="mt-1 h-8 w-8 rounded-full" className={clsx(
'mt-1 rounded-full',
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
)}
src={comment.user.image!} src={comment.user.image!}
/> />
) : ( ) : (
<FaceSmileIcon className="h-8 w-8 rounded-full" /> <FaceSmileIcon
className={clsx(
'mt-1 rounded-full',
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
)}
/>
)} )}
<div className="flex w-full flex-col space-y-1"> <div className="flex w-full flex-col space-y-1">
{/* Name and creation time */} {/* Name and creation time */}
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<div className="flex flex-row items-center space-x-1"> <div className="flex flex-row items-center space-x-1">
<div className="font-medium"> <p
className={clsx(
'font-medium text-black',
!!comment.parentId && 'text-sm',
)}>
{comment.user.name ?? 'Reviewer ABC'} {comment.user.name ?? 'Reviewer ABC'}
</div> </p>
<div className="text-primary-800 text-xs font-medium"> <p className="text-xs font-medium text-indigo-800">
{isCommentOwner ? '(Me)' : ''} {isCommentOwner ? '(Me)' : ''}
</div> </p>
<ResumeUserBadges userId={comment.user.userId} />
</div> </div>
<div className="text-xs text-gray-600"> <div className="px-2 text-xs text-gray-600">
{comment.createdAt.toLocaleString('en-US', { {comment.createdAt.toLocaleString('en-US', {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short', timeStyle: 'short',
@ -54,22 +80,93 @@ export default function ResumeCommentListItem({
</div> </div>
{/* Description */} {/* Description */}
<ResumeExpandableText>{comment.description}</ResumeExpandableText> {isEditingComment ? (
<ResumeCommentEditForm
comment={comment}
setIsEditingComment={setIsEditingComment}
/>
) : (
<ResumeExpandableText
key={comment.description}
text={comment.description}
/>
)}
{/* Upvote and edit */} {/* Upvote and edit */}
<div className="flex flex-row space-x-1 pt-1 align-middle"> <div className="flex flex-row space-x-1 pt-1 align-middle">
{/* TODO: Implement upvote */} <ResumeCommentVoteButtons commentId={comment.id} userId={userId} />
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
<div className="text-xs">{comment.numVotes}</div> {/* Action buttons; only present for authenticated user when not editing/replying */}
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" /> {userId && !isEditingComment && !isReplyingComment && (
<>
{/* TODO: Implement edit */} {isCommentOwner && (
{isCommentOwner ? ( <button
<div className="text-primary-800 hover:text-primary-400 px-1 text-xs"> className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
Edit type="button"
</div> onClick={() => setIsEditingComment(true)}>
) : null} Edit
</button>
)}
{!comment.parentId && (
<button
className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
type="button"
onClick={() => setIsReplyingComment(true)}>
Reply
</button>
)}
</>
)}
</div> </div>
{/* Reply Form */}
{isReplyingComment && (
<ResumeCommentReplyForm
parentId={comment.id}
resumeId={comment.resumeId}
section={comment.section}
setIsReplyingComment={setIsReplyingComment}
/>
)}
{/* Replies */}
{comment.children.length > 0 && (
<div className="min-w-fit space-y-1 pt-2">
<button
className="flex items-center space-x-1 rounded-md text-xs font-medium text-indigo-800 hover:text-indigo-300"
type="button"
onClick={() => setShowReplies(!showReplies)}>
<ChevronUpIcon
className={clsx(
'h-5 w-5 ',
!showReplies && 'rotate-180 transform',
)}
/>
<span>{showReplies ? 'Hide replies' : 'Show replies'}</span>
</button>
{showReplies && (
<div className="flex flex-row">
<div className="relative flex flex-col px-2 py-2">
<div className="flex-grow border-r border-gray-300" />
</div>
<div className="flex flex-col space-y-1">
{comment.children.map((child) => {
return (
<ResumeCommentListItem
key={child.id}
comment={child}
userId={userId}
/>
);
})}
</div>
</div>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

@ -47,6 +47,9 @@ export default function ResumeCommentsForm({
onSuccess: () => { onSuccess: () => {
// New Comment added, invalidate query to trigger refetch // New Comment added, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']); trpcContext.invalidateQueries(['resumes.comments.list']);
trpcContext.invalidateQueries(['resumes.resume.findAll']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserStarred']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserCreated']);
}, },
}, },
); );

@ -1,6 +1,14 @@
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { useState } from 'react'; import {
import { Spinner, Tabs } from '@tih/ui'; 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 { Button } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -21,23 +29,26 @@ export default function ResumeCommentsList({
setShowCommentsForm, setShowCommentsForm,
}: ResumeCommentsListProps) { }: ResumeCommentsListProps) {
const { data: sessionData } = useSession(); 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 }], { 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,
};
});
setTabs(updatedTabs); const renderIcon = (section: ResumesSection) => {
}, const className = 'h-7 w-7';
}); 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 = () => { const renderButton = () => {
if (sessionData === null) { if (sessionData === null) {
@ -45,6 +56,7 @@ export default function ResumeCommentsList({
} }
return ( return (
<Button <Button
className="-mb-2"
display="block" display="block"
label="Add your review" label="Add your review"
variant="tertiary" variant="tertiary"
@ -57,28 +69,44 @@ export default function ResumeCommentsList({
<div className="space-y-3"> <div className="space-y-3">
{renderButton()} {renderButton()}
<Tabs {commentsQuery.isLoading ? (
label="comments"
tabs={tabs}
value={tab}
onChange={(value) => setTab(value)}
/>
{commentsQuery.isFetching ? (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
</div> </div>
) : ( ) : (
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-auto"> <div className="scrollbar-hide m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pt-14 pb-6">
{(commentsQuery.data?.filter((c) => c.section === tab) ?? []).map( {RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
(comment) => ( const comments = commentsQuery.data
<ResumeCommentListItem ? commentsQuery.data.filter((comment: ResumeComment) => {
key={comment.id} return (comment.section as string) === value;
comment={comment} })
userId={sessionData?.user?.id} : [];
/> const commentCount = comments.length;
),
)} return (
<div key={value} className="mb-4 space-y-4">
<div className="flex flex-row items-center space-x-2 text-indigo-800">
{renderIcon(value)}
<div className="w-fit text-lg 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>
)} )}
</div> </div>

@ -0,0 +1,106 @@
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Button, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentEditFormProps = {
comment: ResumeComment;
setIsEditingComment: (value: boolean) => void;
};
type ICommentInput = {
description: string;
};
export default function ResumeCommentEditForm({
comment,
setIsEditingComment,
}: ResumeCommentEditFormProps) {
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']);
},
},
);
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 });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex-column mt-1 space-y-2">
<TextArea
{...(register('description', {
required: 'Comments cannot be empty!',
}),
{})}
defaultValue={comment.description}
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>
);
}

@ -0,0 +1,107 @@
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type { ResumesSection } from '@prisma/client';
import { Button, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type ResumeCommentEditFormProps = {
parentId: string;
resumeId: string;
section: ResumesSection;
setIsReplyingComment: (value: boolean) => void;
};
type IReplyInput = {
description: string;
};
export default function ResumeCommentReplyForm({
parentId,
setIsReplyingComment,
resumeId,
section,
}: ResumeCommentEditFormProps) {
const {
register,
handleSubmit,
setValue,
formState: { errors, isDirty },
reset,
} = useForm<IReplyInput>({
defaultValues: {
description: '',
},
});
const trpcContext = trpc.useContext();
const commentReplyMutation = trpc.useMutation('resumes.comments.user.reply', {
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
},
});
const onCancel = () => {
reset({ description: '' });
setIsReplyingComment(false);
};
const onSubmit: SubmitHandler<IReplyInput> = async (data) => {
return commentReplyMutation.mutate(
{
parentId,
resumeId,
section,
...data,
},
{
onSuccess: () => {
setIsReplyingComment(false);
},
},
);
};
const setFormValue = (value: string) => {
setValue('description', value.trim(), { shouldDirty: true });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex-column space-y-2 pt-2">
<TextArea
{...(register('description', {
required: 'Reply cannot be empty!',
}),
{})}
defaultValue=""
disabled={commentReplyMutation.isLoading}
errorMessage={errors.description?.message}
label=""
placeholder="Leave your reply here"
onChange={setFormValue}
/>
<div className="flex-row space-x-2">
<Button
disabled={commentReplyMutation.isLoading}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentReplyMutation.isLoading}
isLoading={commentReplyMutation.isLoading}
label="Confirm"
size="sm"
type="submit"
variant="primary"
/>
</div>
</div>
</form>
);
}

@ -0,0 +1,131 @@
import clsx from 'clsx';
import { useState } from 'react';
import {
ArrowDownCircleIcon,
ArrowUpCircleIcon,
} from '@heroicons/react/20/solid';
import { Vote } from '@prisma/client';
import { trpc } from '~/utils/trpc';
type ResumeCommentVoteButtonsProps = {
commentId: string;
userId: string | undefined;
};
export default function ResumeCommentVoteButtons({
commentId,
userId,
}: ResumeCommentVoteButtonsProps) {
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
const trpcContext = trpc.useContext();
// COMMENT VOTES
const commentVotesQuery = trpc.useQuery([
'resumes.comments.votes.list',
{ commentId },
]);
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']);
},
},
);
const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => {
setAnimation(true);
if (commentVotesQuery.data?.userVote?.value === value) {
return commentVotesDeleteMutation.mutate(
{
commentId,
},
{
onSettled: async () => setAnimation(false),
},
);
}
return commentVotesUpsertMutation.mutate(
{
commentId,
value,
},
{
onSettled: async () => setAnimation(false),
},
);
};
return (
<>
<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="flex min-w-[1rem] justify-center 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>
</>
);
}

@ -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 lg:px-2', 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 Link from 'next/link';
import { Button } from './Button';
import { Container } from './Container'; 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() { export function Hero() {
return ( 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"> <h1 className="font-display mx-auto max-w-4xl text-5xl font-medium tracking-tight text-slate-900 sm:text-7xl">
Resume review{' '} Resume review{' '}
<span className="relative whitespace-nowrap text-blue-600"> <span className="relative whitespace-nowrap text-indigo-500">
<svg <svg
aria-hidden="true" aria-hidden="true"
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-blue-300/70" className="absolute top-2/3 left-0 h-[0.58em] w-full fill-blue-300/70"
@ -33,41 +25,25 @@ export function Hero() {
</p> </p>
<div className="mt-10 flex justify-center gap-x-4"> <div className="mt-10 flex justify-center gap-x-4">
<Link href="/resumes/browse"> <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>
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"> <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 <svg
aria-hidden="true" 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" /> <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> </svg>
<span className="ml-3">Watch video</span> <span className="ml-3">Watch video</span>
</Button> </button>
</Link> </Link>
</div> </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> </Container>
); );
} }

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

@ -4,28 +4,27 @@ import { useEffect, useState } from 'react';
import { Tab } from '@headlessui/react'; import { Tab } from '@headlessui/react';
import { Container } from './Container'; import { Container } from './Container';
import backgroundImage from './images/background-features.jpg'; import resumeBrowse from './images/screenshots/resumes-browse.png';
import screenshotExpenses from './images/screenshots/expenses.png'; import resumeReview from './images/screenshots/resumes-review.png';
import screenshotPayroll from './images/screenshots/payroll.png'; import resumeSubmit from './images/screenshots/resumes-submit.png';
import screenshotVatReturns from './images/screenshots/vat-returns.png';
const features = [ const features = [
{ {
description: description:
'Browse the most popular reviewed resumes out there and see what you can learn', 'Browse the most popular reviewed resumes out there and see what you can learn',
image: screenshotPayroll, image: resumeBrowse,
title: 'Browse', title: 'Browse',
}, },
{ {
description: description:
'Upload your own resume easily to get feedback from people in industry.', 'Upload your own resume easily to get feedback from people in industry.',
image: screenshotExpenses, image: resumeSubmit,
title: 'Submit', title: 'Submit',
}, },
{ {
description: description:
'Pass the baton forward by reviewing resumes and bounce off ideas with other engineers out there.', 'Pass the baton forward by reviewing resumes and bounce off ideas with other engineers out there.',
image: screenshotVatReturns, image: resumeReview,
title: 'Review', title: 'Review',
}, },
]; ];
@ -36,7 +35,7 @@ export function PrimaryFeatures() {
useEffect(() => { useEffect(() => {
const lgMediaQuery = window.matchMedia('(min-width: 1024px)'); const lgMediaQuery = window.matchMedia('(min-width: 1024px)');
function onMediaQueryChange({ matches }) { function onMediaQueryChange({ matches }: { matches: boolean }) {
setTabOrientation(matches ? 'vertical' : 'horizontal'); setTabOrientation(matches ? 'vertical' : 'horizontal');
} }
@ -50,17 +49,8 @@ export function PrimaryFeatures() {
return ( return (
<section <section
aria-label="Features for running your books" className="relative overflow-hidden bg-gradient-to-r from-indigo-400 to-indigo-700 pt-20 pb-28 sm:py-32"
className="relative overflow-hidden bg-blue-600 pt-20 pb-28 sm:py-32"
id="features"> 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"> <Container className="relative">
<div className="max-w-2xl md:mx-auto md:text-center xl:max-w-none"> <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"> <h2 className="font-display text-3xl tracking-tight text-white sm:text-4xl md:text-5xl">
@ -73,7 +63,7 @@ export function PrimaryFeatures() {
vertical={tabOrientation === 'vertical'}> vertical={tabOrientation === 'vertical'}>
{({ selectedIndex }) => ( {({ selectedIndex }) => (
<> <>
<div className="-mx-4 flex overflow-x-auto pb-4 sm:mx-0 sm:overflow-visible sm:pb-0 lg:col-span-5"> <div className="-mx-4 flex overflow-x-auto pb-4 sm:mx-0 sm:overflow-visible sm:pb-0 lg:col-span-4">
<Tab.List className="relative z-10 flex gap-x-4 whitespace-nowrap px-4 sm:mx-auto sm:px-0 lg:mx-0 lg:block lg:gap-x-0 lg:gap-y-1 lg:whitespace-normal"> <Tab.List className="relative z-10 flex gap-x-4 whitespace-nowrap px-4 sm:mx-auto sm:px-0 lg:mx-0 lg:block lg:gap-x-0 lg:gap-y-1 lg:whitespace-normal">
{features.map((feature, featureIndex) => ( {features.map((feature, featureIndex) => (
<div <div
@ -109,7 +99,7 @@ export function PrimaryFeatures() {
))} ))}
</Tab.List> </Tab.List>
</div> </div>
<Tab.Panels className="lg:col-span-7"> <Tab.Panels className="lg:col-span-8">
{features.map((feature) => ( {features.map((feature) => (
<Tab.Panel key={feature.title} unmount={false}> <Tab.Panel key={feature.title} unmount={false}>
<div className="relative sm:px-6 lg:hidden"> <div className="relative sm:px-6 lg:hidden">

@ -7,6 +7,10 @@ import avatarImage3 from './images/avatars/avatar-3.png';
import avatarImage4 from './images/avatars/avatar-4.png'; import avatarImage4 from './images/avatars/avatar-4.png';
import avatarImage5 from './images/avatars/avatar-5.png'; import avatarImage5 from './images/avatars/avatar-5.png';
type QuoteProps = {
className: string;
};
const testimonials = [ const testimonials = [
{ {
columns: [ columns: [
@ -79,7 +83,7 @@ const testimonials = [
}, },
]; ];
function QuoteIcon(props) { function QuoteIcon(props: QuoteProps) {
return ( return (
<svg aria-hidden="true" height={78} width={105} {...props}> <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" /> <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" />
@ -90,15 +94,14 @@ function QuoteIcon(props) {
export function Testimonials() { export function Testimonials() {
return ( return (
<section <section
aria-label="What our customers are saying" className="bg-gradient-to-r from-indigo-700 to-indigo-400 py-20 sm:py-32"
className="bg-slate-50 py-20 sm:py-32"
id="testimonials"> id="testimonials">
<Container> <Container>
<div className="mx-auto max-w-2xl md:text-center"> <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. Loved by software engineers worldwide.
</h2> </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, We crowdsource ideas and feedback from across the world,
guaranteeing you for success in your job application. guaranteeing you for success in your job application.
</p> </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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

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

@ -1,12 +1,14 @@
import clsx from 'clsx';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
type Props = Readonly<{ type Props = Readonly<{
className?: string;
text: string; text: string;
}>; }>;
export default function ResumeSignInButton({ text }: Props) { export default function ResumeSignInButton({ text, className }: Props) {
return ( return (
<div className="flex justify-center pt-4"> <div className={clsx('flex justify-center pt-4', className)}>
<p> <p>
<a <a
className="text-primary-800 hover:text-primary-500" className="text-primary-800 hover:text-primary-500"

@ -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,628 @@
import type {
Company,
OffersAnalysis,
OffersBackground,
OffersCurrency,
OffersEducation,
OffersExperience,
OffersFullTime,
OffersIntern,
OffersOffer,
OffersProfile,
OffersReply,
OffersSpecificYoe,
User,
} from '@prisma/client';
import { JobType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
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 & {
experiences: Array<OffersExperience & { company: Company | null }>;
})
| null;
};
},
) => {
const { background, profileName } = offer.profile;
const analysisOfferDto: AnalysisOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
income: { baseCurrency: '', baseValue: -1, currency: '', value: -1 },
jobType: offer.jobType,
level: offer.offersFullTime?.level ?? '',
location: offer.location,
monthYearReceived: offer.monthYearReceived,
negotiationStrategy: offer.negotiationStrategy,
previousCompanies:
background?.experiences
?.filter((exp) => exp.company != null)
.map((exp) => exp.company?.name ?? '') ?? [],
profileName,
specialization:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.specialization ?? ''
: offer.offersIntern?.specialization ?? '',
title:
offer.jobType === JobType.FULLTIME
? offer.offersFullTime?.title ?? ''
: offer.offersIntern?.title ?? '',
totalYoe: background?.totalYoe ?? -1,
};
if (offer.offersFullTime?.totalCompensation) {
analysisOfferDto.income.value =
offer.offersFullTime.totalCompensation.value;
analysisOfferDto.income.currency =
offer.offersFullTime.totalCompensation.currency;
analysisOfferDto.income.baseValue =
offer.offersFullTime.totalCompensation.baseValue;
analysisOfferDto.income.baseCurrency =
offer.offersFullTime.totalCompensation.baseCurrency;
} else if (offer.offersIntern?.monthlySalary) {
analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value;
analysisOfferDto.income.currency =
offer.offersIntern.monthlySalary.currency;
analysisOfferDto.income.baseValue =
offer.offersIntern.monthlySalary.baseValue;
analysisOfferDto.income.baseCurrency =
offer.offersIntern.monthlySalary.baseCurrency;
} else {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found',
});
}
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 & {
experiences: Array<
OffersExperience & {
company: Company | null;
}
>;
})
| 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: {
baseCurrency: string;
baseValue: number;
currency: string;
id?: string;
value: number;
}) => {
const valuationDto: Valuation = {
baseCurrency: currency.baseCurrency,
baseValue: currency.baseValue,
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,
location: experience.location,
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({
baseCurrency: '',
baseValue: -1,
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;
};

@ -3,6 +3,7 @@ import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react'; import { SessionProvider } from 'next-auth/react';
import React from 'react'; import React from 'react';
import superjson from 'superjson'; import superjson from 'superjson';
import { ToastsProvider } from '@tih/ui';
import { httpBatchLink } from '@trpc/client/links/httpBatchLink'; import { httpBatchLink } from '@trpc/client/links/httpBatchLink';
import { loggerLink } from '@trpc/client/links/loggerLink'; import { loggerLink } from '@trpc/client/links/loggerLink';
import { withTRPC } from '@trpc/next'; import { withTRPC } from '@trpc/next';
@ -19,9 +20,11 @@ const MyApp: AppType<{ session: Session | null }> = ({
}) => { }) => {
return ( return (
<SessionProvider session={session}> <SessionProvider session={session}>
<AppShell> <ToastsProvider>
<Component {...pageProps} /> <AppShell>
</AppShell> <Component {...pageProps} />
</AppShell>
</ToastsProvider>
</SessionProvider> </SessionProvider>
); );
}; };

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

Loading…
Cancel
Save