Merge branch 'main' into hongpo/update-to-reference-company-table

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

@ -0,0 +1,44 @@
# Copied from https://github.com/facebook/docusaurus/blob/main/.github/workflows/lint.yml
name: Lint
on:
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
env:
DATABASE_URL: 'postgresql://postgres:password@localhost:5432/postgres'
GITHUB_CLIENT_ID: '1234'
GITHUB_CLIENT_SECRET: 'abcd'
NEXTAUTH_SECRET: 'efgh'
NEXTAUTH_URL: 'http://localhost:3000'
NODE_ENV: test
SUPABASE_ANON_KEY: 'ijkl'
SUPABASE_URL: 'https://abcd.supabase.co'
jobs:
lint:
name: Lint
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '16'
cache: yarn
- name: Installation
run: yarn
- name: Check immutable yarn.lock
run: git diff --exit-code
- name: Lint
run: yarn lint

@ -0,0 +1,37 @@
# Copied from https://github.com/facebook/docusaurus/blob/main/.github/workflows/lint.yml
name: Typecheck
on:
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
tsc:
name: Typecheck
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Node
uses: actions/setup-node@v3
with:
node-version: '16'
cache: yarn
- name: Installation
run: yarn
- name: Check immutable yarn.lock
run: git diff --exit-code
# Build the shared types in dependent packages.
- name: Build dependencies
run: yarn turbo run build --filter=ui
- name: Typecheck
run: yarn tsc

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

@ -0,0 +1,120 @@
/*
Warnings:
- You are about to drop the column `isAttending` on the `OffersEducation` table. All the data in the column will be lost.
- The primary key for the `OffersFullTime` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `offerId` on the `OffersFullTime` table. All the data in the column will be lost.
- The primary key for the `OffersIntern` table will be changed. If it partially fails, the table could be left without primary key constraint.
- You are about to drop the column `offerId` on the `OffersIntern` table. All the data in the column will be lost.
- A unique constraint covering the columns `[offersInternId]` on the table `OffersOffer` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[offersFullTimeId]` on the table `OffersOffer` will be added. If there are existing duplicate values, this will fail.
- A unique constraint covering the columns `[profileName]` on the table `OffersProfile` will be added. If there are existing duplicate values, this will fail.
- The required column `id` was added to the `OffersFullTime` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
- The required column `id` was added to the `OffersIntern` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
*/
-- DropForeignKey
ALTER TABLE "OffersBackground" DROP CONSTRAINT "OffersBackground_offersProfileId_fkey";
-- DropForeignKey
ALTER TABLE "OffersEducation" DROP CONSTRAINT "OffersEducation_backgroundId_fkey";
-- DropForeignKey
ALTER TABLE "OffersExperience" DROP CONSTRAINT "OffersExperience_backgroundId_fkey";
-- DropForeignKey
ALTER TABLE "OffersFullTime" DROP CONSTRAINT "OffersFullTime_baseSalaryId_fkey";
-- DropForeignKey
ALTER TABLE "OffersFullTime" DROP CONSTRAINT "OffersFullTime_bonusId_fkey";
-- DropForeignKey
ALTER TABLE "OffersFullTime" DROP CONSTRAINT "OffersFullTime_offerId_fkey";
-- DropForeignKey
ALTER TABLE "OffersFullTime" DROP CONSTRAINT "OffersFullTime_stocksId_fkey";
-- DropForeignKey
ALTER TABLE "OffersFullTime" DROP CONSTRAINT "OffersFullTime_totalCompensationId_fkey";
-- DropForeignKey
ALTER TABLE "OffersIntern" DROP CONSTRAINT "OffersIntern_monthlySalaryId_fkey";
-- DropForeignKey
ALTER TABLE "OffersIntern" DROP CONSTRAINT "OffersIntern_offerId_fkey";
-- DropForeignKey
ALTER TABLE "OffersOffer" DROP CONSTRAINT "OffersOffer_profileId_fkey";
-- DropForeignKey
ALTER TABLE "OffersReply" DROP CONSTRAINT "OffersReply_profileId_fkey";
-- DropForeignKey
ALTER TABLE "OffersSpecificYoe" DROP CONSTRAINT "OffersSpecificYoe_backgroundId_fkey";
-- AlterTable
ALTER TABLE "OffersEducation" DROP COLUMN "isAttending";
-- AlterTable
ALTER TABLE "OffersFullTime" DROP CONSTRAINT "OffersFullTime_pkey",
DROP COLUMN "offerId",
ADD COLUMN "id" TEXT NOT NULL,
ADD CONSTRAINT "OffersFullTime_pkey" PRIMARY KEY ("id");
-- AlterTable
ALTER TABLE "OffersIntern" DROP CONSTRAINT "OffersIntern_pkey",
DROP COLUMN "offerId",
ADD COLUMN "id" TEXT NOT NULL,
ADD CONSTRAINT "OffersIntern_pkey" PRIMARY KEY ("id");
-- AlterTable
ALTER TABLE "OffersOffer" ADD COLUMN "offersFullTimeId" TEXT,
ADD COLUMN "offersInternId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "OffersOffer_offersInternId_key" ON "OffersOffer"("offersInternId");
-- CreateIndex
CREATE UNIQUE INDEX "OffersOffer_offersFullTimeId_key" ON "OffersOffer"("offersFullTimeId");
-- CreateIndex
CREATE UNIQUE INDEX "OffersProfile_profileName_key" ON "OffersProfile"("profileName");
-- AddForeignKey
ALTER TABLE "OffersBackground" ADD CONSTRAINT "OffersBackground_offersProfileId_fkey" FOREIGN KEY ("offersProfileId") REFERENCES "OffersProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersSpecificYoe" ADD CONSTRAINT "OffersSpecificYoe_backgroundId_fkey" FOREIGN KEY ("backgroundId") REFERENCES "OffersBackground"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersExperience" ADD CONSTRAINT "OffersExperience_backgroundId_fkey" FOREIGN KEY ("backgroundId") REFERENCES "OffersBackground"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersEducation" ADD CONSTRAINT "OffersEducation_backgroundId_fkey" FOREIGN KEY ("backgroundId") REFERENCES "OffersBackground"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersReply" ADD CONSTRAINT "OffersReply_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "OffersProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersOffer" ADD CONSTRAINT "OffersOffer_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "OffersProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersOffer" ADD CONSTRAINT "OffersOffer_offersInternId_fkey" FOREIGN KEY ("offersInternId") REFERENCES "OffersIntern"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersOffer" ADD CONSTRAINT "OffersOffer_offersFullTimeId_fkey" FOREIGN KEY ("offersFullTimeId") REFERENCES "OffersFullTime"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersIntern" ADD CONSTRAINT "OffersIntern_monthlySalaryId_fkey" FOREIGN KEY ("monthlySalaryId") REFERENCES "OffersCurrency"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_totalCompensationId_fkey" FOREIGN KEY ("totalCompensationId") REFERENCES "OffersCurrency"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_baseSalaryId_fkey" FOREIGN KEY ("baseSalaryId") REFERENCES "OffersCurrency"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_bonusId_fkey" FOREIGN KEY ("bonusId") REFERENCES "OffersCurrency"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersFullTime" ADD CONSTRAINT "OffersFullTime_stocksId_fkey" FOREIGN KEY ("stocksId") REFERENCES "OffersCurrency"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `creator` on the `OffersReply` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "OffersReply" DROP COLUMN "creator",
ADD COLUMN "userId" TEXT;
-- AddForeignKey
ALTER TABLE "OffersReply" ADD CONSTRAINT "OffersReply_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

@ -0,0 +1,9 @@
/*
Warnings:
- Changed the type of `value` on the `ResumesCommentVote` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
*/
-- AlterTable
ALTER TABLE "ResumesCommentVote" DROP COLUMN "value",
ADD COLUMN "value" "Vote" NOT NULL;

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

@ -1,92 +1,93 @@
// 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[]
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 {
@ -108,65 +109,65 @@ model Company {
// use camelCase for field names, and try to name them consistently
// across all models in this file.
model ResumesResume {
id String @id @default(cuid())
userId String
title String @db.Text
// TODO: Update role, experience, location to use Enums
role String @db.Text
experience String @db.Text
location String @db.Text
url String
additionalInfo String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stars ResumesStar[]
comments ResumesComment[]
id String @id @default(cuid())
userId String
title String @db.Text
// TODO: Update role, experience, location to use Enums
role String @db.Text
experience String @db.Text
location String @db.Text
url String
additionalInfo String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
stars ResumesStar[]
comments ResumesComment[]
}
model ResumesStar {
id String @id @default(cuid())
userId String
resumeId String
createdAt DateTime @default(now())
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String
resumeId String
createdAt DateTime @default(now())
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, resumeId])
@@unique([userId, resumeId])
}
model ResumesComment {
id String @id @default(cuid())
userId String
resumeId String
description String @db.Text
section ResumesSection
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
votes ResumesCommentVote[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
userId String
resumeId String
description String @db.Text
section ResumesSection
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
votes ResumesCommentVote[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum ResumesSection {
GENERAL
EDUCATION
EXPERIENCE
PROJECTS
SKILLS
GENERAL
EDUCATION
EXPERIENCE
PROJECTS
SKILLS
}
model ResumesCommentVote {
id String @id @default(cuid())
userId String
commentId String
value Int
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,175 +178,202 @@ model ResumesCommentVote {
// across all models in this file.
model OffersProfile {
id String @id @default(cuid())
profileName String @unique
createdAt DateTime @default(now())
id String @id @default(cuid())
profileName String @unique
createdAt DateTime @default(now())
background OffersBackground?
background OffersBackground?
editToken String
editToken String
discussion OffersReply[]
discussion OffersReply[]
offers OffersOffer[]
offers OffersOffer[]
user User? @relation(fields: [userId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
userId String?
analysis OffersAnalysis?
}
model OffersBackground {
id String @id @default(cuid())
id String @id @default(cuid())
totalYoe Int?
specificYoes OffersSpecificYoe[]
totalYoe Int
specificYoes OffersSpecificYoe[]
experiences OffersExperience[] // For extensibility in the future
experiences OffersExperience[] // For extensibility in the future
educations OffersEducation[] // For extensibility in the future
educations OffersEducation[] // For extensibility in the future
profile OffersProfile @relation(fields: [offersProfileId], references: [id])
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])
backgroundId String
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
}
model OffersExperience {
id String @id @default(cuid())
id String @id @default(cuid())
company Company? @relation(fields: [companyId], references: [id])
companyId String?
company Company? @relation(fields: [companyId], references: [id])
companyId String?
jobType JobType?
title String?
jobType JobType?
title String?
// Add more fields
durationInMonths Int?
specialization String?
// Add more fields
durationInMonths Int?
specialization String?
// FULLTIME fields
level String?
totalCompensation OffersCurrency? @relation("ExperienceTotalCompensation", fields: [totalCompensationId], references: [id])
totalCompensationId String? @unique
// FULLTIME fields
level String?
totalCompensation OffersCurrency? @relation("ExperienceTotalCompensation", fields: [totalCompensationId], references: [id])
totalCompensationId String? @unique
// INTERN fields
monthlySalary OffersCurrency? @relation("ExperienceMonthlySalary", fields: [monthlySalaryId], references: [id])
monthlySalaryId String? @unique
// INTERN fields
monthlySalary OffersCurrency? @relation("ExperienceMonthlySalary", fields: [monthlySalaryId], references: [id])
monthlySalaryId String? @unique
background OffersBackground @relation(fields: [backgroundId], references: [id])
backgroundId String
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
}
model OffersCurrency {
id String @id @default(cuid())
value Int
currency String
id String @id @default(cuid())
value Int
currency String
// Experience
OffersExperienceTotalCompensation OffersExperience? @relation("ExperienceTotalCompensation")
OffersExperienceMonthlySalary OffersExperience? @relation("ExperienceMonthlySalary")
// Experience
OffersExperienceTotalCompensation OffersExperience? @relation("ExperienceTotalCompensation")
OffersExperienceMonthlySalary OffersExperience? @relation("ExperienceMonthlySalary")
// Full Time
OffersTotalCompensation OffersFullTime? @relation("OfferTotalCompensation")
OffersBaseSalary OffersFullTime? @relation("OfferBaseSalary")
OffersBonus OffersFullTime? @relation("OfferBonus")
OffersStocks OffersFullTime? @relation("OfferStocks")
// Full Time
OffersTotalCompensation OffersFullTime? @relation("OfferTotalCompensation")
OffersBaseSalary OffersFullTime? @relation("OfferBaseSalary")
OffersBonus OffersFullTime? @relation("OfferBonus")
OffersStocks OffersFullTime? @relation("OfferStocks")
// Intern
OffersMonthlySalary OffersIntern?
// Intern
OffersMonthlySalary OffersIntern?
}
enum JobType {
INTERN
FULLTIME
INTERN
FULLTIME
}
model OffersEducation {
id String @id @default(cuid())
type String?
field String?
id String @id @default(cuid())
type String?
field String?
// Add more fields
school String?
startDate DateTime?
endDate DateTime?
school String?
startDate DateTime?
endDate DateTime?
background OffersBackground @relation(fields: [backgroundId], references: [id])
backgroundId String
background OffersBackground @relation(fields: [backgroundId], references: [id], onDelete: Cascade)
backgroundId String
}
model OffersReply {
id String @id @default(cuid())
creator String
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])
profileId 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])
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])
offersInternId String? @unique
OffersIntern OffersIntern? @relation(fields: [offersInternId], references: [id], onDelete: Cascade)
offersInternId String? @unique
OffersFullTime OffersFullTime? @relation(fields: [offersFullTimeId], references: [id])
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])
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])
totalCompensationId String @unique
baseSalary OffersCurrency @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id])
baseSalaryId String @unique
bonus OffersCurrency @relation("OfferBonus", fields: [bonusId], references: [id])
bonusId String @unique
stocks OffersCurrency @relation("OfferStocks", fields: [stocksId], references: [id])
stocksId String @unique
OffersOffer OffersOffer?
id String @id @default(cuid())
title String
specialization String
level String
totalCompensation OffersCurrency @relation("OfferTotalCompensation", fields: [totalCompensationId], references: [id], onDelete: Cascade)
totalCompensationId String @unique
baseSalary OffersCurrency @relation("OfferBaseSalary", fields: [baseSalaryId], references: [id], onDelete: Cascade)
baseSalaryId String @unique
bonus OffersCurrency @relation("OfferBonus", fields: [bonusId], references: [id], onDelete: Cascade)
bonusId String @unique
stocks OffersCurrency @relation("OfferStocks", fields: [stocksId], references: [id], onDelete: Cascade)
stocksId String @unique
OffersOffer OffersOffer?
}
model OffersAnalysis {
id String @id @default(cuid())
profile OffersProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
profileId String @unique
overallHighestOffer OffersOffer @relation("HighestOverallOffer", fields: [offerId], references: [id], onDelete: Cascade)
offerId String @unique
// OVERALL
overallPercentile Float
noOfSimilarOffers Int
topOverallOffers OffersOffer[] @relation("TopOverallOffers")
// Company
companyPercentile Float
noOfSimilarCompanyOffers Int
topCompanyOffers OffersOffer[] @relation("TopCompanyOffers")
}
// End of Offers project models.
@ -356,24 +384,24 @@ 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
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[]
}
model QuestionsQuestionEncounter {
@ -394,99 +422,99 @@ model QuestionsQuestionEncounter {
}
model QuestionsQuestionVote {
id String @id @default(cuid())
questionId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
questionId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
@@unique([questionId, userId])
@@unique([questionId, userId])
}
model QuestionsQuestionComment {
id String @id @default(cuid())
questionId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
questionId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsQuestionCommentVote[]
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsQuestionCommentVote[]
}
model QuestionsQuestionCommentVote {
id String @id @default(cuid())
questionCommentId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
questionCommentId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
comment QuestionsQuestionComment @relation(fields: [questionCommentId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
comment QuestionsQuestionComment @relation(fields: [questionCommentId], references: [id], onDelete: Cascade)
@@unique([questionCommentId, userId])
@@unique([questionCommentId, userId])
}
model QuestionsAnswer {
id String @id @default(cuid())
questionId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
questionId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsAnswerVote[]
comments QuestionsAnswerComment[]
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
question QuestionsQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
votes QuestionsAnswerVote[]
comments QuestionsAnswerComment[]
}
model QuestionsAnswerVote {
id String @id @default(cuid())
answerId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
answerId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
@@unique([answerId, userId])
@@unique([answerId, userId])
}
model QuestionsAnswerComment {
id String @id @default(cuid())
answerId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
answerId String
userId String?
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
votes QuestionsAnswerCommentVote[]
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
answer QuestionsAnswer @relation(fields: [answerId], references: [id], onDelete: Cascade)
votes QuestionsAnswerCommentVote[]
}
model QuestionsAnswerCommentVote {
id String @id @default(cuid())
answerCommentId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
answerCommentId String
userId String?
vote Vote
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
comment QuestionsAnswerComment @relation(fields: [answerCommentId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
comment QuestionsAnswerComment @relation(fields: [answerCommentId], references: [id], onDelete: Cascade)
@@unique([answerCommentId, userId])
@@unique([answerCommentId, userId])
}
// End of Questions project models.

@ -109,6 +109,7 @@ export default function AppShell({ children }: Props) {
navigation: ProductNavigationItems;
showGlobalNav: boolean;
title: string;
titleHref: string;
}> = (() => {
const path = router.pathname;
if (path.startsWith('/resumes')) {
@ -190,6 +191,7 @@ export default function AppShell({ children }: Props) {
<ProductNavigation
items={currentProductNavigation.navigation}
title={currentProductNavigation.title}
titleHref={currentProductNavigation.titleHref}
/>
</div>
<div className="ml-2 flex items-center space-x-4 sm:ml-6 sm:space-x-6">

@ -17,6 +17,7 @@ const config = {
navigation,
showGlobalNav: true,
title: 'Tech Interview Handbook',
titleHref: '/',
};
export default config;

@ -1,13 +1,16 @@
import clsx from 'clsx';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { Fragment } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline';
type NavigationItem = Readonly<{
children?: ReadonlyArray<NavigationItem>;
href: string;
name: string;
target?: '_blank';
}>;
export type ProductNavigationItems = ReadonlyArray<NavigationItem>;
@ -15,15 +18,21 @@ export type ProductNavigationItems = ReadonlyArray<NavigationItem>;
type Props = Readonly<{
items: ProductNavigationItems;
title: string;
titleHref: string;
}>;
export default function ProductNavigation({ items, title }: Props) {
export default function ProductNavigation({ items, title, titleHref }: Props) {
const router = useRouter();
return (
<nav aria-label="Global" className="flex space-x-8">
<span className="text-primary-700 text-sm font-medium">{title}</span>
<div className="hidden space-x-8 md:flex">
{items.map((item) =>
item.children != null && item.children.length > 0 ? (
<nav aria-label="Global" className="flex h-full items-center space-x-8">
<Link className="text-primary-700 text-sm font-medium" href={titleHref}>
{title}
</Link>
<div className="hidden h-full items-center space-x-8 md:flex">
{items.map((item) => {
const isActive = router.pathname === item.href;
return item.children != null && item.children.length > 0 ? (
<Menu key={item.name} as="div" className="relative text-left">
<Menu.Button className="focus:ring-primary-600 flex items-center rounded-md text-sm font-medium text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2">
<span>{item.name}</span>
@ -50,7 +59,13 @@ export default function ProductNavigation({ items, title }: Props) {
active ? 'bg-slate-100' : '',
'block px-4 py-2 text-sm text-slate-700',
)}
href={child.href}>
href={child.href}
rel={
!child.href.startsWith('/')
? 'noopener noreferrer'
: undefined
}
target={child.target}>
{child.name}
</Link>
)}
@ -63,12 +78,22 @@ export default function ProductNavigation({ items, title }: Props) {
) : (
<Link
key={item.name}
className="hover:text-primary-600 text-sm font-medium text-slate-900"
href={item.href}>
className={clsx(
'hover:text-primary-600 inline-flex h-full items-center border-y-2 border-t-transparent text-sm font-medium text-slate-900',
isActive ? 'border-b-primary-500' : 'border-b-transparent',
)}
href={item.href}
rel={
!item.href.startsWith('/') ? 'noopener noreferrer' : undefined
}
target={item.target}>
{item.name}
{item.target ? (
<ArrowTopRightOnSquareIcon className="h-5 w-5 pl-1" />
) : null}
</Link>
),
)}
);
})}
</div>
</nav>
);

@ -0,0 +1,23 @@
type BreadcrumbsProps = Readonly<{
currentStep: number;
stepLabels: Array<string>;
}>;
export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) {
return (
<div className="flex space-x-1">
{stepLabels.map((label, index) => (
<div key={label} className="flex space-x-1">
{index === currentStep ? (
<p className="text-sm text-purple-700">{label}</p>
) : (
<p className="text-sm text-gray-400">{label}</p>
)}
{index !== stepLabels.length - 1 && (
<p className="text-sm text-gray-400">{'>'}</p>
)}
</div>
))}
</div>
);
}

@ -1,7 +1,6 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [
{ href: '/offers', name: 'Home' },
{ href: '/offers/submit', name: 'Benchmark your offer' },
];
@ -9,6 +8,7 @@ const config = {
navigation,
showGlobalNav: false,
title: 'Tech Offers Repo',
titleHref: '/offers',
};
export default config;

@ -1,190 +0,0 @@
import { useState } from 'react';
import { HorizontalDivider, Pagination, Select, Tabs } from '@tih/ui';
import CurrencySelector from '~/components/offers/util/currency/CurrencySelector';
type TableRow = {
company: string;
date: string;
salary: string;
title: string;
yoe: string;
};
// eslint-disable-next-line no-shadow
enum YOE_CATEGORY {
INTERN = 0,
ENTRY = 1,
MID = 2,
SENIOR = 3,
}
export default function OffersTable() {
const [currency, setCurrency] = useState('SGD');
const [selectedTab, setSelectedTab] = useState(YOE_CATEGORY.ENTRY);
const [selectedPage, setSelectedPage] = useState(1);
function renderTabs() {
return (
<div className="flex justify-center">
<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,
},
]}
value={selectedTab}
onChange={(value) => setSelectedTab(value)}
/>
</div>
</div>
);
}
function renderFilters() {
return (
<div className="m-4 flex items-center justify-between">
<div className="justify-left flex items-center space-x-2">
<span>All offers in</span>
<CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency}
/>
</div>
<Select
disabled={true}
isLabelHidden={true}
label=""
options={[
{
label: 'Latest Submitted',
value: 'latest-submitted',
},
]}
value="latest-submitted"
/>
</div>
);
}
function renderHeader() {
return (
<thead className="bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400">
<tr>
{[
'Company',
'Title',
'YOE',
'TC/year',
'Date offered',
'Actions',
].map((header) => (
<th key={header} className="py-3 px-6" scope="col">
{header}
</th>
))}
</tr>
</thead>
);
}
function renderRow({ company, title, yoe, salary, date }: TableRow) {
return (
<tr className="border-b bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600">
<th
className="whitespace-nowrap py-4 px-6 font-medium text-gray-900 dark:text-white"
scope="row">
{company}
</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="space-x-4 py-4 px-6">
<a
className="font-medium text-indigo-600 hover:underline dark:text-indigo-500"
href="#">
View Profile
</a>
<a
className="font-medium text-indigo-600 hover:underline dark:text-indigo-500"
href="#">
Comment
</a>
</td>
</tr>
);
}
function renderPagination() {
return (
<nav
aria-label="Table navigation"
className="flex items-center justify-between p-4">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
Showing{' '}
<span className="font-semibold text-gray-900 dark:text-white">
1-10
</span>{' '}
of{' '}
<span className="font-semibold text-gray-900 dark:text-white">
1000
</span>
</span>
<Pagination
current={selectedPage}
end={10}
label="Pagination"
pagePadding={1}
start={1}
onSelect={(page) => setSelectedPage(page)}
/>
</nav>
);
}
return (
<div className="w-5/6">
{renderTabs()}
<HorizontalDivider />
<div className="relative w-full overflow-x-auto shadow-md sm:rounded-lg">
{renderFilters()}
<table className="w-full text-left text-sm text-gray-500 dark:text-gray-400">
{renderHeader()}
<tbody>
{renderRow({
company: 'Shopee',
date: 'May 2022',
salary: 'TC/yr',
title: 'SWE',
yoe: '5',
})}
{renderRow({
company: 'Shopee',
date: 'May 2022',
salary: 'TC/yr',
title: 'SWE',
yoe: '5',
})}
</tbody>
</table>
{renderPagination()}
</div>
</div>
);
}

@ -1,13 +1,9 @@
import { EducationBackgroundType } from './types';
const emptyOption = {
label: '----',
value: '',
};
export const emptyOption = '----';
// TODO: use enums
export const titleOptions = [
emptyOption,
{
label: 'Software engineer',
value: 'Software engineer',
@ -27,31 +23,29 @@ export const titleOptions = [
];
export const companyOptions = [
emptyOption,
{
label: 'Bytedance',
value: 'id-abc123',
label: 'Amazon',
value: 'cl93patjt0000txewdi601mub',
},
{
label: 'Google',
value: 'id-abc567',
label: 'Microsoft',
value: 'cl93patjt0001txewkglfjsro',
},
{
label: 'Meta',
value: 'id-abc456',
label: 'Apple',
value: 'cl93patjt0002txewf3ug54m8',
},
{
label: 'Shopee',
value: 'id-abc345',
label: 'Google',
value: 'cl93patjt0003txewyiaky7xx',
},
{
label: 'Tik Tok',
value: 'id-abc678',
label: 'Meta',
value: 'cl93patjt0004txew88wkcqpu',
},
];
export const locationOptions = [
emptyOption,
{
label: 'Singapore, Singapore',
value: 'Singapore, Singapore',
@ -67,7 +61,6 @@ export const locationOptions = [
];
export const internshipCycleOptions = [
emptyOption,
{
label: 'Summer',
value: 'Summer',
@ -91,7 +84,6 @@ export const internshipCycleOptions = [
];
export const yearOptions = [
emptyOption,
{
label: '2021',
value: '2021',
@ -110,17 +102,14 @@ export const yearOptions = [
},
];
const educationBackgroundTypes = Object.entries(EducationBackgroundType).map(
([key, value]) => ({
label: key,
value,
}),
);
export const educationLevelOptions = [emptyOption, ...educationBackgroundTypes];
export const educationLevelOptions = Object.entries(
EducationBackgroundType,
).map(([key, value]) => ({
label: key,
value,
}));
export const educationFieldOptions = [
emptyOption,
{
label: 'Computer Science',
value: 'Computer Science',
@ -134,3 +123,9 @@ export const educationFieldOptions = [
value: 'Business 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.',
}

@ -1,18 +1,19 @@
import { useFormContext, useWatch } from 'react-hook-form';
import { Collapsible, RadioList } from '@tih/ui';
import FormRadioList from './FormRadioList';
import FormSelect from './FormSelect';
import FormTextInput from './FormTextInput';
import {
companyOptions,
educationFieldOptions,
educationLevelOptions,
locationOptions,
titleOptions,
} from '../constants';
import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../util/currency/CurrencyEnum';
} 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 { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
function YoeSection() {
const { register } = useFormContext();
@ -28,7 +29,9 @@ function YoeSection() {
label="Total YOE"
placeholder="0"
type="number"
{...register(`background.totalYoe`)}
{...register(`background.totalYoe`, {
valueAsNumber: true,
})}
/>
</div>
<div className="grid grid-cols-1 space-x-3">
@ -37,7 +40,9 @@ function YoeSection() {
<FormTextInput
label="Specific YOE 1"
type="number"
{...register(`background.specificYoes.0.yoe`)}
{...register(`background.specificYoes.0.yoe`, {
valueAsNumber: true,
})}
/>
<FormTextInput
label="Specific Domain 1"
@ -49,7 +54,9 @@ function YoeSection() {
<FormTextInput
label="Specific YOE 2"
type="number"
{...register(`background.specificYoes.1.yoe`)}
{...register(`background.specificYoes.1.yoe`, {
valueAsNumber: true,
})}
/>
<FormTextInput
label="Specific Domain 2"
@ -73,13 +80,13 @@ function FullTimeJobFields() {
display="block"
label="Title"
options={titleOptions}
{...register(`background.experience.title`)}
{...register(`background.experiences.0.title`)}
/>
<FormSelect
display="block"
label="Company"
options={companyOptions}
{...register(`background.experience.companyId`)}
{...register(`background.experiences.0.companyId`)}
/>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
@ -90,7 +97,9 @@ function FullTimeJobFields() {
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`background.experience.totalCompensation.currency`)}
{...register(
`background.experiences.0.totalCompensation.currency`,
)}
/>
}
endAddOnType="element"
@ -99,7 +108,9 @@ function FullTimeJobFields() {
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experience.totalCompensation.value`)}
{...register(`background.experiences.0.totalCompensation.value`, {
valueAsNumber: true,
})}
/>
</div>
<Collapsible label="Add more details">
@ -107,12 +118,12 @@ function FullTimeJobFields() {
<FormTextInput
label="Focus / Specialization"
placeholder="e.g. Front End"
{...register(`background.experience.specialization`)}
{...register(`background.experiences.0.specialization`)}
/>
<FormTextInput
label="Level"
placeholder="e.g. L4, Junior"
{...register(`background.experience.level`)}
{...register(`background.experiences.0.level`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -120,12 +131,14 @@ function FullTimeJobFields() {
display="block"
label="Location"
options={locationOptions}
{...register(`background.experience.location`)}
{...register(`background.experiences.0.location`)}
/>
<FormTextInput
label="Duration (months)"
type="number"
{...register(`background.experience.durationInMonths`)}
{...register(`background.experiences.0.durationInMonths`, {
valueAsNumber: true,
})}
/>
</div>
</Collapsible>
@ -142,13 +155,13 @@ function InternshipJobFields() {
display="block"
label="Title"
options={titleOptions}
{...register(`background.experience.title`)}
{...register(`background.experiences.0.title`)}
/>
<FormSelect
display="block"
label="Company"
options={companyOptions}
{...register(`background.experience.company`)}
{...register(`background.experiences.0.company`)}
/>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
@ -159,7 +172,7 @@ function InternshipJobFields() {
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`background.experience.monthlySalary.currency`)}
{...register(`background.experiences.0.monthlySalary.currency`)}
/>
}
endAddOnType="element"
@ -168,7 +181,7 @@ function InternshipJobFields() {
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experience.monthlySalary.value`)}
{...register(`background.experiences.0.monthlySalary.value`)}
/>
</div>
<Collapsible label="Add more details">
@ -176,13 +189,13 @@ function InternshipJobFields() {
<FormTextInput
label="Focus / Specialization"
placeholder="e.g. Front End"
{...register(`background.experience.specialization`)}
{...register(`background.experiences.0.specialization`)}
/>
<FormSelect
display="block"
label="Location"
options={locationOptions}
{...register(`background.experience.location`)}
{...register(`background.experiences.0.location`)}
/>
</div>
</Collapsible>
@ -194,7 +207,7 @@ function CurrentJobSection() {
const { register } = useFormContext();
const watchJobType = useWatch({
defaultValue: JobType.FullTime,
name: 'background.experience.jobType',
name: 'background.experiences.0.jobType',
});
return (
@ -209,7 +222,7 @@ function CurrentJobSection() {
isLabelHidden={true}
label="Job Type"
orientation="horizontal"
{...register('background.experience.jobType')}>
{...register('background.experiences.0.jobType')}>
<RadioList.Item
key="Full-time"
label="Full-time"
@ -245,13 +258,13 @@ function EducationSection() {
display="block"
label="Education Level"
options={educationLevelOptions}
{...register(`background.education.type`)}
{...register(`background.educations.0.type`)}
/>
<FormSelect
display="block"
label="Field"
options={educationFieldOptions}
{...register(`background.education.field`)}
{...register(`background.educations.0.field`)}
/>
</div>
<Collapsible label="Add more details">
@ -259,7 +272,7 @@ function EducationSection() {
<FormTextInput
label="School"
placeholder="e.g. National University of Singapore"
{...register(`background.experience.specialization`)}
{...register(`background.educations.0.school`)}
/>
</div>
</Collapsible>

@ -1,7 +1,6 @@
import { useState } from 'react';
import { UserCircleIcon } from '@heroicons/react/20/solid';
import { HorizontalDivider, Tabs } from '~/../../../packages/ui/dist';
import { HorizontalDivider, Tabs } from '@tih/ui';
const tabs = [
{
@ -86,8 +85,7 @@ export default function OfferAnalysis() {
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
<div className="mx-40">
<div>
<Tabs
label="Result Navigation"
tabs={tabs}

@ -1,91 +1,127 @@
import { useState } from 'react';
import type {
FieldValues,
UseFieldArrayRemove,
UseFieldArrayReturn,
} from 'react-hook-form';
import { useEffect, useState } from 'react';
import type { FieldValues, 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 { Button } from '@tih/ui';
import { Button, Dialog } from '@tih/ui';
import {
defaultFullTimeOfferValues,
defaultInternshipOfferValues,
} from '~/pages/offers/submit';
import FormSelect from './FormSelect';
import FormTextArea from './FormTextArea';
import FormTextInput from './FormTextInput';
import FormMonthYearPicker from './components/FormMonthYearPicker';
import FormSelect from './components/FormSelect';
import FormTextArea from './components/FormTextArea';
import FormTextInput from './components/FormTextInput';
import {
companyOptions,
emptyOption,
FieldError,
internshipCycleOptions,
locationOptions,
titleOptions,
yearOptions,
} from '../constants';
import type { FullTimeOfferFormData, InternshipOfferFormData } from '../types';
import type {
FullTimeOfferDetailsFormData,
InternshipOfferDetailsFormData,
} from '../types';
import { JobTypeLabel } from '../types';
import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../util/currency/CurrencyEnum';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{
index: number;
remove: UseFieldArrayRemove;
setDialogOpen: (isOpen: boolean) => void;
}>;
function FullTimeOfferDetailsForm({
index,
remove,
setDialogOpen,
}: FullTimeOfferDetailsFormProps) {
const { register } = useFormContext<{
offers: Array<FullTimeOfferFormData>;
const { register, formState, setValue } = useFormContext<{
offers: Array<FullTimeOfferDetailsFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
const watchCurrency = useWatch({
name: `offers.${index}.job.totalCompensation.currency`,
});
useEffect(() => {
setValue(`offers.${index}.job.base.currency`, watchCurrency);
setValue(`offers.${index}.job.bonus.currency`, watchCurrency);
setValue(`offers.${index}.job.stocks.currency`, watchCurrency);
}, [watchCurrency, index, setValue]);
return (
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`, {
required: true,
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
required: true,
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: true })}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.level?.message}
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.job.level`, { required: true })}
{...register(`offers.${index}.job.level`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
label="Location"
options={locationOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.location`, { required: true })}
{...register(`offers.${index}.location`, {
required: FieldError.Required,
})}
/>
<FormTextInput
label="Month Received"
placeholder="MMM/YYYY"
required={true}
{...register(`offers.${index}.monthYearReceived`, { required: true })}
<FormMonthYearPicker
monthLabel="Date Received"
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5">
@ -97,19 +133,22 @@ function FullTimeOfferDetailsForm({
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.totalCompensation.currency`, {
required: true,
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.totalCompensation?.value?.message}
label="Total Compensation (Annual)"
placeholder="0.00"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.totalCompensation.value`, {
required: true,
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
/>
</div>
@ -121,17 +160,24 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.base.currency`)}
{...register(`offers.${index}.job.base.currency`, {
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.base?.value?.message}
label="Base Salary (Annual)"
placeholder="0.00"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.base.value`)}
{...register(`offers.${index}.job.base.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
/>
<FormTextInput
endAddOn={
@ -140,17 +186,24 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.bonus.currency`)}
{...register(`offers.${index}.job.bonus.currency`, {
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.bonus?.value?.message}
label="Bonus (Annual)"
placeholder="0.00"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.bonus.value`)}
{...register(`offers.${index}.job.bonus.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
@ -161,17 +214,24 @@ function FullTimeOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.stocks.currency`)}
{...register(`offers.${index}.job.stocks.currency`, {
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.stocks?.value?.message}
label="Stocks (Annual)"
placeholder="0.00"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.stocks.value`)}
{...register(`offers.${index}.job.stocks.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
/>
</div>
<div className="mb-5">
@ -194,7 +254,7 @@ function FullTimeOfferDetailsForm({
icon={TrashIcon}
label="Delete"
variant="secondary"
onClick={() => remove(index)}
onClick={() => setDialogOpen(true)}
/>
)}
</div>
@ -202,115 +262,103 @@ function FullTimeOfferDetailsForm({
);
}
type OfferDetailsFormArrayProps = Readonly<{
fieldArrayValues: UseFieldArrayReturn<FieldValues, 'offers', 'id'>;
jobType: JobType;
}>;
function OfferDetailsFormArray({
fieldArrayValues,
jobType,
}: OfferDetailsFormArrayProps) {
const { append, remove, fields } = fieldArrayValues;
return (
<div>
{fields.map((item, index) =>
jobType === JobType.FullTime ? (
<FullTimeOfferDetailsForm
key={`offer.${item.id}`}
index={index}
remove={remove}
/>
) : (
<InternshipOfferDetailsForm
key={`offer.${item.id}`}
index={index}
remove={remove}
/>
),
)}
<Button
display="block"
icon={PlusIcon}
label="Add another offer"
size="lg"
variant="tertiary"
onClick={() => append({})}
/>
</div>
);
}
type InternshipOfferDetailsFormProps = Readonly<{
index: number;
remove: UseFieldArrayRemove;
setDialogOpen: (isOpen: boolean) => void;
}>;
function InternshipOfferDetailsForm({
index,
remove,
setDialogOpen,
}: InternshipOfferDetailsFormProps) {
const { register } = useFormContext<{
offers: Array<InternshipOfferFormData>;
const { register, formState } = useFormContext<{
offers: Array<InternshipOfferDetailsFormData>;
}>();
const offerFields = formState.errors.offers?.[index];
return (
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.job.title`)}
{...register(`offers.${index}.job.title`, {
minLength: 1,
required: FieldError.Required,
})}
/>
<FormTextInput
errorMessage={offerFields?.job?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`)}
{...register(`offers.${index}.job.specialization`, {
minLength: 1,
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.companyId?.message}
label="Company"
options={companyOptions}
placeholder={emptyOption}
required={true}
value="Shopee"
{...register(`offers.${index}.companyId`)}
{...register(`offers.${index}.companyId`, {
required: FieldError.Required,
})}
/>
<FormSelect
display="block"
errorMessage={offerFields?.location?.message}
label="Location"
options={locationOptions}
placeholder={emptyOption}
required={true}
value="Singapore, Singapore"
{...register(`offers.${index}.location`)}
{...register(`offers.${index}.location`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5 grid grid-cols-3 space-x-3">
<FormTextInput
label="Date Received"
placeholder="MMM/YYYY"
required={true}
{...register(`offers.${index}.monthYearReceived`)}
/>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
errorMessage={offerFields?.job?.internshipCycle?.message}
label="Internship Cycle"
options={internshipCycleOptions}
placeholder={emptyOption}
required={true}
value="Summer"
{...register(`offers.${index}.job.internshipCycle`)}
{...register(`offers.${index}.job.internshipCycle`, {
required: FieldError.Required,
})}
/>
<FormSelect
display="block"
errorMessage={offerFields?.job?.startYear?.message}
label="Internship Year"
options={yearOptions}
placeholder={emptyOption}
required={true}
value="2023"
{...register(`offers.${index}.job.startYear`)}
{...register(`offers.${index}.job.startYear`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5">
<FormMonthYearPicker
monthLabel="Date Received"
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.Required,
})}
/>
</div>
<div className="mb-5">
@ -321,17 +369,24 @@ function InternshipOfferDetailsForm({
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.monthlySalary.currency`)}
{...register(`offers.${index}.job.monthlySalary.currency`, {
required: FieldError.Required,
})}
/>
}
endAddOnType="element"
errorMessage={offerFields?.job?.monthlySalary?.value?.message}
label="Salary (Monthly)"
placeholder="0.00"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.monthlySalary.value`)}
{...register(`offers.${index}.job.monthlySalary.value`, {
min: { message: FieldError.NonNegativeNumber, value: 0 },
required: FieldError.Required,
valueAsNumber: true,
})}
/>
</div>
<div className="mb-5">
@ -354,7 +409,9 @@ function InternshipOfferDetailsForm({
icon={TrashIcon}
label="Delete"
variant="secondary"
onClick={() => remove(index)}
onClick={() => {
setDialogOpen(true);
}}
/>
)}
</div>
@ -362,20 +419,105 @@ function InternshipOfferDetailsForm({
);
}
type OfferDetailsFormArrayProps = Readonly<{
fieldArrayValues: UseFieldArrayReturn<FieldValues, 'offers', 'id'>;
jobType: JobType;
}>;
function OfferDetailsFormArray({
fieldArrayValues,
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}
/>
) : (
<InternshipOfferDetailsForm
index={index}
setDialogOpen={setDialogOpen}
/>
)}
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="OK"
variant="primary"
onClick={() => {
remove(index);
setDialogOpen(false);
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setDialogOpen(false)}
/>
}
title="Remove this offer"
onClose={() => setDialogOpen(false)}>
<p>
Are you sure you want to remove this offer? This action cannot
be reversed.
</p>
</Dialog>
</div>
);
})}
<Button
display="block"
icon={PlusIcon}
label="Add another offer"
size="lg"
variant="tertiary"
onClick={() =>
append(
jobType === JobType.FullTime
? defaultFullTimeOfferValues
: defaultInternshipOfferValues,
)
}
/>
</div>
);
}
export default function OfferDetailsForm() {
const [jobType, setJobType] = useState(JobType.FullTime);
const { control, register } = useFormContext();
const [isDialogOpen, setDialogOpen] = useState(false);
const { control } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' });
const changeJobType = (jobTypeChosen: JobType) => () => {
if (jobType === jobTypeChosen) {
return;
}
setJobType(jobTypeChosen);
const toggleJobType = () => {
fieldArrayValues.remove();
if (jobType === JobType.FullTime) {
setJobType(JobType.Internship);
fieldArrayValues.append(defaultInternshipOfferValues);
} else {
setJobType(JobType.FullTime);
fieldArrayValues.append(defaultFullTimeOfferValues);
}
};
const switchJobTypeLabel = () =>
jobType === JobType.FullTime
? JobTypeLabel.INTERNSHIP
: JobTypeLabel.FULLTIME;
return (
<div className="mb-5">
<h5 className="mb-8 text-center text-4xl font-bold text-gray-900">
@ -385,21 +527,29 @@ export default function OfferDetailsForm() {
<div className="mx-5 w-1/3">
<Button
display="block"
label="Full-time"
label={JobTypeLabel.FULLTIME}
size="md"
variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'}
onClick={changeJobType(JobType.FullTime)}
{...register(`offers.${0}.jobType`)}
onClick={() => {
if (jobType === JobType.FullTime) {
return;
}
setDialogOpen(true);
}}
/>
</div>
<div className="mx-5 w-1/3">
<Button
display="block"
label="Internship"
label={JobTypeLabel.INTERNSHIP}
size="md"
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'}
onClick={changeJobType(JobType.Internship)}
{...register(`offers.${0}.jobType`)}
onClick={() => {
if (jobType === JobType.Internship) {
return;
}
setDialogOpen(true);
}}
/>
</div>
</div>
@ -407,6 +557,32 @@ export default function OfferDetailsForm() {
fieldArrayValues={fieldArrayValues}
jobType={jobType}
/>
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="Switch"
variant="primary"
onClick={() => {
toggleJobType();
setDialogOpen(false);
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setDialogOpen(false)}
/>
}
title={`Switch to ${switchJobTypeLabel()}`}
onClose={() => setDialogOpen(false)}>
{`Are you sure you want to switch to ${switchJobTypeLabel()}? The data you
entered in the ${JobTypeLabel[jobType]} section will disappear.`}
</Dialog>
</div>
);
}

@ -0,0 +1,42 @@
import type { ComponentProps } from 'react';
import { forwardRef } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
import { getCurrentMonth, getCurrentYear } from '../../../../utils/offers/time';
type MonthYearPickerProps = ComponentProps<typeof MonthYearPicker>;
type FormMonthYearPickerProps = Omit<
MonthYearPickerProps,
'onChange' | 'value'
> & {
name: string;
};
function FormMonthYearPickerWithRef({
name,
...rest
}: FormMonthYearPickerProps) {
const { setValue } = useFormContext();
const value = useWatch({
defaultValue: { month: getCurrentMonth(), year: getCurrentYear() },
name,
});
return (
<MonthYearPicker
{...(rest as MonthYearPickerProps)}
value={value}
onChange={(val) => {
setValue(name, val);
}}
/>
);
}
const FormMonthYearPicker = forwardRef(FormMonthYearPickerWithRef);
export default FormMonthYearPicker;

@ -1,5 +1,4 @@
import type { ComponentProps } from 'react';
import { forwardRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { RadioList } from '@tih/ui';
@ -7,7 +6,7 @@ type RadioListProps = ComponentProps<typeof RadioList>;
type FormRadioListProps = Omit<RadioListProps, 'onChange'>;
function FormRadioListWithRef({ name, ...rest }: FormRadioListProps) {
export default function FormRadioList({ name, ...rest }: FormRadioListProps) {
const { setValue } = useFormContext();
return (
<RadioList
@ -17,7 +16,3 @@ function FormRadioListWithRef({ name, ...rest }: FormRadioListProps) {
/>
);
}
const FormRadioList = forwardRef(FormRadioListWithRef);
export default FormRadioList;

@ -1,8 +1,7 @@
import type { ComponentProps, ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';
import { TextArea } from '~/../../../packages/ui/dist';
import { TextArea } from '@tih/ui';
type TextAreaProps = ComponentProps<typeof TextArea>;

@ -3,14 +3,14 @@ import {
LightBulbIcon,
} from '@heroicons/react/24/outline';
import type { EducationBackgroundType } from '../types';
import type { EducationBackgroundType } from '~/components/offers/types';
type EducationEntity = {
backgroundType?: EducationBackgroundType;
endDate?: string;
field?: string;
fromMonth?: string;
school?: string;
toMonth?: string;
startDate?: string;
type?: EducationBackgroundType;
};
type Props = Readonly<{
@ -18,7 +18,7 @@ type Props = Readonly<{
}>;
export default function EducationCard({
education: { backgroundType, field, fromMonth, school, toMonth },
education: { type, field, startDate, endDate, school },
}: Props) {
return (
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md">
@ -27,9 +27,7 @@ export default function EducationCard({
<div className="flex flex-row">
<LightBulbIcon className="mr-1 h-5" />
<span className="ml-1 font-bold">
{field
? `${backgroundType ?? 'N/A'}, ${field}`
: backgroundType ?? `N/A`}
{field ? `${type ?? 'N/A'}, ${field}` : type ?? `N/A`}
</span>
</div>
{school && (
@ -39,9 +37,11 @@ export default function EducationCard({
</div>
)}
</div>
{(fromMonth || toMonth) && (
{(startDate || endDate) && (
<div className="font-light text-gray-400">
<p>{`${fromMonth ?? 'N/A'} - ${toMonth ?? 'N/A'}`}</p>
<p>{`${startDate ? startDate : 'N/A'} - ${
endDate ? endDate : 'N/A'
}`}</p>
</div>
)}
</div>

@ -6,21 +6,7 @@ import {
} from '@heroicons/react/24/outline';
import { HorizontalDivider } from '@tih/ui';
type OfferEntity = {
base?: string;
bonus?: string;
companyName: string;
duration?: string; // For background
jobLevel?: string;
jobTitle: string;
location: string;
monthlySalary?: string;
negotiationStrategy?: string;
otherComment?: string;
receivedMonth: string;
stocks?: string;
totalCompensation?: string;
};
import type { OfferEntity } from '~/components/offers/types';
type Props = Readonly<{
offer: OfferEntity;
@ -28,16 +14,16 @@ type Props = Readonly<{
export default function OfferCard({
offer: {
companyName = 'Meta',
jobTitle = 'Senior Engineer',
jobLevel,
location = 'Singapore',
receivedMonth = 'Jun 2021',
totalCompensation = '350.1k',
base = '0k',
stocks = '0k',
bonus = '0k',
base,
bonus,
companyName,
duration,
jobTitle,
jobLevel,
location,
receivedMonth,
totalCompensation,
stocks,
monthlySalary,
negotiationStrategy,
otherComment,
@ -57,14 +43,14 @@ export default function OfferCard({
<p>{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}</p>
</div>
</div>
{receivedMonth && (
{!duration && receivedMonth && (
<div className="font-light text-gray-400">
<p>{receivedMonth}</p>
</div>
)}
{duration && (
<div className="font-light text-gray-400">
<p>{duration}</p>
<p>{`${duration} months`}</p>
</div>
)}
</div>

@ -0,0 +1,58 @@
import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline';
import { Button, Spinner } from '@tih/ui';
type ProfileHeaderProps = Readonly<{
handleCopyEditLink: () => void;
handleCopyPublicLink: () => void;
isDisabled: boolean;
isEditable: boolean;
isLoading: boolean;
}>;
export default function ProfileComments({
handleCopyEditLink,
handleCopyPublicLink,
isDisabled,
isEditable,
isLoading,
}: ProfileHeaderProps) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="m-4">
<div className="flex-end flex justify-end space-x-4">
{isEditable && (
<Button
addonPosition="start"
disabled={isDisabled}
icon={ClipboardDocumentIcon}
isLabelHidden={false}
label="Copy profile edit link"
size="sm"
variant="secondary"
onClick={handleCopyEditLink}
/>
)}
<Button
addonPosition="start"
disabled={isDisabled}
icon={ShareIcon}
isLabelHidden={false}
label="Copy public link"
size="sm"
variant="secondary"
onClick={handleCopyPublicLink}
/>
</div>
<h2 className="mt-2 text-2xl font-bold">
Discussions feature coming soon
</h2>
{/* <TextArea label="Comment" placeholder="Type your comment here" /> */}
</div>
);
}

@ -0,0 +1,79 @@
import { AcademicCapIcon, BriefcaseIcon } from '@heroicons/react/24/outline';
import { 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';
type ProfileHeaderProps = Readonly<{
background?: BackgroundCard;
isLoading: boolean;
offers: Array<OfferEntity>;
selectedTab: string;
}>;
export default function ProfileDetails({
background,
isLoading,
offers,
selectedTab,
}: ProfileHeaderProps) {
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</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 === 'background') {
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,
}}
/>
</>
)}
</>
);
}
return <div>Detail page for {selectedTab}</div>;
}

@ -0,0 +1,166 @@
import { useState } from 'react';
import {
BookmarkSquareIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
PencilSquareIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundCard } from '~/components/offers/types';
type ProfileHeaderProps = Readonly<{
background?: BackgroundCard;
handleDelete: () => void;
isEditable: boolean;
isLoading: boolean;
selectedTab: string;
setSelectedTab: (tab: string) => void;
}>;
export default function ProfileHeader({
background,
handleDelete,
isEditable,
isLoading,
selectedTab,
setSelectedTab,
}: ProfileHeaderProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
function renderActionList() {
return (
<div className="space-x-2">
<Button
disabled={isLoading}
icon={BookmarkSquareIcon}
isLabelHidden={true}
label="Save to user account"
size="md"
variant="tertiary"
/>
<Button
disabled={isLoading}
icon={PencilSquareIcon}
isLabelHidden={true}
label="Edit"
size="md"
variant="tertiary"
/>
<Button
disabled={isLoading}
icon={TrashIcon}
isLabelHidden={true}
label="Delete"
size="md"
variant="tertiary"
onClick={() => setIsDialogOpen(true)}
/>
{isDialogOpen && (
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="Delete"
variant="primary"
onClick={() => {
setIsDialogOpen(false);
handleDelete();
}}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setIsDialogOpen(false)}
/>
}
title="Are you sure you want to delete this offer profile?"
onClose={() => setIsDialogOpen(false)}>
<div>
All comments will be gone. You will not be able to access or
recover it.
</div>
</Dialog>
)}
</div>
);
}
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="h-40 bg-white p-4">
<div className="justify-left flex h-1/2">
<div className="mx-4 mt-2">
<ProfilePhotoHolder />
</div>
<div className="w-full">
<div className="justify-left flex flex-1">
<h2 className="flex w-4/5 text-2xl font-bold">
{background?.profileName ?? 'anonymous'}
</h2>
{isEditable && (
<div className="flex h-8 w-1/5 justify-end">
{renderActionList()}
</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>
<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 }) => {
return (
<span
key={domain}
className="mr-4">{`${domain}: ${yoe}`}</span>
);
})}
</div>
</div>
</div>
<div className="mt-8">
<Tabs
label="Profile Detail Navigation"
tabs={[
{
label: 'Offers',
value: 'offers',
},
{
label: 'Background',
value: 'background',
},
{
label: 'Offer Engine Analysis',
value: 'offerEngineAnalysis',
},
]}
value={selectedTab}
onChange={(value) => setSelectedTab(value)}
/>
</div>
</div>
);
}

@ -0,0 +1,35 @@
import Link from 'next/link';
import { convertCurrencyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
import type { DashboardOffer } from '~/types/offers';
export type OfferTableRowProps = Readonly<{ row: DashboardOffer }>;
export default function OfferTableRow({
row: { company, id, income, monthYearReceived, profileId, title, totalYoe },
}: OfferTableRowProps) {
return (
<tr
key={id}
className="border-b bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600">
<th
className="whitespace-nowrap py-4 px-6 font-medium text-gray-900 dark:text-white"
scope="row">
{company.name}
</th>
<td className="py-4 px-6">{title}</td>
<td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertCurrencyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="space-x-4 py-4 px-6">
<Link
className="font-medium text-indigo-600 hover:underline dark:text-indigo-500"
href={`/offers/profile/${profileId}`}>
View Profile
</Link>
</td>
</tr>
);
}

@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { HorizontalDivider, Select, Spinner, Tabs } from '@tih/ui';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import { YOE_CATEGORY } from '~/components/offers/table/types';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
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;
jobTitleFilter: string;
}>;
export default function OffersTable({
companyFilter,
jobTitleFilter,
}: OffersTableProps) {
const [currency, setCurrency] = useState('SGD'); // TODO: Detect location
const [selectedTab, setSelectedTab] = useState(YOE_CATEGORY.ENTRY);
const [pagination, setPagination] = useState<Paging>({
currentPage: 0,
numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
useEffect(() => {
setPagination({
currentPage: 0,
numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
}, [selectedTab]);
const offersQuery = trpc.useQuery(
[
'offers.list',
{
companyId: companyFilter,
limit: NUMBER_OF_OFFERS_IN_PAGE,
location: 'Singapore, Singapore', // TODO: Geolocation
offset: 0,
sortBy: '-monthYearReceived',
title: jobTitleFilter,
yoeCategory: selectedTab,
},
],
{
onSuccess: (response: GetOffersResponse) => {
setOffers(response.data);
setPagination(response.paging);
},
},
);
function renderTabs() {
return (
<div className="flex justify-center">
<div className="w-fit">
<Tabs
label="Table Navigation"
tabs={[
{
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,
},
]}
value={selectedTab}
onChange={(value) => setSelectedTab(value)}
/>
</div>
</div>
);
}
function renderFilters() {
return (
<div className="m-4 flex items-center justify-between">
<div className="justify-left flex items-center space-x-2">
<span>All offers in</span>
<CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency}
/>
</div>
<Select
disabled={true}
isLabelHidden={true}
label=""
options={[
{
label: 'Latest Submitted',
value: 'latest-submitted',
},
]}
value="latest-submitted"
/>
</div>
);
}
function renderHeader() {
return (
<thead className="bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400">
<tr>
{[
'Company',
'Title',
'YOE',
selectedTab === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'TC/year',
'Date offered',
'Actions',
].map((header) => (
<th key={header} className="py-3 px-6" scope="col">
{header}
</th>
))}
</tr>
</thead>
);
}
const handlePageChange = (currPage: number) => {
setPagination({ ...pagination, currentPage: currPage });
};
return (
<div className="w-5/6">
{renderTabs()}
<HorizontalDivider />
<div className="relative w-full overflow-x-auto shadow-md sm:rounded-lg">
{renderFilters()}
{offersQuery.isLoading ? (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
) : (
<table className="w-full text-left text-sm text-gray-500 dark:text-gray-400">
{renderHeader()}
<tbody>
{offers.map((offer) => (
<OffersRow key={offer.id} row={offer} />
))}
</tbody>
</table>
)}
<OffersTablePagination
endNumber={
pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + offers.length
}
handlePageChange={handlePageChange}
pagination={pagination}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + 1}
/>
</div>
</div>
);
}

@ -0,0 +1,44 @@
import { Pagination } from '@tih/ui';
import type { Paging } from '~/types/offers';
type OffersTablePaginationProps = Readonly<{
endNumber: number;
handlePageChange: (page: number) => void;
pagination: Paging;
startNumber: number;
}>;
export default function OffersTablePagination({
endNumber,
pagination,
startNumber,
handlePageChange,
}: OffersTablePaginationProps) {
return (
<nav
aria-label="Table navigation"
className="flex items-center justify-between p-4">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
Showing
<span className="font-semibold text-gray-900 dark:text-white">
{` ${startNumber} - ${endNumber} `}
</span>
{`of `}
<span className="font-semibold text-gray-900 dark:text-white">
{pagination.totalItems}
</span>
</span>
<Pagination
current={pagination.currentPage + 1}
end={pagination.numOfPages}
label="Pagination"
pagePadding={1}
start={1}
onSelect={(currPage) => {
handlePageChange(currPage - 1);
}}
/>
</nav>
);
}

@ -0,0 +1,14 @@
// eslint-disable-next-line no-shadow
export enum YOE_CATEGORY {
INTERN = 0,
ENTRY = 1,
MID = 2,
SENIOR = 3,
}
export type PaginationType = {
currentPage: number;
numOfItems: number;
numOfPages: number;
totalItems: number;
};

@ -1,4 +1,5 @@
/* eslint-disable no-shadow */
import type { MonthYear } from '~/components/shared/MonthYearPicker';
/*
* Offer Profile
*/
@ -8,6 +9,11 @@ export enum JobType {
Internship = 'INTERNSHIP',
}
export const JobTypeLabel = {
FULLTIME: 'Full-time',
INTERNSHIP: 'Internship',
};
export enum EducationBackgroundType {
Bachelor = 'Bachelor',
Diploma = 'Diploma',
@ -18,7 +24,7 @@ export enum EducationBackgroundType {
SelfTaught = 'Self-taught',
}
type Money = {
export type Money = {
currency: string;
value: number;
};
@ -33,16 +39,6 @@ type FullTimeJobData = {
totalCompensation: Money;
};
export type FullTimeOfferFormData = {
comments: string;
companyId: string;
job: FullTimeJobData;
jobType: string;
location: string;
monthYearReceived: string;
negotiationStrategy: string;
};
type InternshipJobData = {
internshipCycle: string;
monthlySalary: Money;
@ -51,17 +47,33 @@ type InternshipJobData = {
title: string;
};
export type InternshipOfferFormData = {
type OfferDetailsGeneralData = {
comments: string;
companyId: string;
job: InternshipJobData;
jobType: string;
location: string;
monthYearReceived: string;
monthYearReceived: MonthYear;
negotiationStrategy: string;
};
type OfferDetailsFormData = FullTimeOfferFormData | InternshipOfferFormData;
export type FullTimeOfferDetailsFormData = OfferDetailsGeneralData & {
job: FullTimeJobData;
};
export type InternshipOfferDetailsFormData = OfferDetailsGeneralData & {
job: InternshipJobData;
};
export type OfferDetailsFormData =
| FullTimeOfferDetailsFormData
| InternshipOfferDetailsFormData;
export type OfferDetailsPostData = Omit<
OfferDetailsFormData,
'monthYearReceived'
> & {
monthYearReceived: Date;
};
type SpecificYoe = {
domain: string;
@ -69,42 +81,80 @@ type SpecificYoe = {
};
type FullTimeExperience = {
level: string;
totalCompensation: Money;
level?: string;
totalCompensation?: Money;
};
type InternshipExperience = {
monthlySalary: Money;
monthlySalary?: Money;
};
type GeneralExperience = {
companyId: string;
durationInMonths: number;
jobType: string;
specialization: string;
title: string;
companyId?: string;
durationInMonths?: number;
jobType?: string;
specialization?: string;
title?: string;
};
type Experience =
export type Experience =
| (FullTimeExperience & GeneralExperience)
| (GeneralExperience & InternshipExperience);
type Education = {
endDate: Date;
field: string;
school: string;
startDate: Date;
type: string;
endDate?: Date;
field?: string;
school?: string;
startDate?: Date;
type?: string;
};
type BackgroundFormData = {
education: Education;
experience: Experience;
educations: Array<Education>;
experiences: Array<Experience>;
specificYoes: Array<SpecificYoe>;
totalYoe: number;
totalYoe?: number;
};
export type SubmitOfferFormData = {
export type OfferProfileFormData = {
background: BackgroundFormData;
offers: Array<OfferDetailsFormData>;
};
export type OfferProfilePostData = {
background: BackgroundFormData;
offers: Array<OfferDetailsPostData>;
};
type EducationDisplay = {
endDate?: string;
field: string;
school: string;
startDate?: string;
type: string;
};
export type OfferEntity = {
base?: string;
bonus?: string;
companyName?: string;
duration?: string;
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>;
profileName: string;
specificYoes: Array<SpecificYoe>;
totalYoe: string;
};

@ -1,7 +0,0 @@
export function formatDate(value: Date | number | string) {
const date = new Date(value);
// Const day = date.toLocaleString('default', { day: '2-digit' });
const month = date.toLocaleString('default', { month: 'short' });
const year = date.toLocaleString('default', { year: 'numeric' });
return `${month} ${year}`;
}

@ -1,8 +1,11 @@
import { format } from 'date-fns';
import { useAnswerCommentVote } from '~/utils/questions/useVote';
import VotingButtons from './VotingButtons';
export type CommentListItemProps = {
export type AnswerCommentListItemProps = {
answerCommentId: string;
authorImageUrl: string;
authorName: string;
content: string;
@ -10,16 +13,26 @@ export type CommentListItemProps = {
upvoteCount: number;
};
export default function CommentListItem({
export default function AnswerCommentListItem({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: CommentListItemProps) {
answerCommentId,
}: AnswerCommentListItemProps) {
const { handleDownvote, handleUpvote, vote } =
useAnswerCommentVote(answerCommentId);
return (
<div className="flex gap-4 border bg-white p-2 ">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<VotingButtons
size="sm"
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
@ -28,7 +41,7 @@ export default function CommentListItem({
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
Posted on: {format(createdAt, 'h:mm a, MMMM dd, yyyy')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>

@ -40,7 +40,7 @@ export default function ContributeQuestionCard({
placeholder="Contribute a question"
onChange={handleOpenContribute}
/>
<div className="flex items-end justify-center gap-x-2">
<div className="flex flex-wrap items-end justify-center gap-x-2">
<div className="min-w-[150px] flex-1">
<TextInput
disabled={true}

@ -1,7 +1,6 @@
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { HorizontalDivider } from '@tih/ui';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import ContributeQuestionForm from './ContributeQuestionForm';
@ -62,9 +61,9 @@ export default function ContributeQuestionDialog({
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">
<div className="bg-white p-6 pt-5 sm:pb-4">
<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:ml-4 sm:text-left">
<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">

@ -1,12 +1,16 @@
import { startOfMonth } from 'date-fns';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
UserIcon,
} from '@heroicons/react/24/outline';
import { Controller, useForm } from 'react-hook-form';
import { CalendarDaysIcon, UserIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Collapsible, Select, TextArea, TextInput } from '@tih/ui';
import {
Button,
CheckboxInput,
Collapsible,
Select,
TextArea,
TextInput,
} from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import {
@ -14,7 +18,9 @@ import {
useSelectRegister,
} from '~/utils/questions/useFormRegister';
import Checkbox from './ui-patch/Checkbox';
import CompaniesTypeahead from '../shared/CompaniesTypeahead';
import type { Month } from '../shared/MonthYearPicker';
import MonthYearPicker from '../shared/MonthYearPicker';
export type ContributeQuestionData = {
company: string;
@ -35,8 +41,15 @@ export default function ContributeQuestionForm({
onSubmit,
onDiscard,
}: ContributeQuestionFormProps) {
const { register: formRegister, handleSubmit } =
useForm<ContributeQuestionData>();
const {
control,
register: formRegister,
handleSubmit,
} = useForm<ContributeQuestionData>({
defaultValues: {
date: startOfMonth(new Date()),
},
});
const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister);
@ -66,24 +79,35 @@ export default function ContributeQuestionForm({
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Company"
required={true}
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
{...register('company')}
<Controller
control={control}
name="company"
render={({ field }) => (
<CompaniesTypeahead
onSelect={({ label }) => {
// TODO: To change from using company name to company id (i.e., value)
field.onChange(label);
}}
/>
)}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Date"
required={true}
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('date', {
valueAsDate: true,
})}
<Controller
control={control}
name="date"
render={({ field }) => (
<MonthYearPicker
value={{
month: (field.value.getMonth() + 1) as Month,
year: field.value.getFullYear(),
}}
onChange={({ month, year }) =>
field.onChange(startOfMonth(new Date(year, month - 1)))
}
/>
)}
/>
</div>
</div>
@ -130,10 +154,11 @@ 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">
<Checkbox
checked={canSubmit}
<CheckboxInput
label="I have checked that my question is new"
onChange={handleCheckSimilarQuestions}></Checkbox>
value={canSubmit}
onChange={handleCheckSimilarQuestions}
/>
</div>
<div className=" flex gap-x-2">
<button

@ -1,5 +0,0 @@
export default function QuestionBankTitle() {
return (
<h1 className="text-center text-4xl font-bold">Interview Questions</h1>
);
}

@ -1,5 +1,8 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Select, TextInput } from '@tih/ui';
import {
AdjustmentsHorizontalIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import { Button, Select, TextInput } from '@tih/ui';
export type SortOption = {
label: string;
@ -7,6 +10,7 @@ export type SortOption = {
};
export type QuestionSearchBarProps<SortOptions extends Array<SortOption>> = {
onFilterOptionsToggle: () => void;
onSortChange?: (sortValue: SortOptions[number]['value']) => void;
sortOptions: SortOptions;
sortValue: SortOptions[number]['value'];
@ -18,10 +22,11 @@ export default function QuestionSearchBar<
onSortChange,
sortOptions,
sortValue,
onFilterOptionsToggle,
}: QuestionSearchBarProps<SortOptions>) {
return (
<div className="flex items-center gap-2">
<div className="flex-1 pt-1">
<div className="flex items-center gap-4">
<div className="flex-1">
<TextInput
isLabelHidden={true}
label="Search by content"
@ -30,16 +35,28 @@ export default function QuestionSearchBar<
startAddOnType="icon"
/>
</div>
<span aria-hidden={true} className="pl-3 pr-1 pt-1 text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={sortOptions}
value={sortValue}
onChange={onSortChange}></Select>
<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>
</div>
);
}

@ -0,0 +1,17 @@
import type { QuestionsQuestionType } from '@prisma/client';
import { Badge } from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
export type QuestionTypeBadgeProps = {
type: QuestionsQuestionType;
};
export default function QuestionTypeBadge({ type }: QuestionTypeBadgeProps) {
return (
<Badge
label={QUESTION_TYPES.find(({ value }) => value === type)!.label}
variant="info"
/>
);
}

@ -1,7 +1,6 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [
{ href: '/questions', name: 'Home' },
{ href: '/questions', name: 'My Lists' },
{ href: '/questions', name: 'My Questions' },
{ href: '/questions', name: 'History' },
@ -11,6 +10,7 @@ const config = {
navigation,
showGlobalNav: false,
title: 'Questions Bank',
titleHref: '/questions',
};
export default config;

@ -1,16 +1,36 @@
import React from 'react';
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import type { Vote } from '@prisma/client';
import type { ButtonSize } from '@tih/ui';
import { Button } from '@tih/ui';
export type VotingButtonsProps = {
export type BackendVote = {
id: string;
vote: Vote;
};
export type VotingButtonsCallbackProps = {
onDownvote: () => void;
onUpvote: () => void;
vote: BackendVote | null;
};
export type VotingButtonsProps = VotingButtonsCallbackProps & {
size?: ButtonSize;
upvoteCount: number;
};
export default function VotingButtons({
vote,
onDownvote,
onUpvote,
upvoteCount,
size = 'md',
}: VotingButtonsProps) {
const upvoteButtonVariant =
vote?.vote === 'UPVOTE' ? 'secondary' : 'tertiary';
const downvoteButtonVariant =
vote?.vote === 'DOWNVOTE' ? 'secondary' : 'tertiary';
return (
<div className="flex flex-col items-center">
<Button
@ -18,7 +38,12 @@ export default function VotingButtons({
isLabelHidden={true}
label="Upvote"
size={size}
variant="tertiary"
variant={upvoteButtonVariant}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onUpvote();
}}
/>
<p>{upvoteCount}</p>
<Button
@ -26,7 +51,12 @@ export default function VotingButtons({
isLabelHidden={true}
label="Downvote"
size={size}
variant="tertiary"
variant={downvoteButtonVariant}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onDownvote();
}}
/>
</div>
);

@ -1,48 +1,63 @@
import { format } from 'date-fns';
import { ChatBubbleLeftRightIcon } from '@heroicons/react/24/outline';
import withHref from '~/utils/questions/withHref';
import { useAnswerVote } from '~/utils/questions/useVote';
import type { VotingButtonsProps } from '../VotingButtons';
import VotingButtons from '../VotingButtons';
export type AnswerCardProps = {
answerId: string;
authorImageUrl: string;
authorName: string;
commentCount: number;
commentCount?: number;
content: string;
createdAt: Date;
upvoteCount: number;
votingButtonsSize: VotingButtonsProps['size'];
};
function AnswerCardWithoutHref({
export default function AnswerCard({
answerId,
authorName,
authorImageUrl,
upvoteCount,
content,
createdAt,
commentCount,
votingButtonsSize,
upvoteCount,
}: AnswerCardProps) {
const { handleUpvote, handleDownvote, vote } = useAnswerVote(answerId);
return (
<div className="flex gap-4 rounded-md border bg-white p-2 hover:bg-slate-50">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<div className="mt-1 flex flex-col gap-1">
<article className="flex gap-4 rounded-md border bg-white p-2">
<VotingButtons
size={votingButtonsSize}
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
<p className="text-xs font-extralight">
Posted on: {format(createdAt, 'h:mm a, MMMM dd, yyyy')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
<p className="py-1 pl-3 text-sm font-light underline underline-offset-4">
{commentCount} comment(s)
</p>
<p>{content}</p>
{commentCount !== undefined && (
<div className="flex items-center gap-2 text-slate-500">
<ChatBubbleLeftRightIcon className="h-6 w-6" />
<p className="text-sm font-medium">
{commentCount} {commentCount === 1 ? 'comment' : 'comments'}
</p>
</div>
)}
</div>
</div>
</article>
);
}
const AnswerCard = withHref(AnswerCardWithoutHref);
export default AnswerCard;

@ -1,38 +1,11 @@
import { format } from 'date-fns';
import type { AnswerCardProps } from './AnswerCard';
import AnswerCard from './AnswerCard';
import VotingButtons from '../VotingButtons';
export type FullAnswerCardProps = Omit<
AnswerCardProps,
'commentCount' | 'votingButtonsSize'
>;
export type FullAnswerCardProps = {
authorImageUrl: string;
authorName: string;
content: string;
createdAt: Date;
upvoteCount: number;
};
export default function FullAnswerCard({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: FullAnswerCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
<VotingButtons upvoteCount={upvoteCount}></VotingButtons>
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
</div>
</article>
);
export default function FullAnswerCard(props: FullAnswerCardProps) {
return <AnswerCard {...props} votingButtonsSize="md" />;
}

@ -1,58 +1,26 @@
import { Badge } from '@tih/ui';
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
import VotingButtons from '../VotingButtons';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: false;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
export type FullQuestionCardProps = UpvoteProps & {
company: string;
content: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
type: string;
};
export default function FullQuestionCard({
company,
content,
showVoteButtons,
upvoteCount,
timestamp,
role,
location,
type,
}: FullQuestionCardProps) {
const altText = company + ' logo';
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<img alt={altText} src="https://logo.clearbit.com/google.com"></img>
<h2 className="ml-2 text-xl">{company}</h2>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500">
<Badge label={type} variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
</div>
<div className="mx-2 mb-2">
<p>{content}</p>
</div>
</div>
</article>
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={false}
showVoteButtons={true}
/>
);
}

@ -0,0 +1,15 @@
import withHref from '~/utils/questions/withHref';
import type { AnswerCardProps } from './AnswerCard';
import AnswerCard from './AnswerCard';
export type QuestionAnswerCardProps = Required<
Omit<AnswerCardProps, 'votingButtonsSize'>
>;
function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) {
return <AnswerCard {...props} votingButtonsSize="sm" />;
}
const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref);
export default QuestionAnswerCard;

@ -1,9 +1,10 @@
import {
ChatBubbleBottomCenterTextIcon,
// EyeIcon,
} from '@heroicons/react/24/outline';
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 =
@ -41,16 +42,19 @@ type ActionButtonProps =
export type QuestionCardProps = ActionButtonProps &
StatisticsProps &
UpvoteProps & {
company: string;
content: string;
href?: string;
location: string;
questionId: string;
receivedCount: number;
role: string;
timestamp: string;
type: string;
type: QuestionsQuestionType;
};
export default function QuestionCard({
questionId,
company,
answerCount,
content,
// ReceivedCount,
@ -65,13 +69,23 @@ export default function QuestionCard({
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 hover:bg-slate-50">
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
<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-center gap-2 text-slate-500">
<Badge label={type} variant="primary" />
<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>

@ -1,8 +1,5 @@
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Collapsible, TextInput } from '@tih/ui';
import Checkbox from '../ui-patch/Checkbox';
import RadioGroup from '../ui-patch/RadioGroup';
import { CheckboxInput, Collapsible, RadioList, TextInput } from '@tih/ui';
export type FilterOption<V extends string = string> = {
checked: boolean;
@ -66,17 +63,29 @@ export default function FilterSection<
/>
)}
{isSingleSelect ? (
<RadioGroup
radioData={options}
onChange={(value) => {
onOptionChange(value);
}}></RadioGroup>
<div className="px-1.5">
<RadioList
label=""
value={options.find((option) => option.checked)?.value}
onChange={(value) => {
onOptionChange(value);
}}>
{options.map((option) => (
<RadioList.Item
key={option.value}
label={option.label}
value={option.value}
/>
))}
</RadioList>
</div>
) : (
<div className="mx-1">
<div className="px-1.5">
{options.map((option) => (
<Checkbox
<CheckboxInput
key={option.value}
{...option}
label={option.label}
value={option.checked}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}

@ -1,25 +0,0 @@
import { useId } from 'react';
export type CheckboxProps = {
checked: boolean;
label: string;
onChange: (checked: boolean) => void;
};
export default function Checkbox({ label, checked, onChange }: CheckboxProps) {
const id = useId();
return (
<div className="flex items-center">
<input
checked={checked}
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-gray-300"
id={id}
type="checkbox"
onChange={(event) => onChange(event.target.checked)}
/>
<label className="ml-3 min-w-0 flex-1 text-gray-700" htmlFor={id}>
{label}
</label>
</div>
);
}

@ -1,36 +0,0 @@
export type RadioProps = {
onChange: (value: string) => void;
radioData: Array<RadioData>;
};
export type RadioData = {
checked: boolean;
label: string;
value: string;
};
export default function RadioGroup({ radioData, onChange }: RadioProps) {
return (
<div className="mx-1 space-y-1">
{radioData.map((radio) => (
<div key={radio.value} className="flex items-center">
<input
checked={radio.checked}
className="text-primary-600 focus:ring-primary-500 h-4 w-4 border-gray-300"
type="radio"
value={radio.value}
onChange={(event) => {
const target = event.target as HTMLInputElement;
onChange(target.value);
}}
/>
<label
className="ml-3 min-w-0 flex-1 text-gray-700"
htmlFor={radio.value}>
{radio.label}
</label>
</div>
))}
</div>
);
}

@ -1,7 +1,13 @@
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 } from '@heroicons/react/20/solid';
import {
ArrowLeftIcon,
ArrowRightIcon,
MagnifyingGlassMinusIcon,
MagnifyingGlassPlusIcon,
} from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
@ -13,21 +19,69 @@ type Props = Readonly<{
export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = 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>
<Document
className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-scroll"
file={url}
loading={<Spinner display="block" label="" size="lg" />}
noData=""
onLoadSuccess={onPdfLoadSuccess}>
<Page pageNumber={pageNumber} />
</Document>
<div id="pdfView">
<div className="group relative">
<Document
className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-auto"
file={url}
loading={<Spinner display="block" size="lg" />}
noData=""
onLoadSuccess={onPdfLoadSuccess}>
<div
style={{
paddingLeft: clsx(
pageWidth > componentWidth
? `${pageWidth - componentWidth}px`
: '',
),
}}>
<Page pageNumber={pageNumber} 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={pageWidth === 450}
icon={MagnifyingGlassMinusIcon}
isLabelHidden={true}
label="Zoom Out"
variant="tertiary"
onClick={() => setPageWidth(pageWidth - 150)}
/>
<Button
className="rounded-l-none focus:ring-0 focus:ring-offset-0"
disabled={pageWidth === 1050}
icon={MagnifyingGlassPlusIcon}
isLabelHidden={true}
label="Zoom In"
variant="tertiary"
onClick={() => setPageWidth(pageWidth + 150)}
/>
</div>
</Document>
</div>
<div className="flex flex-row items-center justify-between p-4">
<Button

@ -3,7 +3,7 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [
{
children: [],
href: '/resumes',
href: '/resumes/browse',
name: 'Browse',
},
{ children: [], href: '/resumes/submit', name: 'Submit for review' },
@ -11,6 +11,7 @@ const navigation: ProductNavigationItems = [
children: [],
href: 'https://www.techinterviewhandbook.org/resume/',
name: 'Resume Guide',
target: '_blank',
},
];
@ -18,6 +19,7 @@ const config = {
navigation,
showGlobalNav: false,
title: 'Resumes',
titleHref: '/resumes',
};
export default config;

@ -3,7 +3,7 @@ type Props = Readonly<{
title: string;
}>;
export default function FilterPill({ title, onClick }: Props) {
export default function ResumeFilterPill({ title, onClick }: 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"

@ -1,7 +1,12 @@
import { formatDistanceToNow } from 'date-fns';
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Link from 'next/link';
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 type { Resume } from '~/types/resume';
@ -11,33 +16,48 @@ type Props = Readonly<{
resumeInfo: Resume;
}>;
export default function BrowseListItem({ href, resumeInfo }: Props) {
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">
<div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100">
<div className="col-span-4">
{resumeInfo.title}
<div className="mt-2 flex items-center justify-start text-xs text-indigo-500">
{resumeInfo.role}
<div className="ml-6 rounded-md border border-indigo-500 p-1">
<div className="flex">
<BriefcaseIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{resumeInfo.role}
</div>
<div className="ml-4 flex">
<AcademicCapIcon
aria-hidden="true"
className="mr-1.5 h-4 w-4 flex-shrink-0"
/>
{resumeInfo.experience}
</div>
</div>
<div className="mt-2 flex justify-start text-xs text-slate-500">
<div className="flex gap-2 pr-8">
<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
</div>
<div className="flex gap-2">
<StarIcon className="w-4" />
{resumeInfo.isStarredByUser ? (
<ColouredStarIcon className="w-4 text-yellow-400" />
) : (
<StarIcon className="w-4" />
)}
{resumeInfo.numStars} stars
</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>

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

@ -1,89 +0,0 @@
export const BROWSE_TABS_VALUES = {
ALL: 'all',
MY: 'my',
STARRED: 'starred',
};
export const SORT_OPTIONS = [
{ current: true, href: '#', name: 'Latest' },
{ current: false, href: '#', name: 'Popular' },
{ current: false, href: '#', name: 'Top Comments' },
];
export const TOP_HITS = [
{ href: '#', name: 'Unreviewed' },
{ href: '#', name: 'Fresh Grad' },
{ href: '#', name: 'GOATs' },
{ href: '#', name: 'US Only' },
];
export const ROLES = [
{
checked: false,
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{ checked: false, label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ checked: false, label: 'Backend Engineer', value: 'Backend Engineer' },
{ checked: false, label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ checked: false, label: 'iOS Engineer', value: 'iOS Engineer' },
{ checked: false, label: 'Android Engineer', value: 'Android Engineer' },
];
export const EXPERIENCE = [
{ checked: false, label: 'Freshman', value: 'Freshman' },
{ checked: false, label: 'Sophomore', value: 'Sophomore' },
{ checked: false, label: 'Junior', value: 'Junior' },
{ checked: false, label: 'Senior', value: 'Senior' },
{
checked: false,
label: 'Fresh Grad (0-1 years)',
value: 'Fresh Grad (0-1 years)',
},
{
checked: false,
label: 'Mid-level (2 - 5 years)',
value: 'Mid-level (2 - 5 years)',
},
{
checked: false,
label: 'Senior (5+ years)',
value: 'Senior (5+ years)',
},
];
export const LOCATION = [
{ checked: false, label: 'Singapore', value: 'Singapore' },
{ checked: false, label: 'United States', value: 'United States' },
{ checked: false, 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,146 @@
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';
type SortOption = {
name: string;
value: SortOrder;
};
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: Array<SortOption> = [
{ name: 'Latest', value: 'latest' },
{ name: 'Popular', value: 'popular' },
{ name: 'Top Comments', value: 'topComments' },
];
export const ROLE: Array<FilterOption<RoleFilter>> = [
{
label: 'Full-Stack Engineer',
value: 'Full-Stack Engineer',
},
{ label: 'Frontend Engineer', value: 'Frontend Engineer' },
{ label: 'Backend Engineer', value: 'Backend Engineer' },
{ label: 'DevOps Engineer', value: 'DevOps Engineer' },
{ label: 'iOS Engineer', value: 'iOS Engineer' },
{ label: 'Android Engineer', value: 'Android Engineer' },
];
export const EXPERIENCE: Array<FilterOption<ExperienceFilter>> = [
{ label: 'Freshman', value: 'Freshman' },
{ label: 'Sophomore', value: 'Sophomore' },
{ label: 'Junior', value: 'Junior' },
{ label: 'Senior', value: 'Senior' },
{
label: 'Entry Level (0 - 2 years)',
value: 'Entry Level (0 - 2 years)',
},
{
label: 'Mid Level (3 - 5 years)',
value: 'Mid Level (3 - 5 years)',
},
{
label: 'Senior Level (5+ years)',
value: 'Senior Level (5+ years)',
},
];
export const LOCATION: Array<FilterOption<LocationFilter>> = [
{ label: 'Singapore', value: 'Singapore' },
{ label: 'United States', value: 'United States' },
{ label: 'India', value: 'India' },
];
export const INITIAL_FILTER_STATE: FilterState = {
experience: Object.values(EXPERIENCE).map(({ value }) => value),
location: Object.values(LOCATION).map(({ value }) => value),
role: Object.values(ROLE).map(({ value }) => value),
};
export const SHORTCUTS: Array<Shortcut> = [
{
filters: INITIAL_FILTER_STATE,
name: 'All',
sortOrder: 'latest',
},
{
filters: {
...INITIAL_FILTER_STATE,
numComments: 0,
},
name: 'Unreviewed',
sortOrder: 'latest',
},
{
filters: {
...INITIAL_FILTER_STATE,
experience: ['Entry Level (0 - 2 years)'],
},
name: 'Fresh Grad',
sortOrder: 'latest',
},
{
filters: INITIAL_FILTER_STATE,
name: 'GOATs',
sortOrder: 'popular',
},
{
filters: {
...INITIAL_FILTER_STATE,
location: ['United States'],
},
name: 'US Only',
sortOrder: 'latest',
},
];

@ -1,35 +0,0 @@
import { useSession } from 'next-auth/react';
import { Spinner } from '@tih/ui';
import Comment from './comment/Comment';
import type { ResumeComment } from '~/types/resume-comments';
type Props = Readonly<{
comments: Array<ResumeComment>;
isLoading: boolean;
}>;
export default function CommentListItems({ comments, isLoading }: Props) {
const { data: session } = useSession();
if (isLoading) {
return (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</div>
);
}
return (
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-3 overflow-y-scroll">
{comments.map((comment) => (
<Comment
key={comment.id}
comment={comment}
userId={session?.user?.id}
/>
))}
</div>
);
}

@ -1,54 +0,0 @@
import { useSession } from 'next-auth/react';
import { useState } from 'react';
import { Tabs } from '@tih/ui';
import { Button } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import CommentListItems from './CommentListItems';
import { COMMENTS_SECTIONS } from './constants';
import SignInButton from '../SignInButton';
type CommentsListProps = Readonly<{
resumeId: string;
setShowCommentsForm: (show: boolean) => void;
}>;
export default function CommentsList({
resumeId,
setShowCommentsForm,
}: CommentsListProps) {
const { data: sessionData } = useSession();
const [tab, setTab] = useState(COMMENTS_SECTIONS[0].value);
const commentsQuery = trpc.useQuery(['resumes.reviews.list', { resumeId }]);
const renderButton = () => {
if (sessionData === null) {
return <SignInButton text="to join discussion" />;
}
return (
<Button
display="block"
label="Add your review"
variant="tertiary"
onClick={() => setShowCommentsForm(true)}
/>
);
};
return (
<div className="space-y-3">
{renderButton()}
<Tabs
label="comments"
tabs={COMMENTS_SECTIONS}
value={tab}
onChange={(value) => setTab(value)}
/>
<CommentListItems
comments={commentsQuery.data?.filter((c) => c.section === tab) ?? []}
isLoading={commentsQuery.isFetching}
/>
</div>
);
}

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

@ -5,7 +5,7 @@ import { Button, Dialog, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type CommentsFormProps = Readonly<{
type ResumeCommentsFormProps = Readonly<{
resumeId: string;
setShowCommentsForm: (show: boolean) => void;
}>;
@ -20,10 +20,10 @@ type IFormInput = {
type InputKeys = keyof IFormInput;
export default function CommentsForm({
export default function ResumeCommentsForm({
resumeId,
setShowCommentsForm,
}: CommentsFormProps) {
}: ResumeCommentsFormProps) {
const [showDialog, setShowDialog] = useState(false);
const {
register,
@ -41,16 +41,19 @@ export default function CommentsForm({
});
const trpcContext = trpc.useContext();
const reviewCreateMutation = trpc.useMutation('resumes.reviews.user.create', {
onSuccess: () => {
// New review added, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.reviews.list']);
const commentCreateMutation = trpc.useMutation(
'resumes.comments.user.create',
{
onSuccess: () => {
// New Comment added, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
},
},
});
);
// TODO: Give a feedback to the user if the action succeeds/fails
const onSubmit: SubmitHandler<IFormInput> = async (data) => {
return await reviewCreateMutation.mutate(
return await commentCreateMutation.mutate(
{
resumeId,
...data,
@ -77,7 +80,7 @@ export default function CommentsForm({
};
return (
<div className="h-[calc(100vh-13rem)] overflow-y-scroll">
<div className="h-[calc(100vh-13rem)] overflow-y-auto">
<h2 className="text-xl font-semibold text-gray-800">Add your review</h2>
<p className="text-gray-800">
Please fill in at least one section to submit your review
@ -89,7 +92,7 @@ export default function CommentsForm({
<div className="mt-4 space-y-4">
<TextArea
{...(register('general'), {})}
disabled={reviewCreateMutation.isLoading}
disabled={commentCreateMutation.isLoading}
label="General"
placeholder="General comments about the resume"
onChange={(value) => onValueChange('general', value)}
@ -97,7 +100,7 @@ export default function CommentsForm({
<TextArea
{...(register('education'), {})}
disabled={reviewCreateMutation.isLoading}
disabled={commentCreateMutation.isLoading}
label="Education"
placeholder="Comments about the Education section"
onChange={(value) => onValueChange('education', value)}
@ -105,7 +108,7 @@ export default function CommentsForm({
<TextArea
{...(register('experience'), {})}
disabled={reviewCreateMutation.isLoading}
disabled={commentCreateMutation.isLoading}
label="Experience"
placeholder="Comments about the Experience section"
onChange={(value) => onValueChange('experience', value)}
@ -113,7 +116,7 @@ export default function CommentsForm({
<TextArea
{...(register('projects'), {})}
disabled={reviewCreateMutation.isLoading}
disabled={commentCreateMutation.isLoading}
label="Projects"
placeholder="Comments about the Projects section"
onChange={(value) => onValueChange('projects', value)}
@ -121,7 +124,7 @@ export default function CommentsForm({
<TextArea
{...(register('skills'), {})}
disabled={reviewCreateMutation.isLoading}
disabled={commentCreateMutation.isLoading}
label="Skills"
placeholder="Comments about the Skills section"
onChange={(value) => onValueChange('skills', value)}
@ -130,7 +133,7 @@ export default function CommentsForm({
<div className="flex justify-end space-x-2 pt-4">
<Button
disabled={reviewCreateMutation.isLoading}
disabled={commentCreateMutation.isLoading}
label="Cancel"
type="button"
variant="tertiary"
@ -138,8 +141,8 @@ export default function CommentsForm({
/>
<Button
disabled={!isDirty || reviewCreateMutation.isLoading}
isLoading={reviewCreateMutation.isLoading}
disabled={!isDirty || commentCreateMutation.isLoading}
isLoading={commentCreateMutation.isLoading}
label="Submit"
type="submit"
variant="primary"

@ -0,0 +1,113 @@
import { useSession } from 'next-auth/react';
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';
import { RESUME_COMMENTS_SECTIONS } from './resumeCommentConstants';
import ResumeCommentListItem from './ResumeCommentListItem';
import ResumeSignInButton from '../shared/ResumeSignInButton';
import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentsListProps = Readonly<{
resumeId: string;
setShowCommentsForm: (show: boolean) => void;
}>;
export default function ResumeCommentsList({
resumeId,
setShowCommentsForm,
}: ResumeCommentsListProps) {
const { data: sessionData } = useSession();
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }]);
const renderIcon = (section: ResumesSection) => {
const className = 'h-8 w-8';
switch (section) {
case ResumesSection.GENERAL:
return <IdentificationIcon className={className} />;
case ResumesSection.EDUCATION:
return <BookOpenIcon className={className} />;
case ResumesSection.EXPERIENCE:
return <BriefcaseIcon className={className} />;
case ResumesSection.PROJECTS:
return <CodeBracketSquareIcon className={className} />;
case ResumesSection.SKILLS:
return <SparklesIcon className={className} />;
default:
return <FaceSmileIcon className={className} />;
}
};
const renderButton = () => {
if (sessionData === null) {
return <ResumeSignInButton text="to join discussion" />;
}
return (
<Button
display="block"
label="Add your review"
variant="tertiary"
onClick={() => setShowCommentsForm(true)}
/>
);
};
return (
<div className="space-y-3">
{renderButton()}
{commentsQuery.isFetching ? (
<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-4 overflow-y-auto">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {
return (comment.section as string) === value;
})
: [];
const commentCount = comments.length;
return (
<div key={value} className="mb-4 space-y-3">
<div className="flex flex-row items-center space-x-2 text-indigo-800">
{renderIcon(value)}
<div className="w-fit text-xl font-medium">{label}</div>
</div>
{commentCount > 0 ? (
comments.map((comment) => {
return (
<ResumeCommentListItem
key={comment.id}
comment={comment}
userId={sessionData?.user?.id}
/>
);
})
) : (
<div>There are no comments for this section yet!</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}

@ -1,13 +1,15 @@
import { useState } from 'react';
import CommentsForm from './CommentsForm';
import CommentsList from './CommentsList';
import ResumeCommentsForm from './ResumeCommentsForm';
import ResumeCommentsList from './ResumeCommentsList';
type ICommentsSectionProps = {
type CommentsSectionProps = {
resumeId: string;
};
export default function CommentsSection({ resumeId }: ICommentsSectionProps) {
export default function ResumeCommentsSection({
resumeId,
}: CommentsSectionProps) {
const [showCommentsForm, setShowCommentsForm] = useState(false);
return (
@ -18,17 +20,17 @@ export default function CommentsSection({ resumeId }: ICommentsSectionProps) {
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
Comments
Reviews
</span>
</div>
</div>
{showCommentsForm ? (
<CommentsForm
<ResumeCommentsForm
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
) : (
<CommentsList
<ResumeCommentsList
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>

@ -1,18 +0,0 @@
import CommentBody from './CommentBody';
import CommentCard from './CommentCard';
import type { ResumeComment } from '~/types/resume-comments';
type CommentProps = {
comment: ResumeComment;
userId?: string;
};
export default function Comment({ comment, userId }: CommentProps) {
const isCommentOwner = userId === comment.user.userId;
return (
<CommentCard isCommentOwner={isCommentOwner}>
<CommentBody comment={comment} isCommentOwner={isCommentOwner} />
</CommentCard>
);
}

@ -1,64 +0,0 @@
import {
ArrowDownCircleIcon,
ArrowUpCircleIcon,
} from '@heroicons/react/20/solid';
import { FaceSmileIcon } from '@heroicons/react/24/outline';
import type { ResumeComment } from '~/types/resume-comments';
type CommentBodyProps = {
comment: ResumeComment;
isCommentOwner?: boolean;
};
export default function CommentBody({
comment,
isCommentOwner,
}: CommentBodyProps) {
return (
<div className="flex w-full flex-row space-x-2 p-1 align-top">
{comment.user.image ? (
<img
alt={comment.user.name ?? 'Reviewer'}
className="mt-1 h-8 w-8 rounded-full"
src={comment.user.image!}
/>
) : (
<FaceSmileIcon className="h-8 w-8 rounded-full" />
)}
<div className="flex w-full flex-col space-y-1">
{/* Name and creation time */}
<div className="flex flex-row justify-between">
<div className="font-medium">
{comment.user.name ?? 'Reviewer ABC'}
</div>
<div className="text-xs text-gray-600">
{comment.createdAt.toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})}
</div>
</div>
{/* Description */}
<div className="text-sm">{comment.description}</div>
{/* 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}
</div>
</div>
</div>
);
}

@ -1,22 +0,0 @@
import type { ReactNode } from 'react';
type CommentCardProps = {
children: ReactNode;
isCommentOwner?: boolean;
};
export default function CommentCard({
isCommentOwner,
children,
}: CommentCardProps) {
// Used two different <div> to allow customisation of owner comments
return isCommentOwner ? (
<div className="border-primary-300 float-right w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
{children}
</div>
) : (
<div className="border-primary-300 float-left w-3/4 rounded-md border-2 bg-white p-2 drop-shadow-md">
{children}
</div>
);
}

@ -1,6 +1,6 @@
import { ResumesSection } from '@prisma/client';
export const COMMENTS_SECTIONS = [
export const RESUME_COMMENTS_SECTIONS = [
{
label: 'General',
value: ResumesSection.GENERAL,

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

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

@ -0,0 +1,49 @@
import Link from 'next/link';
import { Container } from './Container';
export function Hero() {
return (
<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-indigo-500">
<svg
aria-hidden="true"
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-blue-300/70"
preserveAspectRatio="none"
viewBox="0 0 418 42">
<path d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
</svg>
<span className="relative">made simple</span>
</span>{' '}
for software engineers.
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg tracking-tight text-slate-700">
Get valuable feedback from the public or checkout reviewed resumes from
your fellow engineers
</p>
<div className="mt-10 flex justify-center gap-x-4">
<Link href="/resumes/browse">
<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
className="group inline-flex items-center justify-center 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-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>
</Link>
</div>
</Container>
);
}

@ -0,0 +1,34 @@
import type { FC } from 'react';
export const Logo: FC = (props) => {
return (
<svg aria-hidden="true" viewBox="0 0 109 40" {...props}>
<path
clipRule="evenodd"
d="M0 20c0 11.046 8.954 20 20 20s20-8.954 20-20S31.046 0 20 0 0 8.954 0 20Zm20 16c-7.264 0-13.321-5.163-14.704-12.02C4.97 22.358 6.343 21 8 21h24c1.657 0 3.031 1.357 2.704 2.98C33.32 30.838 27.264 36 20 36Z"
fill="#2563EB"
fillRule="evenodd"
/>
<path
d="M55.528 26.57V15.842H52V13.97h9.108v1.872h-3.636V26.57h-1.944Z"
fill="#0F172A"
/>
<path
d="M83.084 26.57v-12.6h5.346c.744 0 1.416.18 2.016.54a3.773 3.773 0 0 1 1.44 1.44c.36.612.54 1.302.54 2.07 0 .78-.18 1.482-.54 2.106a4 4 0 0 1-1.44 1.494c-.6.36-1.272.54-2.016.54h-2.646v4.41h-2.7Zm2.664-6.84h2.376c.288 0 .546-.072.774-.216.228-.156.408-.36.54-.612a1.71 1.71 0 0 0 .216-.864c0-.324-.072-.606-.216-.846a1.394 1.394 0 0 0-.54-.576 1.419 1.419 0 0 0-.774-.216h-2.376v3.33ZM106.227 26.57V13.25h2.556v13.32h-2.556Z"
fill="#2563EB"
/>
<path
clipRule="evenodd"
d="M95.906 26.102c.636.432 1.35.648 2.142.648.444 0 .864-.066 1.26-.198a4.25 4.25 0 0 0 1.062-.558 3.78 3.78 0 0 0 .702-.668v1.244h2.574v-9.522h-2.538v1.248a3.562 3.562 0 0 0-.648-.672 3.13 3.13 0 0 0-1.026-.558 3.615 3.615 0 0 0-1.278-.216c-.828 0-1.566.216-2.214.648-.648.42-1.164 1.002-1.548 1.746-.372.732-.558 1.578-.558 2.538 0 .96.186 1.812.558 2.556.372.744.876 1.332 1.512 1.764Zm4.104-1.908c-.36.228-.78.342-1.26.342-.468 0-.882-.114-1.242-.342a2.387 2.387 0 0 1-.828-.954c-.204-.42-.306-.906-.306-1.458 0-.54.102-1.014.306-1.422.204-.408.48-.726.828-.954.36-.24.774-.36 1.242-.36.48 0 .9.12 1.26.36.36.228.636.546.828.954.204.408.306.882.306 1.422 0 .552-.102 1.038-.306 1.458a2.218 2.218 0 0 1-.828.954Z"
fill="#2563EB"
fillRule="evenodd"
/>
<path
clipRule="evenodd"
d="m76.322 23.197 2.595 3.373h2.268l-3.662-4.787 3.338-4.663h-2.196l-2.162 3.334-2.554-3.334h-2.34l3.652 4.71-3.634 4.74h2.196l2.5-3.373ZM62.738 26.102a3.78 3.78 0 0 0 2.142.648c.456 0 .888-.072 1.296-.216.42-.144.798-.336 1.134-.576a3.418 3.418 0 0 0 .864-.835v1.447h1.872v-9.45h-1.872v1.45a3.118 3.118 0 0 0-.72-.82 3.2 3.2 0 0 0-1.062-.612 4.033 4.033 0 0 0-1.35-.216c-.828 0-1.578.21-2.25.63-.66.42-1.188 1.002-1.584 1.746-.384.732-.576 1.572-.576 2.52 0 .936.192 1.776.576 2.52.384.744.894 1.332 1.53 1.764Zm4.122-1.476c-.432.276-.93.414-1.494.414a2.682 2.682 0 0 1-1.476-.414 2.987 2.987 0 0 1-1.008-1.134c-.24-.492-.36-1.05-.36-1.674 0-.612.12-1.158.36-1.638.252-.48.588-.858 1.008-1.134a2.682 2.682 0 0 1 1.476-.414c.564 0 1.062.138 1.494.414.432.276.768.654 1.008 1.134.252.48.378 1.026.378 1.638 0 .624-.126 1.182-.378 1.674-.24.48-.576.858-1.008 1.134Z"
fill="#0F172A"
fillRule="evenodd"
/>
</svg>
);
};

@ -0,0 +1,130 @@
import clsx from 'clsx';
import Image from 'next/future/image';
import { useEffect, useState } from 'react';
import { Tab } from '@headlessui/react';
import { Container } from './Container';
import screenshotExpenses from './images/screenshots/expenses.png';
import screenshotPayroll from './images/screenshots/payroll.png';
import screenshotVatReturns from './images/screenshots/vat-returns.png';
const features = [
{
description:
'Browse the most popular reviewed resumes out there and see what you can learn',
image: screenshotPayroll,
title: 'Browse',
},
{
description:
'Upload your own resume easily to get feedback from people in industry.',
image: screenshotExpenses,
title: 'Submit',
},
{
description:
'Pass the baton forward by reviewing resumes and bounce off ideas with other engineers out there.',
image: screenshotVatReturns,
title: 'Review',
},
];
export function PrimaryFeatures() {
const [tabOrientation, setTabOrientation] = useState('horizontal');
useEffect(() => {
const lgMediaQuery = window.matchMedia('(min-width: 1024px)');
function onMediaQueryChange({ matches }: { matches: boolean }) {
setTabOrientation(matches ? 'vertical' : 'horizontal');
}
onMediaQueryChange(lgMediaQuery);
lgMediaQuery.addEventListener('change', onMediaQueryChange);
return () => {
lgMediaQuery.removeEventListener('change', onMediaQueryChange);
};
}, []);
return (
<section
aria-label="Features for running your books"
className="relative overflow-hidden bg-gradient-to-r from-indigo-400 to-indigo-700 pt-20 pb-28 sm:py-32"
id="features">
<Container className="relative">
<div className="max-w-2xl md:mx-auto md:text-center xl:max-w-none">
<h2 className="font-display text-3xl tracking-tight text-white sm:text-4xl md:text-5xl">
Everything you need to up your resume game.
</h2>
</div>
<Tab.Group
as="div"
className="mt-16 grid grid-cols-1 items-center gap-y-2 pt-10 sm:gap-y-6 md:mt-20 lg:grid-cols-12 lg:pt-0"
vertical={tabOrientation === 'vertical'}>
{({ selectedIndex }) => (
<>
<div className="-mx-4 flex overflow-x-auto pb-4 sm:mx-0 sm:overflow-visible sm:pb-0 lg:col-span-5">
<Tab.List className="relative z-10 flex gap-x-4 whitespace-nowrap px-4 sm:mx-auto sm:px-0 lg:mx-0 lg:block lg:gap-x-0 lg:gap-y-1 lg:whitespace-normal">
{features.map((feature, featureIndex) => (
<div
key={feature.title}
className={clsx(
'group relative rounded-full py-1 px-4 lg:rounded-r-none lg:rounded-l-xl lg:p-6',
selectedIndex === featureIndex
? 'bg-white lg:bg-white/10 lg:ring-1 lg:ring-inset lg:ring-white/10'
: 'hover:bg-white/10 lg:hover:bg-white/5',
)}>
<h3>
<Tab
className={clsx(
'font-display text-lg [&:not(:focus-visible)]:focus:outline-none',
selectedIndex === featureIndex
? 'text-blue-600 lg:text-white'
: 'text-blue-100 hover:text-white lg:text-white',
)}>
<span className="absolute inset-0 rounded-full lg:rounded-r-none lg:rounded-l-xl" />
{feature.title}
</Tab>
</h3>
<p
className={clsx(
'mt-2 hidden text-sm lg:block',
selectedIndex === featureIndex
? 'text-white'
: 'text-blue-100 group-hover:text-white',
)}>
{feature.description}
</p>
</div>
))}
</Tab.List>
</div>
<Tab.Panels className="lg:col-span-7">
{features.map((feature) => (
<Tab.Panel key={feature.title} unmount={false}>
<div className="relative sm:px-6 lg:hidden">
<div className="absolute -inset-x-4 top-[-6.5rem] bottom-[-4.25rem] bg-white/10 ring-1 ring-inset ring-white/10 sm:inset-x-0 sm:rounded-t-xl" />
<p className="relative mx-auto max-w-2xl text-base text-white sm:text-center">
{feature.description}
</p>
</div>
<div className="mt-10 w-[45rem] overflow-hidden rounded-xl bg-slate-50 shadow-xl shadow-blue-900/20 sm:w-auto lg:mt-0 lg:w-[67.8125rem]">
<Image
alt=""
className="w-full"
priority={true}
sizes="(min-width: 1024px) 67.8125rem, (min-width: 640px) 100vw, 45rem"
src={feature.image}
/>
</div>
</Tab.Panel>
))}
</Tab.Panels>
</>
)}
</Tab.Group>
</Container>
</section>
);
}

@ -0,0 +1,154 @@
import Image from 'next/future/image';
import { Container } from './Container';
import avatarImage1 from './images/avatars/avatar-1.png';
import avatarImage2 from './images/avatars/avatar-2.png';
import avatarImage3 from './images/avatars/avatar-3.png';
import avatarImage4 from './images/avatars/avatar-4.png';
import avatarImage5 from './images/avatars/avatar-5.png';
type QuoteProps = {
className: string;
};
const testimonials = [
{
columns: [
{
author: {
image: avatarImage1,
name: 'Sheryl Berge',
role: 'CEO at Lynch LLC',
},
content:
'TaxPal is so easy to use I cant help but wonder if its really doing the things the government expects me to do.',
},
{
author: {
image: avatarImage4,
name: 'Amy Hahn',
role: 'Director at Velocity Industries',
},
content:
'Im trying to get a hold of someone in support, Im in a lot of trouble right now and they are saying it has something to do with my books. Please get back to me right away.',
},
],
name: 'column-one',
},
{
columns: [
{
author: {
image: avatarImage5,
name: 'Leland Kiehn',
role: 'Founder of Kiehn and Sons',
},
content:
'The best part about TaxPal is every time I pay my employees, my bank balance doesnt go down like it used to. Looking forward to spending this extra cash when I figure out why my card is being declined.',
},
{
author: {
image: avatarImage2,
name: 'Erin Powlowski',
role: 'COO at Armstrong Inc',
},
content:
'There are so many things I had to do with my old software that I just dont do at all with TaxPal. Suspicious but I cant say I dont love it.',
},
],
name: 'column-two',
},
{
columns: [
{
author: {
image: avatarImage3,
name: 'Peter Renolds',
role: 'Founder of West Inc',
},
content:
'I used to have to remit tax to the EU and with TaxPal I somehow dont have to do that anymore. Nervous to travel there now though.',
},
{
author: {
image: avatarImage4,
name: 'Amy Hahn',
role: 'Director at Velocity Industries',
},
content:
'This is the fourth email Ive sent to your support team. I am literally being held in jail for tax fraud. Please answer your damn emails, this is important.',
},
],
name: 'column-three',
},
];
function QuoteIcon(props: QuoteProps) {
return (
<svg aria-hidden="true" height={78} width={105} {...props}>
<path d="M25.086 77.292c-4.821 0-9.115-1.205-12.882-3.616-3.767-2.561-6.78-6.102-9.04-10.622C1.054 58.534 0 53.411 0 47.686c0-5.273.904-10.396 2.712-15.368 1.959-4.972 4.746-9.567 8.362-13.786a59.042 59.042 0 0 1 12.43-11.3C28.325 3.917 33.599 1.507 39.324 0l11.074 13.786c-6.479 2.561-11.677 5.951-15.594 10.17-3.767 4.219-5.65 7.835-5.65 10.848 0 1.356.377 2.863 1.13 4.52.904 1.507 2.637 3.089 5.198 4.746 3.767 2.41 6.328 4.972 7.684 7.684 1.507 2.561 2.26 5.5 2.26 8.814 0 5.123-1.959 9.19-5.876 12.204-3.767 3.013-8.588 4.52-14.464 4.52Zm54.24 0c-4.821 0-9.115-1.205-12.882-3.616-3.767-2.561-6.78-6.102-9.04-10.622-2.11-4.52-3.164-9.643-3.164-15.368 0-5.273.904-10.396 2.712-15.368 1.959-4.972 4.746-9.567 8.362-13.786a59.042 59.042 0 0 1 12.43-11.3C82.565 3.917 87.839 1.507 93.564 0l11.074 13.786c-6.479 2.561-11.677 5.951-15.594 10.17-3.767 4.219-5.65 7.835-5.65 10.848 0 1.356.377 2.863 1.13 4.52.904 1.507 2.637 3.089 5.198 4.746 3.767 2.41 6.328 4.972 7.684 7.684 1.507 2.561 2.26 5.5 2.26 8.814 0 5.123-1.959 9.19-5.876 12.204-3.767 3.013-8.588 4.52-14.464 4.52Z" />
</svg>
);
}
export function Testimonials() {
return (
<section
aria-label="What our customers are saying"
className="bg-gradient-to-r from-indigo-700 to-indigo-400 py-20 sm:py-32"
id="testimonials">
<Container>
<div className="mx-auto max-w-2xl md:text-center">
<h2 className="font-display text-3xl tracking-tight text-white sm:text-4xl">
Loved by software engineers worldwide.
</h2>
<p className="mt-4 text-lg tracking-tight text-white">
We crowdsource ideas and feedback from across the world,
guaranteeing you for success in your job application.
</p>
</div>
<ul
className="mx-auto mt-16 grid max-w-2xl grid-cols-1 gap-6 sm:gap-8 lg:mt-20 lg:max-w-none lg:grid-cols-3"
role="list">
{testimonials.map(({ name, columns }) => (
<li key={name}>
<ul className="flex flex-col gap-y-6 sm:gap-y-8" role="list">
{columns.map((testimonial) => (
<li key={testimonial.author.name}>
<figure className="relative rounded-2xl bg-white p-6 shadow-xl shadow-slate-900/10">
<QuoteIcon className="absolute top-6 left-6 fill-slate-100" />
<blockquote className="relative">
<p className="text-lg tracking-tight text-slate-900">
{testimonial.content}
</p>
</blockquote>
<figcaption className="relative mt-6 flex items-center justify-between border-t border-slate-100 pt-6">
<div>
<div className="font-display text-base text-slate-900">
{testimonial.author.name}
</div>
<div className="mt-1 text-sm text-slate-500">
{testimonial.author.role}
</div>
</div>
<div className="overflow-hidden rounded-full bg-slate-50">
<Image
alt=""
className="h-14 w-14 object-cover"
height={56}
src={testimonial.author.image}
width={56}
/>
</div>
</figcaption>
</figure>
</li>
))}
</ul>
</li>
))}
</ul>
</Container>
</section>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

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

@ -4,7 +4,7 @@ type Props = Readonly<{
text: string;
}>;
export default function SignInButton({ text }: Props) {
export default function ResumeSignInButton({ text }: Props) {
return (
<div className="flex justify-center pt-4">
<p>

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

@ -6,10 +6,17 @@ import { trpc } from '~/utils/trpc';
type Props = Readonly<{
disabled?: boolean;
isLabelHidden?: boolean;
onSelect: (option: TypeaheadOption) => void;
placeHolder?: string;
}>;
export default function CompaniesTypeahead({ disabled, onSelect }: Props) {
export default function CompaniesTypeahead({
disabled,
onSelect,
isLabelHidden,
placeHolder,
}: Props) {
const [query, setQuery] = useState('');
const companies = trpc.useQuery([
'companies.list',
@ -23,6 +30,7 @@ export default function CompaniesTypeahead({ disabled, onSelect }: Props) {
return (
<Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label="Company"
noResultsMessage="No companies found"
nullable={true}
@ -33,6 +41,7 @@ export default function CompaniesTypeahead({ disabled, onSelect }: Props) {
value: id,
})) ?? []
}
placeholder={placeHolder}
onQueryChange={setQuery}
onSelect={onSelect}
/>

@ -1,3 +1,4 @@
import { useId } from 'react';
import { Select } from '@tih/ui';
export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
@ -8,8 +9,13 @@ export type MonthYear = Readonly<{
}>;
type Props = Readonly<{
errorMessage?: string;
monthLabel?: string;
monthRequired?: boolean;
onChange: (value: MonthYear) => void;
value: MonthYear;
yearLabel?: string;
yearRequired?: boolean;
}>;
const MONTH_OPTIONS = [
@ -72,25 +78,45 @@ const YEAR_OPTIONS = Array.from({ length: NUM_YEARS }, (_, i) => {
};
});
export default function MonthYearPicker({ value, onChange }: Props) {
export default function MonthYearPicker({
errorMessage,
monthLabel = 'Month',
value,
onChange,
yearLabel = 'Year',
monthRequired = false,
yearRequired = false,
}: Props) {
const hasError = errorMessage != null;
const errorId = useId();
return (
<div className="flex space-x-4">
<div
aria-describedby={hasError ? errorId : undefined}
className="flex items-end space-x-4">
<Select
label="Month"
label={monthLabel}
options={MONTH_OPTIONS}
required={monthRequired}
value={value.month}
onChange={(newMonth) =>
onChange({ month: Number(newMonth) as Month, year: value.year })
}
/>
<Select
label="Year"
label={yearLabel}
options={YEAR_OPTIONS}
required={yearRequired}
value={value.year}
onChange={(newYear) =>
onChange({ month: value.month, year: Number(newYear) })
}
/>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
{errorMessage}
</p>
)}
</div>
);
}

@ -4,6 +4,8 @@ import { z } from 'zod';
/**
* Specify your server-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
*
* Remember to update existing GitHub workflows that use env vars!
*/
export const serverSchema = z.object({
DATABASE_URL: z.string().url(),
@ -20,13 +22,15 @@ export const serverSchema = z.object({
* Specify your client-side environment variables schema here.
* This way you can ensure the app isn't built with invalid env vars.
* To expose them to the client, prefix them with `NEXT_PUBLIC_`.
*
* Remember to update existing GitHub workflows that use env vars!
*/
export const clientSchema = z.object({
// NEXT_PUBLIC_BAR: z.string(),
});
/**
* You can't destruct `process.env` as a regular object, so you have to do
* You can't destructure `process.env` as a regular object, so you have to do
* it manually here. This is because Next.js evaluates this at build time,
* and only used environment variables are included in the build.
* @type {{ [k in keyof z.infer<typeof clientSchema>]: z.infer<typeof clientSchema>[k] | undefined }}

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

@ -53,13 +53,18 @@ export default withTRPC<AppRouter>({
}),
httpBatchLink({ url }),
],
transformer: superjson,
url,
/**
* @link https://react-query.tanstack.com/reference/QueryClient
* @link https://tanstack.com/query/v4/docs/reference/QueryClient
*/
// queryClientConfig: { defaultOptions: { queries: { staleTime: 60 } } },
queryClientConfig: {
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
},
transformer: superjson,
url,
// To use SSR properly you need to forward the client's headers to the server
// headers: () => {
// if (ctx?.req) {

@ -1,12 +1,13 @@
import { useState } from 'react';
import { Select } from '@tih/ui';
import OffersTable from '~/components/offers/OffersTable';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('Software engineers');
const [companyFilter, setCompanyFilter] = useState('All companies');
const [jobTitleFilter, setjobTitleFilter] = useState('Software Engineer');
const [companyFilter, setCompanyFilter] = useState('');
return (
<main className="flex-1 overflow-y-auto">
@ -21,20 +22,20 @@ export default function OffersHomePage() {
label="Select a job title"
options={[
{
label: 'Software engineers',
value: 'Software engineers',
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend engineers',
value: 'Frontend engineers',
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend engineers',
value: 'Backend engineers',
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: 'Full-stack engineers',
value: 'Full-stack engineers',
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
]}
value={jobTitleFilter}
@ -43,32 +44,20 @@ export default function OffersHomePage() {
</div>
in
<div className="ml-4">
<Select
<CompaniesTypeahead
isLabelHidden={true}
label="Select a company"
options={[
{
label: 'All companies',
value: 'All companies',
},
{
label: 'Shopee',
value: 'Shopee',
},
{
label: 'Meta',
value: 'Meta',
},
]}
value={companyFilter}
onChange={setCompanyFilter}
placeHolder="All companies"
onSelect={({ value }) => setCompanyFilter(value)}
/>
</div>
</div>
</div>
</div>
<div className="flex justify-center bg-white pb-20 pt-10">
<OffersTable />
<OffersTable
companyFilter={companyFilter}
jobTitleFilter={jobTitleFilter}
/>
</div>
</main>
);

@ -1,256 +1,205 @@
import Error from 'next/error';
import { useRouter } from 'next/router';
import { useState } from 'react';
import {
AcademicCapIcon,
BookmarkSquareIcon,
BriefcaseIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
ClipboardDocumentIcon,
PencilSquareIcon,
ShareIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Dialog, Tabs } from '@tih/ui';
import EducationCard from '~/components/offers/profile/EducationCard';
import OfferCard from '~/components/offers/profile/OfferCard';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import { EducationBackgroundType } from '~/components/offers/types';
import ProfileComments from '~/components/offers/profile/ProfileComments';
import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader';
import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
import { convertCurrencyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
export default function OfferProfile() {
const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." />
);
const router = useRouter();
const { offerProfileId, token = '' } = router.query;
const [isEditable, setIsEditable] = useState(false);
const [background, setBackground] = useState<BackgroundCard>();
const [offers, setOffers] = useState<Array<OfferEntity>>([]);
const [selectedTab, setSelectedTab] = useState('offers');
const [isDialogOpen, setIsDialogOpen] = useState(false);
function renderActionList() {
return (
<div className="space-x-2">
<Button
icon={BookmarkSquareIcon}
isLabelHidden={true}
label="Save to user account"
size="md"
variant="tertiary"
/>
<Button
icon={PencilSquareIcon}
isLabelHidden={true}
label="Edit"
size="md"
variant="tertiary"
/>
<Button
icon={TrashIcon}
isLabelHidden={true}
label="Delete"
size="md"
variant="tertiary"
onClick={() => setIsDialogOpen(true)}
/>
{isDialogOpen && (
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="Delete"
variant="primary"
onClick={() => setIsDialogOpen(false)}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setIsDialogOpen(false)}
/>
}
title="Are you sure you want to delete this offer profile?"
onClose={() => setIsDialogOpen(false)}>
<div>
All comments will gone. You will not be able to access or recover
it.
</div>
</Dialog>
)}
</div>
);
}
function ProfileHeader() {
return (
<div className="relative h-40 bg-white p-4">
<div className="justify-left flex h-1/2">
<div className="mx-4 mt-2">
<ProfilePhotoHolder />
</div>
<div className="w-full">
<div className="justify-left flex ">
<h2 className="flex w-4/5 text-2xl font-bold">anonymised-name</h2>
<div className="flex h-8 w-1/5 justify-end">
{renderActionList()}
</div>
</div>
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>Level 4 Google</span>
</div>
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span>4</span>
</div>
</div>
</div>
<div className="absolute left-8 bottom-1 content-center">
<Tabs
label="Profile Detail Navigation"
tabs={[
{
label: 'Offers',
value: 'offers',
},
{
label: 'Background',
value: 'background',
},
const getProfileQuery = trpc.useQuery(
[
'offers.profile.listOne',
{ profileId: offerProfileId as string, token: token as string },
],
{
enabled: typeof offerProfileId === 'string',
onSuccess: (data) => {
if (!data) {
router.push('/offers');
}
// If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') {
router.push(`/offers/profile/${offerProfileId}`);
}
setIsEditable(data?.isEditable ?? false);
if (data?.offers) {
const filteredOffers: Array<OfferEntity> = data
? data?.offers.map((res) => {
if (res.offersFullTime) {
const filteredOffer: OfferEntity = {
base: convertCurrencyToString(
res.offersFullTime.baseSalary,
),
bonus: convertCurrencyToString(res.offersFullTime.bonus),
companyName: res.company.name,
id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title,
location: res.location,
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived),
stocks: convertCurrencyToString(res.offersFullTime.stocks),
totalCompensation: convertCurrencyToString(
res.offersFullTime.totalCompensation,
),
};
return filteredOffer;
}
const filteredOffer: OfferEntity = {
companyName: res.company.name,
id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title,
location: res.location,
monthlySalary: convertCurrencyToString(
res.offersIntern!.monthlySalary,
),
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived),
};
return filteredOffer;
})
: [];
setOffers(filteredOffers);
}
if (data?.background) {
const transformedBackground = {
educations: [
{
label: 'Offer Engine Analysis',
value: 'offerEngineAnalysis',
endDate: data?.background.educations[0].endDate
? formatDate(data.background.educations[0].endDate)
: '-',
field: data.background.educations[0].field || '-',
school: data.background.educations[0].school || '-',
startDate: data.background.educations[0].startDate
? formatDate(data.background.educations[0].startDate)
: '-',
type: data.background.educations[0].type || '-',
},
]}
value={selectedTab}
onChange={(value) => setSelectedTab(value)}
/>
</div>
</div>
);
}
],
experiences: [
data.background.experiences &&
data.background.experiences.length > 0
? {
companyName:
data.background.experiences[0].company?.name ?? '-',
duration:
String(data.background.experiences[0].durationInMonths) ??
'-',
jobLevel: data.background.experiences[0].level ?? '',
jobTitle: data.background.experiences[0].title ?? '-',
monthlySalary: data.background.experiences[0].monthlySalary
? convertCurrencyToString(
data.background.experiences[0].monthlySalary,
)
: '-',
totalCompensation: data.background.experiences[0]
.totalCompensation
? convertCurrencyToString(
data.background.experiences[0].totalCompensation,
)
: '-',
}
: {},
],
profileName: data.profileName,
specificYoes: data.background.specificYoes ?? [],
totalYoe: String(data.background.totalYoe) || '-',
};
setBackground(transformedBackground);
}
},
},
);
function ProfileDetails() {
if (selectedTab === 'offers') {
return (
<>
{[
{
base: undefined,
bonus: undefined,
companyName: 'Meta',
id: 1,
jobLevel: 'G5',
jobTitle: 'Software Engineer',
location: 'Singapore',
monthlySalary: undefined,
negotiationStrategy:
'Nostrud nulla aliqua deserunt commodo id aute.',
otherComment:
'Pariatur ut est voluptate incididunt consequat do veniam quis irure adipisicing. Deserunt laborum dolor quis voluptate enim.',
receivedMonth: 'Jun 2022',
stocks: undefined,
totalCompensation: undefined,
},
{
companyName: 'Meta',
id: 2,
jobLevel: 'G5',
jobTitle: 'Software Engineer',
location: 'Singapore',
receivedMonth: 'Jun 2022',
},
{
companyName: 'Meta',
id: 3,
jobLevel: 'G5',
jobTitle: 'Software Engineer',
location: 'Singapore',
receivedMonth: 'Jun 2022',
},
].map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>
);
}
if (selectedTab === 'background') {
return (
<>
<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={{
base: undefined,
bonus: undefined,
companyName: 'Prefer not to say',
jobLevel: 'G4',
jobTitle: 'N/A',
location: '',
monthlySalary: '1,400k',
receivedMonth: '',
stocks: undefined,
totalCompensation: undefined,
}}
/>
<div className="mx-8 my-4 flex flex-row">
<AcademicCapIcon className="mr-1 h-5" />
<span className="font-bold">Education</span>
</div>
<EducationCard
education={{
backgroundType: EducationBackgroundType.Bachelor,
field: 'CS',
fromMonth: 'Aug 2019',
school: 'NUS',
toMonth: 'May 2021',
}}
/>
</>
);
const trpcContext = trpc.useContext();
const deleteMutation = trpc.useMutation(['offers.profile.delete'], {
onError: () => {
alert('Error deleting profile'); // TODO: replace with toast
},
onSuccess: () => {
trpcContext.invalidateQueries(['offers.profile.listOne']);
router.push('/offers');
},
});
function handleDelete() {
if (isEditable) {
deleteMutation.mutate({
profileId: offerProfileId as string,
token: token as string,
});
}
return <div>Detail page for {selectedTab}</div>;
}
function ProfileComments() {
return (
<div className="m-4">
<div className="flex-end flex justify-end space-x-4">
<Button
addonPosition="start"
icon={ClipboardDocumentIcon}
isLabelHidden={false}
label="Copy profile edit link"
size="sm"
variant="secondary"
/>
<Button
addonPosition="start"
icon={ShareIcon}
isLabelHidden={false}
label="Copy public link"
size="sm"
variant="secondary"
/>
</div>
<h2 className="mt-2 text-2xl font-bold">
Discussions feature coming soon
</h2>
{/* <TextArea label="Comment" placeholder="Type your comment here" /> */}
</div>
function handleCopyEditLink() {
// TODO: Add notification
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${offerProfileId}?token=${token}`,
);
}
function handleCopyPublicLink() {
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${offerProfileId}`,
);
}
return (
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
<div className="h-full w-2/3 divide-y">
<ProfileHeader />
<div className="h-4/5 w-full overflow-y-scroll pb-32">
<ProfileDetails />
<>
{getProfileQuery.isError && ErrorPage}
{!getProfileQuery.isError && (
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
<div className="h-full w-2/3 divide-y">
<ProfileHeader
background={background}
handleDelete={handleDelete}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>
<div className="h-4/5 w-full overflow-y-scroll pb-32">
<ProfileDetails
background={background}
isLoading={getProfileQuery.isLoading}
offers={offers}
selectedTab={selectedTab}
/>
</div>
</div>
<div className="h-full w-1/3 bg-white">
<ProfileComments
handleCopyEditLink={handleCopyEditLink}
handleCopyPublicLink={handleCopyPublicLink}
isDisabled={deleteMutation.isLoading}
isEditable={isEditable}
isLoading={getProfileQuery.isLoading}
/>
</div>
</div>
</div>
<div className="h-full w-1/3 bg-white">
<ProfileComments />
</div>
</div>
)}
</>
);
}

@ -1,93 +1,179 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import { Breadcrumbs } from '~/components/offers/Breadcrumb';
import BackgroundForm from '~/components/offers/forms/BackgroundForm';
import OfferAnalysis from '~/components/offers/forms/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/forms/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/forms/OfferProfileSave';
import type { SubmitOfferFormData } from '~/components/offers/types';
import type {
OfferDetailsFormData,
OfferProfileFormData,
} from '~/components/offers/types';
import { JobType } from '~/components/offers/types';
import type { Month } from '~/components/shared/MonthYearPicker';
function Breadcrumbs() {
return (
<p className="mb-4 text-right text-sm text-gray-400">
{'Offer details > Background > Analysis > Save'}
</p>
);
}
import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
const defaultOfferValues = {
offers: [
{
comments: '',
companyId: '',
job: {
base: {
currency: 'USD',
value: 0,
},
bonus: {
currency: 'USD',
value: 0,
},
level: '',
specialization: '',
stocks: {
currency: 'USD',
value: 0,
},
title: '',
totalCompensation: {
currency: 'USD',
value: 0,
},
},
jobType: 'FULLTIME',
location: '',
monthYearReceived: '',
negotiationStrategy: '',
},
],
comments: '',
companyId: '',
job: {},
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.Internship,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
specificYoes: [],
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
export default function OffersSubmissionPage() {
const [formStep, setFormStep] = useState(0);
const formMethods = useForm<SubmitOfferFormData>({
defaultValues: defaultOfferValues,
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OfferProfileFormData>({
defaultValues: defaultOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const nextStep = () => setFormStep(formStep + 1);
const previousStep = () => setFormStep(formStep - 1);
const formComponents = [
<OfferDetailsForm key={0} />,
<BackgroundForm key={1} />,
<OfferAnalysis key={2} />,
<OfferProfileSave key={3} />,
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: <OfferAnalysis key={2} />,
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: <OfferProfileSave key={3} />,
hasNext: false,
hasPrevious: false,
label: 'Save',
},
];
const onSubmit: SubmitHandler<SubmitOfferFormData> = async () => {
nextStep();
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 createMutation = trpc.useMutation(['offers.profile.create'], {
onError(error) {
console.error(error.message);
},
onSuccess() {
alert('offer profile submit success!');
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OfferProfileFormData> = 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: OfferDetailsFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,
offer.monthYearReceived.month,
),
}));
const postData = { background, offers };
createMutation.mutate(postData);
};
return (
<div className="fixed h-full w-full overflow-y-scroll">
<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">
<Breadcrumbs />
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={formMethods.handleSubmit(onSubmit)}>
{formComponents[formStep]}
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{(formStep === 0 || formStep === 2) && (
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={nextStep}
onClick={() => nextStep(formStep)}
/>
</div>
)}

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

Loading…
Cancel
Save