Merge branch 'main' into hongpo/add-list-crud

pull/393/head
hpkoh 3 years ago
commit 025d4aa451

@ -15,6 +15,7 @@
"@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.11",
"@next-auth/prisma-adapter": "^1.0.4",
"@popperjs/core": "^2.11.6",
"@prisma/client": "^4.4.0",
"@supabase/supabase-js": "^1.35.7",
"@tih/ui": "*",
@ -30,8 +31,11 @@
"next-auth": "~4.10.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.36.1",
"react-pdf": "^5.7.2",
"react-popper": "^2.3.0",
"react-popper-tooltip": "^4.4.2",
"react-query": "^3.39.2",
"superjson": "^1.10.0",
"zod": "^3.18.0"

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

@ -5,43 +5,20 @@ export const emptyOption = '----';
// TODO: use enums
export const titleOptions = [
{
label: 'Software engineer',
value: 'Software engineer',
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend engineer',
value: 'Frontend engineer',
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend engineer',
value: 'Backend engineer',
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: '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',
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
];
@ -86,26 +63,26 @@ export const internshipCycleOptions = [
export const yearOptions = [
{
label: '2021',
value: '2021',
value: 2021,
},
{
label: '2022',
value: '2022',
value: 2022,
},
{
label: '2023',
value: '2023',
value: 2023,
},
{
label: '2024',
value: '2024',
value: 2024,
},
];
export const educationLevelOptions = Object.entries(
EducationBackgroundType,
).map(([key, value]) => ({
label: key,
).map(([, value]) => ({
label: value,
value,
}));
@ -118,14 +95,45 @@ export const educationFieldOptions = [
label: 'Information Security',
value: 'Information Security',
},
{
label: 'Information Systems',
value: 'Information Systems',
},
{
label: 'Business Analytics',
value: 'Business Analytics',
},
{
label: 'Data Science and Analytics',
value: 'Data Science and Analytics',
},
];
export enum FieldError {
NonNegativeNumber = 'Please fill in a non-negative number in this field.',
Number = 'Please fill in a number in this field.',
Required = 'Please fill in this field.',
NON_NEGATIVE_NUMBER = 'Please fill in a non-negative number in this field.',
NUMBER = 'Please fill in a number in this field.',
REQUIRED = 'Please fill in this field.',
}
export const OVERALL_TAB = 'Overall';
export enum ProfileDetailTab {
ANALYSIS = 'Offer Engine Analysis',
BACKGROUND = 'Background',
OFFERS = 'Offers',
}
export const profileDetailTabs = [
{
label: ProfileDetailTab.OFFERS,
value: ProfileDetailTab.OFFERS,
},
{
label: ProfileDetailTab.BACKGROUND,
value: ProfileDetailTab.BACKGROUND,
},
{
label: ProfileDetailTab.ANALYSIS,
value: ProfileDetailTab.ANALYSIS,
},
];

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

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

@ -0,0 +1,126 @@
import { useEffect } from 'react';
import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
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) {
if (tab === OVERALL_TAB) {
return (
<p className="m-10">
You are the first to submit an offer for your job title and YOE! Check
back later when there are more submissions.
</p>
);
}
return (
<p className="m-10">
You are the first to submit an offer for this company, job title and
YOE! Check back later when there are more submissions.
</p>
);
}
return (
<>
<OfferPercentileAnalysisText
companyName={offer.company.name}
offerAnalysis={offerAnalysis}
tab={tab}
/>
<p className="mt-5">Here are some of the top offers relevant to you:</p>
{offerAnalysis.topPercentileOffers.map((topPercentileOffer) => (
<OfferProfileCard
key={topPercentileOffer.id}
offerProfile={topPercentileOffer}
/>
))}
</>
);
}
type OfferAnalysisProps = Readonly<{
allAnalysis?: ProfileAnalysis | null;
isError: boolean;
isLoading: boolean;
}>;
export default function OfferAnalysis({
allAnalysis,
isError,
isLoading,
}: OfferAnalysisProps) {
const [tab, setTab] = useState(OVERALL_TAB);
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]);
const tabOptions = [
{
label: OVERALL_TAB,
value: OVERALL_TAB,
},
{
label: allAnalysis?.overallHighestOffer.company.name || '',
value: allAnalysis?.overallHighestOffer.company.id || '',
},
];
return (
analysis && (
<div>
{isError && (
<p className="m-10 text-center">
An error occurred while generating profile analysis.
</p>
)}
{isLoading && <Spinner className="m-10" display="block" size="lg" />}
{!isError && !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,29 @@
import { OVERALL_TAB } from '../constants';
import type { Analysis } from '~/types/offers';
type OfferPercentileAnalysisTextProps = Readonly<{
companyName: string;
offerAnalysis: Analysis;
tab: string;
}>;
export default function OfferPercentileAnalysisText({
tab,
companyName,
offerAnalysis: { noOfOffers, percentile },
}: OfferPercentileAnalysisTextProps) {
return tab === OVERALL_TAB ? (
<p>
Your highest offer is from <b>{companyName}</b>, which is{' '}
<b>{percentile.toFixed(1)}</b> percentile out of <b>{noOfOffers}</b>{' '}
offers received for the same job title and YOE(±1) in the last year.
</p>
) : (
<p>
Your offer from <b>{companyName}</b> is <b>{percentile.toFixed(1)}</b>{' '}
percentile out of <b>{noOfOffers}</b> offers received in {companyName} for
the same job title and YOE(±1) in the last year.
</p>
);
}

@ -0,0 +1,74 @@
import {
BuildingOffice2Icon,
CalendarDaysIcon,
} from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
import ProfilePhotoHolder from '../profile/ProfilePhotoHolder';
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 bg-white p-4 px-8 shadow-md">
<div className="flex items-center gap-x-5">
<div>
<ProfilePhotoHolder size="sm" />
</div>
<div className="col-span-10">
<p className="font-bold">{profileName}</p>
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>{previousCompanies[0]}</span>
</div>
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span>{totalYoe}</span>
</div>
</div>
</div>
<HorizontalDivider />
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<p className="font-bold">{title}</p>
<p>
Company: {company.name}, {location}
</p>
<p>Level: {level}</p>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl">
{jobType === JobType.FULLTIME
? `${convertMoneyToString(income)} / year`
: `${convertMoneyToString(income)} / month`}
</p>
</div>
</div>
</div>
);
}

@ -1,13 +1,30 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import { setTimeout } from 'timers';
import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline';
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 OffersProfileSave({
profileId,
token,
}: OfferProfileSaveProps) {
const [linkCopied, setLinkCopied] = useState(false);
const [isSaving, setSaving] = useState(false);
const [isSaved, setSaved] = useState(false);
const router = useRouter();
const saveProfile = () => {
setSaving(true);
setTimeout(() => {
@ -27,13 +44,13 @@ export default function OfferProfileSave() {
To keep you offer profile strictly anonymous, only people who have the
link below can edit it.
</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">
<TextInput
disabled={true}
isLabelHidden={true}
label="Edit link"
value="link.myprofile-auto-generate..."
value={getProfileLink(profileId, token)}
/>
</div>
<Button
@ -41,10 +58,12 @@ export default function OfferProfileSave() {
isLabelHidden={true}
label="Copy"
variant="primary"
onClick={() => setLinkCopied(true)}
onClick={() => {
copyProfileLink(profileId, token), setLinkCopied(true);
}}
/>
</div>
<div className="mb-5">
<div className="mb-20">
{linkCopied && (
<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">
If you do not want to keep the edit link, you can opt to save this
profile under your user accont. It will still only be editable by you.
profile under your user account. It will still only be editable by
you.
</p>
<div className="mb-20">
<Button
disabled={isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon}
isLoading={isSaving}
label="Save to user profile"
label={isSaved ? 'Saved to user profile' : 'Save to user profile'}
variant="primary"
onClick={saveProfile}
/>
</div>
<div className="mb-10">
<Button icon={EyeIcon} label="View your profile" variant="special" />
<div>
<Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div>
</div>
</div>

@ -0,0 +1,263 @@
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 { JobType } from '@prisma/client';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import OffersProfileSave from '~/components/offers/offersSubmission/OffersProfileSave';
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 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 OfferAnalysis from '../offerAnalysis/OfferAnalysis';
import type {
CreateOfferProfileResponse,
ProfileAnalysis,
} 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 [analysis, setAnalysis] = useState<ProfileAnalysis | null>(null);
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 generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
onSuccess(data) {
setAnalysis(data);
},
},
);
const formSteps: Array<FormStep> = [
{
component: (
<OfferDetailsForm
key={0}
defaultJobType={initialOfferProfileValues.offers[0].jobType}
/>
),
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: (
<OfferAnalysis
key={2}
allAnalysis={analysis}
isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
/>
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: (
<OffersProfileSave
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 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>
);
}

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

@ -1,60 +1,64 @@
import { useEffect, useState } from 'react';
import type { FieldValues, UseFieldArrayReturn } from 'react-hook-form';
import type {
FieldValues,
UseFieldArrayRemove,
UseFieldArrayReturn,
} from 'react-hook-form';
import { useWatch } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client';
import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import {
defaultFullTimeOfferValues,
defaultInternshipOfferValues,
} from '~/pages/offers/submit';
import FormMonthYearPicker from './components/FormMonthYearPicker';
import FormSelect from './components/FormSelect';
import FormTextArea from './components/FormTextArea';
import FormTextInput from './components/FormTextInput';
} from '../OffersSubmissionForm';
import {
companyOptions,
emptyOption,
FieldError,
internshipCycleOptions,
locationOptions,
titleOptions,
yearOptions,
} from '../constants';
import type {
FullTimeOfferDetailsFormData,
InternshipOfferDetailsFormData,
} from '../types';
import { JobTypeLabel } from '../types';
import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
} from '../../constants';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormSelect from '../../forms/FormSelect';
import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../../forms/FormTextInput';
import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../../types';
import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{
index: number;
setDialogOpen: (isOpen: boolean) => void;
remove: UseFieldArrayRemove;
}>;
function FullTimeOfferDetailsForm({
index,
setDialogOpen,
remove,
}: FullTimeOfferDetailsFormProps) {
const { register, formState, setValue } = useFormContext<{
offers: Array<FullTimeOfferDetailsFormData>;
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
const watchCurrency = useWatch({
name: `offers.${index}.job.totalCompensation.currency`,
name: `offers.${index}.offersFullTime.totalCompensation.currency`,
});
useEffect(() => {
setValue(`offers.${index}.job.base.currency`, watchCurrency);
setValue(`offers.${index}.job.bonus.currency`, watchCurrency);
setValue(`offers.${index}.job.stocks.currency`, watchCurrency);
setValue(
`offers.${index}.offersFullTime.baseSalary.currency`,
watchCurrency,
);
setValue(`offers.${index}.offersFullTime.bonus.currency`, watchCurrency);
setValue(`offers.${index}.offersFullTime.stocks.currency`, watchCurrency);
}, [watchCurrency, index, setValue]);
return (
@ -62,48 +66,44 @@ function FullTimeOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
errorMessage={offerFields?.offersFullTime?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
required: FieldError.Required,
{...register(`offers.${index}.offersFullTime.title`, {
required: FieldError.REQUIRED,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
errorMessage={offerFields?.offersFullTime?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
required: FieldError.Required,
{...register(`offers.${index}.offersFullTime.specialization`, {
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<div className="mb-5 flex grid grid-cols-2 space-x-3">
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
}
/>
</div>
<FormTextInput
errorMessage={offerFields?.job?.level?.message}
errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.job.level`, {
required: FieldError.Required,
{...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
@ -112,7 +112,7 @@ function FullTimeOfferDetailsForm({
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.location`, {
required: FieldError.Required,
required: FieldError.REQUIRED,
})}
/>
<FormMonthYearPicker
@ -120,7 +120,7 @@ function FullTimeOfferDetailsForm({
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required,
required: FieldError.REQUIRED,
})}
/>
</div>
@ -132,24 +132,32 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.totalCompensation.currency`, {
required: FieldError.Required,
})}
{...register(
`offers.${index}.offersFullTime.totalCompensation.currency`,
{
required: FieldError.REQUIRED,
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.totalCompensation?.value?.message}
errorMessage={
offerFields?.offersFullTime?.totalCompensation?.value?.message
}
label="Total Compensation (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.totalCompensation.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
{...register(
`offers.${index}.offersFullTime.totalCompensation.value`,
{
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true,
},
)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -160,22 +168,25 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.base.currency`, {
required: FieldError.Required,
})}
{...register(
`offers.${index}.offersFullTime.baseSalary.currency`,
{
required: FieldError.REQUIRED,
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.base?.value?.message}
errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message}
label="Base Salary (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.base.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
{...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
@ -186,22 +197,22 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.bonus.currency`, {
required: FieldError.Required,
{...register(`offers.${index}.offersFullTime.bonus.currency`, {
required: FieldError.REQUIRED,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.bonus?.value?.message}
errorMessage={offerFields?.offersFullTime?.bonus?.value?.message}
label="Bonus (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.bonus.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
{...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
@ -214,22 +225,22 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.stocks.currency`, {
required: FieldError.Required,
{...register(`offers.${index}.offersFullTime.stocks.currency`, {
required: FieldError.REQUIRED,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.stocks?.value?.message}
errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
label="Stocks (Annual)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.stocks.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
{...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
@ -254,7 +265,7 @@ function FullTimeOfferDetailsForm({
icon={TrashIcon}
label="Delete"
variant="secondary"
onClick={() => setDialogOpen(true)}
onClick={() => remove(index)}
/>
)}
</div>
@ -264,15 +275,15 @@ function FullTimeOfferDetailsForm({
type InternshipOfferDetailsFormProps = Readonly<{
index: number;
setDialogOpen: (isOpen: boolean) => void;
remove: UseFieldArrayRemove;
}>;
function InternshipOfferDetailsForm({
index,
setDialogOpen,
remove,
}: InternshipOfferDetailsFormProps) {
const { register, formState } = useFormContext<{
offers: Array<InternshipOfferDetailsFormData>;
const { register, formState, setValue } = useFormContext<{
offers: Array<OfferFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
@ -282,39 +293,35 @@ function InternshipOfferDetailsForm({
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
errorMessage={offerFields?.offersIntern?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
{...register(`offers.${index}.offersIntern.title`, {
minLength: 1,
required: FieldError.Required,
required: FieldError.REQUIRED,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
errorMessage={offerFields?.offersIntern?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
{...register(`offers.${index}.offersIntern.specialization`, {
minLength: 1,
required: FieldError.Required,
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value)
}
/>
</div>
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
@ -323,31 +330,32 @@ function InternshipOfferDetailsForm({
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.location`, {
required: FieldError.Required,
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.internshipCycle?.message}
errorMessage={offerFields?.offersIntern?.internshipCycle?.message}
label="Internship Cycle"
options={internshipCycleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.internshipCycle`, {
required: FieldError.Required,
{...register(`offers.${index}.offersIntern.internshipCycle`, {
required: FieldError.REQUIRED,
})}
/>
<FormSelect
display="block"
errorMessage={offerFields?.job?.startYear?.message}
errorMessage={offerFields?.offersIntern?.startYear?.message}
label="Internship Year"
options={yearOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.startYear`, {
required: FieldError.Required,
{...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
</div>
@ -357,7 +365,7 @@ function InternshipOfferDetailsForm({
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required,
required: FieldError.REQUIRED,
})}
/>
</div>
@ -369,22 +377,27 @@ function InternshipOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.monthlySalary.currency`, {
required: FieldError.Required,
})}
{...register(
`offers.${index}.offersIntern.monthlySalary.currency`,
{
required: FieldError.REQUIRED,
},
)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.monthlySalary?.value?.message}
errorMessage={
offerFields?.offersIntern?.monthlySalary?.value?.message
}
label="Salary (Monthly)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
{...register(`offers.${index}.offersIntern.monthlySalary.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
@ -410,7 +423,7 @@ function InternshipOfferDetailsForm({
label="Delete"
variant="secondary"
onClick={() => {
setDialogOpen(true);
remove(index);
}}
/>
)}
@ -429,52 +442,17 @@ function OfferDetailsFormArray({
jobType,
}: OfferDetailsFormArrayProps) {
const { append, remove, fields } = fieldArrayValues;
const [isDialogOpen, setDialogOpen] = useState(false);
return (
<div>
{fields.map((item, index) => {
return (
<div key={item.id}>
{jobType === JobType.FullTime ? (
<FullTimeOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
{jobType === JobType.FULLTIME ? (
<FullTimeOfferDetailsForm index={index} remove={remove} />
) : (
<InternshipOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
<InternshipOfferDetailsForm index={index} remove={remove} />
)}
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="OK"
variant="primary"
onClick={() => {
remove(index);
setDialogOpen(false);
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setDialogOpen(false)}
/>
}
title="Remove this offer"
onClose={() => setDialogOpen(false)}>
<p>
Are you sure you want to remove this offer? This action cannot
be reversed.
</p>
</Dialog>
</div>
);
})}
@ -486,7 +464,7 @@ function OfferDetailsFormArray({
variant="tertiary"
onClick={() =>
append(
jobType === JobType.FullTime
jobType === JobType.FULLTIME
? defaultFullTimeOfferValues
: defaultInternshipOfferValues,
)
@ -496,27 +474,32 @@ function OfferDetailsFormArray({
);
}
export default function OfferDetailsForm() {
const [jobType, setJobType] = useState(JobType.FullTime);
type OfferDetailsFormProps = Readonly<{
defaultJobType?: JobType;
}>;
export default function OfferDetailsForm({
defaultJobType = JobType.FULLTIME,
}: OfferDetailsFormProps) {
const [jobType, setJobType] = useState(defaultJobType);
const [isDialogOpen, setDialogOpen] = useState(false);
const { control } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' });
const { append, remove } = fieldArrayValues;
const toggleJobType = () => {
fieldArrayValues.remove();
if (jobType === JobType.FullTime) {
setJobType(JobType.Internship);
fieldArrayValues.append(defaultInternshipOfferValues);
remove();
if (jobType === JobType.FULLTIME) {
setJobType(JobType.INTERN);
append(defaultInternshipOfferValues);
} else {
setJobType(JobType.FullTime);
fieldArrayValues.append(defaultFullTimeOfferValues);
setJobType(JobType.FULLTIME);
append(defaultFullTimeOfferValues);
}
};
const switchJobTypeLabel = () =>
jobType === JobType.FullTime
? JobTypeLabel.INTERNSHIP
: JobTypeLabel.FULLTIME;
jobType === JobType.FULLTIME ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
return (
<div className="mb-5">
@ -529,9 +512,9 @@ export default function OfferDetailsForm() {
display="block"
label={JobTypeLabel.FULLTIME}
size="md"
variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'}
variant={jobType === JobType.FULLTIME ? 'secondary' : 'tertiary'}
onClick={() => {
if (jobType === JobType.FullTime) {
if (jobType === JobType.FULLTIME) {
return;
}
setDialogOpen(true);
@ -541,11 +524,11 @@ export default function OfferDetailsForm() {
<div className="mx-5 w-1/3">
<Button
display="block"
label={JobTypeLabel.INTERNSHIP}
label={JobTypeLabel.INTERN}
size="md"
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'}
variant={jobType === JobType.INTERN ? 'secondary' : 'tertiary'}
onClick={() => {
if (jobType === JobType.Internship) {
if (jobType === JobType.INTERN) {
return;
}
setDialogOpen(true);

@ -3,18 +3,10 @@ import {
LightBulbIcon,
} from '@heroicons/react/24/outline';
import type { EducationBackgroundType } from '~/components/offers/types';
type EducationEntity = {
endDate?: string;
field?: string;
school?: string;
startDate?: string;
type?: EducationBackgroundType;
};
import type { EducationDisplayData } from '~/components/offers/types';
type Props = Readonly<{
education: EducationEntity;
education: EducationDisplayData;
}>;
export default function EducationCard({
@ -39,9 +31,7 @@ export default function EducationCard({
</div>
{(startDate || endDate) && (
<div className="font-light text-gray-400">
<p>{`${startDate ? startDate : 'N/A'} - ${
endDate ? endDate : 'N/A'
}`}</p>
<p>{`${startDate || 'N/A'} - ${endDate || 'N/A'}`}</p>
</div>
)}
</div>

@ -6,10 +6,10 @@ import {
} from '@heroicons/react/24/outline';
import { HorizontalDivider } from '@tih/ui';
import type { OfferEntity } from '~/components/offers/types';
import type { OfferDisplayData } from '~/components/offers/types';
type Props = Readonly<{
offer: OfferEntity;
offer: OfferDisplayData;
}>;
export default function OfferCard({
@ -58,52 +58,64 @@ export default function OfferCard({
}
function BottomSection() {
if (
!totalCompensation &&
!monthlySalary &&
!negotiationStrategy &&
!otherComment
) {
return null;
}
return (
<div className="px-8">
<div className="flex flex-col py-2">
<div className="flex flex-row">
<CurrencyDollarIcon className="mr-1 h-5" />
<p>
{totalCompensation
? `TC: ${totalCompensation}`
: `Monthly Salary: ${monthlySalary}`}
</p>
<>
<HorizontalDivider />
<div className="px-8">
<div className="flex flex-col py-2">
{totalCompensation ||
(monthlySalary && (
<div className="flex flex-row">
<CurrencyDollarIcon className="mr-1 h-5" />
<p>
{totalCompensation && `TC: ${totalCompensation}`}
{monthlySalary && `Monthly Salary: ${monthlySalary}`}
</p>
</div>
))}
{totalCompensation && (
<div className="ml-6 flex flex-row font-light text-gray-400">
<p>
Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '}
{bonus}
</p>
</div>
)}
</div>
{totalCompensation && (
<div className="ml-6 flex flex-row font-light text-gray-400">
<p>
Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '}
{bonus}
</p>
{negotiationStrategy && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<ScaleIcon className="h-5 w-5" />
<span className="overflow-wrap ml-2">
"{negotiationStrategy}"
</span>
</div>
</div>
)}
</div>
{negotiationStrategy && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<ScaleIcon className="h-5 w-5" />
<span className="overflow-wrap ml-2">
"{negotiationStrategy}"
</span>
{otherComment && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<ChatBubbleBottomCenterTextIcon className="h-5 w-5" />
<span className="overflow-wrap ml-2">"{otherComment}"</span>
</div>
</div>
</div>
)}
{otherComment && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<ChatBubbleBottomCenterTextIcon className="h-8 w-8" />
<span className="overflow-wrap ml-2">"{otherComment}"</span>
</div>
</div>
)}
</div>
)}
</div>
</>
);
}
return (
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md">
<UpperSection />
<HorizontalDivider />
<BottomSection />
</div>
);

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

@ -1,24 +1,155 @@
import { AcademicCapIcon, BriefcaseIcon } from '@heroicons/react/24/outline';
import { Spinner } from '@tih/ui';
import { useState } from 'react';
import {
AcademicCapIcon,
ArrowPathIcon,
BriefcaseIcon,
} from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
import EducationCard from '~/components/offers/profile/EducationCard';
import OfferCard from '~/components/offers/profile/OfferCard';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
import { EducationBackgroundType } from '~/components/offers/types';
import type {
BackgroundDisplayData,
OfferDisplayData,
} from '~/components/offers/types';
type ProfileHeaderProps = Readonly<{
background?: BackgroundCard;
import { trpc } from '~/utils/trpc';
import { ProfileDetailTab } from '../constants';
import OfferAnalysis from '../offerAnalysis/OfferAnalysis';
import { ProfileAnalysis } from '~/types/offers';
type ProfileOffersProps = Readonly<{
offers: Array<OfferDisplayData>;
}>;
function ProfileOffers({ offers }: ProfileOffersProps) {
if (offers.length !== 0) {
return (
<>
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>
);
}
return (
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">No offer is attached.</span>
</div>
);
}
type ProfileBackgroundProps = Readonly<{
background?: BackgroundDisplayData;
}>;
function ProfileBackground({ background }: ProfileBackgroundProps) {
if (!background?.experiences?.length && !background?.educations?.length) {
return (
<div className="mx-8 my-4">
<p>No background information available.</p>
</div>
);
}
return (
<>
{background?.experiences?.length > 0 && (
<>
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">Work Experience</span>
</div>
<OfferCard offer={background.experiences[0]} />
</>
)}
{background?.educations?.length > 0 && (
<>
<div className="mx-8 my-4 flex flex-row">
<AcademicCapIcon className="mr-1 h-5" />
<span className="font-bold">Education</span>
</div>
<EducationCard education={background.educations[0]} />
</>
)}
</>
);
}
type ProfileAnalysisProps = Readonly<{
analysis?: ProfileAnalysis;
isEditable: boolean;
profileId: string;
}>;
function ProfileAnalysis({
analysis: profileAnalysis,
profileId,
isEditable,
}: ProfileAnalysisProps) {
const [analysis, setAnalysis] = useState(profileAnalysis);
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
onSuccess(data) {
if (data) {
setAnalysis(data);
}
},
},
);
if (generateAnalysisMutation.isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="mx-8 my-4">
<OfferAnalysis allAnalysis={analysis} isError={false} isLoading={false} />
{isEditable && (
<div className="flex justify-end">
<Button
addonPosition="start"
icon={ArrowPathIcon}
label="Refresh Analysis"
variant="secondary"
onClick={() => generateAnalysisMutation.mutate({ profileId })}
/>
</div>
)}
</div>
);
}
type ProfileDetailsProps = Readonly<{
analysis?: ProfileAnalysis;
background?: BackgroundDisplayData;
isEditable: boolean;
isLoading: boolean;
offers: Array<OfferEntity>;
selectedTab: string;
offers: Array<OfferDisplayData>;
profileId: string;
selectedTab: ProfileDetailTab;
}>;
export default function ProfileDetails({
analysis,
background,
isLoading,
offers,
selectedTab,
}: ProfileHeaderProps) {
profileId,
isEditable,
}: ProfileDetailsProps) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
@ -26,54 +157,20 @@ export default function ProfileDetails({
</div>
);
}
if (selectedTab === 'offers') {
if (offers && offers.length !== 0) {
return (
<>
{[...offers].map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>
);
}
return (
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">No offer is attached.</span>
</div>
);
if (selectedTab === ProfileDetailTab.OFFERS) {
return <ProfileOffers offers={offers} />;
}
if (selectedTab === ProfileDetailTab.BACKGROUND) {
return <ProfileBackground background={background} />;
}
if (selectedTab === 'background') {
if (selectedTab === ProfileDetailTab.ANALYSIS) {
return (
<>
{background?.experiences && background?.experiences.length > 0 && (
<>
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">Work Experience</span>
</div>
<OfferCard offer={background?.experiences[0]} />
</>
)}
{background?.educations && background?.educations.length > 0 && (
<>
<div className="mx-8 my-4 flex flex-row">
<AcademicCapIcon className="mr-1 h-5" />
<span className="font-bold">Education</span>
</div>
<EducationCard
education={{
endDate: background.educations[0].endDate,
field: background.educations[0].field,
school: background.educations[0].school,
startDate: background.educations[0].startDate,
type: EducationBackgroundType.Bachelor,
}}
/>
</>
)}
</>
<ProfileAnalysis
analysis={analysis}
isEditable={isEditable}
profileId={profileId}
/>
);
}
return <div>Detail page for {selectedTab}</div>;
return null;
}

@ -1,6 +1,6 @@
import { useRouter } from 'next/router';
import { useState } from 'react';
import {
BookmarkSquareIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
PencilSquareIcon,
@ -9,15 +9,20 @@ import {
import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundCard } from '~/components/offers/types';
import type { BackgroundDisplayData } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link';
import type { ProfileDetailTab } from '../constants';
import { profileDetailTabs } from '../constants';
type ProfileHeaderProps = Readonly<{
background?: BackgroundCard;
background?: BackgroundDisplayData;
handleDelete: () => void;
isEditable: boolean;
isLoading: boolean;
selectedTab: string;
setSelectedTab: (tab: string) => void;
selectedTab: ProfileDetailTab;
setSelectedTab: (tab: ProfileDetailTab) => void;
}>;
export default function ProfileHeader({
@ -29,18 +34,24 @@ export default function ProfileHeader({
setSelectedTab,
}: ProfileHeaderProps) {
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() {
return (
<div className="space-x-2">
<Button
{/* <Button
disabled={isLoading}
icon={BookmarkSquareIcon}
isLabelHidden={true}
label="Save to user account"
size="md"
variant="tertiary"
/>
/> */}
<Button
disabled={isLoading}
icon={PencilSquareIcon}
@ -48,6 +59,7 @@ export default function ProfileHeader({
label="Edit"
size="md"
variant="tertiary"
onClick={handleEditClick}
/>
<Button
disabled={isLoading}
@ -99,6 +111,13 @@ export default function ProfileHeader({
</div>
);
}
if (!background) {
return null;
}
const { experiences, totalYoe, specificYoes, profileName } = background;
return (
<div className="h-40 bg-white p-4">
<div className="justify-left flex h-1/2">
@ -108,7 +127,7 @@ export default function ProfileHeader({
<div className="w-full">
<div className="justify-left flex flex-1">
<h2 className="flex w-4/5 text-2xl font-bold">
{background?.profileName ?? 'anonymous'}
{profileName ?? 'anonymous'}
</h2>
{isEditable && (
<div className="flex h-8 w-1/5 justify-end">
@ -116,20 +135,26 @@ export default function ProfileHeader({
</div>
)}
</div>
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>{`${background?.experiences[0].companyName ?? '-'} ${
background?.experiences[0].jobLevel
} ${background?.experiences[0].jobTitle}`}</span>
</div>
{(experiences[0]?.companyName ||
experiences[0]?.jobLevel ||
experiences[0]?.jobTitle) && (
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>
{`${experiences[0].companyName || ''} ${
experiences[0].jobLevel || ''
} ${experiences[0].jobTitle || ''}`}
</span>
</div>
)}
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span className="mr-4">{background?.totalYoe}</span>
{background?.specificYoes &&
background?.specificYoes.length > 0 &&
background?.specificYoes.map(({ domain, yoe }) => {
<span className="mr-4">{totalYoe}</span>
{specificYoes &&
specificYoes.length > 0 &&
specificYoes.map(({ domain, yoe }) => {
return (
<span
key={domain}
@ -143,20 +168,7 @@ export default function ProfileHeader({
<div className="mt-8">
<Tabs
label="Profile Detail Navigation"
tabs={[
{
label: 'Offers',
value: 'offers',
},
{
label: 'Background',
value: 'background',
},
{
label: 'Offer Engine Analysis',
value: 'offerEngineAnalysis',
},
]}
tabs={profileDetailTabs}
value={selectedTab}
onChange={(value) => setSelectedTab(value)}
/>

@ -1,6 +1,14 @@
export default function ProfilePhotoHolder() {
type ProfilePhotoHolderProps = {
size?: 'lg' | 'sm';
};
export default function ProfilePhotoHolder({
size = 'lg',
}: ProfilePhotoHolderProps) {
const sizeMap = { lg: '16', sm: '12' };
return (
<span className="inline-block h-16 w-16 overflow-hidden rounded-full bg-gray-100">
<span
className={`inline-block h-${sizeMap[size]} w-${sizeMap[size]} overflow-hidden rounded-full bg-gray-100`}>
<svg
className="h-full w-full text-gray-300"
fill="currentColor"

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

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

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

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

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

@ -1,13 +1,3 @@
export type OfferTableRowData = {
company: string;
date: string;
id: string;
profileId: string;
salary: number | undefined;
title: string;
yoe: number;
};
// eslint-disable-next-line no-shadow
export enum YOE_CATEGORY {
INTERN = 0,
@ -16,9 +6,47 @@ export enum YOE_CATEGORY {
SENIOR = 3,
}
export type PaginationType = {
currentPage: number;
numOfItems: number;
numOfPages: number;
totalItems: number;
export const OfferTableTabOptions = [
{
label: 'Fresh Grad (0-2 YOE)',
value: YOE_CATEGORY.ENTRY,
},
{
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',
};

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

@ -7,7 +7,7 @@ import {
import { TextInput } from '@tih/ui';
import ContributeQuestionDialog from './ContributeQuestionDialog';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
export type ContributeQuestionCardProps = Pick<
ContributeQuestionFormProps,

@ -2,9 +2,9 @@ import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '@tih/ui';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import ContributeQuestionForm from './ContributeQuestionForm';
import DiscardDraftDialog from './DiscardDraftDialog';
import type { ContributeQuestionFormProps } from './forms/ContributeQuestionForm';
import ContributeQuestionForm from './forms/ContributeQuestionForm';
export type ContributeQuestionDialogProps = Pick<
ContributeQuestionFormProps,
@ -60,14 +60,14 @@ export default function ContributeQuestionDialog({
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full">
<Dialog.Panel className="relative w-full max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8">
<div className="bg-white p-6 pt-5 sm:pb-4">
<div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900">
Question Draft
Contribute question
</Dialog.Title>
<div className="w-full">
<HorizontalDivider />

@ -1,13 +1,15 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { ArrowSmallRightIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Select } from '@tih/ui';
import {
COMPANIES,
LOCATIONS,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import useDefaultCompany from '~/utils/questions/useDefaultCompany';
import useDefaultLocation from '~/utils/questions/useDefaultLocation';
import type { FilterChoice } from './filter/FilterSection';
import CompanyTypeahead from './typeahead/CompanyTypeahead';
import LocationTypeahead from './typeahead/LocationTypeahead';
export type LandingQueryData = {
company: string;
@ -22,76 +24,109 @@ export type LandingComponentProps = {
export default function LandingComponent({
onLanded: handleLandingQuery,
}: LandingComponentProps) {
const [landingQueryData, setLandingQueryData] = useState<LandingQueryData>({
company: 'Google',
location: 'Singapore',
questionType: 'CODING',
});
const defaultCompany = useDefaultCompany();
const defaultLocation = useDefaultLocation();
const [company, setCompany] = useState<FilterChoice | undefined>(
defaultCompany,
);
const [location, setLocation] = useState<FilterChoice | undefined>(
defaultLocation,
);
const [questionType, setQuestionType] =
useState<QuestionsQuestionType>('CODING');
const handleChangeCompany = (company: string) => {
setLandingQueryData((prev) => ({ ...prev, company }));
const handleChangeCompany = (newCompany: FilterChoice) => {
setCompany(newCompany);
};
const handleChangeLocation = (location: string) => {
setLandingQueryData((prev) => ({ ...prev, location }));
const handleChangeLocation = (newLocation: FilterChoice) => {
setLocation(newLocation);
};
const handleChangeType = (questionType: QuestionsQuestionType) => {
setLandingQueryData((prev) => ({ ...prev, questionType }));
const handleChangeType = (newQuestionType: QuestionsQuestionType) => {
setQuestionType(newQuestionType);
};
return (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto bg-white">
<div className="pb-4"></div>
<div className="flex flex-1 flex-col justify-center gap-3">
<div className="flex items-center justify-center">
<img alt="app logo" className=" h-20 w-20" src="/logo.svg"></img>
<h1 className="text-primary-600 p-4 text-center text-5xl font-bold">
Tech Interview Question Bank
</h1>
</div>
<p className="mx-auto max-w-lg p-6 text-center text-xl text-black sm:max-w-3xl">
Get to know the latest SWE interview questions asked by top companies
</p>
useEffect(() => {
if (company === undefined) {
setCompany(defaultCompany);
}
}, [defaultCompany, company]);
useEffect(() => {
if (location === undefined) {
setLocation(defaultLocation);
}
}, [defaultLocation, location]);
<div className="mx-auto flex max-w-lg items-baseline gap-3 p-4 text-center text-xl text-black sm:max-w-3xl">
<p>Find</p>
<div className=" space-x-2">
return (
<main className="flex flex-1 flex-col items-center overflow-y-auto bg-white">
<div className="flex flex-1 flex-col items-start justify-center gap-12 px-4">
<header className="flex flex-col items-start gap-4">
<div className="flex items-center justify-center">
<h1 className="text-3xl font-semibold text-slate-900">
Tech Interview Question Bank
</h1>
<img alt="app logo" className="h-20 w-20" src="/logo.svg"></img>
</div>
<p className="mb-2 max-w-lg text-5xl font-semibold text-slate-900 sm:max-w-3xl">
Know the{' '}
<span className="text-primary-700">
latest SWE interview questions
</span>{' '}
asked by top companies.
</p>
</header>
<div className="flex flex-col items-start gap-3 text-xl font-semibold text-slate-900">
<p className="text-3xl">Find questions</p>
<div className="grid grid-cols-[auto_auto] items-baseline gap-x-4 gap-y-2">
<p className="text-slate-600">about</p>
<Select
isLabelHidden={true}
label="Type"
options={QUESTION_TYPES}
value={landingQueryData.questionType}
value={questionType}
onChange={(value) => {
handleChangeType(value.toUpperCase() as QuestionsQuestionType);
}}
/>
<p className="text-slate-600">from</p>
<CompanyTypeahead
isLabelHidden={true}
value={company}
onSelect={(value) => {
handleChangeCompany(value);
}}
/>
<p className="text-slate-600">in</p>
<LocationTypeahead
isLabelHidden={true}
value={location}
onSelect={(value) => {
handleChangeLocation(value);
}}
/>
</div>
<p>questions from</p>
<Select
isLabelHidden={true}
label="Company"
options={COMPANIES}
value={landingQueryData.company}
onChange={handleChangeCompany}
/>
<p>in</p>
<Select
isLabelHidden={true}
label="Location"
options={LOCATIONS}
value={landingQueryData.location}
onChange={handleChangeLocation}
/>
<Button
addonPosition="end"
icon={ArrowSmallRightIcon}
label="Go"
size="md"
variant="primary"
onClick={() => handleLandingQuery(landingQueryData)}></Button>
onClick={() => {
if (company !== undefined && location !== undefined) {
return handleLandingQuery({
company: company.value,
location: location.value,
questionType,
});
}
}}
/>
</div>
<div className="flex justify-center p-4">
<div className="flex justify-center">
<iframe
height={30}
src="https://ghbtns.com/github-btn.html?user=yangshun&amp;repo=tech-interview-handbook&amp;type=star&amp;count=true&amp;size=large"

@ -0,0 +1,80 @@
import type { ComponentProps } from 'react';
import { useMemo } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { Badge } from '@tih/ui';
import 'react-popper-tooltip/dist/styles.css';
type BadgeProps = ComponentProps<typeof Badge>;
export type QuestionAggregateBadgeProps = Omit<BadgeProps, 'label'> & {
statistics: Record<string, number>;
};
export default function QuestionAggregateBadge({
statistics,
...badgeProps
}: QuestionAggregateBadgeProps) {
const { getTooltipProps, setTooltipRef, setTriggerRef, visible } =
usePopperTooltip({
interactive: true,
placement: 'bottom-start',
trigger: ['focus', 'hover'],
});
const mostCommonStatistic = useMemo(
() =>
Object.entries(statistics).reduce(
(mostCommon, [key, value]) => {
if (value > mostCommon.value) {
return { key, value };
}
return mostCommon;
},
{ key: '', value: 0 },
),
[statistics],
);
const sortedStatistics = useMemo(
() =>
Object.entries(statistics)
.sort((a, b) => b[1] - a[1])
.map(([key, value]) => ({ key, value })),
[statistics],
);
const additionalStatisticCount = Object.keys(statistics).length - 1;
const label = useMemo(() => {
if (additionalStatisticCount === 0) {
return mostCommonStatistic.key;
}
return `${mostCommonStatistic.key} (+${additionalStatisticCount})`;
}, [mostCommonStatistic, additionalStatisticCount]);
return (
<>
<button ref={setTriggerRef} className="rounded-full" type="button">
<Badge label={label} {...badgeProps} />
</button>
{visible && (
<div ref={setTooltipRef} {...getTooltipProps()}>
<div className="flex flex-col gap-2 rounded-md bg-white p-2 shadow-md">
<ul>
{sortedStatistics.map(({ key, value }) => (
<li
key={key}
className="flex justify-between gap-x-4 rtl:flex-row-reverse">
<span className="flex text-start font-semibold">{key}</span>
<span className="float-end">{value}</span>
</li>
))}
</ul>
</div>
</div>
)}
</>
);
}

@ -4,29 +4,41 @@ import {
} from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui';
export type SortOption = {
export type SortOption<Value> = {
label: string;
value: string;
value: Value;
};
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = {
onFilterOptionsToggle: () => void;
onSortChange?: (sortValue: SortOptions[number]['value']) => void;
sortOptions: SortOptions;
sortValue: SortOptions[number]['value'];
type SortOrderProps<SortOrder> = {
onSortOrderChange?: (sortValue: SortOrder) => void;
sortOrderOptions: ReadonlyArray<SortOption<SortOrder>>;
sortOrderValue: SortOrder;
};
export default function QuestionSearchBar<
SortOptions extends Array<SortOption>,
>({
onSortChange,
sortOptions,
sortValue,
type SortTypeProps<SortType> = {
onSortTypeChange?: (sortType: SortType) => void;
sortTypeOptions: ReadonlyArray<SortOption<SortType>>;
sortTypeValue: SortType;
};
export type QuestionSearchBarProps<SortType, SortOrder> =
SortOrderProps<SortOrder> &
SortTypeProps<SortType> & {
onFilterOptionsToggle: () => void;
};
export default function QuestionSearchBar<SortType, SortOrder>({
onSortOrderChange,
sortOrderOptions,
sortOrderValue,
onSortTypeChange,
sortTypeOptions,
sortTypeValue,
onFilterOptionsToggle,
}: QuestionSearchBarProps<SortOptions>) {
}: QuestionSearchBarProps<SortType, SortOrder>) {
return (
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1 ">
<TextInput
isLabelHidden={true}
label="Search by content"
@ -35,27 +47,48 @@ export default function QuestionSearchBar<
startAddOnType="icon"
/>
</div>
<div className="flex items-center gap-2">
<span aria-hidden={true} className="align-middle text-sm font-medium">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={sortOptions}
value={sortValue}
onChange={onSortChange}
/>
</div>
<div className="lg:hidden">
<Button
addonPosition="start"
icon={AdjustmentsHorizontalIcon}
label="Filter options"
variant="tertiary"
onClick={onFilterOptionsToggle}
/>
<div className="flex items-end justify-end gap-4">
<div className="flex items-center gap-2">
<Select
display="inline"
label="Sort by"
options={sortTypeOptions}
value={sortTypeValue}
onChange={(value) => {
const chosenOption = sortTypeOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortTypeChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="flex items-center gap-2">
<Select
display="inline"
label="Order by"
options={sortOrderOptions}
value={sortOrderValue}
onChange={(value) => {
const chosenOption = sortOrderOptions.find(
(option) => String(option.value) === value,
);
if (chosenOption) {
onSortOrderChange?.(chosenOption.value);
}
}}
/>
</div>
<div className="lg:hidden">
<Button
addonPosition="start"
icon={AdjustmentsHorizontalIcon}
label="Filter options"
variant="tertiary"
onClick={onFilterOptionsToggle}
/>
</div>
</div>
</div>
);

@ -1,9 +1,10 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [
{ href: '/questions', name: 'My Lists' },
{ href: '/questions', name: 'My Questions' },
{ href: '/questions', name: 'History' },
{ href: '/questions/browse', name: 'Browse' },
{ href: '/questions/lists', name: 'My Lists' },
{ href: '/questions/my-questions', name: 'My Questions' },
{ href: '/questions/history', name: 'History' },
];
const config = {

@ -13,6 +13,7 @@ export type AnswerCardProps = {
commentCount?: number;
content: string;
createdAt: Date;
showHover?: boolean;
upvoteCount: number;
votingButtonsSize: VotingButtonsProps['size'];
};
@ -26,10 +27,14 @@ export default function AnswerCard({
commentCount,
votingButtonsSize,
upvoteCount,
showHover,
}: AnswerCardProps) {
const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
return (
<article className="flex gap-4 rounded-md border bg-white p-2">
<article
className={`flex gap-4 rounded-md border bg-white p-2 ${hoverClass}`}>
<VotingButtons
size={votingButtonsSize}
upvoteCount={upvoteCount}

@ -1,26 +0,0 @@
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: false;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
return (
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={false}
showVoteButtons={true}
/>
);
}

@ -4,11 +4,11 @@ import type { AnswerCardProps } from './AnswerCard';
import AnswerCard from './AnswerCard';
export type QuestionAnswerCardProps = Required<
Omit<AnswerCardProps, 'votingButtonsSize'>
Omit<AnswerCardProps, 'showHover' | 'votingButtonsSize'>
>;
function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) {
return <AnswerCard {...props} votingButtonsSize="sm" />;
return <AnswerCard {...props} showHover={true} votingButtonsSize="sm" />;
}
const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref);

@ -1,126 +0,0 @@
import { ChatBubbleBottomCenterTextIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Badge, Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote';
import QuestionTypeBadge from '../QuestionTypeBadge';
import VotingButtons from '../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
type StatisticsProps =
| {
answerCount: number;
showUserStatistics: true;
}
| {
answerCount?: never;
showUserStatistics?: false;
};
type ActionButtonProps =
| {
actionButtonLabel: string;
onActionButtonClick: () => void;
showActionButton: true;
}
| {
actionButtonLabel?: never;
onActionButtonClick?: never;
showActionButton?: false;
};
export type QuestionCardProps = ActionButtonProps &
StatisticsProps &
UpvoteProps & {
company: string;
content: string;
location: string;
questionId: string;
receivedCount: number;
role: string;
timestamp: string;
type: QuestionsQuestionType;
};
export default function QuestionCard({
questionId,
company,
answerCount,
content,
// ReceivedCount,
type,
showVoteButtons,
showUserStatistics,
showActionButton,
actionButtonLabel,
onActionButtonClick,
upvoteCount,
timestamp,
role,
location,
}: QuestionCardProps) {
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
)}
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-2 text-slate-500">
<Badge label={company} variant="primary" />
<QuestionTypeBadge type={type} />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
onClick={onActionButtonClick}
/>
)}
</div>
<div className="ml-2">
<p className="line-clamp-2 text-ellipsis ">{content}</p>
</div>
{showUserStatistics && (
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
{/* <Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/> */}
</div>
)}
</div>
</article>
);
}

@ -1,31 +0,0 @@
import withHref from '~/utils/questions/withHref';
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: true;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
return (
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={true}
showVoteButtons={true}
/>
);
}
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
export default QuestionOverviewCard;

@ -1,31 +0,0 @@
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type SimilarQuestionCardProps = Omit<
QuestionCardProps & {
showActionButton: true;
showUserStatistics: false;
showVoteButtons: false;
},
| 'actionButtonLabel'
| 'answerCount'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
| 'upvoteCount'
> & {
onSimilarQuestionClick: () => void;
};
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
const { onSimilarQuestionClick, ...rest } = props;
return (
<QuestionCard
{...rest}
actionButtonLabel="Yes, this is my question"
showActionButton={true}
onActionButtonClick={onSimilarQuestionClick}
/>
);
}

@ -0,0 +1,232 @@
import clsx from 'clsx';
import { useState } from 'react';
import {
ChatBubbleBottomCenterTextIcon,
CheckIcon,
EyeIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button } from '@tih/ui';
import { useQuestionVote } from '~/utils/questions/useVote';
import type { CreateQuestionEncounterData } from '../../forms/CreateQuestionEncounterForm';
import CreateQuestionEncounterForm from '../../forms/CreateQuestionEncounterForm';
import QuestionAggregateBadge from '../../QuestionAggregateBadge';
import QuestionTypeBadge from '../../QuestionTypeBadge';
import VotingButtons from '../../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
type DeleteProps =
| {
onDelete: () => void;
showDeleteButton: true;
}
| {
onDelete?: never;
showDeleteButton?: false;
};
type AnswerStatisticsProps =
| {
answerCount: number;
showAnswerStatistics: true;
}
| {
answerCount?: never;
showAnswerStatistics?: false;
};
type ActionButtonProps =
| {
actionButtonLabel: string;
onActionButtonClick: () => void;
showActionButton: true;
}
| {
actionButtonLabel?: never;
onActionButtonClick?: never;
showActionButton?: false;
};
type ReceivedStatisticsProps =
| {
receivedCount: number;
showReceivedStatistics: true;
}
| {
receivedCount?: never;
showReceivedStatistics?: false;
};
type CreateEncounterProps =
| {
onReceivedSubmit: (data: CreateQuestionEncounterData) => void;
showCreateEncounterButton: true;
}
| {
onReceivedSubmit?: never;
showCreateEncounterButton?: false;
};
export type BaseQuestionCardProps = ActionButtonProps &
AnswerStatisticsProps &
CreateEncounterProps &
DeleteProps &
ReceivedStatisticsProps &
UpvoteProps & {
companies: Record<string, number>;
content: string;
locations: Record<string, number>;
questionId: string;
roles: Record<string, number>;
showHover?: boolean;
timestamp: string;
truncateContent?: boolean;
type: QuestionsQuestionType;
};
export default function BaseQuestionCard({
questionId,
companies,
answerCount,
content,
receivedCount,
type,
showVoteButtons,
showAnswerStatistics,
showReceivedStatistics,
showCreateEncounterButton,
showActionButton,
actionButtonLabel,
onActionButtonClick,
upvoteCount,
timestamp,
roles,
locations,
showHover,
onReceivedSubmit,
showDeleteButton,
onDelete,
truncateContent = true,
}: BaseQuestionCardProps) {
const [showReceivedForm, setShowReceivedForm] = useState(false);
const { handleDownvote, handleUpvote, vote } = useQuestionVote(questionId);
const hoverClass = showHover ? 'hover:bg-slate-50' : '';
const cardContent = (
<>
{showVoteButtons && (
<VotingButtons
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
)}
<div className="flex flex-col items-start gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-baseline gap-2 text-slate-500">
<QuestionTypeBadge type={type} />
<QuestionAggregateBadge statistics={companies} variant="primary" />
<QuestionAggregateBadge statistics={locations} variant="success" />
<QuestionAggregateBadge statistics={roles} variant="danger" />
<p className="text-xs">{timestamp}</p>
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
onClick={onActionButtonClick}
/>
)}
</div>
<p className={clsx(truncateContent && 'line-clamp-2 text-ellipsis')}>
{content}
</p>
{!showReceivedForm &&
(showAnswerStatistics ||
showReceivedStatistics ||
showCreateEncounterButton) && (
<div className="flex gap-2">
{showAnswerStatistics && (
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
)}
{showReceivedStatistics && (
<Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/>
)}
{showCreateEncounterButton && (
<Button
addonPosition="start"
icon={CheckIcon}
label="I received this too"
size="sm"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
setShowReceivedForm(true);
}}
/>
)}
</div>
)}
{showReceivedForm && (
<CreateQuestionEncounterForm
onCancel={() => {
setShowReceivedForm(false);
}}
onSubmit={(data) => {
onReceivedSubmit?.(data);
setShowReceivedForm(false);
}}
/>
)}
</div>
</>
);
return (
<article
className={`group flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}>
{cardContent}
{showDeleteButton && (
<div className="invisible self-center fill-red-700 group-hover:visible">
<Button
icon={TrashIcon}
isLabelHidden={true}
label="Delete"
size="md"
variant="tertiary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onDelete();
}}
/>
</div>
)}
</article>
);
}

@ -0,0 +1,35 @@
import type { BaseQuestionCardProps } from './BaseQuestionCard';
import BaseQuestionCard from './BaseQuestionCard';
export type QuestionOverviewCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAnswerStatistics: false;
showCreateEncounterButton: true;
showDeleteButton: false;
showReceivedStatistics: false;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
| 'showReceivedStatistics'
| 'showVoteButtons'
>;
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
return (
<BaseQuestionCard
{...props}
showActionButton={false}
showAnswerStatistics={false}
showCreateEncounterButton={true}
showReceivedStatistics={false}
showVoteButtons={true}
truncateContent={false}
/>
);
}

@ -0,0 +1,36 @@
import withHref from '~/utils/questions/withHref';
import type { BaseQuestionCardProps } from './BaseQuestionCard';
import BaseQuestionCard from './BaseQuestionCard';
export type QuestionListCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAnswerStatistics: false;
showDeleteButton: true;
showVoteButtons: false;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAnswerStatistics'
| 'showDeleteButton'
| 'showVoteButtons'
>;
function QuestionListCardWithoutHref(props: QuestionListCardProps) {
return (
<BaseQuestionCard
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(props as any)}
showActionButton={false}
showAnswerStatistics={false}
showDeleteButton={true}
showHover={true}
showVoteButtons={false}
/>
);
}
const QuestionListCard = withHref(QuestionListCardWithoutHref);
export default QuestionListCard;

@ -0,0 +1,42 @@
import withHref from '~/utils/questions/withHref';
import type { BaseQuestionCardProps } from './BaseQuestionCard';
import BaseQuestionCard from './BaseQuestionCard';
export type QuestionOverviewCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: false;
showAnswerStatistics: true;
showCreateEncounterButton: false;
showDeleteButton: false;
showReceivedStatistics: true;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'onDelete'
| 'showActionButton'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
| 'showReceivedStatistics'
| 'showVoteButtons'
>;
function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
return (
<BaseQuestionCard
{...props}
showActionButton={false}
showAnswerStatistics={true}
showCreateEncounterButton={false}
showDeleteButton={false}
showHover={true}
showReceivedStatistics={true}
showVoteButtons={true}
/>
);
}
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
export default QuestionOverviewCard;

@ -0,0 +1,44 @@
import type { BaseQuestionCardProps } from './BaseQuestionCard';
import BaseQuestionCard from './BaseQuestionCard';
export type SimilarQuestionCardProps = Omit<
BaseQuestionCardProps & {
showActionButton: true;
showAnswerStatistics: true;
showCreateEncounterButton: false;
showDeleteButton: false;
showHover: true;
showReceivedStatistics: false;
showVoteButtons: false;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showAnswerStatistics'
| 'showCreateEncounterButton'
| 'showDeleteButton'
| 'showHover'
| 'showReceivedStatistics'
| 'showVoteButtons'
> & {
onSimilarQuestionClick: () => void;
};
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
const { onSimilarQuestionClick, ...rest } = props;
return (
<BaseQuestionCard
actionButtonLabel="Yes, this is my question"
showActionButton={true}
showAnswerStatistics={true}
showCreateEncounterButton={false}
showDeleteButton={false}
showHover={true}
showReceivedStatistics={true}
showVoteButtons={true}
onActionButtonClick={onSimilarQuestionClick}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{...(rest as any)}
/>
);
}

@ -1,14 +1,20 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { CheckboxInput, Collapsible, RadioList, TextInput } from '@tih/ui';
import { useMemo } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { CheckboxInput, Collapsible, RadioList } from '@tih/ui';
export type FilterOption<V extends string = string> = {
checked: boolean;
export type FilterChoice<V extends string = string> = {
id: string;
label: string;
value: V;
};
export type FilterOption<V extends string = string> = FilterChoice<V> & {
checked: boolean;
};
export type FilterChoices<V extends string = string> = ReadonlyArray<
Omit<FilterOption<V>, 'checked'>
FilterChoice<V>
>;
type FilterSectionType<FilterOptions extends Array<FilterOption>> =
@ -30,42 +36,87 @@ export type FilterSectionProps<FilterOptions extends Array<FilterOption>> =
options: FilterOptions;
} & (
| {
searchPlaceholder: string;
renderInput: (props: {
field: UseFormRegisterReturn<'search'>;
onOptionChange: FilterSectionType<FilterOptions>['onOptionChange'];
options: FilterOptions;
}) => React.ReactNode;
showAll?: never;
}
| {
searchPlaceholder?: never;
renderInput?: never;
showAll: true;
}
);
export type FilterSectionFormData = {
search: string;
};
export default function FilterSection<
FilterOptions extends Array<FilterOption>,
>({
label,
options,
searchPlaceholder,
showAll,
onOptionChange,
isSingleSelect,
renderInput,
}: FilterSectionProps<FilterOptions>) {
const { register, reset } = useForm<FilterSectionFormData>();
const registerSearch = register('search');
const field: UseFormRegisterReturn<'search'> = {
...registerSearch,
onChange: async (event) => {
await registerSearch.onChange(event);
reset();
},
};
const autocompleteOptions = useMemo(() => {
return options.filter((option) => !option.checked) as FilterOptions;
}, [options]);
const selectedCount = useMemo(() => {
return options.filter((option) => option.checked).length;
}, [options]);
const collapsibleLabel = useMemo(() => {
if (isSingleSelect) {
return label;
}
if (selectedCount === 0) {
return `${label} (all)`;
}
return `${label} (${selectedCount})`;
}, [label, selectedCount, isSingleSelect]);
return (
<div className="mx-2">
<Collapsible defaultOpen={true} label={label}>
<div className="mx-2 py-2">
<Collapsible defaultOpen={true} label={collapsibleLabel}>
<div className="-mx-2 flex flex-col items-stretch gap-2">
{!showAll && (
<TextInput
isLabelHidden={true}
label={label}
placeholder={searchPlaceholder}
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
/>
<div className="z-10">
{renderInput({
field,
onOptionChange: async (
optionValue: FilterOptions[number]['value'],
) => {
reset();
return onOptionChange(optionValue, true);
},
options: autocompleteOptions,
})}
</div>
)}
{isSingleSelect ? (
<div className="px-1.5">
<RadioList
label=""
isLabelHidden={true}
label={label}
value={options.find((option) => option.checked)?.value}
onChange={(value) => {
onOptionChange(value);
@ -81,16 +132,18 @@ export default function FilterSection<
</div>
) : (
<div className="px-1.5">
{options.map((option) => (
<CheckboxInput
key={option.value}
label={option.label}
value={option.checked}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
{options
.filter((option) => showAll || option.checked)
.map((option) => (
<CheckboxInput
key={option.value}
label={option.label}
value={option.checked}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
)}
</div>

@ -1,26 +1,26 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { CalendarDaysIcon, UserIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import {
Button,
CheckboxInput,
Collapsible,
HorizontalDivider,
Select,
TextArea,
TextInput,
} from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import { LOCATIONS, QUESTION_TYPES, ROLES } from '~/utils/questions/constants';
import {
useFormRegister,
useSelectRegister,
} from '~/utils/questions/useFormRegister';
import CompaniesTypeahead from '../shared/CompaniesTypeahead';
import type { Month } from '../shared/MonthYearPicker';
import MonthYearPicker from '../shared/MonthYearPicker';
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
import type { Month } from '../../shared/MonthYearPicker';
import MonthYearPicker from '../../shared/MonthYearPicker';
export type ContributeQuestionData = {
company: string;
@ -59,8 +59,17 @@ export default function ContributeQuestionForm({
};
return (
<form
className=" flex flex-1 flex-col items-stretch justify-center pb-[50px]"
className="flex flex-1 flex-col items-stretch justify-center gap-y-4"
onSubmit={handleSubmit(onSubmit)}>
<div className="min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<TextArea
label="Question Prompt"
placeholder="Contribute a question"
@ -68,41 +77,41 @@ export default function ContributeQuestionForm({
rows={5}
{...register('questionContent')}
/>
<div className="mt-3 mb-1 flex flex-wrap items-end gap-2">
<div className="mr-2 min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<HorizontalDivider />
<h2 className="text-md text-primary-800 font-semibold">
Additional information
</h2>
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="company"
name="location"
render={({ field }) => (
<CompaniesTypeahead
onSelect={({ label }) => {
// TODO: To change from using company name to company id (i.e., value)
field.onChange(label);
<LocationTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={LOCATIONS.find(
(location) => location.value === field.value,
)}
/>
)}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="date"
render={({ field }) => (
<MonthYearPicker
monthRequired={true}
value={{
month: (field.value.getMonth() + 1) as Month,
month: ((field.value.getMonth() as number) + 1) as Month,
year: field.value.getFullYear(),
}}
yearRequired={true}
onChange={({ month, year }) =>
field.onChange(startOfMonth(new Date(year, month - 1)))
}
@ -111,28 +120,38 @@ export default function ContributeQuestionForm({
/>
</div>
</div>
<Collapsible defaultOpen={true} label="Additional info">
<div className="justify-left flex flex-wrap items-end gap-2">
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Location"
required={true}
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('location')}
/>
</div>
<div className="min-w-[150px] max-w-[200px] flex-1">
<TextInput
label="Role"
required={true}
startAddOn={UserIcon}
startAddOnType="icon"
{...register('role')}
/>
</div>
<div className="flex flex-col flex-wrap items-stretch gap-2 sm:flex-row sm:items-end">
<div className="flex-1 sm:min-w-[150px] sm:max-w-[300px]">
<Controller
control={control}
name="company"
render={({ field }) => (
<CompanyTypeahead
required={true}
onSelect={({ id }) => {
field.onChange(id);
}}
/>
)}
/>
</div>
</Collapsible>
<div className="flex-1 sm:min-w-[150px] sm:max-w-[200px]">
<Controller
control={control}
name="role"
render={({ field }) => (
<RoleTypeahead
required={true}
onSelect={(option) => {
field.onChange(option.value);
}}
{...field}
value={ROLES.find((role) => role.value === field.value)}
/>
)}
/>
</div>
</div>
{/* <div className="w-full">
<HorizontalDivider />
</div>
@ -152,15 +171,20 @@ export default function ContributeQuestionForm({
}}
/>
</div> */}
<div className="bg-primary-50 fixed bottom-0 left-0 w-full px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6">
<div className="mb-1 flex">
<div
className="bg-primary-50 flex w-full flex-col gap-y-2 py-3 shadow-[0_0_0_100vmax_theme(colors.primary.50)] sm:flex-row sm:justify-between"
style={{
// Hack to make the background bleed outside the container
clipPath: 'inset(0 -100vmax)',
}}>
<div className="my-2 flex sm:my-0">
<CheckboxInput
label="I have checked that my question is new"
value={canSubmit}
onChange={handleCheckSimilarQuestions}
/>
</div>
<div className=" flex gap-x-2">
<div className="flex gap-x-2">
<button
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button"

@ -0,0 +1,148 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { Button } from '@tih/ui';
import type { Month } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
import CompanyTypeahead from '../typeahead/CompanyTypeahead';
import LocationTypeahead from '../typeahead/LocationTypeahead';
import RoleTypeahead from '../typeahead/RoleTypeahead';
export type CreateQuestionEncounterData = {
company: string;
location: string;
role: string;
seenAt: Date;
};
export type CreateQuestionEncounterFormProps = {
onCancel: () => void;
onSubmit: (data: CreateQuestionEncounterData) => void;
};
export default function CreateQuestionEncounterForm({
onCancel,
onSubmit,
}: CreateQuestionEncounterFormProps) {
const [step, setStep] = useState(0);
const [selectedCompany, setSelectedCompany] = useState<string | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<string | null>(null);
const [selectedDate, setSelectedDate] = useState<Date>(
startOfMonth(new Date()),
);
return (
<div className="flex items-center gap-2">
<p className="font-md text-md text-slate-600">I saw this question at</p>
{step === 0 && (
<div>
<CompanyTypeahead
isLabelHidden={true}
placeholder="Other company"
suggestedCount={3}
onSelect={({ value: company }) => {
setSelectedCompany(company);
}}
onSuggestionClick={({ value: company }) => {
setSelectedCompany(company);
setStep(step + 1);
}}
/>
</div>
)}
{step === 1 && (
<div>
<LocationTypeahead
isLabelHidden={true}
placeholder="Other location"
suggestedCount={3}
onSelect={({ value: location }) => {
setSelectedLocation(location);
}}
onSuggestionClick={({ value: location }) => {
setSelectedLocation(location);
setStep(step + 1);
}}
/>
</div>
)}
{step === 2 && (
<div>
<RoleTypeahead
isLabelHidden={true}
placeholder="Other role"
suggestedCount={3}
onSelect={({ value: role }) => {
setSelectedRole(role);
}}
onSuggestionClick={({ value: role }) => {
setSelectedRole(role);
setStep(step + 1);
}}
/>
</div>
)}
{step === 3 && (
<MonthYearPicker
monthLabel=""
value={{
month: ((selectedDate?.getMonth() ?? 0) + 1) as Month,
year: selectedDate?.getFullYear() as number,
}}
yearLabel=""
onChange={(value) => {
setSelectedDate(
startOfMonth(new Date(value.year, value.month - 1)),
);
}}
/>
)}
{step < 3 && (
<Button
disabled={
(step === 0 && selectedCompany === null) ||
(step === 1 && selectedLocation === null) ||
(step === 2 && selectedRole === null)
}
label="Next"
variant="primary"
onClick={() => {
setStep(step + 1);
}}
/>
)}
{step === 3 && (
<Button
label="Submit"
variant="primary"
onClick={() => {
if (
selectedCompany &&
selectedLocation &&
selectedRole &&
selectedDate
) {
onSubmit({
company: selectedCompany,
location: selectedLocation,
role: selectedRole,
seenAt: selectedDate,
});
}
}}
/>
)}
<Button
label="Cancel"
variant="tertiary"
onClick={(event) => {
event.preventDefault();
onCancel();
}}
/>
</div>
);
}

@ -0,0 +1,41 @@
import { useMemo, useState } from 'react';
import { trpc } from '~/utils/trpc';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type CompanyTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function CompanyTypeahead(props: CompanyTypeaheadProps) {
const [query, setQuery] = useState('');
const { data: companies } = trpc.useQuery([
'companies.list',
{
name: query,
},
]);
const companyOptions = useMemo(() => {
return (
companies?.map(({ id, name }) => ({
id,
label: name,
value: id,
})) ?? []
);
}, [companies]);
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Company"
options={companyOptions}
onQueryChange={setQuery}
/>
);
}

@ -0,0 +1,39 @@
import type { ComponentProps } from 'react';
import { Button, Typeahead } from '@tih/ui';
import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{
onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number;
}> &
TypeaheadProps;
export default function ExpandedTypeahead({
suggestedCount = 0,
onSuggestionClick,
...typeaheadProps
}: ExpandedTypeaheadProps) {
const suggestions = typeaheadProps.options.slice(0, suggestedCount);
return (
<div className="flex flex-wrap gap-x-2">
{suggestions.map((suggestion) => (
<Button
key={suggestion.id}
label={suggestion.label}
variant="tertiary"
onClick={() => {
onSuggestionClick?.(suggestion);
}}
/>
))}
<div className="flex-1">
<Typeahead {...typeaheadProps} />
</div>
</div>
);
}

@ -0,0 +1,21 @@
import { LOCATIONS } from '~/utils/questions/constants';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type LocationTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function LocationTypeahead(props: LocationTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Location"
options={LOCATIONS}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
/>
);
}

@ -0,0 +1,21 @@
import { ROLES } from '~/utils/questions/constants';
import type { ExpandedTypeaheadProps } from './ExpandedTypeahead';
import ExpandedTypeahead from './ExpandedTypeahead';
export type RoleTypeaheadProps = Omit<
ExpandedTypeaheadProps,
'label' | 'onQueryChange' | 'options'
>;
export default function RoleTypeahead(props: RoleTypeaheadProps) {
return (
<ExpandedTypeahead
{...(props as ExpandedTypeaheadProps)}
label="Role"
options={ROLES}
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
/>
);
}

@ -1,13 +1,12 @@
import { useState } from 'react';
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import {
ArrowLeftIcon,
ArrowRightIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui';
import { Button, Pagination, Spinner } from '@tih/ui';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
@ -18,66 +17,84 @@ type Props = Readonly<{
export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1);
const [pageWidth, setPageWidth] = useState(750);
const [componentWidth, setComponentWidth] = useState(780);
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages);
};
useEffect(() => {
const onPageResize = () => {
setComponentWidth(
document.querySelector('#pdfView')?.getBoundingClientRect().width ??
780,
);
};
window.addEventListener('resize', onPageResize);
return () => {
window.removeEventListener('resize', onPageResize);
};
}, []);
return (
<div>
<div id="pdfView">
<div className="group relative">
<Document
className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-auto"
className="flex h-[calc(100vh-16rem)] flex-row justify-center overflow-auto"
file={url}
loading={<Spinner display="block" size="lg" />}
noData=""
onLoadSuccess={onPdfLoadSuccess}>
<Page pageNumber={pageNumber} scale={scale} width={750} />
<div
style={{
paddingLeft: clsx(
pageWidth > componentWidth
? `${pageWidth - componentWidth}px`
: '',
),
}}>
<Page
pageNumber={pageNumber}
renderTextLayer={false}
width={pageWidth}
/>
</div>
<div className="absolute top-2 right-5 hidden hover:block group-hover:block">
<Button
className="rounded-r-none focus:ring-0 focus:ring-offset-0"
disabled={scale === 0.5}
disabled={pageWidth === 450}
icon={MagnifyingGlassMinusIcon}
isLabelHidden={true}
label="Zoom Out"
variant="tertiary"
onClick={() => setScale(scale - 0.25)}
onClick={() => setPageWidth(pageWidth - 150)}
/>
<Button
className="rounded-l-none focus:ring-0 focus:ring-offset-0"
disabled={scale === 1.5}
disabled={pageWidth === 1050}
icon={MagnifyingGlassPlusIcon}
isLabelHidden={true}
label="Zoom In"
variant="tertiary"
onClick={() => setScale(scale + 0.25)}
onClick={() => setPageWidth(pageWidth + 150)}
/>
</div>
</Document>
</div>
<div className="flex flex-row items-center justify-between p-4">
<Button
disabled={pageNumber === 1}
icon={ArrowLeftIcon}
isLabelHidden={true}
label="Previous"
variant="tertiary"
onClick={() => setPageNumber(pageNumber - 1)}
/>
<p className="text-md text-gray-600">
Page {pageNumber} of {numPages}
</p>
<Button
disabled={pageNumber >= numPages}
icon={ArrowRightIcon}
isLabelHidden={true}
label="Next"
variant="tertiary"
onClick={() => setPageNumber(pageNumber + 1)}
/>
</div>
{numPages > 1 && (
<div className="flex justify-center p-4">
<Pagination
current={pageNumber}
end={numPages}
label="pagination"
start={1}
onSelect={(page) => setPageNumber(page)}
/>
</div>
)}
</div>
);
}

@ -1,13 +0,0 @@
import { Badge } from '@tih/ui';
export default function ResumeReviewsTitle() {
return (
<div>
<h1 className="text-2xl font-bold">Resume Reviews</h1>
<Badge
label="Check out reviewed resumes or look for resumes to review"
variant="info"
/>
</div>
);
}

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

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

@ -1,28 +1,17 @@
import { Spinner } from '@tih/ui';
import ResumseListItem from './ResumeListItem';
import ResumeListItem from './ResumeListItem';
import type { Resume } from '~/types/resume';
type Props = Readonly<{
isLoading: boolean;
resumes: Array<Resume>;
}>;
export default function ResumeListItems({ isLoading, resumes }: Props) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
export default function ResumeListItems({ resumes }: Props) {
return (
<ul role="list">
{resumes.map((resumeObj: Resume) => (
<li key={resumeObj.id}>
<ResumseListItem
<ResumeListItem
href={`/resumes/${resumeObj.id}`}
resumeInfo={resumeObj}
/>

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

@ -0,0 +1,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: 'Most Comments',
};
export const ROLES: Array<FilterOption<RoleFilter>> = [
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{ label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ label: 'Backend Engineer', value: 'Backend Engineer' },
{ label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android Engineer' },
];
export const EXPERIENCES: 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 LOCATIONS: 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(EXPERIENCES).map(({ value }) => value),
location: Object.values(LOCATIONS).map(({ value }) => value),
role: Object.values(ROLES).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 {
ArrowDownCircleIcon,
ArrowUpCircleIcon,
} from '@heroicons/react/20/solid';
import clsx from 'clsx';
import { useState } from 'react';
import { ChevronUpIcon } from '@heroicons/react/20/solid';
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 type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentListItemProps = {
comment: ResumeComment;
userId?: string;
userId: string | undefined;
};
export default function ResumeCommentListItem({
@ -18,34 +21,57 @@ export default function ResumeCommentListItem({
userId,
}: ResumeCommentListItemProps) {
const isCommentOwner = userId === comment.user.userId;
const [isEditingComment, setIsEditingComment] = useState(false);
const [isReplyingComment, setIsReplyingComment] = useState(false);
const [showReplies, setShowReplies] = useState(true);
return (
<div className="border-primary-300 w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
<div className="flex w-full flex-row space-x-2 p-1 align-top">
<div
className={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 ? (
<img
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!}
/>
) : (
<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">
{/* Name and creation time */}
<div className="flex flex-row justify-between">
<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'}
</div>
</p>
<div className="text-primary-800 text-xs font-medium">
<p className="text-xs font-medium text-indigo-800">
{isCommentOwner ? '(Me)' : ''}
</div>
</p>
<ResumeUserBadges userId={comment.user.userId} />
</div>
<div className="text-xs text-gray-600">
<div className="px-2 text-xs text-gray-600">
{comment.createdAt.toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
@ -54,22 +80,93 @@ export default function ResumeCommentListItem({
</div>
{/* Description */}
<ResumeExpandableText>{comment.description}</ResumeExpandableText>
{isEditingComment ? (
<ResumeCommentEditForm
comment={comment}
setIsEditingComment={setIsEditingComment}
/>
) : (
<ResumeExpandableText
key={comment.description}
text={comment.description}
/>
)}
{/* Upvote and edit */}
<div className="flex flex-row space-x-1 pt-1 align-middle">
{/* TODO: Implement upvote */}
<ArrowUpCircleIcon className="h-4 w-4 fill-gray-400" />
<div className="text-xs">{comment.numVotes}</div>
<ArrowDownCircleIcon className="h-4 w-4 fill-gray-400" />
{/* TODO: Implement edit */}
{isCommentOwner ? (
<div className="text-primary-800 hover:text-primary-400 px-1 text-xs">
Edit
</div>
) : null}
<ResumeCommentVoteButtons commentId={comment.id} userId={userId} />
{/* Action buttons; only present for authenticated user when not editing/replying */}
{userId && !isEditingComment && !isReplyingComment && (
<>
{isCommentOwner && (
<button
className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
type="button"
onClick={() => setIsEditingComment(true)}>
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>
{/* 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>

@ -47,6 +47,9 @@ export default function ResumeCommentsForm({
onSuccess: () => {
// New Comment added, invalidate query to trigger refetch
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 { useState } from 'react';
import { Spinner, Tabs } from '@tih/ui';
import {
BookOpenIcon,
BriefcaseIcon,
CodeBracketSquareIcon,
FaceSmileIcon,
IdentificationIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import { ResumesSection } from '@prisma/client';
import { Spinner } from '@tih/ui';
import { Button } from '@tih/ui';
import { trpc } from '~/utils/trpc';
@ -21,23 +29,26 @@ export default function ResumeCommentsList({
setShowCommentsForm,
}: ResumeCommentsListProps) {
const { data: sessionData } = useSession();
const [tab, setTab] = useState(RESUME_COMMENTS_SECTIONS[0].value);
const [tabs, setTabs] = useState(RESUME_COMMENTS_SECTIONS);
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }], {
onSuccess: (data: Array<ResumeComment>) => {
const updatedTabs = RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const count = data.filter(({ section }) => section === value).length;
const updatedLabel = count > 0 ? `${label} (${count})` : label;
return {
label: updatedLabel,
value,
};
});
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }]);
setTabs(updatedTabs);
},
});
const renderIcon = (section: ResumesSection) => {
const className = 'h-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 = () => {
if (sessionData === null) {
@ -45,6 +56,7 @@ export default function ResumeCommentsList({
}
return (
<Button
className="-mb-2"
display="block"
label="Add your review"
variant="tertiary"
@ -57,28 +69,44 @@ export default function ResumeCommentsList({
<div className="space-y-3">
{renderButton()}
<Tabs
label="comments"
tabs={tabs}
value={tab}
onChange={(value) => setTab(value)}
/>
{commentsQuery.isFetching ? (
{commentsQuery.isLoading ? (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
) : (
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-auto">
{(commentsQuery.data?.filter((c) => c.section === tab) ?? []).map(
(comment) => (
<ResumeCommentListItem
key={comment.id}
comment={comment}
userId={sessionData?.user?.id}
/>
),
)}
<div className="m-2 flow-root h-[calc(100vh-17rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pt-14 pb-6">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {
return (comment.section as string) === value;
})
: [];
const commentCount = comments.length;
return (
<div key={value} className="mb-4 space-y-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>

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

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

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

Loading…
Cancel
Save