@ -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
|
@ -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;
|
@ -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,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>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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,8 +1,7 @@
|
|||||||
import type { ComponentProps, ForwardedRef } from 'react';
|
import type { ComponentProps, ForwardedRef } from 'react';
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import type { UseFormRegisterReturn } from 'react-hook-form';
|
import type { UseFormRegisterReturn } from 'react-hook-form';
|
||||||
|
import { TextArea } from '@tih/ui';
|
||||||
import { TextArea } from '~/../../../packages/ui/dist';
|
|
||||||
|
|
||||||
type TextAreaProps = ComponentProps<typeof TextArea>;
|
type TextAreaProps = ComponentProps<typeof TextArea>;
|
||||||
|
|
@ -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,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,5 +0,0 @@
|
|||||||
export default function QuestionBankTitle() {
|
|
||||||
return (
|
|
||||||
<h1 className="text-center text-4xl font-bold">Interview Questions</h1>
|
|
||||||
);
|
|
||||||
}
|
|
@ -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,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 = {
|
export default function FullAnswerCard(props: FullAnswerCardProps) {
|
||||||
authorImageUrl: string;
|
return <AnswerCard {...props} votingButtonsSize="md" />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -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 & {
|
||||||
type UpvoteProps =
|
showActionButton: false;
|
||||||
| {
|
showUserStatistics: false;
|
||||||
showVoteButtons: true;
|
showVoteButtons: true;
|
||||||
upvoteCount: number;
|
},
|
||||||
}
|
| 'actionButtonLabel'
|
||||||
| {
|
| 'onActionButtonClick'
|
||||||
showVoteButtons?: false;
|
| 'showActionButton'
|
||||||
upvoteCount?: never;
|
| 'showUserStatistics'
|
||||||
};
|
| 'showVoteButtons'
|
||||||
|
>;
|
||||||
export type FullQuestionCardProps = UpvoteProps & {
|
|
||||||
company: string;
|
|
||||||
content: string;
|
|
||||||
location: string;
|
|
||||||
receivedCount: number;
|
|
||||||
role: string;
|
|
||||||
timestamp: string;
|
|
||||||
type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FullQuestionCard({
|
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
|
||||||
company,
|
|
||||||
content,
|
|
||||||
showVoteButtons,
|
|
||||||
upvoteCount,
|
|
||||||
timestamp,
|
|
||||||
role,
|
|
||||||
location,
|
|
||||||
type,
|
|
||||||
}: FullQuestionCardProps) {
|
|
||||||
const altText = company + ' logo';
|
|
||||||
return (
|
return (
|
||||||
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
|
<QuestionCard
|
||||||
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
|
{...props}
|
||||||
<div className="flex flex-col gap-2">
|
showActionButton={false}
|
||||||
<div className="flex items-center gap-2">
|
showUserStatistics={false}
|
||||||
<img alt={altText} src="https://logo.clearbit.com/google.com"></img>
|
showVoteButtons={true}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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,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,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>
|
||||||
|
);
|
||||||
|
}
|
@ -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,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';
|
import { ResumesSection } from '@prisma/client';
|
||||||
|
|
||||||
export const COMMENTS_SECTIONS = [
|
export const RESUME_COMMENTS_SECTIONS = [
|
||||||
{
|
{
|
||||||
label: 'General',
|
label: 'General',
|
||||||
value: ResumesSection.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>
|
||||||
|
);
|
||||||
|
}
|
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 181 KiB |
After Width: | Height: | Size: 179 KiB |
After Width: | Height: | Size: 86 KiB |
After Width: | Height: | Size: 174 KiB |
After Width: | Height: | Size: 93 KiB |
After Width: | Height: | Size: 186 KiB |
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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
export default function SubmissionGuidelines() {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 text-left text-sm text-slate-700">
|
||||||
|
<h2 className="mb-2 text-xl font-medium">Submission Guidelines</h2>
|
||||||
|
<p>
|
||||||
|
Before you submit, please review and acknolwedge our
|
||||||
|
<span className="font-bold"> submission guidelines </span>
|
||||||
|
stated below.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-lg font-bold">• </span>
|
||||||
|
Ensure that you do not divulge any of your
|
||||||
|
<span className="font-bold"> personal particulars</span>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-lg font-bold">• </span>
|
||||||
|
Ensure that you do not divulge any
|
||||||
|
<span className="font-bold">
|
||||||
|
{' '}
|
||||||
|
company's proprietary and confidential information
|
||||||
|
</span>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-lg font-bold">• </span>
|
||||||
|
Proof-read your resumes to look for grammatical/spelling errors.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,574 @@
|
|||||||
|
import type {
|
||||||
|
Company,
|
||||||
|
OffersAnalysis,
|
||||||
|
OffersBackground,
|
||||||
|
OffersCurrency,
|
||||||
|
OffersEducation,
|
||||||
|
OffersExperience,
|
||||||
|
OffersFullTime,
|
||||||
|
OffersIntern,
|
||||||
|
OffersOffer,
|
||||||
|
OffersProfile,
|
||||||
|
OffersReply,
|
||||||
|
OffersSpecificYoe,
|
||||||
|
User,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { JobType } from '@prisma/client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AddToProfileResponse,
|
||||||
|
Analysis,
|
||||||
|
AnalysisHighestOffer,
|
||||||
|
AnalysisOffer,
|
||||||
|
Background,
|
||||||
|
CreateOfferProfileResponse,
|
||||||
|
DashboardOffer,
|
||||||
|
Education,
|
||||||
|
Experience,
|
||||||
|
GetOffersResponse,
|
||||||
|
OffersCompany,
|
||||||
|
Paging,
|
||||||
|
Profile,
|
||||||
|
ProfileAnalysis,
|
||||||
|
ProfileOffer,
|
||||||
|
SpecificYoe,
|
||||||
|
Valuation,
|
||||||
|
} from '~/types/offers';
|
||||||
|
|
||||||
|
const analysisOfferDtoMapper = (
|
||||||
|
offer: OffersOffer & {
|
||||||
|
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;
|
||||||
|
};
|
@ -1,256 +1,205 @@
|
|||||||
|
import Error from 'next/error';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
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 ProfileComments from '~/components/offers/profile/ProfileComments';
|
||||||
import OfferCard from '~/components/offers/profile/OfferCard';
|
import ProfileDetails from '~/components/offers/profile/ProfileDetails';
|
||||||
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
|
import ProfileHeader from '~/components/offers/profile/ProfileHeader';
|
||||||
import { EducationBackgroundType } from '~/components/offers/types';
|
import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
|
||||||
|
|
||||||
|
import { convertCurrencyToString } from '~/utils/offers/currency';
|
||||||
|
import { formatDate } from '~/utils/offers/time';
|
||||||
|
import { trpc } from '~/utils/trpc';
|
||||||
export default function OfferProfile() {
|
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 [selectedTab, setSelectedTab] = useState('offers');
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
||||||
function renderActionList() {
|
const getProfileQuery = trpc.useQuery(
|
||||||
return (
|
[
|
||||||
<div className="space-x-2">
|
'offers.profile.listOne',
|
||||||
<Button
|
{ profileId: offerProfileId as string, token: token as string },
|
||||||
icon={BookmarkSquareIcon}
|
],
|
||||||
isLabelHidden={true}
|
{
|
||||||
label="Save to user account"
|
enabled: typeof offerProfileId === 'string',
|
||||||
size="md"
|
onSuccess: (data) => {
|
||||||
variant="tertiary"
|
if (!data) {
|
||||||
/>
|
router.push('/offers');
|
||||||
<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={
|
// If the profile is not editable with a wrong token, redirect to the profile page
|
||||||
<Button
|
if (!data?.isEditable && token !== '') {
|
||||||
display="block"
|
router.push(`/offers/profile/${offerProfileId}`);
|
||||||
label="Cancel"
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={() => setIsDialogOpen(false)}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
title="Are you sure you want to delete this offer profile?"
|
|
||||||
onClose={() => setIsDialogOpen(false)}>
|
setIsEditable(data?.isEditable ?? false);
|
||||||
<div>
|
|
||||||
All comments will gone. You will not be able to access or recover
|
if (data?.offers) {
|
||||||
it.
|
const filteredOffers: Array<OfferEntity> = data
|
||||||
</div>
|
? data?.offers.map((res) => {
|
||||||
</Dialog>
|
if (res.offersFullTime) {
|
||||||
)}
|
const filteredOffer: OfferEntity = {
|
||||||
</div>
|
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);
|
||||||
}
|
}
|
||||||
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">
|
if (data?.background) {
|
||||||
<Tabs
|
const transformedBackground = {
|
||||||
label="Profile Detail Navigation"
|
educations: [
|
||||||
tabs={[
|
|
||||||
{
|
{
|
||||||
label: 'Offers',
|
endDate: data?.background.educations[0].endDate
|
||||||
value: 'offers',
|
? 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 || '-',
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
label: 'Background',
|
experiences: [
|
||||||
value: 'background',
|
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);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: 'Offer Engine Analysis',
|
|
||||||
value: 'offerEngineAnalysis',
|
|
||||||
},
|
},
|
||||||
]}
|
|
||||||
value={selectedTab}
|
|
||||||
onChange={(value) => setSelectedTab(value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileDetails() {
|
const trpcContext = trpc.useContext();
|
||||||
if (selectedTab === 'offers') {
|
const deleteMutation = trpc.useMutation(['offers.profile.delete'], {
|
||||||
return (
|
onError: () => {
|
||||||
<>
|
alert('Error deleting profile'); // TODO: replace with toast
|
||||||
{[
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
},
|
||||||
{
|
onSuccess: () => {
|
||||||
companyName: 'Meta',
|
trpcContext.invalidateQueries(['offers.profile.listOne']);
|
||||||
id: 3,
|
router.push('/offers');
|
||||||
jobLevel: 'G5',
|
|
||||||
jobTitle: 'Software Engineer',
|
|
||||||
location: 'Singapore',
|
|
||||||
receivedMonth: 'Jun 2022',
|
|
||||||
},
|
},
|
||||||
].map((offer) => (
|
});
|
||||||
<OfferCard key={offer.id} offer={offer} />
|
|
||||||
))}
|
function handleDelete() {
|
||||||
</>
|
if (isEditable) {
|
||||||
);
|
deleteMutation.mutate({
|
||||||
|
profileId: offerProfileId as string,
|
||||||
|
token: token as string,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
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',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return <div>Detail page for {selectedTab}</div>;
|
|
||||||
|
function handleCopyEditLink() {
|
||||||
|
// TODO: Add notification
|
||||||
|
navigator.clipboard.writeText(
|
||||||
|
`${window.location.origin}/offers/profile/${offerProfileId}?token=${token}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileComments() {
|
function handleCopyPublicLink() {
|
||||||
return (
|
navigator.clipboard.writeText(
|
||||||
<div className="m-4">
|
`${window.location.origin}/offers/profile/${offerProfileId}`,
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{getProfileQuery.isError && ErrorPage}
|
||||||
|
{!getProfileQuery.isError && (
|
||||||
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
|
<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">
|
<div className="h-full w-2/3 divide-y">
|
||||||
<ProfileHeader />
|
<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">
|
<div className="h-4/5 w-full overflow-y-scroll pb-32">
|
||||||
<ProfileDetails />
|
<ProfileDetails
|
||||||
|
background={background}
|
||||||
|
isLoading={getProfileQuery.isLoading}
|
||||||
|
offers={offers}
|
||||||
|
selectedTab={selectedTab}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-full w-1/3 bg-white">
|
<div className="h-full w-1/3 bg-white">
|
||||||
<ProfileComments />
|
<ProfileComments
|
||||||
|
handleCopyEditLink={handleCopyEditLink}
|
||||||
|
handleCopyPublicLink={handleCopyPublicLink}
|
||||||
|
isDisabled={deleteMutation.isLoading}
|
||||||
|
isEditable={isEditable}
|
||||||
|
isLoading={getProfileQuery.isLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|