@ -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 { forwardRef } from 'react';
|
||||
import type { UseFormRegisterReturn } from 'react-hook-form';
|
||||
|
||||
import { TextArea } from '~/../../../packages/ui/dist';
|
||||
import { TextArea } from '@tih/ui';
|
||||
|
||||
type TextAreaProps = ComponentProps<typeof TextArea>;
|
||||
|
@ -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 = {
|
||||
authorImageUrl: string;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: Date;
|
||||
upvoteCount: number;
|
||||
};
|
||||
|
||||
export default function FullAnswerCard({
|
||||
authorImageUrl,
|
||||
authorName,
|
||||
content,
|
||||
createdAt,
|
||||
upvoteCount,
|
||||
}: FullAnswerCardProps) {
|
||||
return (
|
||||
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
|
||||
<VotingButtons upvoteCount={upvoteCount}></VotingButtons>
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
alt={`${authorName} profile picture`}
|
||||
className="h-8 w-8 rounded-full"
|
||||
src={authorImageUrl}></img>
|
||||
<h1 className="font-bold">{authorName}</h1>
|
||||
<p className="pt-1 text-xs font-extralight">
|
||||
Posted on: {format(createdAt, 'Pp')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="pl-1 pt-1">{content}</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
export default function FullAnswerCard(props: FullAnswerCardProps) {
|
||||
return <AnswerCard {...props} votingButtonsSize="md" />;
|
||||
}
|
||||
|
@ -1,58 +1,26 @@
|
||||
import { Badge } from '@tih/ui';
|
||||
import type { QuestionCardProps } from './QuestionCard';
|
||||
import QuestionCard from './QuestionCard';
|
||||
|
||||
import VotingButtons from '../VotingButtons';
|
||||
export type QuestionOverviewCardProps = Omit<
|
||||
QuestionCardProps & {
|
||||
showActionButton: false;
|
||||
showUserStatistics: false;
|
||||
showVoteButtons: true;
|
||||
},
|
||||
| 'actionButtonLabel'
|
||||
| 'onActionButtonClick'
|
||||
| 'showActionButton'
|
||||
| 'showUserStatistics'
|
||||
| 'showVoteButtons'
|
||||
>;
|
||||
|
||||
type UpvoteProps =
|
||||
| {
|
||||
showVoteButtons: true;
|
||||
upvoteCount: number;
|
||||
}
|
||||
| {
|
||||
showVoteButtons?: false;
|
||||
upvoteCount?: never;
|
||||
};
|
||||
|
||||
export type FullQuestionCardProps = UpvoteProps & {
|
||||
company: string;
|
||||
content: string;
|
||||
location: string;
|
||||
receivedCount: number;
|
||||
role: string;
|
||||
timestamp: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export default function FullQuestionCard({
|
||||
company,
|
||||
content,
|
||||
showVoteButtons,
|
||||
upvoteCount,
|
||||
timestamp,
|
||||
role,
|
||||
location,
|
||||
type,
|
||||
}: FullQuestionCardProps) {
|
||||
const altText = company + ' logo';
|
||||
export default function FullQuestionCard(props: QuestionOverviewCardProps) {
|
||||
return (
|
||||
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
|
||||
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<img alt={altText} src="https://logo.clearbit.com/google.com"></img>
|
||||
<h2 className="ml-2 text-xl">{company}</h2>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-500">
|
||||
<Badge label={type} variant="primary" />
|
||||
<p className="text-xs">
|
||||
{timestamp} · {location} · {role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-2 mb-2">
|
||||
<p>{content}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<QuestionCard
|
||||
{...props}
|
||||
showActionButton={false}
|
||||
showUserStatistics={false}
|
||||
showVoteButtons={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,15 @@
|
||||
import withHref from '~/utils/questions/withHref';
|
||||
|
||||
import type { AnswerCardProps } from './AnswerCard';
|
||||
import AnswerCard from './AnswerCard';
|
||||
|
||||
export type QuestionAnswerCardProps = Required<
|
||||
Omit<AnswerCardProps, 'votingButtonsSize'>
|
||||
>;
|
||||
|
||||
function QuestionAnswerCardWithoutHref(props: QuestionAnswerCardProps) {
|
||||
return <AnswerCard {...props} votingButtonsSize="sm" />;
|
||||
}
|
||||
|
||||
const QuestionAnswerCard = withHref(QuestionAnswerCardWithoutHref);
|
||||
export default QuestionAnswerCard;
|
@ -1,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';
|
||||
|
||||
export const COMMENTS_SECTIONS = [
|
||||
export const RESUME_COMMENTS_SECTIONS = [
|
||||
{
|
||||
label: 'General',
|
||||
value: ResumesSection.GENERAL,
|
@ -0,0 +1,28 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Container } from './Container';
|
||||
|
||||
export function CallToAction() {
|
||||
return (
|
||||
<section className="relative overflow-hidden py-32" id="get-started-today">
|
||||
<Container className="relative">
|
||||
<div className="mx-auto max-w-lg text-center">
|
||||
<h2 className="font-display text-3xl tracking-tight text-gray-900 sm:text-4xl">
|
||||
Resume review can start right now.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg tracking-tight text-gray-600">
|
||||
It's free! Take charge of your resume game by learning from the top
|
||||
engineers in the field.
|
||||
</p>
|
||||
<Link href="/resumes/browse">
|
||||
<button
|
||||
className="mt-4 rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white"
|
||||
type="button">
|
||||
Start browsing now
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import clsx from 'clsx';
|
||||
import type { FC } from 'react';
|
||||
|
||||
type ContainerProps = {
|
||||
children: Array<JSX.Element> | JSX.Element;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Container: FC<ContainerProps> = ({ className, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx('mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
@ -0,0 +1,49 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Container } from './Container';
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<Container className="pb-36 pt-20 text-center lg:pt-32">
|
||||
<h1 className="font-display mx-auto max-w-4xl text-5xl font-medium tracking-tight text-slate-900 sm:text-7xl">
|
||||
Resume review{' '}
|
||||
<span className="relative whitespace-nowrap text-indigo-500">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-blue-300/70"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 418 42">
|
||||
<path d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
|
||||
</svg>
|
||||
<span className="relative">made simple</span>
|
||||
</span>{' '}
|
||||
for software engineers.
|
||||
</h1>
|
||||
<p className="mx-auto mt-6 max-w-2xl text-lg tracking-tight text-slate-700">
|
||||
Get valuable feedback from the public or checkout reviewed resumes from
|
||||
your fellow engineers
|
||||
</p>
|
||||
<div className="mt-10 flex justify-center gap-x-4">
|
||||
<Link href="/resumes/browse">
|
||||
<button
|
||||
className="rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white"
|
||||
type="button">
|
||||
Start browsing now
|
||||
</button>
|
||||
</Link>
|
||||
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
|
||||
<button
|
||||
className="group inline-flex items-center justify-center py-2 px-4 text-sm ring-1 ring-slate-200 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:outline-indigo-600 focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600"
|
||||
type="button">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3 flex-none fill-indigo-600 group-active:fill-current">
|
||||
<path d="m9.997 6.91-7.583 3.447A1 1 0 0 1 1 9.447V2.553a1 1 0 0 1 1.414-.91L9.997 5.09c.782.355.782 1.465 0 1.82Z" />
|
||||
</svg>
|
||||
<span className="ml-3">Watch video</span>
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import type { FC } from 'react';
|
||||
|
||||
export const Logo: FC = (props) => {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 109 40" {...props}>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M0 20c0 11.046 8.954 20 20 20s20-8.954 20-20S31.046 0 20 0 0 8.954 0 20Zm20 16c-7.264 0-13.321-5.163-14.704-12.02C4.97 22.358 6.343 21 8 21h24c1.657 0 3.031 1.357 2.704 2.98C33.32 30.838 27.264 36 20 36Z"
|
||||
fill="#2563EB"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M55.528 26.57V15.842H52V13.97h9.108v1.872h-3.636V26.57h-1.944Z"
|
||||
fill="#0F172A"
|
||||
/>
|
||||
<path
|
||||
d="M83.084 26.57v-12.6h5.346c.744 0 1.416.18 2.016.54a3.773 3.773 0 0 1 1.44 1.44c.36.612.54 1.302.54 2.07 0 .78-.18 1.482-.54 2.106a4 4 0 0 1-1.44 1.494c-.6.36-1.272.54-2.016.54h-2.646v4.41h-2.7Zm2.664-6.84h2.376c.288 0 .546-.072.774-.216.228-.156.408-.36.54-.612a1.71 1.71 0 0 0 .216-.864c0-.324-.072-.606-.216-.846a1.394 1.394 0 0 0-.54-.576 1.419 1.419 0 0 0-.774-.216h-2.376v3.33ZM106.227 26.57V13.25h2.556v13.32h-2.556Z"
|
||||
fill="#2563EB"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M95.906 26.102c.636.432 1.35.648 2.142.648.444 0 .864-.066 1.26-.198a4.25 4.25 0 0 0 1.062-.558 3.78 3.78 0 0 0 .702-.668v1.244h2.574v-9.522h-2.538v1.248a3.562 3.562 0 0 0-.648-.672 3.13 3.13 0 0 0-1.026-.558 3.615 3.615 0 0 0-1.278-.216c-.828 0-1.566.216-2.214.648-.648.42-1.164 1.002-1.548 1.746-.372.732-.558 1.578-.558 2.538 0 .96.186 1.812.558 2.556.372.744.876 1.332 1.512 1.764Zm4.104-1.908c-.36.228-.78.342-1.26.342-.468 0-.882-.114-1.242-.342a2.387 2.387 0 0 1-.828-.954c-.204-.42-.306-.906-.306-1.458 0-.54.102-1.014.306-1.422.204-.408.48-.726.828-.954.36-.24.774-.36 1.242-.36.48 0 .9.12 1.26.36.36.228.636.546.828.954.204.408.306.882.306 1.422 0 .552-.102 1.038-.306 1.458a2.218 2.218 0 0 1-.828.954Z"
|
||||
fill="#2563EB"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="m76.322 23.197 2.595 3.373h2.268l-3.662-4.787 3.338-4.663h-2.196l-2.162 3.334-2.554-3.334h-2.34l3.652 4.71-3.634 4.74h2.196l2.5-3.373ZM62.738 26.102a3.78 3.78 0 0 0 2.142.648c.456 0 .888-.072 1.296-.216.42-.144.798-.336 1.134-.576a3.418 3.418 0 0 0 .864-.835v1.447h1.872v-9.45h-1.872v1.45a3.118 3.118 0 0 0-.72-.82 3.2 3.2 0 0 0-1.062-.612 4.033 4.033 0 0 0-1.35-.216c-.828 0-1.578.21-2.25.63-.66.42-1.188 1.002-1.584 1.746-.384.732-.576 1.572-.576 2.52 0 .936.192 1.776.576 2.52.384.744.894 1.332 1.53 1.764Zm4.122-1.476c-.432.276-.93.414-1.494.414a2.682 2.682 0 0 1-1.476-.414 2.987 2.987 0 0 1-1.008-1.134c-.24-.492-.36-1.05-.36-1.674 0-.612.12-1.158.36-1.638.252-.48.588-.858 1.008-1.134a2.682 2.682 0 0 1 1.476-.414c.564 0 1.062.138 1.494.414.432.276.768.654 1.008 1.134.252.48.378 1.026.378 1.638 0 .624-.126 1.182-.378 1.674-.24.48-.576.858-1.008 1.134Z"
|
||||
fill="#0F172A"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
@ -0,0 +1,130 @@
|
||||
import clsx from 'clsx';
|
||||
import Image from 'next/future/image';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Tab } from '@headlessui/react';
|
||||
|
||||
import { Container } from './Container';
|
||||
import screenshotExpenses from './images/screenshots/expenses.png';
|
||||
import screenshotPayroll from './images/screenshots/payroll.png';
|
||||
import screenshotVatReturns from './images/screenshots/vat-returns.png';
|
||||
|
||||
const features = [
|
||||
{
|
||||
description:
|
||||
'Browse the most popular reviewed resumes out there and see what you can learn',
|
||||
image: screenshotPayroll,
|
||||
title: 'Browse',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Upload your own resume easily to get feedback from people in industry.',
|
||||
image: screenshotExpenses,
|
||||
title: 'Submit',
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Pass the baton forward by reviewing resumes and bounce off ideas with other engineers out there.',
|
||||
image: screenshotVatReturns,
|
||||
title: 'Review',
|
||||
},
|
||||
];
|
||||
|
||||
export function PrimaryFeatures() {
|
||||
const [tabOrientation, setTabOrientation] = useState('horizontal');
|
||||
|
||||
useEffect(() => {
|
||||
const lgMediaQuery = window.matchMedia('(min-width: 1024px)');
|
||||
|
||||
function onMediaQueryChange({ matches }: { matches: boolean }) {
|
||||
setTabOrientation(matches ? 'vertical' : 'horizontal');
|
||||
}
|
||||
|
||||
onMediaQueryChange(lgMediaQuery);
|
||||
lgMediaQuery.addEventListener('change', onMediaQueryChange);
|
||||
|
||||
return () => {
|
||||
lgMediaQuery.removeEventListener('change', onMediaQueryChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Features for running your books"
|
||||
className="relative overflow-hidden bg-gradient-to-r from-indigo-400 to-indigo-700 pt-20 pb-28 sm:py-32"
|
||||
id="features">
|
||||
<Container className="relative">
|
||||
<div className="max-w-2xl md:mx-auto md:text-center xl:max-w-none">
|
||||
<h2 className="font-display text-3xl tracking-tight text-white sm:text-4xl md:text-5xl">
|
||||
Everything you need to up your resume game.
|
||||
</h2>
|
||||
</div>
|
||||
<Tab.Group
|
||||
as="div"
|
||||
className="mt-16 grid grid-cols-1 items-center gap-y-2 pt-10 sm:gap-y-6 md:mt-20 lg:grid-cols-12 lg:pt-0"
|
||||
vertical={tabOrientation === 'vertical'}>
|
||||
{({ selectedIndex }) => (
|
||||
<>
|
||||
<div className="-mx-4 flex overflow-x-auto pb-4 sm:mx-0 sm:overflow-visible sm:pb-0 lg:col-span-5">
|
||||
<Tab.List className="relative z-10 flex gap-x-4 whitespace-nowrap px-4 sm:mx-auto sm:px-0 lg:mx-0 lg:block lg:gap-x-0 lg:gap-y-1 lg:whitespace-normal">
|
||||
{features.map((feature, featureIndex) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className={clsx(
|
||||
'group relative rounded-full py-1 px-4 lg:rounded-r-none lg:rounded-l-xl lg:p-6',
|
||||
selectedIndex === featureIndex
|
||||
? 'bg-white lg:bg-white/10 lg:ring-1 lg:ring-inset lg:ring-white/10'
|
||||
: 'hover:bg-white/10 lg:hover:bg-white/5',
|
||||
)}>
|
||||
<h3>
|
||||
<Tab
|
||||
className={clsx(
|
||||
'font-display text-lg [&:not(:focus-visible)]:focus:outline-none',
|
||||
selectedIndex === featureIndex
|
||||
? 'text-blue-600 lg:text-white'
|
||||
: 'text-blue-100 hover:text-white lg:text-white',
|
||||
)}>
|
||||
<span className="absolute inset-0 rounded-full lg:rounded-r-none lg:rounded-l-xl" />
|
||||
{feature.title}
|
||||
</Tab>
|
||||
</h3>
|
||||
<p
|
||||
className={clsx(
|
||||
'mt-2 hidden text-sm lg:block',
|
||||
selectedIndex === featureIndex
|
||||
? 'text-white'
|
||||
: 'text-blue-100 group-hover:text-white',
|
||||
)}>
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</Tab.List>
|
||||
</div>
|
||||
<Tab.Panels className="lg:col-span-7">
|
||||
{features.map((feature) => (
|
||||
<Tab.Panel key={feature.title} unmount={false}>
|
||||
<div className="relative sm:px-6 lg:hidden">
|
||||
<div className="absolute -inset-x-4 top-[-6.5rem] bottom-[-4.25rem] bg-white/10 ring-1 ring-inset ring-white/10 sm:inset-x-0 sm:rounded-t-xl" />
|
||||
<p className="relative mx-auto max-w-2xl text-base text-white sm:text-center">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-10 w-[45rem] overflow-hidden rounded-xl bg-slate-50 shadow-xl shadow-blue-900/20 sm:w-auto lg:mt-0 lg:w-[67.8125rem]">
|
||||
<Image
|
||||
alt=""
|
||||
className="w-full"
|
||||
priority={true}
|
||||
sizes="(min-width: 1024px) 67.8125rem, (min-width: 640px) 100vw, 45rem"
|
||||
src={feature.image}
|
||||
/>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
))}
|
||||
</Tab.Panels>
|
||||
</>
|
||||
)}
|
||||
</Tab.Group>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
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 {
|
||||
AcademicCapIcon,
|
||||
BookmarkSquareIcon,
|
||||
BriefcaseIcon,
|
||||
BuildingOffice2Icon,
|
||||
CalendarDaysIcon,
|
||||
ClipboardDocumentIcon,
|
||||
PencilSquareIcon,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Button, Dialog, Tabs } from '@tih/ui';
|
||||
|
||||
import EducationCard from '~/components/offers/profile/EducationCard';
|
||||
import OfferCard from '~/components/offers/profile/OfferCard';
|
||||
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
|
||||
import { EducationBackgroundType } from '~/components/offers/types';
|
||||
import ProfileComments from '~/components/offers/profile/ProfileComments';
|
||||
import ProfileDetails from '~/components/offers/profile/ProfileDetails';
|
||||
import ProfileHeader from '~/components/offers/profile/ProfileHeader';
|
||||
import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
|
||||
|
||||
import { convertCurrencyToString } from '~/utils/offers/currency';
|
||||
import { formatDate } from '~/utils/offers/time';
|
||||
import { trpc } from '~/utils/trpc';
|
||||
export default function OfferProfile() {
|
||||
const ErrorPage = (
|
||||
<Error statusCode={404} title="Requested profile does not exist." />
|
||||
);
|
||||
const router = useRouter();
|
||||
const { offerProfileId, token = '' } = router.query;
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [background, setBackground] = useState<BackgroundCard>();
|
||||
const [offers, setOffers] = useState<Array<OfferEntity>>([]);
|
||||
|
||||
const [selectedTab, setSelectedTab] = useState('offers');
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
function renderActionList() {
|
||||
return (
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
icon={BookmarkSquareIcon}
|
||||
isLabelHidden={true}
|
||||
label="Save to user account"
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
/>
|
||||
<Button
|
||||
icon={PencilSquareIcon}
|
||||
isLabelHidden={true}
|
||||
label="Edit"
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
/>
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
isLabelHidden={true}
|
||||
label="Delete"
|
||||
size="md"
|
||||
variant="tertiary"
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
/>
|
||||
{isDialogOpen && (
|
||||
<Dialog
|
||||
isShown={isDialogOpen}
|
||||
primaryButton={
|
||||
<Button
|
||||
display="block"
|
||||
label="Delete"
|
||||
variant="primary"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
}
|
||||
secondaryButton={
|
||||
<Button
|
||||
display="block"
|
||||
label="Cancel"
|
||||
variant="tertiary"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
}
|
||||
title="Are you sure you want to delete this offer profile?"
|
||||
onClose={() => setIsDialogOpen(false)}>
|
||||
<div>
|
||||
All comments will gone. You will not be able to access or recover
|
||||
it.
|
||||
</div>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function ProfileHeader() {
|
||||
return (
|
||||
<div className="relative h-40 bg-white p-4">
|
||||
<div className="justify-left flex h-1/2">
|
||||
<div className="mx-4 mt-2">
|
||||
<ProfilePhotoHolder />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="justify-left flex ">
|
||||
<h2 className="flex w-4/5 text-2xl font-bold">anonymised-name</h2>
|
||||
<div className="flex h-8 w-1/5 justify-end">
|
||||
{renderActionList()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<BuildingOffice2Icon className="mr-2.5 h-5" />
|
||||
<span className="mr-2 font-bold">Current:</span>
|
||||
<span>Level 4 Google</span>
|
||||
</div>
|
||||
<div className="flex flex-row">
|
||||
<CalendarDaysIcon className="mr-2.5 h-5" />
|
||||
<span className="mr-2 font-bold">YOE:</span>
|
||||
<span>4</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute left-8 bottom-1 content-center">
|
||||
<Tabs
|
||||
label="Profile Detail Navigation"
|
||||
tabs={[
|
||||
{
|
||||
label: 'Offers',
|
||||
value: 'offers',
|
||||
},
|
||||
{
|
||||
label: 'Background',
|
||||
value: 'background',
|
||||
},
|
||||
const getProfileQuery = trpc.useQuery(
|
||||
[
|
||||
'offers.profile.listOne',
|
||||
{ profileId: offerProfileId as string, token: token as string },
|
||||
],
|
||||
{
|
||||
enabled: typeof offerProfileId === 'string',
|
||||
onSuccess: (data) => {
|
||||
if (!data) {
|
||||
router.push('/offers');
|
||||
}
|
||||
// If the profile is not editable with a wrong token, redirect to the profile page
|
||||
if (!data?.isEditable && token !== '') {
|
||||
router.push(`/offers/profile/${offerProfileId}`);
|
||||
}
|
||||
|
||||
setIsEditable(data?.isEditable ?? false);
|
||||
|
||||
if (data?.offers) {
|
||||
const filteredOffers: Array<OfferEntity> = data
|
||||
? data?.offers.map((res) => {
|
||||
if (res.offersFullTime) {
|
||||
const filteredOffer: OfferEntity = {
|
||||
base: convertCurrencyToString(
|
||||
res.offersFullTime.baseSalary,
|
||||
),
|
||||
bonus: convertCurrencyToString(res.offersFullTime.bonus),
|
||||
companyName: res.company.name,
|
||||
id: res.offersFullTime.id,
|
||||
jobLevel: res.offersFullTime.level,
|
||||
jobTitle: res.offersFullTime.title,
|
||||
location: res.location,
|
||||
negotiationStrategy: res.negotiationStrategy || '',
|
||||
otherComment: res.comments || '',
|
||||
receivedMonth: formatDate(res.monthYearReceived),
|
||||
stocks: convertCurrencyToString(res.offersFullTime.stocks),
|
||||
totalCompensation: convertCurrencyToString(
|
||||
res.offersFullTime.totalCompensation,
|
||||
),
|
||||
};
|
||||
|
||||
return filteredOffer;
|
||||
}
|
||||
const filteredOffer: OfferEntity = {
|
||||
companyName: res.company.name,
|
||||
id: res.offersIntern!.id,
|
||||
jobTitle: res.offersIntern!.title,
|
||||
location: res.location,
|
||||
monthlySalary: convertCurrencyToString(
|
||||
res.offersIntern!.monthlySalary,
|
||||
),
|
||||
negotiationStrategy: res.negotiationStrategy || '',
|
||||
otherComment: res.comments || '',
|
||||
receivedMonth: formatDate(res.monthYearReceived),
|
||||
};
|
||||
return filteredOffer;
|
||||
})
|
||||
: [];
|
||||
setOffers(filteredOffers);
|
||||
}
|
||||
|
||||
if (data?.background) {
|
||||
const transformedBackground = {
|
||||
educations: [
|
||||
{
|
||||
label: 'Offer Engine Analysis',
|
||||
value: 'offerEngineAnalysis',
|
||||
endDate: data?.background.educations[0].endDate
|
||||
? formatDate(data.background.educations[0].endDate)
|
||||
: '-',
|
||||
field: data.background.educations[0].field || '-',
|
||||
school: data.background.educations[0].school || '-',
|
||||
startDate: data.background.educations[0].startDate
|
||||
? formatDate(data.background.educations[0].startDate)
|
||||
: '-',
|
||||
type: data.background.educations[0].type || '-',
|
||||
},
|
||||
]}
|
||||
value={selectedTab}
|
||||
onChange={(value) => setSelectedTab(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
],
|
||||
experiences: [
|
||||
data.background.experiences &&
|
||||
data.background.experiences.length > 0
|
||||
? {
|
||||
companyName:
|
||||
data.background.experiences[0].company?.name ?? '-',
|
||||
duration:
|
||||
String(data.background.experiences[0].durationInMonths) ??
|
||||
'-',
|
||||
jobLevel: data.background.experiences[0].level ?? '',
|
||||
jobTitle: data.background.experiences[0].title ?? '-',
|
||||
monthlySalary: data.background.experiences[0].monthlySalary
|
||||
? convertCurrencyToString(
|
||||
data.background.experiences[0].monthlySalary,
|
||||
)
|
||||
: '-',
|
||||
totalCompensation: data.background.experiences[0]
|
||||
.totalCompensation
|
||||
? convertCurrencyToString(
|
||||
data.background.experiences[0].totalCompensation,
|
||||
)
|
||||
: '-',
|
||||
}
|
||||
: {},
|
||||
],
|
||||
profileName: data.profileName,
|
||||
specificYoes: data.background.specificYoes ?? [],
|
||||
totalYoe: String(data.background.totalYoe) || '-',
|
||||
};
|
||||
setBackground(transformedBackground);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ProfileDetails() {
|
||||
if (selectedTab === 'offers') {
|
||||
return (
|
||||
<>
|
||||
{[
|
||||
{
|
||||
base: undefined,
|
||||
bonus: undefined,
|
||||
companyName: 'Meta',
|
||||
id: 1,
|
||||
jobLevel: 'G5',
|
||||
jobTitle: 'Software Engineer',
|
||||
location: 'Singapore',
|
||||
monthlySalary: undefined,
|
||||
negotiationStrategy:
|
||||
'Nostrud nulla aliqua deserunt commodo id aute.',
|
||||
otherComment:
|
||||
'Pariatur ut est voluptate incididunt consequat do veniam quis irure adipisicing. Deserunt laborum dolor quis voluptate enim.',
|
||||
receivedMonth: 'Jun 2022',
|
||||
stocks: undefined,
|
||||
totalCompensation: undefined,
|
||||
},
|
||||
{
|
||||
companyName: 'Meta',
|
||||
id: 2,
|
||||
jobLevel: 'G5',
|
||||
jobTitle: 'Software Engineer',
|
||||
location: 'Singapore',
|
||||
receivedMonth: 'Jun 2022',
|
||||
},
|
||||
{
|
||||
companyName: 'Meta',
|
||||
id: 3,
|
||||
jobLevel: 'G5',
|
||||
jobTitle: 'Software Engineer',
|
||||
location: 'Singapore',
|
||||
receivedMonth: 'Jun 2022',
|
||||
},
|
||||
].map((offer) => (
|
||||
<OfferCard key={offer.id} offer={offer} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (selectedTab === 'background') {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-8 my-4 flex flex-row">
|
||||
<BriefcaseIcon className="mr-1 h-5" />
|
||||
<span className="font-bold">Work Experience</span>
|
||||
</div>
|
||||
<OfferCard
|
||||
offer={{
|
||||
base: undefined,
|
||||
bonus: undefined,
|
||||
companyName: 'Prefer not to say',
|
||||
jobLevel: 'G4',
|
||||
jobTitle: 'N/A',
|
||||
location: '',
|
||||
monthlySalary: '1,400k',
|
||||
receivedMonth: '',
|
||||
stocks: undefined,
|
||||
totalCompensation: undefined,
|
||||
}}
|
||||
/>
|
||||
<div className="mx-8 my-4 flex flex-row">
|
||||
<AcademicCapIcon className="mr-1 h-5" />
|
||||
<span className="font-bold">Education</span>
|
||||
</div>
|
||||
<EducationCard
|
||||
education={{
|
||||
backgroundType: EducationBackgroundType.Bachelor,
|
||||
field: 'CS',
|
||||
fromMonth: 'Aug 2019',
|
||||
school: 'NUS',
|
||||
toMonth: 'May 2021',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
const trpcContext = trpc.useContext();
|
||||
const deleteMutation = trpc.useMutation(['offers.profile.delete'], {
|
||||
onError: () => {
|
||||
alert('Error deleting profile'); // TODO: replace with toast
|
||||
},
|
||||
onSuccess: () => {
|
||||
trpcContext.invalidateQueries(['offers.profile.listOne']);
|
||||
router.push('/offers');
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
if (isEditable) {
|
||||
deleteMutation.mutate({
|
||||
profileId: offerProfileId as string,
|
||||
token: token as string,
|
||||
});
|
||||
}
|
||||
return <div>Detail page for {selectedTab}</div>;
|
||||
}
|
||||
|
||||
function ProfileComments() {
|
||||
return (
|
||||
<div className="m-4">
|
||||
<div className="flex-end flex justify-end space-x-4">
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={ClipboardDocumentIcon}
|
||||
isLabelHidden={false}
|
||||
label="Copy profile edit link"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
/>
|
||||
<Button
|
||||
addonPosition="start"
|
||||
icon={ShareIcon}
|
||||
isLabelHidden={false}
|
||||
label="Copy public link"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mt-2 text-2xl font-bold">
|
||||
Discussions feature coming soon
|
||||
</h2>
|
||||
{/* <TextArea label="Comment" placeholder="Type your comment here" /> */}
|
||||
</div>
|
||||
function handleCopyEditLink() {
|
||||
// TODO: Add notification
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/offers/profile/${offerProfileId}?token=${token}`,
|
||||
);
|
||||
}
|
||||
|
||||
function handleCopyPublicLink() {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/offers/profile/${offerProfileId}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
|
||||
<div className="h-full w-2/3 divide-y">
|
||||
<ProfileHeader />
|
||||
<div className="h-4/5 w-full overflow-y-scroll pb-32">
|
||||
<ProfileDetails />
|
||||
<>
|
||||
{getProfileQuery.isError && ErrorPage}
|
||||
{!getProfileQuery.isError && (
|
||||
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
|
||||
<div className="h-full w-2/3 divide-y">
|
||||
<ProfileHeader
|
||||
background={background}
|
||||
handleDelete={handleDelete}
|
||||
isEditable={isEditable}
|
||||
isLoading={getProfileQuery.isLoading}
|
||||
selectedTab={selectedTab}
|
||||
setSelectedTab={setSelectedTab}
|
||||
/>
|
||||
<div className="h-4/5 w-full overflow-y-scroll pb-32">
|
||||
<ProfileDetails
|
||||
background={background}
|
||||
isLoading={getProfileQuery.isLoading}
|
||||
offers={offers}
|
||||
selectedTab={selectedTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full w-1/3 bg-white">
|
||||
<ProfileComments
|
||||
handleCopyEditLink={handleCopyEditLink}
|
||||
handleCopyPublicLink={handleCopyPublicLink}
|
||||
isDisabled={deleteMutation.isLoading}
|
||||
isEditable={isEditable}
|
||||
isLoading={getProfileQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-full w-1/3 bg-white">
|
||||
<ProfileComments />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|