- {company}
+ {company.name}
{title}
- {yoe}
- {salary}
- {date}
+ {totalYoe}
+ {convertMoneyToString(income)}
+ {formatDate(monthYearReceived)}
({
- currentPage: 1,
- numOfItems: 1,
+ const [pagination, setPagination] = useState({
+ currentPage: 0,
+ numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
- const [offers, setOffers] = useState>([]);
-
+ const [offers, setOffers] = useState>([]);
+ const [selectedFilter, setSelectedFilter] = useState(
+ OfferTableFilterOptions[0].value,
+ );
useEffect(() => {
setPagination({
- currentPage: 1,
- numOfItems: 1,
+ currentPage: 0,
+ numOfItems: 0,
numOfPages: 0,
totalItems: 0,
});
- }, [selectedTab]);
+ }, [selectedTab, currency]);
const offersQuery = trpc.useQuery(
[
'offers.list',
{
companyId: companyFilter,
+ currency,
limit: NUMBER_OF_OFFERS_IN_PAGE,
location: 'Singapore, Singapore', // TODO: Geolocation
- offset: pagination.currentPage - 1,
- sortBy: '-monthYearReceived',
+ offset: pagination.currentPage,
+ sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
title: jobTitleFilter,
yoeCategory: selectedTab,
},
],
{
- onSuccess: (response) => {
- const filteredData = response.data.map((res) => {
- return {
- company: res.company.name,
- date: formatDate(res.monthYearReceived),
- id: res.OffersFullTime
- ? res.OffersFullTime!.id
- : res.OffersIntern!.id,
- profileId: res.profileId,
- salary: res.OffersFullTime
- ? res.OffersFullTime?.totalCompensation.value
- : res.OffersIntern?.monthlySalary.value,
- title: res.OffersFullTime ? res.OffersFullTime?.level : '',
- yoe: 100,
- };
- });
- setOffers(filteredData);
- setPagination({
- currentPage: (response.paging.currPage as number) + 1,
- numOfItems: response.paging.numOfItemsInPage,
- numOfPages: response.paging.numOfPages,
- totalItems: response.paging.totalNumberOfOffers,
- });
+ onError: (err) => {
+ alert(err);
+ },
+ onSuccess: (response: GetOffersResponse) => {
+ setOffers(response.data);
+ setPagination(response.paging);
},
},
);
@@ -88,24 +77,7 @@ export default function OffersTable({
setSelectedTab(value)}
/>
@@ -125,16 +97,11 @@ export default function OffersTable({
/>
setSelectedFilter(value)}
/>
);
@@ -162,7 +129,9 @@ export default function OffersTable({
}
const handlePageChange = (currPage: number) => {
- setPagination({ ...pagination, currentPage: currPage });
+ if (0 < currPage && currPage < pagination.numOfPages) {
+ setPagination({ ...pagination, currentPage: currPage });
+ }
};
return (
@@ -187,14 +156,11 @@ export default function OffersTable({
)}
diff --git a/apps/portal/src/components/offers/table/OffersTablePagination.tsx b/apps/portal/src/components/offers/table/OffersTablePagination.tsx
index e7346c44..ad0bc0e6 100644
--- a/apps/portal/src/components/offers/table/OffersTablePagination.tsx
+++ b/apps/portal/src/components/offers/table/OffersTablePagination.tsx
@@ -1,11 +1,11 @@
import { Pagination } from '@tih/ui';
-import type { PaginationType } from '~/components/offers/table/types';
+import type { Paging } from '~/types/offers';
type OffersTablePaginationProps = Readonly<{
endNumber: number;
handlePageChange: (page: number) => void;
- pagination: PaginationType;
+ pagination: Paging;
startNumber: number;
}>;
@@ -30,13 +30,13 @@ export default function OffersTablePagination({
{
- handlePageChange(currPage);
+ handlePageChange(currPage - 1);
}}
/>
diff --git a/apps/portal/src/components/offers/table/types.ts b/apps/portal/src/components/offers/table/types.ts
index 9522a9bb..ca14aa32 100644
--- a/apps/portal/src/components/offers/table/types.ts
+++ b/apps/portal/src/components/offers/table/types.ts
@@ -1,13 +1,3 @@
-export type OfferTableRowData = {
- company: string;
- date: string;
- id: string;
- profileId: string;
- salary: number | undefined;
- title: string;
- yoe: number;
-};
-
// eslint-disable-next-line no-shadow
export enum YOE_CATEGORY {
INTERN = 0,
@@ -16,9 +6,47 @@ export enum YOE_CATEGORY {
SENIOR = 3,
}
-export type PaginationType = {
- currentPage: number;
- numOfItems: number;
- numOfPages: number;
- totalItems: number;
+export const OfferTableTabOptions = [
+ {
+ label: 'Fresh Grad (0-2 YOE)',
+ value: YOE_CATEGORY.ENTRY,
+ },
+ {
+ label: 'Mid (3-5 YOE)',
+ value: YOE_CATEGORY.MID,
+ },
+ {
+ label: 'Senior (6+ YOE)',
+ value: YOE_CATEGORY.SENIOR,
+ },
+ {
+ label: 'Internship',
+ value: YOE_CATEGORY.INTERN,
+ },
+];
+
+export const OfferTableFilterOptions = [
+ {
+ label: 'Latest Submitted',
+ value: 'latest-submitted',
+ },
+ {
+ label: 'Highest Salary',
+ value: 'highest-salary',
+ },
+ {
+ label: 'Highest YOE first',
+ value: 'highest-yoe-first',
+ },
+ {
+ label: 'Lowest YOE first',
+ value: 'lowest-yoe-first',
+ },
+];
+
+export const OfferTableSortBy: Record = {
+ 'highest-salary': '-totalCompensation',
+ 'highest-yoe-first': '-totalYoe',
+ 'latest-submitted': '-monthYearReceived',
+ 'lowest-yoe-first': '+totalYoe',
};
diff --git a/apps/portal/src/components/offers/types.ts b/apps/portal/src/components/offers/types.ts
index 1841a3a5..b89403cf 100644
--- a/apps/portal/src/components/offers/types.ts
+++ b/apps/portal/src/components/offers/types.ts
@@ -6,12 +6,12 @@ import type { MonthYear } from '~/components/shared/MonthYearPicker';
export enum JobType {
FullTime = 'FULLTIME',
- Internship = 'INTERNSHIP',
+ Intern = 'INTERN',
}
export const JobTypeLabel = {
FULLTIME: 'Full-time',
- INTERNSHIP: 'Internship',
+ INTERN: 'Internship',
};
export enum EducationBackgroundType {
@@ -20,110 +20,91 @@ export enum EducationBackgroundType {
Masters = 'Masters',
PhD = 'PhD',
Professional = 'Professional',
- Seconday = 'Secondary',
+ Secondary = 'Secondary',
SelfTaught = 'Self-taught',
}
-export type Money = {
- currency: string;
- value: number;
+export type OffersProfilePostData = {
+ background: BackgroundPostData;
+ offers: Array;
};
-type FullTimeJobData = {
- base: Money;
- bonus: Money;
- level: string;
- specialization: string;
- stocks: Money;
- title: string;
- totalCompensation: Money;
+export type OffersProfileFormData = {
+ background: BackgroundPostData;
+ offers: Array;
};
-type InternshipJobData = {
- internshipCycle: string;
- monthlySalary: Money;
- specialization: string;
- startYear: number;
- title: string;
+export type BackgroundPostData = {
+ educations: Array;
+ experiences: Array;
+ specificYoes: Array;
+ totalYoe: number;
};
-type OfferDetailsGeneralData = {
- comments: string;
- companyId: string;
- jobType: string;
- location: string;
- monthYearReceived: MonthYear;
- negotiationStrategy: string;
+type ExperiencePostData = {
+ companyId?: string | null;
+ durationInMonths?: number | null;
+ jobType?: string | null;
+ level?: string | null;
+ location?: string | null;
+ monthlySalary?: Money | null;
+ specialization?: string | null;
+ title?: string | null;
+ totalCompensation?: Money | null;
+ totalCompensationId?: string | null;
};
-export type FullTimeOfferDetailsFormData = OfferDetailsGeneralData & {
- job: FullTimeJobData;
+type EducationPostData = {
+ endDate?: Date | null;
+ field?: string | null;
+ school?: string | null;
+ startDate?: Date | null;
+ type?: string | null;
};
-export type InternshipOfferDetailsFormData = OfferDetailsGeneralData & {
- job: InternshipJobData;
-};
-
-export type OfferDetailsFormData =
- | FullTimeOfferDetailsFormData
- | InternshipOfferDetailsFormData;
-
-export type OfferDetailsPostData = Omit<
- OfferDetailsFormData,
- 'monthYearReceived'
-> & {
- monthYearReceived: Date;
-};
-
-type SpecificYoe = {
+type SpecificYoePostData = {
domain: string;
yoe: number;
};
-type FullTimeExperience = {
- level?: string;
- totalCompensation?: Money;
-};
-
-type InternshipExperience = {
- monthlySalary?: Money;
-};
+type SpecificYoe = SpecificYoePostData;
-type GeneralExperience = {
- companyId?: string;
- durationInMonths?: number;
- jobType?: string;
- specialization?: string;
- title?: string;
+export type OfferPostData = {
+ comments: string;
+ companyId: string;
+ jobType: string;
+ location: string;
+ monthYearReceived: Date;
+ negotiationStrategy: string;
+ offersFullTime?: OfferFullTimePostData | null;
+ offersIntern?: OfferInternPostData | null;
};
-export type Experience =
- | (FullTimeExperience & GeneralExperience)
- | (GeneralExperience & InternshipExperience);
-
-type Education = {
- endDate?: Date;
- field?: string;
- school?: string;
- startDate?: Date;
- type?: string;
+export type OfferFormData = Omit & {
+ monthYearReceived: MonthYear;
};
-type BackgroundFormData = {
- educations: Array;
- experiences: Array;
- specificYoes: Array;
- totalYoe?: number;
+export type OfferFullTimePostData = {
+ baseSalary: Money;
+ bonus: Money;
+ level: string;
+ specialization: string;
+ stocks: Money;
+ title: string;
+ totalCompensation: Money;
};
-export type OfferProfileFormData = {
- background: BackgroundFormData;
- offers: Array;
+export type OfferInternPostData = {
+ internshipCycle: string;
+ monthlySalary: Money;
+ specialization: string;
+ startYear: number;
+ title: string;
};
-export type OfferProfilePostData = {
- background: BackgroundFormData;
- offers: Array;
+export type Money = {
+ currency: string;
+ value: number;
};
type EducationDisplay = {
@@ -158,3 +139,14 @@ export type BackgroundCard = {
specificYoes: Array;
totalYoe: string;
};
+
+export type CommentEntity = {
+ createdAt: Date;
+ id: string;
+ message: string;
+ profileId: string;
+ replies?: Array;
+ replyingToId: string;
+ userId: string;
+ username: string;
+};
diff --git a/apps/portal/src/components/questions/ContributeQuestionForm.tsx b/apps/portal/src/components/questions/ContributeQuestionForm.tsx
index 5e230d73..a0e4d0b6 100644
--- a/apps/portal/src/components/questions/ContributeQuestionForm.tsx
+++ b/apps/portal/src/components/questions/ContributeQuestionForm.tsx
@@ -84,9 +84,8 @@ export default function ContributeQuestionForm({
name="company"
render={({ field }) => (
{
- // TODO: To change from using company name to company id (i.e., value)
- field.onChange(label);
+ onSelect={({ id }) => {
+ field.onChange(id);
}}
/>
)}
diff --git a/apps/portal/src/components/resumes/ResumePdf.tsx b/apps/portal/src/components/resumes/ResumePdf.tsx
index 668d0f6f..caa53063 100644
--- a/apps/portal/src/components/resumes/ResumePdf.tsx
+++ b/apps/portal/src/components/resumes/ResumePdf.tsx
@@ -1,4 +1,5 @@
-import { useState } from 'react';
+import clsx from 'clsx';
+import { useEffect, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import {
@@ -18,14 +19,30 @@ type Props = Readonly<{
export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
- const [scale, setScale] = useState(1);
+ const [pageWidth, setPageWidth] = useState(750);
+ const [componentWidth, setComponentWidth] = useState(780);
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages);
};
+ useEffect(() => {
+ const onPageResize = () => {
+ setComponentWidth(
+ document.querySelector('#pdfView')?.getBoundingClientRect().width ??
+ 780,
+ );
+ };
+
+ window.addEventListener('resize', onPageResize);
+
+ return () => {
+ window.removeEventListener('resize', onPageResize);
+ };
+ }, []);
+
return (
-
+
}
noData=""
onLoadSuccess={onPdfLoadSuccess}>
-
+
componentWidth
+ ? `${pageWidth - componentWidth}px`
+ : '',
+ ),
+ }}>
+
+
setScale(scale - 0.25)}
+ onClick={() => setPageWidth(pageWidth - 150)}
/>
setScale(scale + 0.25)}
+ onClick={() => setPageWidth(pageWidth + 150)}
/>
diff --git a/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeCoolIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeCoolIcon.tsx
new file mode 100644
index 00000000..47ac2dad
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeCoolIcon.tsx
@@ -0,0 +1,68 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeCoolIcon({ className }: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeRocketIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeRocketIcon.tsx
new file mode 100644
index 00000000..c7545311
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeRocketIcon.tsx
@@ -0,0 +1,78 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeRocketIcon({ className }: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeTreasureIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeTreasureIcon.tsx
new file mode 100644
index 00000000..a5eddfd7
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/popularResumes/ResumeBadgeTreasureIcon.tsx
@@ -0,0 +1,198 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeTreasureIcon({
+ className,
+}: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badgeIcons/resume-badge.d.ts b/apps/portal/src/components/resumes/badgeIcons/resume-badge.d.ts
new file mode 100644
index 00000000..f73d9281
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/resume-badge.d.ts
@@ -0,0 +1,3 @@
+export type ResumeBadgeProps = Readonly<{
+ className: string;
+}>;
diff --git a/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeDetectiveIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeDetectiveIcon.tsx
new file mode 100644
index 00000000..053d9497
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeDetectiveIcon.tsx
@@ -0,0 +1,59 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeDetectiveIcon({
+ className,
+}: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeEagleIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeEagleIcon.tsx
new file mode 100644
index 00000000..b3ad7523
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeEagleIcon.tsx
@@ -0,0 +1,27 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeEagleIcon({ className }: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeSuperheroIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeSuperheroIcon.tsx
new file mode 100644
index 00000000..bdda0f09
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/reviewer/ResumeBadgeSuperheroIcon.tsx
@@ -0,0 +1,54 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeSuperheroIcon({
+ className,
+}: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeBookIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeBookIcon.tsx
new file mode 100644
index 00000000..47b53315
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeBookIcon.tsx
@@ -0,0 +1,135 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeBookIcon({ className }: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeOwlIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeOwlIcon.tsx
new file mode 100644
index 00000000..c3905682
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeOwlIcon.tsx
@@ -0,0 +1,125 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeOwlIcon({ className }: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeSageIcon.tsx b/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeSageIcon.tsx
new file mode 100644
index 00000000..72794151
--- /dev/null
+++ b/apps/portal/src/components/resumes/badgeIcons/topComment/ResumeBadgeSageIcon.tsx
@@ -0,0 +1,103 @@
+import type { ResumeBadgeProps } from '../resume-badge';
+
+export default function ResumeBadgeSageIcon({ className }: ResumeBadgeProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badges/ResumeUserBadge.tsx b/apps/portal/src/components/resumes/badges/ResumeUserBadge.tsx
new file mode 100644
index 00000000..52d0de40
--- /dev/null
+++ b/apps/portal/src/components/resumes/badges/ResumeUserBadge.tsx
@@ -0,0 +1,30 @@
+import type { BadgeIcon } from './resumeBadgeConstants';
+
+type Props = Readonly<{
+ description: string;
+ icon: BadgeIcon;
+ title: string;
+}>;
+
+export default function ResumeUserBadge({
+ description,
+ icon: Icon,
+ title,
+}: Props) {
+ return (
+
+
+
+
{title}
+
{description}.
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badges/ResumeUserBadges.tsx b/apps/portal/src/components/resumes/badges/ResumeUserBadges.tsx
new file mode 100644
index 00000000..1622dd9e
--- /dev/null
+++ b/apps/portal/src/components/resumes/badges/ResumeUserBadges.tsx
@@ -0,0 +1,45 @@
+import { trpc } from '~/utils/trpc';
+
+import type { BadgePayload } from './resumeBadgeConstants';
+import { RESUME_USER_BADGES } from './resumeBadgeConstants';
+import ResumeUserBadge from './ResumeUserBadge';
+
+type Props = Readonly<{
+ userId: string;
+}>;
+
+export default function ResumeUserBadges({ userId }: Props) {
+ const userReviewedResumeCountQuery = trpc.useQuery([
+ 'resumes.resume.findUserReviewedResumeCount',
+ { userId },
+ ]);
+ const userMaxResumeUpvoteCountQuery = trpc.useQuery([
+ 'resumes.resume.findUserMaxResumeUpvoteCount',
+ { userId },
+ ]);
+ const userTopUpvotedCommentCountQuery = trpc.useQuery([
+ 'resumes.resume.findUserTopUpvotedCommentCount',
+ { userId },
+ ]);
+
+ const payload: BadgePayload = {
+ maxResumeUpvoteCount: userMaxResumeUpvoteCountQuery.data ?? 0,
+ reviewedResumesCount: userReviewedResumeCountQuery.data ?? 0,
+ topUpvotedCommentCount: userTopUpvotedCommentCountQuery.data ?? 0,
+ };
+
+ return (
+
+ {RESUME_USER_BADGES.filter((badge) => badge.isValid(payload)).map(
+ (badge) => (
+
+ ),
+ )}
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/badges/resumeBadgeConstants.ts b/apps/portal/src/components/resumes/badges/resumeBadgeConstants.ts
new file mode 100644
index 00000000..3b01ee9e
--- /dev/null
+++ b/apps/portal/src/components/resumes/badges/resumeBadgeConstants.ts
@@ -0,0 +1,113 @@
+import ResumeBadgeCoolIcon from '../badgeIcons/popularResumes/ResumeBadgeCoolIcon';
+import ResumeBadgeRocketIcon from '../badgeIcons/popularResumes/ResumeBadgeRocketIcon';
+import ResumeBadgeTreasureIcon from '../badgeIcons/popularResumes/ResumeBadgeTreasureIcon';
+import ResumeBadgeDetectiveIcon from '../badgeIcons/reviewer/ResumeBadgeDetectiveIcon';
+import ResumeBadgeEagleIcon from '../badgeIcons/reviewer/ResumeBadgeEagleIcon';
+import ResumeBadgeSuperheroIcon from '../badgeIcons/reviewer/ResumeBadgeSuperheroIcon';
+import ResumeBadgeBookIcon from '../badgeIcons/topComment/ResumeBadgeBookIcon';
+import ResumeBadgeOwlIcon from '../badgeIcons/topComment/ResumeBadgeOwlIcon';
+import ResumeBadgeSageIcon from '../badgeIcons/topComment/ResumeBadgeSageIcon';
+
+export type BadgeIcon = (
+ props: React.ComponentProps
,
+) => JSX.Element;
+
+export type BadgeInfo = {
+ description: string;
+ icon: BadgeIcon;
+ id: string;
+ isValid: (payload: BadgePayload) => boolean;
+ title: string;
+};
+
+// TODO: Add other badges in
+export type BadgePayload = {
+ maxResumeUpvoteCount: number;
+ reviewedResumesCount: number;
+ topUpvotedCommentCount: number;
+};
+
+const TIER_THREE = 20;
+const TIER_TWO = 10;
+const TIER_ONE = 5;
+
+export const RESUME_USER_BADGES: Array = [
+ {
+ description: `Reviewed over ${TIER_THREE} resumes`,
+ icon: ResumeBadgeSuperheroIcon,
+ id: 'Superhero',
+ isValid: (payload: BadgePayload) =>
+ payload.reviewedResumesCount >= TIER_THREE,
+ title: 'True saviour of the people',
+ },
+ {
+ description: `Reviewed over ${TIER_TWO} resumes`,
+ icon: ResumeBadgeDetectiveIcon,
+ id: 'Detective',
+ isValid: (payload: BadgePayload) =>
+ payload.reviewedResumesCount >= TIER_TWO &&
+ payload.reviewedResumesCount < TIER_THREE,
+ title: 'Keen eye for details like a private eye',
+ },
+ {
+ description: `Reviewed over ${TIER_ONE} resumes`,
+ icon: ResumeBadgeEagleIcon,
+ id: 'Eagle',
+ isValid: (payload: BadgePayload) =>
+ payload.reviewedResumesCount >= TIER_ONE &&
+ payload.reviewedResumesCount < TIER_TWO,
+ title: 'As sharp as an eagle',
+ },
+ {
+ description: `${TIER_THREE} upvotes on a resume`,
+ icon: ResumeBadgeRocketIcon,
+ id: 'Rocket',
+ isValid: (payload: BadgePayload) =>
+ payload.maxResumeUpvoteCount >= TIER_THREE,
+ title: 'To the moon!',
+ },
+ {
+ description: `${TIER_TWO} upvotes on a resume`,
+ icon: ResumeBadgeTreasureIcon,
+ id: 'Treasure',
+ isValid: (payload: BadgePayload) =>
+ payload.maxResumeUpvoteCount >= TIER_TWO &&
+ payload.maxResumeUpvoteCount < TIER_THREE,
+ title: "Can't get enough of this!",
+ },
+ {
+ description: `${TIER_ONE} upvotes on a resume`,
+ icon: ResumeBadgeCoolIcon,
+ id: 'Cool',
+ isValid: (payload: BadgePayload) =>
+ payload.maxResumeUpvoteCount >= TIER_ONE &&
+ payload.maxResumeUpvoteCount < TIER_TWO,
+ title: 'Like the cool kids',
+ },
+ {
+ description: `${TIER_THREE} top upvoted comment`,
+ icon: ResumeBadgeSageIcon,
+ id: 'Sage',
+ isValid: (payload: BadgePayload) =>
+ payload.topUpvotedCommentCount >= TIER_THREE,
+ title: 'I am wisdom',
+ },
+ {
+ description: `${TIER_TWO} top upvoted comment`,
+ icon: ResumeBadgeBookIcon,
+ id: 'Book',
+ isValid: (payload: BadgePayload) =>
+ payload.topUpvotedCommentCount >= TIER_TWO &&
+ payload.topUpvotedCommentCount < TIER_THREE,
+ title: 'The walking encyclopaedia',
+ },
+ {
+ description: `${TIER_ONE} top upvoted comment`,
+ icon: ResumeBadgeOwlIcon,
+ id: 'Owl',
+ isValid: (payload: BadgePayload) =>
+ payload.topUpvotedCommentCount >= TIER_ONE &&
+ payload.topUpvotedCommentCount < TIER_TWO,
+ title: 'Wise as an owl',
+ },
+];
diff --git a/apps/portal/src/components/resumes/browse/ResumeFilterPill.tsx b/apps/portal/src/components/resumes/browse/ResumeFilterPill.tsx
index 3961ac9d..a8aa10d7 100644
--- a/apps/portal/src/components/resumes/browse/ResumeFilterPill.tsx
+++ b/apps/portal/src/components/resumes/browse/ResumeFilterPill.tsx
@@ -1,12 +1,22 @@
+import clsx from 'clsx';
+
type Props = Readonly<{
+ isSelected: boolean;
onClick?: (event: React.MouseEvent) => void;
title: string;
}>;
-export default function ResumeFilterPill({ title, onClick }: Props) {
+export default function ResumeFilterPill({
+ title,
+ onClick,
+ isSelected,
+}: Props) {
return (
{title}
diff --git a/apps/portal/src/components/resumes/browse/ResumeListItem.tsx b/apps/portal/src/components/resumes/browse/ResumeListItem.tsx
index 0d0a2756..5726badd 100644
--- a/apps/portal/src/components/resumes/browse/ResumeListItem.tsx
+++ b/apps/portal/src/components/resumes/browse/ResumeListItem.tsx
@@ -1,17 +1,14 @@
import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Link from 'next/link';
-import { useSession } from 'next-auth/react';
import type { UrlObject } from 'url';
-import { ChevronRightIcon } from '@heroicons/react/20/solid';
import {
AcademicCapIcon,
BriefcaseIcon,
+ ChevronRightIcon,
StarIcon as ColouredStarIcon,
} from '@heroicons/react/20/solid';
import { ChatBubbleLeftIcon, StarIcon } from '@heroicons/react/24/outline';
-import { trpc } from '~/utils/trpc';
-
import type { Resume } from '~/types/resume';
type Props = Readonly<{
@@ -19,16 +16,7 @@ type Props = Readonly<{
resumeInfo: Resume;
}>;
-export default function BrowseListItem({ href, resumeInfo }: Props) {
- const { data: sessionData } = useSession();
-
- // Find out if user has starred this particular resume
- const resumeId = resumeInfo.id;
- const isStarredQuery = trpc.useQuery([
- 'resumes.resume.user.isResumeStarred',
- { resumeId },
- ]);
-
+export default function ResumeListItem({ href, resumeInfo }: Props) {
return (
@@ -53,22 +41,27 @@ export default function BrowseListItem({ href, resumeInfo }: Props) {
- {resumeInfo.numComments} comments
+ {`${resumeInfo.numComments} comment${
+ resumeInfo.numComments === 1 ? '' : 's'
+ }`}
- {isStarredQuery.data && sessionData?.user ? (
+ {resumeInfo.isStarredByUser ? (
) : (
)}
- {resumeInfo.numStars} stars
+ {`${resumeInfo.numStars} star${
+ resumeInfo.numStars === 1 ? '' : 's'
+ }`}
- Uploaded {formatDistanceToNow(resumeInfo.createdAt)} ago by{' '}
- {resumeInfo.user}
+ {`Uploaded ${formatDistanceToNow(resumeInfo.createdAt, {
+ addSuffix: true,
+ })} by ${resumeInfo.user}`}
{resumeInfo.location}
diff --git a/apps/portal/src/components/resumes/browse/ResumeListItems.tsx b/apps/portal/src/components/resumes/browse/ResumeListItems.tsx
index 1216e36b..07f61770 100644
--- a/apps/portal/src/components/resumes/browse/ResumeListItems.tsx
+++ b/apps/portal/src/components/resumes/browse/ResumeListItems.tsx
@@ -1,6 +1,6 @@
import { Spinner } from '@tih/ui';
-import ResumseListItem from './ResumeListItem';
+import ResumeListItem from './ResumeListItem';
import type { Resume } from '~/types/resume';
@@ -22,7 +22,7 @@ export default function ResumeListItems({ isLoading, resumes }: Props) {
{resumes.map((resumeObj: Resume) => (
-
diff --git a/apps/portal/src/components/resumes/browse/resumeConstants.ts b/apps/portal/src/components/resumes/browse/resumeConstants.ts
deleted file mode 100644
index 9f0a2058..00000000
--- a/apps/portal/src/components/resumes/browse/resumeConstants.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-export const BROWSE_TABS_VALUES = {
- ALL: 'all',
- MY: 'my',
- STARRED: 'starred',
-};
-
-export type SortOrder = 'latest' | 'popular' | 'topComments';
-type SortOption = {
- name: string;
- value: SortOrder;
-};
-
-export const SORT_OPTIONS: Array = [
- { name: 'Latest', value: 'latest' },
- { name: 'Popular', value: 'popular' },
- { name: 'Top Comments', value: 'topComments' },
-];
-
-export const TOP_HITS = [
- { href: '#', name: 'Unreviewed' },
- { href: '#', name: 'Fresh Grad' },
- { href: '#', name: 'GOATs' },
- { href: '#', name: 'US Only' },
-];
-
-export type FilterOption = {
- label: string;
- value: string;
-};
-
-export const ROLE: Array = [
- {
- 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 = [
- { 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 = [
- { label: 'Singapore', value: 'Singapore' },
- { label: 'United States', value: 'United States' },
- { label: 'India', value: 'India' },
-];
-
-export const TEST_RESUMES = [
- {
- createdAt: new Date(),
- experience: 'Fresh Grad (0-1 years)',
- numComments: 9,
- numStars: 1,
- role: 'Backend Engineer',
- title: 'Rejected from multiple companies, please help...:(',
- user: 'Git Ji Ra',
- },
- {
- createdAt: new Date(),
- experience: 'Fresh Grad (0-1 years)',
- numComments: 9,
- numStars: 1,
- role: 'Backend Engineer',
- title: 'Rejected from multiple companies, please help...:(',
- user: 'Git Ji Ra',
- },
- {
- createdAt: new Date(),
- experience: 'Fresh Grad (0-1 years)',
- numComments: 9,
- numStars: 1,
- role: 'Backend Engineer',
- title: 'Rejected from multiple companies, please help...:(',
- user: 'Git Ji Ra',
- },
-];
diff --git a/apps/portal/src/components/resumes/browse/resumeFilters.ts b/apps/portal/src/components/resumes/browse/resumeFilters.ts
new file mode 100644
index 00000000..de40a371
--- /dev/null
+++ b/apps/portal/src/components/resumes/browse/resumeFilters.ts
@@ -0,0 +1,151 @@
+export type FilterId = 'experience' | 'location' | 'role';
+
+export type CustomFilter = {
+ numComments: number;
+};
+
+type RoleFilter =
+ | 'Android Engineer'
+ | 'Backend Engineer'
+ | 'DevOps Engineer'
+ | 'Frontend Engineer'
+ | 'Full-Stack Engineer'
+ | 'iOS Engineer';
+
+type ExperienceFilter =
+ | 'Entry Level (0 - 2 years)'
+ | 'Freshman'
+ | 'Junior'
+ | 'Mid Level (3 - 5 years)'
+ | 'Senior Level (5+ years)'
+ | 'Senior'
+ | 'Sophomore';
+
+type LocationFilter = 'India' | 'Singapore' | 'United States';
+
+export type FilterValue = ExperienceFilter | LocationFilter | RoleFilter;
+
+export type FilterOption = {
+ label: string;
+ value: T;
+};
+
+export type Filter = {
+ id: FilterId;
+ label: string;
+ options: Array>;
+};
+
+export type FilterState = Partial &
+ Record>;
+
+export type SortOrder = 'latest' | 'popular' | 'topComments';
+
+export type Shortcut = {
+ customFilters?: CustomFilter;
+ filters: FilterState;
+ name: string;
+ sortOrder: SortOrder;
+};
+
+export const BROWSE_TABS_VALUES = {
+ ALL: 'all',
+ MY: 'my',
+ STARRED: 'starred',
+};
+
+export const SORT_OPTIONS: Record = {
+ latest: 'Latest',
+ popular: 'Popular',
+ topComments: 'Top Comments',
+};
+
+export const ROLE: Array> = [
+ {
+ 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> = [
+ { 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> = [
+ { 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 = [
+ {
+ filters: INITIAL_FILTER_STATE,
+ name: 'All',
+ sortOrder: 'latest',
+ },
+ {
+ filters: {
+ ...INITIAL_FILTER_STATE,
+ numComments: 0,
+ },
+ name: 'Unreviewed',
+ sortOrder: 'latest',
+ },
+ {
+ filters: {
+ ...INITIAL_FILTER_STATE,
+ experience: ['Entry Level (0 - 2 years)'],
+ },
+ name: 'Fresh Grad',
+ sortOrder: 'latest',
+ },
+ {
+ filters: INITIAL_FILTER_STATE,
+ name: 'GOATs',
+ sortOrder: 'popular',
+ },
+ {
+ filters: {
+ ...INITIAL_FILTER_STATE,
+ location: ['United States'],
+ },
+ name: 'US Only',
+ sortOrder: 'latest',
+ },
+];
+
+export const isInitialFilterState = (filters: FilterState) =>
+ Object.keys(filters).every((filter) => {
+ if (!['experience', 'location', 'role'].includes(filter)) {
+ return true;
+ }
+ return INITIAL_FILTER_STATE[filter as FilterId].every((value) =>
+ filters[filter as FilterId].includes(value),
+ );
+ });
diff --git a/apps/portal/src/components/resumes/comments/ResumeCommentListItem.tsx b/apps/portal/src/components/resumes/comments/ResumeCommentListItem.tsx
index 537f3fc2..07d951bb 100644
--- a/apps/portal/src/components/resumes/comments/ResumeCommentListItem.tsx
+++ b/apps/portal/src/components/resumes/comments/ResumeCommentListItem.tsx
@@ -1,16 +1,19 @@
-import {
- ArrowDownCircleIcon,
- ArrowUpCircleIcon,
-} from '@heroicons/react/20/solid';
+import clsx from 'clsx';
+import { useState } from 'react';
+import { ChevronUpIcon } from '@heroicons/react/20/solid';
import { FaceSmileIcon } from '@heroicons/react/24/outline';
+import ResumeCommentEditForm from './comment/ResumeCommentEditForm';
+import ResumeCommentReplyForm from './comment/ResumeCommentReplyForm';
+import ResumeCommentVoteButtons from './comment/ResumeCommentVoteButtons';
+import ResumeUserBadges from '../badges/ResumeUserBadges';
import ResumeExpandableText from '../shared/ResumeExpandableText';
import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentListItemProps = {
comment: ResumeComment;
- userId?: string;
+ userId: string | undefined;
};
export default function ResumeCommentListItem({
@@ -18,34 +21,57 @@ export default function ResumeCommentListItem({
userId,
}: ResumeCommentListItemProps) {
const isCommentOwner = userId === comment.user.userId;
+ const [isEditingComment, setIsEditingComment] = useState(false);
+ const [isReplyingComment, setIsReplyingComment] = useState(false);
+ const [showReplies, setShowReplies] = useState(true);
return (
-
-
+
+
+ {/* Image Icon */}
{comment.user.image ? (
) : (
-
+
)}
{/* Name and creation time */}
-
+
{comment.user.name ?? 'Reviewer ABC'}
-
+
-
+
{isCommentOwner ? '(Me)' : ''}
-
+
+
+
-
+
{comment.createdAt.toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
@@ -54,22 +80,93 @@ export default function ResumeCommentListItem({
{/* Description */}
-
{comment.description}
+ {isEditingComment ? (
+
+ ) : (
+
+ )}
{/* Upvote and edit */}
- {/* TODO: Implement upvote */}
-
-
{comment.numVotes}
-
-
- {/* TODO: Implement edit */}
- {isCommentOwner ? (
-
- Edit
-
- ) : null}
+
+
+ {/* Action buttons; only present for authenticated user when not editing/replying */}
+ {userId && !isEditingComment && !isReplyingComment && (
+ <>
+ {isCommentOwner && (
+
setIsEditingComment(true)}>
+ Edit
+
+ )}
+
+ {!comment.parentId && (
+
setIsReplyingComment(true)}>
+ Reply
+
+ )}
+ >
+ )}
+
+ {/* Reply Form */}
+ {isReplyingComment && (
+
+ )}
+
+ {/* Replies */}
+ {comment.children.length > 0 && (
+
+
setShowReplies(!showReplies)}>
+
+ {showReplies ? 'Hide replies' : 'Show replies'}
+
+
+ {showReplies && (
+
+
+
+
+ {comment.children.map((child) => {
+ return (
+
+ );
+ })}
+
+
+ )}
+
+ )}
diff --git a/apps/portal/src/components/resumes/comments/ResumeCommentsForm.tsx b/apps/portal/src/components/resumes/comments/ResumeCommentsForm.tsx
index 31bade2f..584f88d0 100644
--- a/apps/portal/src/components/resumes/comments/ResumeCommentsForm.tsx
+++ b/apps/portal/src/components/resumes/comments/ResumeCommentsForm.tsx
@@ -47,6 +47,9 @@ export default function ResumeCommentsForm({
onSuccess: () => {
// New Comment added, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
+ trpcContext.invalidateQueries(['resumes.resume.findAll']);
+ trpcContext.invalidateQueries(['resumes.resume.user.findUserStarred']);
+ trpcContext.invalidateQueries(['resumes.resume.user.findUserCreated']);
},
},
);
diff --git a/apps/portal/src/components/resumes/comments/ResumeCommentsList.tsx b/apps/portal/src/components/resumes/comments/ResumeCommentsList.tsx
index 5b50f0ad..bb13da81 100644
--- a/apps/portal/src/components/resumes/comments/ResumeCommentsList.tsx
+++ b/apps/portal/src/components/resumes/comments/ResumeCommentsList.tsx
@@ -1,6 +1,14 @@
import { useSession } from 'next-auth/react';
-import { useState } from 'react';
-import { Spinner, Tabs } from '@tih/ui';
+import {
+ BookOpenIcon,
+ BriefcaseIcon,
+ CodeBracketSquareIcon,
+ FaceSmileIcon,
+ IdentificationIcon,
+ SparklesIcon,
+} from '@heroicons/react/24/outline';
+import { ResumesSection } from '@prisma/client';
+import { Spinner } from '@tih/ui';
import { Button } from '@tih/ui';
import { trpc } from '~/utils/trpc';
@@ -21,23 +29,26 @@ export default function ResumeCommentsList({
setShowCommentsForm,
}: ResumeCommentsListProps) {
const { data: sessionData } = useSession();
- const [tab, setTab] = useState(RESUME_COMMENTS_SECTIONS[0].value);
- const [tabs, setTabs] = useState(RESUME_COMMENTS_SECTIONS);
- const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }], {
- onSuccess: (data: Array
) => {
- const updatedTabs = RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
- const count = data.filter(({ section }) => section === value).length;
- const updatedLabel = count > 0 ? `${label} (${count})` : label;
- return {
- label: updatedLabel,
- value,
- };
- });
+ const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }]);
- setTabs(updatedTabs);
- },
- });
+ const renderIcon = (section: ResumesSection) => {
+ const className = 'h-7 w-7';
+ switch (section) {
+ case ResumesSection.GENERAL:
+ return ;
+ case ResumesSection.EDUCATION:
+ return ;
+ case ResumesSection.EXPERIENCE:
+ return ;
+ case ResumesSection.PROJECTS:
+ return ;
+ case ResumesSection.SKILLS:
+ return ;
+ default:
+ return ;
+ }
+ };
const renderButton = () => {
if (sessionData === null) {
@@ -45,6 +56,7 @@ export default function ResumeCommentsList({
}
return (
{renderButton()}
- setTab(value)}
- />
-
- {commentsQuery.isFetching ? (
+ {commentsQuery.isLoading ? (
) : (
-
- {(commentsQuery.data?.filter((c) => c.section === tab) ?? []).map(
- (comment) => (
-
- ),
- )}
+
+ {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 (
+
+
+ {renderIcon(value)}
+
+
{label}
+
+
+ {commentCount > 0 ? (
+ comments.map((comment) => {
+ return (
+
+ );
+ })
+ ) : (
+
There are no comments for this section yet!
+ )}
+
+ );
+ })}
)}
diff --git a/apps/portal/src/components/resumes/comments/comment/ResumeCommentEditForm.tsx b/apps/portal/src/components/resumes/comments/comment/ResumeCommentEditForm.tsx
new file mode 100644
index 00000000..1d0a7bd7
--- /dev/null
+++ b/apps/portal/src/components/resumes/comments/comment/ResumeCommentEditForm.tsx
@@ -0,0 +1,106 @@
+import type { SubmitHandler } from 'react-hook-form';
+import { useForm } from 'react-hook-form';
+import { Button, TextArea } from '@tih/ui';
+
+import { trpc } from '~/utils/trpc';
+
+import type { ResumeComment } from '~/types/resume-comments';
+
+type ResumeCommentEditFormProps = {
+ comment: ResumeComment;
+ setIsEditingComment: (value: boolean) => void;
+};
+
+type ICommentInput = {
+ description: string;
+};
+
+export default function ResumeCommentEditForm({
+ comment,
+ setIsEditingComment,
+}: ResumeCommentEditFormProps) {
+ const {
+ register,
+ handleSubmit,
+ setValue,
+ formState: { errors, isDirty },
+ reset,
+ } = useForm({
+ defaultValues: {
+ description: comment.description,
+ },
+ });
+
+ const trpcContext = trpc.useContext();
+ const commentUpdateMutation = trpc.useMutation(
+ 'resumes.comments.user.update',
+ {
+ onSuccess: () => {
+ // Comment updated, invalidate query to trigger refetch
+ trpcContext.invalidateQueries(['resumes.comments.list']);
+ },
+ },
+ );
+
+ const onCancel = () => {
+ reset({ description: comment.description });
+ setIsEditingComment(false);
+ };
+
+ const onSubmit: SubmitHandler = async (data) => {
+ const { id } = comment;
+ return commentUpdateMutation.mutate(
+ {
+ id,
+ ...data,
+ },
+ {
+ onSuccess: () => {
+ setIsEditingComment(false);
+ },
+ },
+ );
+ };
+
+ const setFormValue = (value: string) => {
+ setValue('description', value.trim(), { shouldDirty: true });
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/comments/comment/ResumeCommentReplyForm.tsx b/apps/portal/src/components/resumes/comments/comment/ResumeCommentReplyForm.tsx
new file mode 100644
index 00000000..ea14632a
--- /dev/null
+++ b/apps/portal/src/components/resumes/comments/comment/ResumeCommentReplyForm.tsx
@@ -0,0 +1,107 @@
+import type { SubmitHandler } from 'react-hook-form';
+import { useForm } from 'react-hook-form';
+import type { ResumesSection } from '@prisma/client';
+import { Button, TextArea } from '@tih/ui';
+
+import { trpc } from '~/utils/trpc';
+
+type ResumeCommentEditFormProps = {
+ parentId: string;
+ resumeId: string;
+ section: ResumesSection;
+ setIsReplyingComment: (value: boolean) => void;
+};
+
+type IReplyInput = {
+ description: string;
+};
+
+export default function ResumeCommentReplyForm({
+ parentId,
+ setIsReplyingComment,
+ resumeId,
+ section,
+}: ResumeCommentEditFormProps) {
+ const {
+ register,
+ handleSubmit,
+ setValue,
+ formState: { errors, isDirty },
+ reset,
+ } = useForm({
+ defaultValues: {
+ description: '',
+ },
+ });
+
+ const trpcContext = trpc.useContext();
+ const commentReplyMutation = trpc.useMutation('resumes.comments.user.reply', {
+ onSuccess: () => {
+ // Comment updated, invalidate query to trigger refetch
+ trpcContext.invalidateQueries(['resumes.comments.list']);
+ },
+ });
+
+ const onCancel = () => {
+ reset({ description: '' });
+ setIsReplyingComment(false);
+ };
+
+ const onSubmit: SubmitHandler = async (data) => {
+ return commentReplyMutation.mutate(
+ {
+ parentId,
+ resumeId,
+ section,
+ ...data,
+ },
+ {
+ onSuccess: () => {
+ setIsReplyingComment(false);
+ },
+ },
+ );
+ };
+
+ const setFormValue = (value: string) => {
+ setValue('description', value.trim(), { shouldDirty: true });
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/comments/comment/ResumeCommentVoteButtons.tsx b/apps/portal/src/components/resumes/comments/comment/ResumeCommentVoteButtons.tsx
new file mode 100644
index 00000000..e17e2546
--- /dev/null
+++ b/apps/portal/src/components/resumes/comments/comment/ResumeCommentVoteButtons.tsx
@@ -0,0 +1,131 @@
+import clsx from 'clsx';
+import { useState } from 'react';
+import {
+ ArrowDownCircleIcon,
+ ArrowUpCircleIcon,
+} from '@heroicons/react/20/solid';
+import { Vote } from '@prisma/client';
+
+import { trpc } from '~/utils/trpc';
+
+type ResumeCommentVoteButtonsProps = {
+ commentId: string;
+ userId: string | undefined;
+};
+
+export default function ResumeCommentVoteButtons({
+ commentId,
+ userId,
+}: ResumeCommentVoteButtonsProps) {
+ const [upvoteAnimation, setUpvoteAnimation] = useState(false);
+ const [downvoteAnimation, setDownvoteAnimation] = useState(false);
+
+ const trpcContext = trpc.useContext();
+
+ // COMMENT VOTES
+ const commentVotesQuery = trpc.useQuery([
+ 'resumes.comments.votes.list',
+ { commentId },
+ ]);
+ const commentVotesUpsertMutation = trpc.useMutation(
+ 'resumes.comments.votes.user.upsert',
+ {
+ onSuccess: () => {
+ // Comment updated, invalidate query to trigger refetch
+ trpcContext.invalidateQueries(['resumes.comments.votes.list']);
+ },
+ },
+ );
+ const commentVotesDeleteMutation = trpc.useMutation(
+ 'resumes.comments.votes.user.delete',
+ {
+ onSuccess: () => {
+ // Comment updated, invalidate query to trigger refetch
+ trpcContext.invalidateQueries(['resumes.comments.votes.list']);
+ },
+ },
+ );
+
+ const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => {
+ setAnimation(true);
+
+ if (commentVotesQuery.data?.userVote?.value === value) {
+ return commentVotesDeleteMutation.mutate(
+ {
+ commentId,
+ },
+ {
+ onSettled: async () => setAnimation(false),
+ },
+ );
+ }
+ return commentVotesUpsertMutation.mutate(
+ {
+ commentId,
+ value,
+ },
+ {
+ onSettled: async () => setAnimation(false),
+ },
+ );
+ };
+
+ return (
+ <>
+ onVote(Vote.UPVOTE, setUpvoteAnimation)}>
+
+
+
+
+ {commentVotesQuery.data?.numVotes ?? 0}
+
+
+ onVote(Vote.DOWNVOTE, setDownvoteAnimation)}>
+
+
+ >
+ );
+}
diff --git a/apps/portal/src/components/resumes/landing/Button.jsx b/apps/portal/src/components/resumes/landing/Button.jsx
deleted file mode 100644
index 9d8c3ecc..00000000
--- a/apps/portal/src/components/resumes/landing/Button.jsx
+++ /dev/null
@@ -1,45 +0,0 @@
-import clsx from 'clsx';
-import Link from 'next/link';
-
-const baseStyles = {
- outline:
- 'group inline-flex ring-1 items-center justify-center rounded-full py-2 px-4 text-sm focus:outline-none',
- solid:
- 'group inline-flex items-center justify-center rounded-full py-2 px-4 text-sm font-semibold focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2',
-};
-
-const variantStyles = {
- outline: {
- slate:
- 'ring-slate-200 text-slate-700 hover:text-slate-900 hover:ring-slate-300 active:bg-slate-100 active:text-slate-600 focus-visible:outline-blue-600 focus-visible:ring-slate-300',
- white:
- 'ring-slate-700 text-white hover:ring-slate-500 active:ring-slate-700 active:text-slate-400 focus-visible:outline-white',
- },
- solid: {
- blue: 'bg-blue-600 text-white hover:text-slate-100 hover:bg-blue-500 active:bg-blue-800 active:text-blue-100 focus-visible:outline-blue-600',
- slate:
- 'bg-slate-900 text-white hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900',
- white:
- 'bg-white text-slate-900 hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white',
- },
-};
-
-export function Button({
- variant = 'solid',
- color = 'slate',
- className,
- href,
- ...props
-}) {
- className = clsx(
- baseStyles[variant],
- variantStyles[variant][color],
- className,
- );
-
- return href ? (
-
- ) : (
-
- );
-}
diff --git a/apps/portal/src/components/resumes/landing/CallToAction.jsx b/apps/portal/src/components/resumes/landing/CallToAction.jsx
deleted file mode 100644
index b4f8f455..00000000
--- a/apps/portal/src/components/resumes/landing/CallToAction.jsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import Image from 'next/future/image';
-
-import { Button } from './Button';
-import { Container } from './Container';
-import backgroundImage from './images/background-call-to-action.jpg';
-
-export function CallToAction() {
- return (
-
-
-
-
-
- Resume review can start right now.
-
-
- It's free! Take charge of your resume game by learning from the top
- engineers in the field.
-
-
- Start browsing now
-
-
-
-
- );
-}
diff --git a/apps/portal/src/components/resumes/landing/CallToAction.tsx b/apps/portal/src/components/resumes/landing/CallToAction.tsx
new file mode 100644
index 00000000..623509b3
--- /dev/null
+++ b/apps/portal/src/components/resumes/landing/CallToAction.tsx
@@ -0,0 +1,28 @@
+import Link from 'next/link';
+
+import { Container } from './Container';
+
+export function CallToAction() {
+ return (
+
+
+
+
+ Resume review can start right now.
+
+
+ It's free! Take charge of your resume game by learning from the top
+ engineers in the field.
+
+
+
+ Start browsing now
+
+
+
+
+
+ );
+}
diff --git a/apps/portal/src/components/resumes/landing/Container.jsx b/apps/portal/src/components/resumes/landing/Container.jsx
deleted file mode 100644
index a17ea3c1..00000000
--- a/apps/portal/src/components/resumes/landing/Container.jsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import clsx from 'clsx'
-
-export function Container({ className, ...props }) {
- return (
-
- )
-}
diff --git a/apps/portal/src/components/resumes/landing/Container.tsx b/apps/portal/src/components/resumes/landing/Container.tsx
new file mode 100644
index 00000000..94d35463
--- /dev/null
+++ b/apps/portal/src/components/resumes/landing/Container.tsx
@@ -0,0 +1,16 @@
+import clsx from 'clsx';
+import type { FC } from 'react';
+
+type ContainerProps = {
+ children: Array | JSX.Element;
+ className?: string;
+};
+
+export const Container: FC = ({ className, ...props }) => {
+ return (
+
+ );
+};
diff --git a/apps/portal/src/components/resumes/landing/Footer.jsx b/apps/portal/src/components/resumes/landing/Footer.jsx
deleted file mode 100644
index d9f83e2c..00000000
--- a/apps/portal/src/components/resumes/landing/Footer.jsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import Link from 'next/link';
-
-import { Container } from './Container';
-import { Logo } from './Logo';
-
-export function Footer() {
- return (
-
-
-
-
-
-
- Features
- Testimonials
-
-
-
-
-
-
- Copyright © {new Date().getFullYear()} Resume Review. All
- rights reserved.
-
-
-
-
- );
-}
diff --git a/apps/portal/src/components/resumes/landing/Hero.jsx b/apps/portal/src/components/resumes/landing/Hero.tsx
similarity index 56%
rename from apps/portal/src/components/resumes/landing/Hero.jsx
rename to apps/portal/src/components/resumes/landing/Hero.tsx
index 192b6562..ab44b764 100644
--- a/apps/portal/src/components/resumes/landing/Hero.jsx
+++ b/apps/portal/src/components/resumes/landing/Hero.tsx
@@ -1,21 +1,13 @@
-import Image from 'next/future/image';
import Link from 'next/link';
-import { Button } from './Button';
import { Container } from './Container';
-import logoLaravel from './images/logos/laravel.svg';
-import logoMirage from './images/logos/mirage.svg';
-import logoStatamic from './images/logos/statamic.svg';
-import logoStaticKit from './images/logos/statickit.svg';
-import logoTransistor from './images/logos/transistor.svg';
-import logoTuple from './images/logos/tuple.svg';
export function Hero() {
return (
-
+
Resume review{' '}
-
+
-
Start browsing now
+
+ Start browsing now
+
-
+
+ className="h-3 w-3 flex-none fill-indigo-600 group-active:fill-current">
Watch video
-
+
-
-
- Resumes reviewed from engineers from these companies so far
-
-
- {[
- { logo: logoTransistor, name: 'Apple' },
- { logo: logoTuple, name: 'Meta' },
- { logo: logoStaticKit, name: 'Google' },
-
- { logo: logoMirage, name: 'Mirage' },
- { logo: logoLaravel, name: 'Laravel' },
- { logo: logoStatamic, name: 'Statamic' },
- ].map((company) => (
-
-
-
- ))}
-
-
);
}
diff --git a/apps/portal/src/components/resumes/landing/Logo.jsx b/apps/portal/src/components/resumes/landing/Logo.tsx
similarity index 97%
rename from apps/portal/src/components/resumes/landing/Logo.jsx
rename to apps/portal/src/components/resumes/landing/Logo.tsx
index 230baceb..5ef1b032 100644
--- a/apps/portal/src/components/resumes/landing/Logo.jsx
+++ b/apps/portal/src/components/resumes/landing/Logo.tsx
@@ -1,4 +1,6 @@
-export function Logo(props) {
+import type { FC } from 'react';
+
+export const Logo: FC = (props) => {
return (
);
-}
+};
diff --git a/apps/portal/src/components/resumes/landing/PrimaryFeatures.jsx b/apps/portal/src/components/resumes/landing/PrimaryFeatures.tsx
similarity index 84%
rename from apps/portal/src/components/resumes/landing/PrimaryFeatures.jsx
rename to apps/portal/src/components/resumes/landing/PrimaryFeatures.tsx
index 15d1196b..b2fedb17 100644
--- a/apps/portal/src/components/resumes/landing/PrimaryFeatures.jsx
+++ b/apps/portal/src/components/resumes/landing/PrimaryFeatures.tsx
@@ -4,28 +4,27 @@ import { useEffect, useState } from 'react';
import { Tab } from '@headlessui/react';
import { Container } from './Container';
-import backgroundImage from './images/background-features.jpg';
-import screenshotExpenses from './images/screenshots/expenses.png';
-import screenshotPayroll from './images/screenshots/payroll.png';
-import screenshotVatReturns from './images/screenshots/vat-returns.png';
+import resumeBrowse from './images/screenshots/resumes-browse.png';
+import resumeReview from './images/screenshots/resumes-review.png';
+import resumeSubmit from './images/screenshots/resumes-submit.png';
const features = [
{
description:
'Browse the most popular reviewed resumes out there and see what you can learn',
- image: screenshotPayroll,
+ image: resumeBrowse,
title: 'Browse',
},
{
description:
'Upload your own resume easily to get feedback from people in industry.',
- image: screenshotExpenses,
+ image: resumeSubmit,
title: 'Submit',
},
{
description:
'Pass the baton forward by reviewing resumes and bounce off ideas with other engineers out there.',
- image: screenshotVatReturns,
+ image: resumeReview,
title: 'Review',
},
];
@@ -36,7 +35,7 @@ export function PrimaryFeatures() {
useEffect(() => {
const lgMediaQuery = window.matchMedia('(min-width: 1024px)');
- function onMediaQueryChange({ matches }) {
+ function onMediaQueryChange({ matches }: { matches: boolean }) {
setTabOrientation(matches ? 'vertical' : 'horizontal');
}
@@ -50,17 +49,8 @@ export function PrimaryFeatures() {
return (
-
@@ -73,7 +63,7 @@ export function PrimaryFeatures() {
vertical={tabOrientation === 'vertical'}>
{({ selectedIndex }) => (
<>
-
+
{features.map((feature, featureIndex) => (
-
+
{features.map((feature) => (
diff --git a/apps/portal/src/components/resumes/landing/Testimonials.jsx b/apps/portal/src/components/resumes/landing/Testimonials.tsx
similarity index 96%
rename from apps/portal/src/components/resumes/landing/Testimonials.jsx
rename to apps/portal/src/components/resumes/landing/Testimonials.tsx
index 91d31f40..ad43f019 100644
--- a/apps/portal/src/components/resumes/landing/Testimonials.jsx
+++ b/apps/portal/src/components/resumes/landing/Testimonials.tsx
@@ -7,6 +7,10 @@ import avatarImage3 from './images/avatars/avatar-3.png';
import avatarImage4 from './images/avatars/avatar-4.png';
import avatarImage5 from './images/avatars/avatar-5.png';
+type QuoteProps = {
+ className: string;
+};
+
const testimonials = [
{
columns: [
@@ -79,7 +83,7 @@ const testimonials = [
},
];
-function QuoteIcon(props) {
+function QuoteIcon(props: QuoteProps) {
return (
@@ -90,15 +94,14 @@ function QuoteIcon(props) {
export function Testimonials() {
return (
-
+
Loved by software engineers worldwide.
-
+
We crowdsource ideas and feedback from across the world,
guaranteeing you for success in your job application.
diff --git a/apps/portal/src/components/resumes/landing/images/background-auth.jpg b/apps/portal/src/components/resumes/landing/images/background-auth.jpg
deleted file mode 100644
index ab481c34..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/background-auth.jpg and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/background-call-to-action.jpg b/apps/portal/src/components/resumes/landing/images/background-call-to-action.jpg
deleted file mode 100644
index 13d8ee59..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/background-call-to-action.jpg and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/background-faqs.jpg b/apps/portal/src/components/resumes/landing/images/background-faqs.jpg
deleted file mode 100644
index d9de04f7..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/background-faqs.jpg and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/background-features.jpg b/apps/portal/src/components/resumes/landing/images/background-features.jpg
deleted file mode 100644
index 6bea1038..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/background-features.jpg and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/logos/laravel.svg b/apps/portal/src/components/resumes/landing/images/logos/laravel.svg
deleted file mode 100644
index bfa63bd4..00000000
--- a/apps/portal/src/components/resumes/landing/images/logos/laravel.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/portal/src/components/resumes/landing/images/logos/mirage.svg b/apps/portal/src/components/resumes/landing/images/logos/mirage.svg
deleted file mode 100644
index 204df737..00000000
--- a/apps/portal/src/components/resumes/landing/images/logos/mirage.svg
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/portal/src/components/resumes/landing/images/logos/statamic.svg b/apps/portal/src/components/resumes/landing/images/logos/statamic.svg
deleted file mode 100644
index 25d7ba6c..00000000
--- a/apps/portal/src/components/resumes/landing/images/logos/statamic.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/portal/src/components/resumes/landing/images/logos/statickit.svg b/apps/portal/src/components/resumes/landing/images/logos/statickit.svg
deleted file mode 100644
index 381d21e7..00000000
--- a/apps/portal/src/components/resumes/landing/images/logos/statickit.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
diff --git a/apps/portal/src/components/resumes/landing/images/logos/transistor.svg b/apps/portal/src/components/resumes/landing/images/logos/transistor.svg
deleted file mode 100644
index 2b858cf4..00000000
--- a/apps/portal/src/components/resumes/landing/images/logos/transistor.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/apps/portal/src/components/resumes/landing/images/logos/tuple.svg b/apps/portal/src/components/resumes/landing/images/logos/tuple.svg
deleted file mode 100644
index 2a9c2415..00000000
--- a/apps/portal/src/components/resumes/landing/images/logos/tuple.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/contacts.png b/apps/portal/src/components/resumes/landing/images/screenshots/contacts.png
deleted file mode 100644
index 5901470b..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/screenshots/contacts.png and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/expenses.png b/apps/portal/src/components/resumes/landing/images/screenshots/expenses.png
deleted file mode 100644
index ca35472e..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/screenshots/expenses.png and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/inventory.png b/apps/portal/src/components/resumes/landing/images/screenshots/inventory.png
deleted file mode 100644
index b98721c6..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/screenshots/inventory.png and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/payroll.png b/apps/portal/src/components/resumes/landing/images/screenshots/payroll.png
deleted file mode 100644
index 16f5b5a7..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/screenshots/payroll.png and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/profit-loss.png b/apps/portal/src/components/resumes/landing/images/screenshots/profit-loss.png
deleted file mode 100644
index 229df78a..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/screenshots/profit-loss.png and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/reporting.png b/apps/portal/src/components/resumes/landing/images/screenshots/reporting.png
deleted file mode 100644
index 72b5e8e9..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/screenshots/reporting.png and /dev/null differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/resumes-browse.png b/apps/portal/src/components/resumes/landing/images/screenshots/resumes-browse.png
new file mode 100644
index 00000000..6bdc81c9
Binary files /dev/null and b/apps/portal/src/components/resumes/landing/images/screenshots/resumes-browse.png differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/resumes-review.png b/apps/portal/src/components/resumes/landing/images/screenshots/resumes-review.png
new file mode 100644
index 00000000..1a6b466a
Binary files /dev/null and b/apps/portal/src/components/resumes/landing/images/screenshots/resumes-review.png differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/resumes-submit.png b/apps/portal/src/components/resumes/landing/images/screenshots/resumes-submit.png
new file mode 100644
index 00000000..ccfcce1f
Binary files /dev/null and b/apps/portal/src/components/resumes/landing/images/screenshots/resumes-submit.png differ
diff --git a/apps/portal/src/components/resumes/landing/images/screenshots/vat-returns.png b/apps/portal/src/components/resumes/landing/images/screenshots/vat-returns.png
deleted file mode 100644
index 3dd043bd..00000000
Binary files a/apps/portal/src/components/resumes/landing/images/screenshots/vat-returns.png and /dev/null differ
diff --git a/apps/portal/src/components/resumes/shared/ResumeExpandableText.tsx b/apps/portal/src/components/resumes/shared/ResumeExpandableText.tsx
index 82c7c9df..fcb26ca3 100644
--- a/apps/portal/src/components/resumes/shared/ResumeExpandableText.tsx
+++ b/apps/portal/src/components/resumes/shared/ResumeExpandableText.tsx
@@ -1,48 +1,46 @@
import clsx from 'clsx';
-import type { ReactNode } from 'react';
import { useLayoutEffect, useRef, useState } from 'react';
type ResumeExpandableTextProps = Readonly<{
- children: ReactNode;
+ text: string;
}>;
export default function ResumeExpandableText({
- children,
+ text,
}: ResumeExpandableTextProps) {
const ref = useRef
(null);
- const [descriptionExpanded, setDescriptionExpanded] = useState(false);
+ const [isExpanded, setIsExpanded] = useState(false);
const [descriptionOverflow, setDescriptionOverflow] = useState(false);
useLayoutEffect(() => {
if (ref.current && ref.current.clientHeight < ref.current.scrollHeight) {
setDescriptionOverflow(true);
+ } else {
+ setDescriptionOverflow(false);
}
}, [ref]);
const onSeeActionClicked = () => {
- setDescriptionExpanded(!descriptionExpanded);
+ setIsExpanded((prevExpanded) => !prevExpanded);
};
return (
- <>
+
- {children}
+ {text}
{descriptionOverflow && (
-
-
- {descriptionExpanded ? 'See Less' : 'See More'}
-
-
+
+ {isExpanded ? 'See Less' : 'See More'}
+
)}
- >
+
);
}
diff --git a/apps/portal/src/components/resumes/shared/ResumeSignInButton.tsx b/apps/portal/src/components/resumes/shared/ResumeSignInButton.tsx
index 51d55067..332758b0 100644
--- a/apps/portal/src/components/resumes/shared/ResumeSignInButton.tsx
+++ b/apps/portal/src/components/resumes/shared/ResumeSignInButton.tsx
@@ -1,12 +1,14 @@
+import clsx from 'clsx';
import { signIn } from 'next-auth/react';
type Props = Readonly<{
+ className?: string;
text: string;
}>;
-export default function ResumeSignInButton({ text }: Props) {
+export default function ResumeSignInButton({ text, className }: Props) {
return (
-
+
+ );
+}
diff --git a/apps/portal/src/mappers/offers-mappers.ts b/apps/portal/src/mappers/offers-mappers.ts
new file mode 100644
index 00000000..4faee7ae
--- /dev/null
+++ b/apps/portal/src/mappers/offers-mappers.ts
@@ -0,0 +1,628 @@
+import type {
+ Company,
+ OffersAnalysis,
+ OffersBackground,
+ OffersCurrency,
+ OffersEducation,
+ OffersExperience,
+ OffersFullTime,
+ OffersIntern,
+ OffersOffer,
+ OffersProfile,
+ OffersReply,
+ OffersSpecificYoe,
+ User,
+} from '@prisma/client';
+import { JobType } from '@prisma/client';
+import { TRPCError } from '@trpc/server';
+
+import type {
+ AddToProfileResponse,
+ Analysis,
+ AnalysisHighestOffer,
+ AnalysisOffer,
+ Background,
+ CreateOfferProfileResponse,
+ DashboardOffer,
+ Education,
+ Experience,
+ GetOffersResponse,
+ OffersCompany,
+ Paging,
+ Profile,
+ ProfileAnalysis,
+ ProfileOffer,
+ SpecificYoe,
+ Valuation,
+} from '~/types/offers';
+
+const analysisOfferDtoMapper = (
+ offer: OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & { totalCompensation: OffersCurrency })
+ | null;
+ offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ profile: OffersProfile & {
+ background:
+ | (OffersBackground & {
+ experiences: Array
;
+ })
+ | null;
+ };
+ },
+) => {
+ const { background, profileName } = offer.profile;
+ const analysisOfferDto: AnalysisOffer = {
+ company: offersCompanyDtoMapper(offer.company),
+ id: offer.id,
+ income: { baseCurrency: '', baseValue: -1, currency: '', value: -1 },
+ jobType: offer.jobType,
+ level: offer.offersFullTime?.level ?? '',
+ location: offer.location,
+ monthYearReceived: offer.monthYearReceived,
+ negotiationStrategy: offer.negotiationStrategy,
+ previousCompanies:
+ background?.experiences
+ ?.filter((exp) => exp.company != null)
+ .map((exp) => exp.company?.name ?? '') ?? [],
+ profileName,
+ specialization:
+ offer.jobType === JobType.FULLTIME
+ ? offer.offersFullTime?.specialization ?? ''
+ : offer.offersIntern?.specialization ?? '',
+ title:
+ offer.jobType === JobType.FULLTIME
+ ? offer.offersFullTime?.title ?? ''
+ : offer.offersIntern?.title ?? '',
+ totalYoe: background?.totalYoe ?? -1,
+ };
+
+ if (offer.offersFullTime?.totalCompensation) {
+ analysisOfferDto.income.value =
+ offer.offersFullTime.totalCompensation.value;
+ analysisOfferDto.income.currency =
+ offer.offersFullTime.totalCompensation.currency;
+ analysisOfferDto.income.baseValue =
+ offer.offersFullTime.totalCompensation.baseValue;
+ analysisOfferDto.income.baseCurrency =
+ offer.offersFullTime.totalCompensation.baseCurrency;
+ } else if (offer.offersIntern?.monthlySalary) {
+ analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value;
+ analysisOfferDto.income.currency =
+ offer.offersIntern.monthlySalary.currency;
+ analysisOfferDto.income.baseValue =
+ offer.offersIntern.monthlySalary.baseValue;
+ analysisOfferDto.income.baseCurrency =
+ offer.offersIntern.monthlySalary.baseCurrency;
+ } else {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Total Compensation or Salary not found',
+ });
+ }
+
+ return analysisOfferDto;
+};
+
+const analysisDtoMapper = (
+ noOfOffers: number,
+ percentile: number,
+ topPercentileOffers: Array<
+ OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & {
+ totalCompensation: OffersCurrency;
+ })
+ | null;
+ offersIntern:
+ | (OffersIntern & {
+ monthlySalary: OffersCurrency;
+ })
+ | null;
+ profile: OffersProfile & {
+ background:
+ | (OffersBackground & {
+ experiences: Array<
+ OffersExperience & {
+ company: Company | null;
+ }
+ >;
+ })
+ | null;
+ };
+ }
+ >,
+) => {
+ const analysisDto: Analysis = {
+ noOfOffers,
+ percentile,
+ topPercentileOffers: topPercentileOffers.map((offer) =>
+ analysisOfferDtoMapper(offer),
+ ),
+ };
+ return analysisDto;
+};
+
+const analysisHighestOfferDtoMapper = (
+ offer: OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & { totalCompensation: OffersCurrency })
+ | null;
+ offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ profile: OffersProfile & { background: OffersBackground | null };
+ },
+) => {
+ const analysisHighestOfferDto: AnalysisHighestOffer = {
+ company: offersCompanyDtoMapper(offer.company),
+ id: offer.id,
+ level: offer.offersFullTime?.level ?? '',
+ location: offer.location,
+ specialization:
+ offer.jobType === JobType.FULLTIME
+ ? offer.offersFullTime?.specialization ?? ''
+ : offer.offersIntern?.specialization ?? '',
+ totalYoe: offer.profile.background?.totalYoe ?? -1,
+ };
+ return analysisHighestOfferDto;
+};
+
+export const profileAnalysisDtoMapper = (
+ analysis:
+ | (OffersAnalysis & {
+ overallHighestOffer: OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & { totalCompensation: OffersCurrency })
+ | null;
+ offersIntern:
+ | (OffersIntern & { monthlySalary: OffersCurrency })
+ | null;
+ profile: OffersProfile & { background: OffersBackground | null };
+ };
+ topCompanyOffers: Array<
+ OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & { totalCompensation: OffersCurrency })
+ | null;
+ offersIntern:
+ | (OffersIntern & { monthlySalary: OffersCurrency })
+ | null;
+ profile: OffersProfile & {
+ background:
+ | (OffersBackground & {
+ experiences: Array<
+ OffersExperience & { company: Company | null }
+ >;
+ })
+ | null;
+ };
+ }
+ >;
+ topOverallOffers: Array<
+ OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & { totalCompensation: OffersCurrency })
+ | null;
+ offersIntern:
+ | (OffersIntern & { monthlySalary: OffersCurrency })
+ | null;
+ profile: OffersProfile & {
+ background:
+ | (OffersBackground & {
+ experiences: Array<
+ OffersExperience & { company: Company | null }
+ >;
+ })
+ | null;
+ };
+ }
+ >;
+ })
+ | null,
+) => {
+ if (!analysis) {
+ return null;
+ }
+
+ const profileAnalysisDto: ProfileAnalysis = {
+ companyAnalysis: [
+ analysisDtoMapper(
+ analysis.noOfSimilarCompanyOffers,
+ analysis.companyPercentile,
+ analysis.topCompanyOffers,
+ ),
+ ],
+ id: analysis.id,
+ overallAnalysis: analysisDtoMapper(
+ analysis.noOfSimilarOffers,
+ analysis.overallPercentile,
+ analysis.topOverallOffers,
+ ),
+ overallHighestOffer: analysisHighestOfferDtoMapper(
+ analysis.overallHighestOffer,
+ ),
+ profileId: analysis.profileId,
+ };
+ return profileAnalysisDto;
+};
+
+export const valuationDtoMapper = (currency: {
+ baseCurrency: string;
+ baseValue: number;
+ currency: string;
+ id?: string;
+ value: number;
+}) => {
+ const valuationDto: Valuation = {
+ baseCurrency: currency.baseCurrency,
+ baseValue: currency.baseValue,
+ currency: currency.currency,
+ value: currency.value,
+ };
+ return valuationDto;
+};
+
+export const offersCompanyDtoMapper = (company: Company) => {
+ const companyDto: OffersCompany = {
+ createdAt: company.createdAt,
+ description: company?.description ?? '',
+ id: company.id,
+ logoUrl: company.logoUrl ?? '',
+ name: company.name,
+ slug: company.slug,
+ updatedAt: company.updatedAt,
+ };
+ return companyDto;
+};
+
+export const educationDtoMapper = (education: {
+ backgroundId?: string;
+ endDate: Date | null;
+ field: string | null;
+ id: string;
+ school: string | null;
+ startDate: Date | null;
+ type: string | null;
+}) => {
+ const educationDto: Education = {
+ endDate: education.endDate,
+ field: education.field,
+ id: education.id,
+ school: education.school,
+ startDate: education.startDate,
+ type: education.type,
+ };
+ return educationDto;
+};
+
+export const experienceDtoMapper = (
+ experience: OffersExperience & {
+ company: Company | null;
+ monthlySalary: OffersCurrency | null;
+ totalCompensation: OffersCurrency | null;
+ },
+) => {
+ const experienceDto: Experience = {
+ company: experience.company
+ ? offersCompanyDtoMapper(experience.company)
+ : null,
+ durationInMonths: experience.durationInMonths,
+ id: experience.id,
+ jobType: experience.jobType,
+ level: experience.level,
+ location: experience.location,
+ monthlySalary: experience.monthlySalary
+ ? valuationDtoMapper(experience.monthlySalary)
+ : experience.monthlySalary,
+ specialization: experience.specialization,
+ title: experience.title,
+ totalCompensation: experience.totalCompensation
+ ? valuationDtoMapper(experience.totalCompensation)
+ : experience.totalCompensation,
+ };
+ return experienceDto;
+};
+
+export const specificYoeDtoMapper = (specificYoe: {
+ backgroundId?: string;
+ domain: string;
+ id: string;
+ yoe: number;
+}) => {
+ const specificYoeDto: SpecificYoe = {
+ domain: specificYoe.domain,
+ id: specificYoe.id,
+ yoe: specificYoe.yoe,
+ };
+ return specificYoeDto;
+};
+
+export const backgroundDtoMapper = (
+ background:
+ | (OffersBackground & {
+ educations: Array;
+ experiences: Array<
+ OffersExperience & {
+ company: Company | null;
+ monthlySalary: OffersCurrency | null;
+ totalCompensation: OffersCurrency | null;
+ }
+ >;
+ specificYoes: Array;
+ })
+ | null,
+) => {
+ if (!background) {
+ return null;
+ }
+
+ const educations = background.educations.map((education) =>
+ educationDtoMapper(education),
+ );
+
+ const experiences = background.experiences.map((experience) =>
+ experienceDtoMapper(experience),
+ );
+
+ const specificYoes = background.specificYoes.map((specificYoe) =>
+ specificYoeDtoMapper(specificYoe),
+ );
+
+ const backgroundDto: Background = {
+ educations,
+ experiences,
+ id: background.id,
+ specificYoes,
+ totalYoe: background.totalYoe,
+ };
+
+ return backgroundDto;
+};
+
+export const profileOfferDtoMapper = (
+ offer: OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & {
+ baseSalary: OffersCurrency;
+ bonus: OffersCurrency;
+ stocks: OffersCurrency;
+ totalCompensation: OffersCurrency;
+ })
+ | null;
+ offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ },
+) => {
+ const profileOfferDto: ProfileOffer = {
+ comments: offer.comments,
+ company: offersCompanyDtoMapper(offer.company),
+ id: offer.id,
+ jobType: offer.jobType,
+ location: offer.location,
+ monthYearReceived: offer.monthYearReceived,
+ negotiationStrategy: offer.negotiationStrategy,
+ offersFullTime: offer.offersFullTime,
+ offersIntern: offer.offersIntern,
+ };
+
+ if (offer.offersFullTime) {
+ profileOfferDto.offersFullTime = {
+ baseSalary: valuationDtoMapper(offer.offersFullTime.baseSalary),
+ bonus: valuationDtoMapper(offer.offersFullTime.bonus),
+ id: offer.offersFullTime.id,
+ level: offer.offersFullTime.level,
+ specialization: offer.offersFullTime.specialization,
+ stocks: valuationDtoMapper(offer.offersFullTime.stocks),
+ title: offer.offersFullTime.title,
+ totalCompensation: valuationDtoMapper(
+ offer.offersFullTime.totalCompensation,
+ ),
+ };
+ } else if (offer.offersIntern) {
+ profileOfferDto.offersIntern = {
+ id: offer.offersIntern.id,
+ internshipCycle: offer.offersIntern.internshipCycle,
+ monthlySalary: valuationDtoMapper(offer.offersIntern.monthlySalary),
+ specialization: offer.offersIntern.specialization,
+ startYear: offer.offersIntern.startYear,
+ title: offer.offersIntern.title,
+ };
+ }
+
+ return profileOfferDto;
+};
+
+export const profileDtoMapper = (
+ profile: OffersProfile & {
+ analysis:
+ | (OffersAnalysis & {
+ overallHighestOffer: OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & { totalCompensation: OffersCurrency })
+ | null;
+ offersIntern:
+ | (OffersIntern & { monthlySalary: OffersCurrency })
+ | null;
+ profile: OffersProfile & { background: OffersBackground | null };
+ };
+ topCompanyOffers: Array<
+ OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & { totalCompensation: OffersCurrency })
+ | null;
+ offersIntern:
+ | (OffersIntern & { monthlySalary: OffersCurrency })
+ | null;
+ profile: OffersProfile & {
+ background:
+ | (OffersBackground & {
+ experiences: Array<
+ OffersExperience & { company: Company | null }
+ >;
+ })
+ | null;
+ };
+ }
+ >;
+ topOverallOffers: Array<
+ OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & { totalCompensation: OffersCurrency })
+ | null;
+ offersIntern:
+ | (OffersIntern & { monthlySalary: OffersCurrency })
+ | null;
+ profile: OffersProfile & {
+ background:
+ | (OffersBackground & {
+ experiences: Array<
+ OffersExperience & { company: Company | null }
+ >;
+ })
+ | null;
+ };
+ }
+ >;
+ })
+ | null;
+ background:
+ | (OffersBackground & {
+ educations: Array;
+ experiences: Array<
+ OffersExperience & {
+ company: Company | null;
+ monthlySalary: OffersCurrency | null;
+ totalCompensation: OffersCurrency | null;
+ }
+ >;
+ specificYoes: Array;
+ })
+ | null;
+ discussion: Array<
+ OffersReply & {
+ replies: Array;
+ replyingTo: OffersReply | null;
+ user: User | null;
+ }
+ >;
+ offers: Array<
+ OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & {
+ baseSalary: OffersCurrency;
+ bonus: OffersCurrency;
+ stocks: OffersCurrency;
+ totalCompensation: OffersCurrency;
+ })
+ | null;
+ offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ }
+ >;
+ },
+ inputToken: string | undefined,
+) => {
+ const profileDto: Profile = {
+ analysis: profileAnalysisDtoMapper(profile.analysis),
+ background: backgroundDtoMapper(profile.background),
+ editToken: null,
+ id: profile.id,
+ isEditable: false,
+ offers: profile.offers.map((offer) => profileOfferDtoMapper(offer)),
+ profileName: profile.profileName,
+ };
+
+ if (inputToken === profile.editToken) {
+ profileDto.editToken = profile.editToken;
+ profileDto.isEditable = true;
+ }
+
+ return profileDto;
+};
+
+export const createOfferProfileResponseMapper = (
+ profile: { id: string },
+ token: string,
+) => {
+ const res: CreateOfferProfileResponse = {
+ id: profile.id,
+ token,
+ };
+ return res;
+};
+
+export const addToProfileResponseMapper = (updatedProfile: {
+ id: string;
+ profileName: string;
+ userId?: string | null;
+}) => {
+ const addToProfileResponse: AddToProfileResponse = {
+ id: updatedProfile.id,
+ profileName: updatedProfile.profileName,
+ userId: updatedProfile.userId ?? '',
+ };
+
+ return addToProfileResponse;
+};
+
+export const dashboardOfferDtoMapper = (
+ offer: OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & {
+ baseSalary: OffersCurrency;
+ bonus: OffersCurrency;
+ stocks: OffersCurrency;
+ totalCompensation: OffersCurrency;
+ })
+ | null;
+ offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ profile: OffersProfile & { background: OffersBackground | null };
+ },
+) => {
+ const dashboardOfferDto: DashboardOffer = {
+ company: offersCompanyDtoMapper(offer.company),
+ id: offer.id,
+ income: valuationDtoMapper({
+ baseCurrency: '',
+ baseValue: -1,
+ currency: '',
+ value: -1,
+ }),
+ monthYearReceived: offer.monthYearReceived,
+ profileId: offer.profileId,
+ title: offer.offersFullTime?.title ?? '',
+ totalYoe: offer.profile.background?.totalYoe ?? -1,
+ };
+
+ if (offer.offersFullTime) {
+ dashboardOfferDto.income = valuationDtoMapper(
+ offer.offersFullTime.totalCompensation,
+ );
+ } else if (offer.offersIntern) {
+ dashboardOfferDto.income = valuationDtoMapper(
+ offer.offersIntern.monthlySalary,
+ );
+ }
+
+ return dashboardOfferDto;
+};
+
+export const getOffersResponseMapper = (
+ data: Array,
+ paging: Paging,
+) => {
+ const getOffersResponse: GetOffersResponse = {
+ data,
+ paging,
+ };
+ return getOffersResponse;
+};
diff --git a/apps/portal/src/pages/_app.tsx b/apps/portal/src/pages/_app.tsx
index 11606124..9914942e 100644
--- a/apps/portal/src/pages/_app.tsx
+++ b/apps/portal/src/pages/_app.tsx
@@ -3,6 +3,7 @@ import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react';
import React from 'react';
import superjson from 'superjson';
+import { ToastsProvider } from '@tih/ui';
import { httpBatchLink } from '@trpc/client/links/httpBatchLink';
import { loggerLink } from '@trpc/client/links/loggerLink';
import { withTRPC } from '@trpc/next';
@@ -19,9 +20,11 @@ const MyApp: AppType<{ session: Session | null }> = ({
}) => {
return (
-
-
-
+
+
+
+
+
);
};
diff --git a/apps/portal/src/pages/index.tsx b/apps/portal/src/pages/index.tsx
index 360c81e8..51f58469 100644
--- a/apps/portal/src/pages/index.tsx
+++ b/apps/portal/src/pages/index.tsx
@@ -1,32 +1,11 @@
-import { useState } from 'react';
-import type { TypeaheadOption } from '@tih/ui';
-import { HorizontalDivider } from '@tih/ui';
-
-import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
-import type { Month, MonthYear } from '~/components/shared/MonthYearPicker';
-import MonthYearPicker from '~/components/shared/MonthYearPicker';
-
export default function HomePage() {
- const [selectedCompany, setSelectedCompany] =
- useState(null);
- const [monthYear, setMonthYear] = useState({
- month: (new Date().getMonth() + 1) as Month,
- year: new Date().getFullYear(),
- });
-
return (
- Homepage
+ Tech Interview Handbook Portal
-
setSelectedCompany(option)}
- />
- {JSON.stringify(selectedCompany, null, 2)}
-
-
diff --git a/apps/portal/src/pages/offers/index.tsx b/apps/portal/src/pages/offers/index.tsx
index eee97296..8095aa0c 100644
--- a/apps/portal/src/pages/offers/index.tsx
+++ b/apps/portal/src/pages/offers/index.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Select } from '@tih/ui';
+import { titleOptions } from '~/components/offers/constants';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@@ -20,24 +21,7 @@ export default function OffersHomePage() {
diff --git a/apps/portal/src/pages/offers/profile/[offerProfileId].tsx b/apps/portal/src/pages/offers/profile/[offerProfileId].tsx
index b59921cc..d6b18e65 100644
--- a/apps/portal/src/pages/offers/profile/[offerProfileId].tsx
+++ b/apps/portal/src/pages/offers/profile/[offerProfileId].tsx
@@ -5,12 +5,15 @@ import { useState } from 'react';
import ProfileComments from '~/components/offers/profile/ProfileComments';
import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader';
-import type { OfferEntity } from '~/components/offers/types';
-import type { BackgroundCard } from '~/components/offers/types';
+import type { BackgroundCard, OfferEntity } from '~/components/offers/types';
-import { convertCurrencyToString } from '~/utils/offers/currency';
+import { convertMoneyToString } from '~/utils/offers/currency';
+import { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc';
+
+import type { Profile, ProfileOffer } from '~/types/offers';
+
export default function OfferProfile() {
const ErrorPage = (
@@ -30,51 +33,46 @@ export default function OfferProfile() {
],
{
enabled: typeof offerProfileId === 'string',
- onSuccess: (data) => {
+ onSuccess: (data: Profile) => {
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}`);
+ router.push(getProfilePath(offerProfileId as string));
}
setIsEditable(data?.isEditable ?? false);
if (data?.offers) {
const filteredOffers: Array = data
- ? data?.offers.map((res) => {
- if (res.OffersFullTime) {
+ ? data?.offers.map((res: ProfileOffer) => {
+ if (res.offersFullTime) {
const filteredOffer: OfferEntity = {
- base: convertCurrencyToString(
- res.OffersFullTime.baseSalary.value,
- ),
- bonus: convertCurrencyToString(
- res.OffersFullTime.bonus.value,
- ),
+ base: convertMoneyToString(res.offersFullTime.baseSalary),
+ bonus: convertMoneyToString(res.offersFullTime.bonus),
companyName: res.company.name,
- id: res.OffersFullTime.id,
- jobLevel: res.OffersFullTime.level,
- jobTitle: res.OffersFullTime.title,
+ 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,
+ stocks: convertMoneyToString(res.offersFullTime.stocks),
+ totalCompensation: convertMoneyToString(
+ res.offersFullTime.totalCompensation,
),
};
-
return filteredOffer;
}
const filteredOffer: OfferEntity = {
companyName: res.company.name,
- id: res.OffersIntern!.id,
- jobTitle: res.OffersIntern!.title,
+ id: res.offersIntern!.id,
+ jobTitle: res.offersIntern!.title,
location: res.location,
- monthlySalary: convertCurrencyToString(
- res.OffersIntern!.monthlySalary,
+ monthlySalary: convertMoneyToString(
+ res.offersIntern!.monthlySalary,
),
negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '',
@@ -88,46 +86,29 @@ export default function OfferProfile() {
if (data?.background) {
const transformedBackground = {
- educations: [
- {
- 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 || '-',
- },
- ],
- 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,
- )
- : '-',
- }
- : {},
- ],
+ educations: data.background.educations.map((education) => ({
+ endDate: education.endDate ? formatDate(education.endDate) : '-',
+ field: education.field || '-',
+ school: education.school || '-',
+ startDate: education.startDate
+ ? formatDate(education.startDate)
+ : '-',
+ type: education.type || '-',
+ })),
+ experiences: data.background.experiences.map((experience) => ({
+ companyName: experience.company?.name ?? '-',
+ duration: String(experience.durationInMonths) ?? '-',
+ jobLevel: experience.level ?? '',
+ jobTitle: experience.title ?? '-',
+ monthlySalary: experience.monthlySalary
+ ? convertMoneyToString(experience.monthlySalary)
+ : '-',
+ totalCompensation: experience.totalCompensation
+ ? convertMoneyToString(experience.totalCompensation)
+ : '-',
+ })),
profileName: data.profileName,
- specificYoes: data.background.specificYoes ?? [],
+ specificYoes: data.background.specificYoes,
totalYoe: String(data.background.totalYoe) || '-',
};
setBackground(transformedBackground);
@@ -156,19 +137,6 @@ export default function OfferProfile() {
}
}
- 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 (
<>
{getProfileQuery.isError && ErrorPage}
@@ -194,11 +162,12 @@ export default function OfferProfile() {
diff --git a/apps/portal/src/pages/offers/profile/edit/[offerProfileId].tsx b/apps/portal/src/pages/offers/profile/edit/[offerProfileId].tsx
new file mode 100644
index 00000000..c5b7c15c
--- /dev/null
+++ b/apps/portal/src/pages/offers/profile/edit/[offerProfileId].tsx
@@ -0,0 +1,79 @@
+import { useRouter } from 'next/router';
+import { useState } from 'react';
+
+import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
+import type { OffersProfileFormData } from '~/components/offers/types';
+import { JobType } from '~/components/offers/types';
+
+import { Spinner } from '~/../../../packages/ui/dist';
+import { getProfilePath } from '~/utils/offers/link';
+import { convertToMonthYear } from '~/utils/offers/time';
+import { trpc } from '~/utils/trpc';
+
+export default function OffersEditPage() {
+ const [initialData, setInitialData] = useState();
+ const router = useRouter();
+ const { offerProfileId, token = '' } = router.query;
+
+ const getProfileResult = trpc.useQuery(
+ [
+ 'offers.profile.listOne',
+ { profileId: offerProfileId as string, token: token as string },
+ ],
+ {
+ onError(error) {
+ console.error(error.message);
+ },
+ onSuccess(data) {
+ const { educations, experiences, specificYoes, totalYoe } =
+ data.background!;
+
+ setInitialData({
+ background: {
+ educations,
+ experiences:
+ experiences.length === 0
+ ? [{ jobType: JobType.FullTime }]
+ : experiences,
+ specificYoes,
+ totalYoe,
+ },
+ offers: data.offers.map((offer) => ({
+ comments: offer.comments,
+ companyId: offer.company.id,
+ id: offer.id,
+ jobType: offer.jobType,
+ location: offer.location,
+ monthYearReceived: convertToMonthYear(offer.monthYearReceived),
+ negotiationStrategy: offer.negotiationStrategy,
+ offersFullTime: offer.offersFullTime,
+ offersIntern: offer.offersIntern,
+ })),
+ });
+ },
+ },
+ );
+
+ const profile = getProfileResult.data;
+
+ if (profile && !profile.isEditable) {
+ router.push(getProfilePath(profile.id));
+ }
+
+ return (
+ <>
+ {getProfileResult.isLoading && (
+
+
+
+ )}
+ {!getProfileResult.isLoading && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/portal/src/pages/offers/submit.tsx b/apps/portal/src/pages/offers/submit.tsx
index 8e461859..df2015f1 100644
--- a/apps/portal/src/pages/offers/submit.tsx
+++ b/apps/portal/src/pages/offers/submit.tsx
@@ -1,197 +1,5 @@
-import { useRef, useState } from 'react';
-import type { SubmitHandler } from 'react-hook-form';
-import { FormProvider, useForm } from 'react-hook-form';
-import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
-import { Button } from '@tih/ui';
-
-import { Breadcrumbs } from '~/components/offers/Breadcrumb';
-import BackgroundForm from '~/components/offers/forms/BackgroundForm';
-import OfferAnalysis from '~/components/offers/forms/OfferAnalysis';
-import OfferDetailsForm from '~/components/offers/forms/OfferDetailsForm';
-import OfferProfileSave from '~/components/offers/forms/OfferProfileSave';
-import type {
- OfferDetailsFormData,
- OfferProfileFormData,
-} from '~/components/offers/types';
-import { JobType } from '~/components/offers/types';
-import type { Month } from '~/components/shared/MonthYearPicker';
-
-import { cleanObject, removeInvalidMoneyData } from '~/utils/offers/form';
-import { getCurrentMonth, getCurrentYear } from '~/utils/offers/time';
-import { trpc } from '~/utils/trpc';
-
-const defaultOfferValues = {
- comments: '',
- companyId: '',
- job: {},
- jobType: JobType.FullTime,
- location: '',
- monthYearReceived: {
- month: getCurrentMonth() as Month,
- year: getCurrentYear(),
- },
- negotiationStrategy: '',
-};
-
-export const defaultFullTimeOfferValues = {
- ...defaultOfferValues,
- jobType: JobType.FullTime,
-};
-
-export const defaultInternshipOfferValues = {
- ...defaultOfferValues,
- jobType: JobType.Internship,
-};
-
-const defaultOfferProfileValues = {
- background: {
- educations: [],
- experiences: [{ jobType: JobType.FullTime }],
- specificYoes: [],
- },
- offers: [defaultOfferValues],
-};
-
-type FormStep = {
- component: JSX.Element;
- hasNext: boolean;
- hasPrevious: boolean;
- label: string;
-};
+import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
export default function OffersSubmissionPage() {
- const [formStep, setFormStep] = useState(0);
- const pageRef = useRef(null);
- const scrollToTop = () =>
- pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
- const formMethods = useForm({
- defaultValues: defaultOfferProfileValues,
- mode: 'all',
- });
- const { handleSubmit, trigger } = formMethods;
-
- const formSteps: Array = [
- {
- component: ,
- hasNext: true,
- hasPrevious: false,
- label: 'Offer details',
- },
- {
- component: ,
- hasNext: false,
- hasPrevious: true,
- label: 'Background',
- },
- {
- component: ,
- hasNext: true,
- hasPrevious: false,
- label: 'Analysis',
- },
- {
- component: ,
- hasNext: false,
- hasPrevious: false,
- label: 'Save',
- },
- ];
-
- const formStepsLabels = formSteps.map((step) => step.label);
-
- const nextStep = async (currStep: number) => {
- if (currStep === 0) {
- const result = await trigger('offers');
- if (!result) {
- return;
- }
- }
- setFormStep(formStep + 1);
- scrollToTop();
- };
-
- const previousStep = () => {
- setFormStep(formStep - 1);
- scrollToTop();
- };
-
- const createMutation = trpc.useMutation(['offers.profile.create'], {
- onError(error) {
- console.error(error.message);
- },
- onSuccess() {
- alert('offer profile submit success!');
- setFormStep(formStep + 1);
- scrollToTop();
- },
- });
-
- const onSubmit: SubmitHandler = async (data) => {
- const result = await trigger();
- if (!result) {
- return;
- }
-
- data = removeInvalidMoneyData(data);
-
- const background = cleanObject(data.background);
- background.specificYoes = data.background.specificYoes.filter(
- (specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
- );
- if (Object.entries(background.experiences[0]).length === 1) {
- background.experiences = [];
- }
-
- const offers = data.offers.map((offer: OfferDetailsFormData) => ({
- ...offer,
- monthYearReceived: new Date(
- offer.monthYearReceived.year,
- offer.monthYearReceived.month,
- ),
- }));
-
- const postData = { background, offers };
-
- createMutation.mutate(postData);
- };
-
- return (
-
-
-
-
-
-
-
-
- {formSteps[formStep].component}
- {/* {JSON.stringify(formMethods.watch(), null, 2)} */}
- {formSteps[formStep].hasNext && (
-
- nextStep(formStep)}
- />
-
- )}
- {formStep === 1 && (
-
-
- {' '}
-
- )}
-
-
-
-
-
- );
+ return ;
}
diff --git a/apps/portal/src/pages/offers/test/createProfile.tsx b/apps/portal/src/pages/offers/test/createProfile.tsx
index b174d8a7..972ba6ee 100644
--- a/apps/portal/src/pages/offers/test/createProfile.tsx
+++ b/apps/portal/src/pages/offers/test/createProfile.tsx
@@ -4,10 +4,10 @@ import { trpc } from '~/utils/trpc';
function Test() {
const [createdData, setCreatedData] = useState('');
- const [error, setError] = useState("");
+ const [error, setError] = useState('');
const createMutation = trpc.useMutation(['offers.profile.create'], {
- onError(err: any) {
+ onError(err) {
alert(err);
},
onSuccess(data) {
@@ -15,17 +15,20 @@ function Test() {
},
});
- const addToUserProfileMutation = trpc.useMutation(['offers.profile.addToUserProfile'], {
- onError(err: any) {
- alert(err);
- },
- onSuccess(data) {
- setCreatedData(JSON.stringify(data));
+ const addToUserProfileMutation = trpc.useMutation(
+ ['offers.profile.addToUserProfile'],
+ {
+ onError(err) {
+ alert(err);
+ },
+ onSuccess(data) {
+ setCreatedData(JSON.stringify(data));
+ },
},
- })
+ );
const deleteCommentMutation = trpc.useMutation(['offers.comments.delete'], {
- onError(err: any) {
+ onError(err) {
alert(err);
},
onSuccess(data) {
@@ -37,13 +40,13 @@ function Test() {
deleteCommentMutation.mutate({
id: 'cl97fprun001j7iyg6ev9x983',
profileId: 'cl96stky5002ew32gx2kale2x',
- token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1',
- userId: 'cl97dl51k001e7iygd5v5gt58'
- })
- }
+ token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
+ userId: 'cl97dl51k001e7iygd5v5gt58',
+ });
+ };
const updateCommentMutation = trpc.useMutation(['offers.comments.update'], {
- onError(err: any) {
+ onError(err) {
alert(err);
},
onSuccess(data) {
@@ -56,12 +59,12 @@ function Test() {
id: 'cl97fxb0y001l7iyg14sdobt2',
message: 'hello hello',
profileId: 'cl96stky5002ew32gx2kale2x',
- token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba'
- })
- }
+ token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
+ });
+ };
const createCommentMutation = trpc.useMutation(['offers.comments.create'], {
- onError(err: any) {
+ onError(err) {
alert(err);
},
onSuccess(data) {
@@ -71,19 +74,20 @@ function Test() {
const handleCreate = () => {
createCommentMutation.mutate({
- message: 'hello',
- profileId: 'cl96stky5002ew32gx2kale2x',
- // UserId: 'cl97dl51k001e7iygd5v5gt58'
- })
- }
+ message: 'wassup bro',
+ profileId: 'cl9efyn9p004ww3u42mjgl1vn',
+ replyingToId: 'cl9el4xj10001w3w21o3p2iny',
+ userId: 'cl9ehvpng0000w3ec2mpx0bdd',
+ });
+ };
const handleLink = () => {
addToUserProfileMutation.mutate({
- profileId: 'cl96stky5002ew32gx2kale2x',
- token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
- userId: 'cl97dl51k001e7iygd5v5gt58'
- })
- }
+ profileId: 'cl9efyn9p004ww3u42mjgl1vn',
+ token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
+ userId: 'cl9ehvpng0000w3ec2mpx0bdd',
+ });
+ };
const handleClick = () => {
createMutation.mutate({
@@ -99,11 +103,10 @@ function Test() {
],
experiences: [
{
- companyId: 'cl95u79f000007im531ysjg79',
+ companyId: 'cl9j4yawz0003utlp1uaa1t8o',
durationInMonths: 24,
jobType: 'FULLTIME',
level: 'Junior',
- // "monthlySalary": undefined,
specialization: 'Front End',
title: 'Software Engineer',
totalCompensation: {
@@ -126,37 +129,43 @@ function Test() {
},
offers: [
{
-
- OffersFullTime: {
+ comments: 'I am a Raffles Institution almumni',
+ // Comments: '',
+ companyId: 'cl9j4yawz0003utlp1uaa1t8o',
+ jobType: 'FULLTIME',
+ location: 'Singapore, Singapore',
+ monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
+ negotiationStrategy: 'Leveraged having multiple offers',
+ offersFullTime: {
baseSalary: {
currency: 'SGD',
- value: 84000,
+ value: 2222,
},
bonus: {
currency: 'SGD',
- value: 20000,
+ value: 2222,
},
level: 'Junior',
specialization: 'Front End',
stocks: {
currency: 'SGD',
- value: 100,
+ value: 0,
},
title: 'Software Engineer',
totalCompensation: {
currency: 'SGD',
- value: 104100,
+ value: 4444,
},
},
- // Comments: '',
- companyId: 'cl95u79f000007im531ysjg79',
+ },
+ {
+ comments: '',
+ companyId: 'cl9j4yawz0003utlp1uaa1t8o',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Leveraged having multiple offers',
- },
- {
- OffersFullTime: {
+ offersFullTime: {
baseSalary: {
currency: 'SGD',
value: 84000,
@@ -177,47 +186,48 @@ function Test() {
value: 104100,
},
},
- comments: undefined,
- companyId: 'cl95u79f000007im531ysjg79',
- jobType: 'FULLTIME',
- location: 'Singapore, Singapore',
- monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
- // NegotiationStrategy: 'Leveraged having multiple offers',
},
],
});
};
- const profileId = 'cl96stky5002ew32gx2kale2x'; // Remember to change this filed after testing deleting
- const data = trpc.useQuery([
- `offers.profile.listOne`,
+ const profileId = 'cl9j50xzk008vutfqg6mta2ey'; // Remember to change this filed after testing deleting
+ const data = trpc.useQuery(
+ [
+ `offers.profile.listOne`,
+ {
+ profileId,
+ token:
+ '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
+ },
+ ],
{
- profileId,
- token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
+ onError(err) {
+ setError(err.shape?.message || '');
+ },
},
- ], {
- onError(err) {
- setError(err.shape?.message || "")
- }
- });
+ );
- const replies = trpc.useQuery(['offers.comments.getComments', {profileId: 'cl96stky5002ew32gx2kale2x'}], {
- onError(err) {
- setError(err.shape?.message || "")
+ const replies = trpc.useQuery(
+ ['offers.comments.getComments', { profileId }],
+ {
+ onError(err) {
+ setError(err.shape?.message || '');
+ },
},
- });
+ );
const deleteMutation = trpc.useMutation(['offers.profile.delete']);
const handleDelete = (id: string) => {
deleteMutation.mutate({
profileId: id,
- token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
+ token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
});
};
const updateMutation = trpc.useMutation(['offers.profile.update'], {
- onError(err: any) {
+ onError(err) {
alert(err);
},
onSuccess(response) {
@@ -230,362 +240,344 @@ function Test() {
background: {
educations: [
{
- backgroundId: "cl96stky6002fw32g6vj4meyr",
- endDate: new Date("2018-09-30T07:58:54.000Z"),
- field: "Computer Science",
- id: "cl96stky6002gw32gey2ffawd",
- school: "National University of Singapore",
- startDate: new Date("2014-09-30T07:58:54.000Z"),
- type: "Bachelors"
- }
+ backgroundId: 'cl9i68fv60001tthj23g9tuv4',
+ endDate: new Date('2018-09-30T07:58:54.000Z'),
+ field: 'Computer Science',
+ id: 'cl9i87y7z004otthjmpsd48wo',
+ school: 'National University of Singapore',
+ startDate: new Date('2014-09-30T07:58:54.000Z'),
+ type: 'Bachelors',
+ },
],
experiences: [
{
- backgroundId: "cl96stky6002fw32g6vj4meyr",
+ backgroundId: 'cl9i68fv60001tthj23g9tuv4',
company: {
- createdAt: new Date("2022-10-12T16:19:05.196Z"),
- description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
- id: "cl95u79f000007im531ysjg79",
- logoUrl: "https://logo.clearbit.com/meta.com",
- name: "Meta",
- slug: "meta",
- updatedAt: new Date("2022-10-12T16:19:05.196Z")
+ createdAt: new Date('2022-10-12T16:19:05.196Z'),
+ description:
+ 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
+ id: 'cl9j4yawz0003utlp1uaa1t8o',
+ logoUrl: 'https://logo.clearbit.com/meta.com',
+ name: 'Meta',
+ slug: 'meta',
+ updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
- companyId: "cl95u79f000007im531ysjg79",
+ companyId: 'cl9j4yawz0003utlp1uaa1t8o',
durationInMonths: 24,
- id: "cl96stky6002iw32gpt6t87s2",
- jobType: "FULLTIME",
- level: "Junior",
+ // Id: 'cl9j4yawz0003utlp1uaa1t8o',
+ jobType: 'FULLTIME',
+ level: 'Junior',
monthlySalary: null,
monthlySalaryId: null,
- specialization: "Front End",
- title: "Software Engineer",
+ specialization: 'Front End',
+ title: 'Software Engineer',
totalCompensation: {
- currency: "SGD",
- id: "cl96stky6002jw32g73svfacr",
- value: 104100
+ currency: 'SGD',
+ id: 'cl9i68fvc0005tthj7r1rhvb1',
+ value: 100,
},
- totalCompensationId: "cl96stky6002jw32g73svfacr"
- }
+ totalCompensationId: 'cl9i68fvc0005tthj7r1rhvb1',
+ },
],
- id: "cl96stky6002fw32g6vj4meyr",
- offersProfileId: "cl96stky5002ew32gx2kale2x",
+ id: 'cl9i68fv60001tthj23g9tuv4',
+ offersProfileId: 'cl9i68fv60000tthj8t3zkox0',
specificYoes: [
{
- backgroundId: "cl96stky6002fw32g6vj4meyr",
- domain: "Backend",
- id: "cl96t7890004tw32g5in3px5j",
- yoe: 2
- },
- {
- backgroundId: "cl96stky6002fw32g6vj4meyr",
- domain: "Backend",
- id: "cl96tb87x004xw32gnu17jbzv",
- yoe: 2
+ backgroundId: 'cl9i68fv60001tthj23g9tuv4',
+ domain: 'Backend',
+ id: 'cl9i68fvc0008tthjlxslzfo4',
+ yoe: 5,
},
{
- backgroundId: "cl96stky6002fw32g6vj4meyr",
- domain: "Backend",
- id: "cl976t39z00007iygt3np3cgo",
- yoe: 2
+ backgroundId: 'cl9i68fv60001tthj23g9tuv4',
+ domain: 'Backend',
+ id: 'cl9i68fvc0009tthjwol3285l',
+ yoe: 4,
},
- {
- backgroundId: "cl96stky6002fw32g6vj4meyr",
- domain: "Front End",
- id: "cl96stky7002mw32gn4jc7uml",
- yoe: 2
- },
- {
- backgroundId: "cl96stky6002fw32g6vj4meyr",
- domain: "Full Stack",
- id: "cl96stky7002nw32gpprghtxr",
- yoe: 2
- },
- {
- backgroundId: "cl96stky6002fw32g6vj4meyr",
- domain: "Backend",
- id: "cl976we5h000p7iygiomdo9fh",
- yoe: 2
- }
],
- totalYoe: 6
+ totalYoe: 1,
},
- createdAt: "2022-10-13T08:28:13.518Z",
- discussion: [],
- id: "cl96stky5002ew32gx2kale2x",
+ createdAt: '2022-10-13T08:28:13.518Z',
+ // Discussion: [],
+ id: 'cl9i68fv60000tthj8t3zkox0',
isEditable: true,
offers: [
{
- OffersFullTime: {
- baseSalary: {
- currency: "SGD",
- id: "cl976t4de00067iyg3pjir7k9",
- value: 1999999999
- },
- baseSalaryId: "cl976t4de00067iyg3pjir7k9",
- bonus: {
- currency: "SGD",
- id: "cl976t4de00087iygcnlmh8aw",
- value: 1410065407
- },
- bonusId: "cl976t4de00087iygcnlmh8aw",
- id: "cl976t4de00057iygq3ktce3v",
- level: "EXPERT",
- specialization: "FRONTEND",
- stocks: {
- currency: "SGD",
- id: "cl976t4df000a7iygkrsgr1xh",
- value: -558038585
- },
- stocksId: "cl976t4df000a7iygkrsgr1xh",
- title: "Software Engineer",
- totalCompensation: {
- currency: "SGD",
- id: "cl976t4df000c7iyg73ryf5uw",
- value: 55555555
- },
- totalCompensationId: "cl976t4df000c7iyg73ryf5uw"
- },
- OffersIntern: null,
- comments: "this IS SO IEUHDAEUIGDI",
+ comments: 'this IS SO IEUHDAEUIGDI',
company: {
- createdAt: new Date("2022-10-12T16:19:05.196Z"),
- description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
- id: "cl95u79f000007im531ysjg79",
- logoUrl: "https://logo.clearbit.com/meta.com",
- name: "Meta",
- slug: "meta",
- updatedAt: new Date("2022-10-12T16:19:05.196Z")
+ createdAt: new Date('2022-10-12T16:19:05.196Z'),
+ description:
+ 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
+ id: 'cl9j4yawz0003utlp1uaa1t8o',
+ logoUrl: 'https://logo.clearbit.com/meta.com',
+ name: 'Meta',
+ slug: 'meta',
+ updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
- companyId: "cl95u79f000007im531ysjg79",
- id: "cl976t4de00047iygl0zbce11",
- jobType: "FULLTIME",
- location: "Singapore, Singapore",
- monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
- negotiationStrategy: "Charmed the guy with my face",
- offersFullTimeId: "cl976t4de00057iygq3ktce3v",
- offersInternId: null,
- profileId: "cl96stky5002ew32gx2kale2x"
- },
- {
- OffersFullTime: {
- baseSalary: {
- currency: "SGD",
- id: "cl96stky80033w32gxw5goc4z",
- value: 84000
- },
- baseSalaryId: "cl96stky80033w32gxw5goc4z",
- bonus: {
- currency: "SGD",
- id: "cl96stky80035w32gajjwdo1p",
- value: 123456789
- },
- bonusId: "cl96stky80035w32gajjwdo1p",
- id: "cl96stky80032w32gep9ovgj3",
- level: "Junior",
- specialization: "Front End",
- stocks: {
- currency: "SGD",
- id: "cl96stky90037w32gu04t6ybh",
- value: 100
- },
- stocksId: "cl96stky90037w32gu04t6ybh",
- title: "Software Engineer",
- totalCompensation: {
- currency: "SGD",
- id: "cl96stky90039w32glbpktd0o",
- value: 104100
- },
- totalCompensationId: "cl96stky90039w32glbpktd0o"
- },
- OffersIntern: null,
- comments: null,
- company: {
- createdAt: new Date("2022-10-12T16:19:05.196Z"),
- description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
- id: "cl95u79f000007im531ysjg79",
- logoUrl: "https://logo.clearbit.com/meta.com",
- name: "Meta",
- slug: "meta",
- updatedAt: new Date("2022-10-12T16:19:05.196Z")
- },
- companyId: "cl95u79f000007im531ysjg79",
- id: "cl96stky80031w32gau9mu1gs",
- jobType: "FULLTIME",
- location: "Singapore, Singapore",
- monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
- negotiationStrategy: "Leveraged having million offers",
- offersFullTimeId: "cl96stky80032w32gep9ovgj3",
- offersInternId: null,
- profileId: "cl96stky5002ew32gx2kale2x"
- },
- {
- OffersFullTime: {
- baseSalary: {
- currency: "SGD",
- id: "cl96stky9003dw32gcvqbijlo",
- value: 1
- },
- baseSalaryId: "cl96stky9003dw32gcvqbijlo",
- bonus: {
- currency: "SGD",
- id: "cl96stky9003fw32goc3zqxwr",
- value: 0
- },
- bonusId: "cl96stky9003fw32goc3zqxwr",
- id: "cl96stky9003cw32g5v10izfu",
- level: "Senior",
- specialization: "Front End",
- stocks: {
- currency: "SGD",
- id: "cl96stky9003hw32g1lbbkqqr",
- value: 999999
- },
- stocksId: "cl96stky9003hw32g1lbbkqqr",
- title: "Software Engineer DOG",
- totalCompensation: {
- currency: "SGD",
- id: "cl96stky9003jw32gzumcoi7v",
- value: 999999
- },
- totalCompensationId: "cl96stky9003jw32gzumcoi7v"
- },
- OffersIntern: null,
- comments: null,
- company: {
- createdAt: new Date("2022-10-12T16:19:05.196Z"),
- description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
- id: "cl95u79f000007im531ysjg79",
- logoUrl: "https://logo.clearbit.com/meta.com",
- name: "Meta",
- slug: "meta",
- updatedAt: new Date("2022-10-12T16:19:05.196Z")
- },
- companyId: "cl95u79f000007im531ysjg79",
- id: "cl96stky9003bw32gc3l955vr",
- jobType: "FULLTIME",
- location: "Singapore, Singapore",
- monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
- negotiationStrategy: "LOst out having multiple offers",
- offersFullTimeId: "cl96stky9003cw32g5v10izfu",
- offersInternId: null,
- profileId: "cl96stky5002ew32gx2kale2x"
- },
- {
- OffersFullTime: {
+ companyId: 'cl9j4yawz0003utlp1uaa1t8o',
+ id: 'cl9i68fve000ntthj5h9yvqnh',
+ jobType: 'FULLTIME',
+ location: 'Singapore, Singapore',
+ monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
+ negotiationStrategy: 'Charmed the guy with my face',
+ offersFullTime: {
baseSalary: {
- currency: "SGD",
- id: "cl976wf28000v7iygmk1b7qaq",
- value: 1999999999
+ currency: 'SGD',
+ id: 'cl9i68fve000ptthjn55hpoe4',
+ value: 1999999999,
},
- baseSalaryId: "cl976wf28000v7iygmk1b7qaq",
+ baseSalaryId: 'cl9i68fve000ptthjn55hpoe4',
bonus: {
- currency: "SGD",
- id: "cl976wf28000x7iyg63w7kcli",
- value: 1410065407
+ currency: 'SGD',
+ id: 'cl9i68fve000rtthjqo2ktljt',
+ value: 1410065407,
},
- bonusId: "cl976wf28000x7iyg63w7kcli",
- id: "cl976wf28000u7iyg6euei8e9",
- level: "EXPERT",
- specialization: "FRONTEND",
+ bonusId: 'cl9i68fve000rtthjqo2ktljt',
+ id: 'cl9i68fve000otthjqk0g01k0',
+ level: 'EXPERT',
+ specialization: 'FRONTEND',
stocks: {
- currency: "SGD",
- id: "cl976wf28000z7iyg9ivun6ap",
- value: 111222333
+ currency: 'SGD',
+ id: 'cl9i68fvf000ttthjt2ode0cc',
+ value: -558038585,
},
- stocksId: "cl976wf28000z7iyg9ivun6ap",
- title: "Software Engineer",
+ stocksId: 'cl9i68fvf000ttthjt2ode0cc',
+ title: 'Software Engineer',
totalCompensation: {
- currency: "SGD",
- id: "cl976wf2800117iygmzsc0xit",
- value: 55555555
+ currency: 'SGD',
+ id: 'cl9i68fvf000vtthjg90s48nj',
+ value: 55555555,
},
- totalCompensationId: "cl976wf2800117iygmzsc0xit"
- },
- OffersIntern: null,
- comments: "this IS SO COOL",
- company: {
- createdAt: new Date("2022-10-12T16:19:05.196Z"),
- description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
- id: "cl95u79f000007im531ysjg79",
- logoUrl: "https://logo.clearbit.com/meta.com",
- name: "Meta",
- slug: "meta",
- updatedAt: new Date("2022-10-12T16:19:05.196Z")
+ totalCompensationId: 'cl9i68fvf000vtthjg90s48nj',
},
- companyId: "cl95u79f000007im531ysjg79",
- id: "cl976wf28000t7iyga4noyz7s",
- jobType: "FULLTIME",
- location: "Singapore, Singapore",
- monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
- negotiationStrategy: "Charmed the guy with my face",
- offersFullTimeId: "cl976wf28000u7iyg6euei8e9",
+ offersFullTimeId: 'cl9i68fve000otthjqk0g01k0',
+ offersIntern: null,
offersInternId: null,
- profileId: "cl96stky5002ew32gx2kale2x"
+ profileId: 'cl9i68fv60000tthj8t3zkox0',
},
- {
- OffersFullTime: {
- baseSalary: {
- currency: "SGD",
- id: "cl96tbb3o0053w32gz11paaxu",
- value: 1999999999
- },
- baseSalaryId: "cl96tbb3o0053w32gz11paaxu",
- bonus: {
- currency: "SGD",
- id: "cl96tbb3o0055w32gpyqgz5hx",
- value: 1410065407
- },
- bonusId: "cl96tbb3o0055w32gpyqgz5hx",
- id: "cl96tbb3o0052w32guguajzin",
- level: "EXPERT",
- specialization: "FRONTEND",
- stocks: {
- currency: "SGD",
- id: "cl96tbb3o0057w32gu4nyxguf",
- value: 500
- },
- stocksId: "cl96tbb3o0057w32gu4nyxguf",
- title: "Software Engineer",
- totalCompensation: {
- currency: "SGD",
- id: "cl96tbb3o0059w32gm3iy1zk4",
- value: 55555555
- },
- totalCompensationId: "cl96tbb3o0059w32gm3iy1zk4"
- },
- OffersIntern: null,
- comments: "this rocks",
- company: {
- createdAt: new Date("2022-10-12T16:19:05.196Z"),
- description: "Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.",
- id: "cl95u79f000007im531ysjg79",
- logoUrl: "https://logo.clearbit.com/meta.com",
- name: "Meta",
- slug: "meta",
- updatedAt: new Date("2022-10-12T16:19:05.196Z")
- },
- companyId: "cl95u79f000007im531ysjg79",
- id: "cl96tbb3o0051w32gjrpaiiit",
- jobType: "FULLTIME",
- location: "Singapore, Singapore",
- monthYearReceived: new Date("2022-09-30T07:58:54.000Z"),
- negotiationStrategy: "Charmed the guy with my face",
- offersFullTimeId: "cl96tbb3o0052w32guguajzin",
- offersInternId: null,
- profileId: "cl96stky5002ew32gx2kale2x"
- }
+ // {
+ // comments: '',
+ // company: {
+ // createdAt: new Date('2022-10-12T16:19:05.196Z'),
+ // description:
+ // 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
+ // id: 'cl9j4yawz0003utlp1uaa1t8o',
+ // logoUrl: 'https://logo.clearbit.com/meta.com',
+ // name: 'Meta',
+ // slug: 'meta',
+ // updatedAt: new Date('2022-10-12T16:19:05.196Z'),
+ // },
+ // companyId: 'cl9j4yawz0003utlp1uaa1t8o',
+ // id: 'cl9i68fvf000ytthj0ltsqt1d',
+ // jobType: 'FULLTIME',
+ // location: 'Singapore, Singapore',
+ // monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
+ // negotiationStrategy: 'Leveraged having million offers',
+ // offersFullTime: {
+ // baseSalary: {
+ // currency: 'SGD',
+ // id: 'cl9i68fvf0010tthj0iym6woh',
+ // value: 84000,
+ // },
+ // baseSalaryId: 'cl9i68fvf0010tthj0iym6woh',
+ // bonus: {
+ // currency: 'SGD',
+ // id: 'cl9i68fvf0012tthjioltnspk',
+ // value: 123456789,
+ // },
+ // bonusId: 'cl9i68fvf0012tthjioltnspk',
+ // id: 'cl9i68fvf000ztthjcovbiehc',
+ // level: 'Junior',
+ // specialization: 'Front End',
+ // stocks: {
+ // currency: 'SGD',
+ // id: 'cl9i68fvf0014tthjz2gff3hs',
+ // value: 100,
+ // },
+ // stocksId: 'cl9i68fvf0014tthjz2gff3hs',
+ // title: 'Software Engineer',
+ // totalCompensation: {
+ // currency: 'SGD',
+ // id: 'cl9i68fvf0016tthjrtb7iuvj',
+ // value: 104100,
+ // },
+ // totalCompensationId: 'cl9i68fvf0016tthjrtb7iuvj',
+ // },
+ // offersFullTimeId: 'cl9i68fvf000ztthjcovbiehc',
+ // offersIntern: null,
+ // offersInternId: null,
+ // profileId: 'cl9i68fv60000tthj8t3zkox0',
+ // },
+ // {
+ // comments: '',
+ // company: {
+ // createdAt: new Date('2022-10-12T16:19:05.196Z'),
+ // description:
+ // 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
+ // id: 'cl9j4yawz0003utlp1uaa1t8o',
+ // logoUrl: 'https://logo.clearbit.com/meta.com',
+ // name: 'Meta',
+ // slug: 'meta',
+ // updatedAt: new Date('2022-10-12T16:19:05.196Z'),
+ // },
+ // companyId: 'cl9j4yawz0003utlp1uaa1t8o',
+ // id: 'cl96stky9003bw32gc3l955vr',
+ // jobType: 'FULLTIME',
+ // location: 'Singapore, Singapore',
+ // monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
+ // negotiationStrategy: 'LOst out having multiple offers',
+ // offersFullTime: {
+ // baseSalary: {
+ // currency: 'SGD',
+ // id: 'cl96stky9003dw32gcvqbijlo',
+ // value: 1,
+ // },
+ // baseSalaryId: 'cl96stky9003dw32gcvqbijlo',
+ // bonus: {
+ // currency: 'SGD',
+ // id: 'cl96stky9003fw32goc3zqxwr',
+ // value: 0,
+ // },
+ // bonusId: 'cl96stky9003fw32goc3zqxwr',
+ // id: 'cl96stky9003cw32g5v10izfu',
+ // level: 'Senior',
+ // specialization: 'Front End',
+ // stocks: {
+ // currency: 'SGD',
+ // id: 'cl96stky9003hw32g1lbbkqqr',
+ // value: 999999,
+ // },
+ // stocksId: 'cl96stky9003hw32g1lbbkqqr',
+ // title: 'Software Engineer DOG',
+ // totalCompensation: {
+ // currency: 'SGD',
+ // id: 'cl96stky9003jw32gzumcoi7v',
+ // value: 999999,
+ // },
+ // totalCompensationId: 'cl96stky9003jw32gzumcoi7v',
+ // },
+ // offersFullTimeId: 'cl96stky9003cw32g5v10izfu',
+ // offersIntern: null,
+ // offersInternId: null,
+ // profileId: 'cl96stky5002ew32gx2kale2x',
+ // },
+ // {
+ // comments: 'this IS SO COOL',
+ // company: {
+ // createdAt: new Date('2022-10-12T16:19:05.196Z'),
+ // description:
+ // 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
+ // id: 'cl9j4yawz0003utlp1uaa1t8o',
+ // logoUrl: 'https://logo.clearbit.com/meta.com',
+ // name: 'Meta',
+ // slug: 'meta',
+ // updatedAt: new Date('2022-10-12T16:19:05.196Z'),
+ // },
+ // companyId: 'cl9j4yawz0003utlp1uaa1t8o',
+ // id: 'cl976wf28000t7iyga4noyz7s',
+ // jobType: 'FULLTIME',
+ // location: 'Singapore, Singapore',
+ // monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
+ // negotiationStrategy: 'Charmed the guy with my face',
+ // offersFullTime: {
+ // baseSalary: {
+ // currency: 'SGD',
+ // id: 'cl976wf28000v7iygmk1b7qaq',
+ // value: 1999999999,
+ // },
+ // baseSalaryId: 'cl976wf28000v7iygmk1b7qaq',
+ // bonus: {
+ // currency: 'SGD',
+ // id: 'cl976wf28000x7iyg63w7kcli',
+ // value: 1410065407,
+ // },
+ // bonusId: 'cl976wf28000x7iyg63w7kcli',
+ // id: 'cl976wf28000u7iyg6euei8e9',
+ // level: 'EXPERT',
+ // specialization: 'FRONTEND',
+ // stocks: {
+ // currency: 'SGD',
+ // id: 'cl976wf28000z7iyg9ivun6ap',
+ // value: 111222333,
+ // },
+ // stocksId: 'cl976wf28000z7iyg9ivun6ap',
+ // title: 'Software Engineer',
+ // totalCompensation: {
+ // currency: 'SGD',
+ // id: 'cl976wf2800117iygmzsc0xit',
+ // value: 55555555,
+ // },
+ // totalCompensationId: 'cl976wf2800117iygmzsc0xit',
+ // },
+ // offersFullTimeId: 'cl976wf28000u7iyg6euei8e9',
+ // offersIntern: null,
+ // offersInternId: null,
+ // profileId: 'cl96stky5002ew32gx2kale2x',
+ // },
+ // {
+ // comments: 'this rocks',
+ // company: {
+ // createdAt: new Date('2022-10-12T16:19:05.196Z'),
+ // description:
+ // 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
+ // id: 'cl9j4yawz0003utlp1uaa1t8o',
+ // logoUrl: 'https://logo.clearbit.com/meta.com',
+ // name: 'Meta',
+ // slug: 'meta',
+ // updatedAt: new Date('2022-10-12T16:19:05.196Z'),
+ // },
+ // companyId: 'cl9j4yawz0003utlp1uaa1t8o',
+ // id: 'cl96tbb3o0051w32gjrpaiiit',
+ // jobType: 'FULLTIME',
+ // location: 'Singapore, Singapore',
+ // monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
+ // negotiationStrategy: 'Charmed the guy with my face',
+ // offersFullTime: {
+ // baseSalary: {
+ // currency: 'SGD',
+ // id: 'cl96tbb3o0053w32gz11paaxu',
+ // value: 1999999999,
+ // },
+ // baseSalaryId: 'cl96tbb3o0053w32gz11paaxu',
+ // bonus: {
+ // currency: 'SGD',
+ // id: 'cl96tbb3o0055w32gpyqgz5hx',
+ // value: 1410065407,
+ // },
+ // bonusId: 'cl96tbb3o0055w32gpyqgz5hx',
+ // id: 'cl96tbb3o0052w32guguajzin',
+ // level: 'EXPERT',
+ // specialization: 'FRONTEND',
+ // stocks: {
+ // currency: 'SGD',
+ // id: 'cl96tbb3o0057w32gu4nyxguf',
+ // value: 500,
+ // },
+ // stocksId: 'cl96tbb3o0057w32gu4nyxguf',
+ // title: 'Software Engineer',
+ // totalCompensation: {
+ // currency: 'SGD',
+ // id: 'cl96tbb3o0059w32gm3iy1zk4',
+ // value: 55555555,
+ // },
+ // totalCompensationId: 'cl96tbb3o0059w32gm3iy1zk4',
+ // },
+ // offersFullTimeId: 'cl96tbb3o0052w32guguajzin',
+ // offersIntern: null,
+ // offersInternId: null,
+ // profileId: 'cl96stky5002ew32gx2kale2x',
+ // },
],
- profileName: "ailing bryann stuart ziqing",
- token: "afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba",
- userId: null
+ // ProfileName: 'ailing bryann stuart ziqing',
+ token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
+ userId: null,
});
- }
+ };
return (
<>
{createdData}
- {JSON.stringify(replies.data)}
+ {JSON.stringify(replies.data?.data)}
Click Me!
diff --git a/apps/portal/src/pages/offers/test/generateAnalysis.tsx b/apps/portal/src/pages/offers/test/generateAnalysis.tsx
new file mode 100644
index 00000000..87c4cfbb
--- /dev/null
+++ b/apps/portal/src/pages/offers/test/generateAnalysis.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+import { trpc } from '~/utils/trpc';
+
+function GenerateAnalysis() {
+ const analysisMutation = trpc.useMutation(['offers.analysis.generate']);
+
+ return (
+
+ {JSON.stringify(
+ analysisMutation.mutate({ profileId: 'cl9jj2ks1001li9fn9np47wjr' }),
+ )}
+
+ );
+}
+
+export default GenerateAnalysis;
diff --git a/apps/portal/src/pages/offers/test/getAnalysis.tsx b/apps/portal/src/pages/offers/test/getAnalysis.tsx
new file mode 100644
index 00000000..8ad7bff0
--- /dev/null
+++ b/apps/portal/src/pages/offers/test/getAnalysis.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { trpc } from '~/utils/trpc';
+
+function GetAnalysis() {
+ const analysis = trpc.useQuery([
+ 'offers.analysis.get',
+ { profileId: 'cl9jj2ks1001li9fn9np47wjr' },
+ ]);
+
+ return {JSON.stringify(analysis.data)}
;
+}
+
+export default GetAnalysis;
diff --git a/apps/portal/src/pages/offers/test/listOffers.tsx b/apps/portal/src/pages/offers/test/listOffers.tsx
index 7baaac90..b59f50c6 100644
--- a/apps/portal/src/pages/offers/test/listOffers.tsx
+++ b/apps/portal/src/pages/offers/test/listOffers.tsx
@@ -6,12 +6,12 @@ function Test() {
const data = trpc.useQuery([
'offers.list',
{
- companyId: 'cl95u79f000007im531ysjg79',
- limit: 20,
+ currency: 'SGD',
+ limit: 100,
location: 'Singapore, Singapore',
offset: 0,
- sortBy: '-monthYearReceived',
- yoeCategory: 1,
+ sortBy: '-totalCompensation',
+ yoeCategory: 2,
},
]);
diff --git a/apps/portal/src/pages/questions/index.tsx b/apps/portal/src/pages/questions/index.tsx
index 93ac9725..feaffa80 100644
--- a/apps/portal/src/pages/questions/index.tsx
+++ b/apps/portal/src/pages/questions/index.tsx
@@ -26,6 +26,8 @@ import {
} from '~/utils/questions/useSearchFilter';
import { trpc } from '~/utils/trpc';
+import { SortOrder, SortType } from '~/types/questions.d';
+
export default function QuestionsHomePage() {
const router = useRouter();
@@ -65,11 +67,14 @@ export default function QuestionsHomePage() {
[
'questions.questions.getQuestionsByFilter',
{
- companies: selectedCompanies,
+ companyNames: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: [],
+ // TODO: Implement sort order and sort type choices
+ sortOrder: SortOrder.DESC,
+ sortType: SortType.NEW,
startDate,
},
],
@@ -252,7 +257,7 @@ export default function QuestionsHomePage() {
{
createQuestion({
- company: data.company,
+ companyId: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
diff --git a/apps/portal/src/pages/resumes/[resumeId].tsx b/apps/portal/src/pages/resumes/[resumeId].tsx
index 40f7b630..782ead96 100644
--- a/apps/portal/src/pages/resumes/[resumeId].tsx
+++ b/apps/portal/src/pages/resumes/[resumeId].tsx
@@ -4,21 +4,26 @@ import Error from 'next/error';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
+import { useState } from 'react';
import {
AcademicCapIcon,
BriefcaseIcon,
CalendarIcon,
InformationCircleIcon,
MapPinIcon,
+ PencilSquareIcon,
StarIcon,
} from '@heroicons/react/20/solid';
import { Spinner } from '@tih/ui';
import ResumeCommentsSection from '~/components/resumes/comments/ResumeCommentsSection';
import ResumePdf from '~/components/resumes/ResumePdf';
+import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import { trpc } from '~/utils/trpc';
+import SubmitResumeForm from './submit';
+
export default function ResumeReviewPage() {
const ErrorPage = (
@@ -37,13 +42,23 @@ export default function ResumeReviewPage() {
const starMutation = trpc.useMutation('resumes.resume.star', {
onSuccess() {
utils.invalidateQueries(['resumes.resume.findOne']);
+ utils.invalidateQueries(['resumes.resume.findAll']);
+ utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
+ utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
},
});
const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
onSuccess() {
utils.invalidateQueries(['resumes.resume.findOne']);
+ utils.invalidateQueries(['resumes.resume.findAll']);
+ utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
+ utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
},
});
+ const userIsOwner =
+ session?.user?.id != null && session.user.id === detailsQuery.data?.userId;
+
+ const [isEditMode, setIsEditMode] = useState(false);
const onStarButtonClick = () => {
if (session?.user?.id == null) {
@@ -51,8 +66,6 @@ export default function ResumeReviewPage() {
return;
}
- // Star button only rendered if resume exists
- // Star button only clickable if user exists
if (detailsQuery.data?.stars.length) {
unstarMutation.mutate({
resumeId: resumeId as string,
@@ -64,6 +77,33 @@ export default function ResumeReviewPage() {
}
};
+ const onEditButtonClick = () => {
+ setIsEditMode(true);
+ };
+
+ if (isEditMode && detailsQuery.data != null) {
+ return (
+ {
+ utils.invalidateQueries(['resumes.resume.findOne']);
+ utils.invalidateQueries(['resumes.resume.findAll']);
+ utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
+ utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
+ setIsEditMode(false);
+ }}
+ />
+ );
+ }
+
return (
<>
{(detailsQuery.isError || detailsQuery.data === null) && ErrorPage}
@@ -79,45 +119,48 @@ export default function ResumeReviewPage() {
{detailsQuery.data.title}
-
+
{detailsQuery.data.title}
-
+
+
+
+ {starMutation.isLoading ||
+ unstarMutation.isLoading ||
+ detailsQuery.isLoading ? (
+
+ ) : (
+
+ )}
+
+ Star
+
+
+ {detailsQuery.data?._count.stars}
+
+
+ {userIsOwner && (
+
+
+
)}
- disabled={
- session?.user === undefined ||
- starMutation.isLoading ||
- unstarMutation.isLoading
- }
- type="button"
- onClick={onStarButtonClick}>
-
-
- {starMutation.isLoading || unstarMutation.isLoading ? (
-
- ) : (
-
- )}
-
- Star
-
-
- {detailsQuery.data?._count.stars}
-
-
+
@@ -146,10 +189,9 @@ export default function ResumeReviewPage() {
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
- {`Uploaded ${formatDistanceToNow(
- new Date(detailsQuery.data.createdAt),
- { addSuffix: true },
- )} by ${detailsQuery.data.user.name}`}
+ {`Uploaded ${formatDistanceToNow(detailsQuery.data.createdAt, {
+ addSuffix: true,
+ })} by ${detailsQuery.data.user.name}`}
{detailsQuery.data.additionalInfo && (
@@ -158,7 +200,10 @@ export default function ResumeReviewPage() {
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
- {detailsQuery.data.additionalInfo}
+
)}
diff --git a/apps/portal/src/pages/resumes/browse.tsx b/apps/portal/src/pages/resumes/browse.tsx
index 0331c720..4182fd63 100644
--- a/apps/portal/src/pages/resumes/browse.tsx
+++ b/apps/portal/src/pages/resumes/browse.tsx
@@ -1,154 +1,181 @@
-import compareAsc from 'date-fns/compareAsc';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
-import { useState } from 'react';
-import { Disclosure } from '@headlessui/react';
-import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
-import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
+import { Fragment, useEffect, useState } from 'react';
+import { Dialog, Disclosure, Transition } from '@headlessui/react';
+import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
+import { XMarkIcon } from '@heroicons/react/24/outline';
+import {
+ MagnifyingGlassIcon,
+ NewspaperIcon,
+} from '@heroicons/react/24/outline';
import {
CheckboxInput,
CheckboxList,
DropdownMenu,
+ Pagination,
+ Spinner,
Tabs,
TextInput,
} from '@tih/ui';
+import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import type {
- FilterOption,
- SortOrder,
-} from '~/components/resumes/browse/resumeConstants';
+ Filter,
+ FilterId,
+ Shortcut,
+} from '~/components/resumes/browse/resumeFilters';
import {
BROWSE_TABS_VALUES,
EXPERIENCE,
+ INITIAL_FILTER_STATE,
+ isInitialFilterState,
LOCATION,
ROLE,
+ SHORTCUTS,
SORT_OPTIONS,
- TOP_HITS,
-} from '~/components/resumes/browse/resumeConstants';
-import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
+} from '~/components/resumes/browse/resumeFilters';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
+import useDebounceValue from '~/utils/resumes/useDebounceValue';
import { trpc } from '~/utils/trpc';
-import type { Resume } from '~/types/resume';
-
-type FilterId = 'experience' | 'location' | 'role';
-type Filter = {
- id: FilterId;
- name: string;
- options: Array
;
-};
-type FilterState = Record>;
+import type { FilterState } from '../../components/resumes/browse/resumeFilters';
+const PAGE_LIMIT = 10;
const filters: Array = [
{
id: 'role',
- name: 'Role',
+ label: 'Role',
options: ROLE,
},
{
id: 'experience',
- name: 'Experience',
+ label: 'Experience',
options: EXPERIENCE,
},
{
id: 'location',
- name: 'Location',
+ label: 'Location',
options: LOCATION,
},
];
-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),
+const getLoggedOutText = (tabsValue: string) => {
+ switch (tabsValue) {
+ case BROWSE_TABS_VALUES.STARRED:
+ return 'to view starred resumes!';
+ case BROWSE_TABS_VALUES.MY:
+ return 'to view your submitted resumes!';
+ default:
+ return '';
+ }
};
-const filterResumes = (
- resumes: Array,
+const getEmptyDataText = (
+ tabsValue: string,
searchValue: string,
userFilters: FilterState,
-) =>
- resumes
- .filter((resume) =>
- resume.title.toLowerCase().includes(searchValue.toLocaleLowerCase()),
- )
- .filter(
- ({ experience, location, role }) =>
- userFilters.role.includes(role) &&
- userFilters.experience.includes(experience) &&
- userFilters.location.includes(location),
- );
-
-const sortComparators: Record<
- SortOrder,
- (resume1: Resume, resume2: Resume) => number
-> = {
- latest: (resume1, resume2) =>
- compareAsc(resume2.createdAt, resume1.createdAt),
- popular: (resume1, resume2) => resume2.numStars - resume1.numStars,
- topComments: (resume1, resume2) => resume2.numComments - resume1.numComments,
+) => {
+ if (searchValue.length > 0) {
+ return 'Try tweaking your search text to see more resumes.';
+ }
+ if (!isInitialFilterState(userFilters)) {
+ return 'Try tweaking your filters to see more resumes.';
+ }
+ switch (tabsValue) {
+ case BROWSE_TABS_VALUES.ALL:
+ return 'Looks like SWEs are feeling lucky!';
+ case BROWSE_TABS_VALUES.STARRED:
+ return 'You have not starred any resumes. Star one to see it here!';
+ case BROWSE_TABS_VALUES.MY:
+ return 'Upload a resume to see it here!';
+ default:
+ return '';
+ }
};
-const sortResumes = (resumes: Array, sortOrder: SortOrder) =>
- resumes.sort(sortComparators[sortOrder]);
export default function ResumeHomePage() {
const { data: sessionData } = useSession();
const router = useRouter();
const [tabsValue, setTabsValue] = useState(BROWSE_TABS_VALUES.ALL);
- const [sortOrder, setSortOrder] = useState(SORT_OPTIONS[0].value);
+ const [sortOrder, setSortOrder] = useState('latest');
const [searchValue, setSearchValue] = useState('');
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
- const [resumes, setResumes] = useState>([]);
- const [renderSignInButton, setRenderSignInButton] = useState(false);
- const [signInButtonText, setSignInButtonText] = useState('');
+ const [shortcutSelected, setShortcutSelected] = useState('All');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
+
+ const skip = (currentPage - 1) * PAGE_LIMIT;
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [userFilters, sortOrder]);
- const allResumesQuery = trpc.useQuery(['resumes.resume.findAll'], {
- enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
- onSuccess: (data) => {
- setResumes(data);
- setRenderSignInButton(false);
+ const allResumesQuery = trpc.useQuery(
+ [
+ 'resumes.resume.findAll',
+ {
+ experienceFilters: userFilters.experience,
+ locationFilters: userFilters.location,
+ numComments: userFilters.numComments,
+ roleFilters: userFilters.role,
+ searchValue: useDebounceValue(searchValue, 800),
+ skip,
+ sortOrder,
+ },
+ ],
+ {
+ enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
+ staleTime: 5 * 60 * 1000,
},
- });
+ );
const starredResumesQuery = trpc.useQuery(
- ['resumes.resume.user.findUserStarred'],
+ [
+ 'resumes.resume.user.findUserStarred',
+ {
+ experienceFilters: userFilters.experience,
+ locationFilters: userFilters.location,
+ numComments: userFilters.numComments,
+ roleFilters: userFilters.role,
+ searchValue: useDebounceValue(searchValue, 800),
+ skip,
+ sortOrder,
+ },
+ ],
{
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
- onError: () => {
- setResumes([]);
- setRenderSignInButton(true);
- setSignInButtonText('to view starred resumes');
- },
- onSuccess: (data) => {
- setResumes(data);
- },
retry: false,
+ staleTime: 5 * 60 * 1000,
},
);
const myResumesQuery = trpc.useQuery(
- ['resumes.resume.user.findUserCreated'],
+ [
+ 'resumes.resume.user.findUserCreated',
+ {
+ experienceFilters: userFilters.experience,
+ locationFilters: userFilters.location,
+ numComments: userFilters.numComments,
+ roleFilters: userFilters.role,
+ searchValue: useDebounceValue(searchValue, 800),
+ skip,
+ sortOrder,
+ },
+ ],
{
enabled: tabsValue === BROWSE_TABS_VALUES.MY,
- onError: () => {
- setResumes([]);
- setRenderSignInButton(true);
- setSignInButtonText('to view your submitted resumes');
- },
- onSuccess: (data) => {
- setResumes(data);
- },
retry: false,
+ staleTime: 5 * 60 * 1000,
},
);
const onSubmitResume = () => {
- if (sessionData?.user?.id) {
- router.push('/resumes/submit');
- } else {
+ if (sessionData === null) {
router.push('/api/auth/signin');
+ } else {
+ router.push('/resumes/submit');
}
};
@@ -172,116 +199,118 @@ export default function ResumeHomePage() {
}
};
+ const onShortcutChange = ({
+ sortOrder: shortcutSortOrder,
+ filters: shortcutFilters,
+ name: shortcutName,
+ }: Shortcut) => {
+ setShortcutSelected(shortcutName);
+ setSortOrder(shortcutSortOrder);
+ setUserFilters(shortcutFilters);
+ };
+
+ const onTabChange = (tab: string) => {
+ setTabsValue(tab);
+ setCurrentPage(1);
+ };
+
+ const getTabQueryData = () => {
+ switch (tabsValue) {
+ case BROWSE_TABS_VALUES.ALL:
+ return allResumesQuery.data;
+ case BROWSE_TABS_VALUES.STARRED:
+ return starredResumesQuery.data;
+ case BROWSE_TABS_VALUES.MY:
+ return myResumesQuery.data;
+ default:
+ return null;
+ }
+ };
+
+ const getTabResumes = () => {
+ return getTabQueryData()?.mappedResumeData ?? [];
+ };
+
+ const getTabTotalPages = () => {
+ const numRecords = getTabQueryData()?.totalRecords ?? 0;
+ return numRecords % PAGE_LIMIT === 0
+ ? numRecords / PAGE_LIMIT
+ : Math.floor(numRecords / PAGE_LIMIT) + 1;
+ };
+
return (
<>
Resume Review Portal
-
-
-
-
-
-
-
-
-
- Shortcuts:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {SORT_OPTIONS.map((option) => (
-
- setSortOrder(option.value)
- }>
- ))}
-
-
-
+
+ {/* Mobile Filters */}
+
+
+
+
+
+
+
+
+
+
+
+
+ Shortcuts
+
- Submit
+ onClick={() => setMobileFiltersOpen(false)}>
+ Close menu
+
-
-
-
-
-
-
-
- Shortcuts
+
- {TOP_HITS.map((category) => (
-
- {/* TODO: Replace onClick with filtering function */}
+ {SHORTCUTS.map((shortcut) => (
+
true}
+ isSelected={shortcutSelected === shortcut.name}
+ title={shortcut.name}
+ onClick={() => onShortcutChange(shortcut)}
/>
))}
-
- Explore these filters:
-
- {filters.map((section) => (
+
+ {filters.map((filter) => (
+ className="border-t border-gray-200 px-4 py-6">
{({ open }) => (
<>
-
-
+
+
- {section.name}
+ {filter.label}
{open ? (
@@ -298,57 +327,252 @@ export default function ResumeHomePage() {
-
-
- {section.options.map((option) => (
+
+
+ {filter.options.map((option) => (
onFilterCheckboxChange(
isChecked,
- section.id,
+ filter.id,
option.value,
)
}
/>
))}
-
+
>
)}
))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Shortcuts:
+
+
+
+
+ {SHORTCUTS.map((shortcut) => (
+
+ onShortcutChange(shortcut)}
+ />
+
+ ))}
+
+
+ Explore these filters:
+
+ {filters.map((filter) => (
+
+ {({ open }) => (
+ <>
+
+
+
+ {filter.label}
+
+
+ {open ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {filter.options.map((option) => (
+
+
+ onFilterCheckboxChange(
+ isChecked,
+ filter.id,
+ option.value,
+ )
+ }
+ />
+
+ ))}
+
+
+ >
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ Submit Resume
+
-
- {renderSignInButton && (
-
- )}
-
+
+
+
+
+
+
+
+
+ {Object.entries(SORT_OPTIONS).map(([key, value]) => (
+ setSortOrder(key)}>
+ ))}
+
+
+
setMobileFiltersOpen(true)}>
+ Filters
+
+
+
+
+
+ Submit Resume
+
+
+
+ {allResumesQuery.isLoading ||
+ starredResumesQuery.isLoading ||
+ myResumesQuery.isLoading ? (
+
+ {' '}
+ {' '}
+
+ ) : sessionData === null &&
+ tabsValue !== BROWSE_TABS_VALUES.ALL ? (
+
+ ) : getTabResumes().length === 0 ? (
+
+
+ {getEmptyDataText(tabsValue, searchValue, userFilters)}
+
+ ) : (
+ <>
+
+
+
setCurrentPage(page)}
+ />
+
+ >
+ )}
+
diff --git a/apps/portal/src/pages/resumes/index.jsx b/apps/portal/src/pages/resumes/index.tsx
similarity index 74%
rename from apps/portal/src/pages/resumes/index.jsx
rename to apps/portal/src/pages/resumes/index.tsx
index 89fbc1bb..ae063c0b 100644
--- a/apps/portal/src/pages/resumes/index.jsx
+++ b/apps/portal/src/pages/resumes/index.tsx
@@ -1,10 +1,8 @@
import Head from 'next/head';
import { CallToAction } from '~/components/resumes/landing/CallToAction';
-import { Footer } from '~/components/resumes/landing/Footer';
import { Hero } from '~/components/resumes/landing/Hero';
import { PrimaryFeatures } from '~/components/resumes/landing/PrimaryFeatures';
-import { Testimonials } from '~/components/resumes/landing/Testimonials';
export default function Home() {
return (
@@ -17,8 +15,6 @@ export default function Home() {
-
-
>
);
diff --git a/apps/portal/src/pages/resumes/submit.tsx b/apps/portal/src/pages/resumes/submit.tsx
index ad9ffa49..28b7a3b6 100644
--- a/apps/portal/src/pages/resumes/submit.tsx
+++ b/apps/portal/src/pages/resumes/submit.tsx
@@ -3,10 +3,12 @@ import clsx from 'clsx';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
-import { useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import type { FileRejection } from 'react-dropzone';
+import { useDropzone } from 'react-dropzone';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
-import { PaperClipIcon } from '@heroicons/react/24/outline';
+import { ArrowUpCircleIcon } from '@heroicons/react/24/outline';
import {
Button,
CheckboxInput,
@@ -16,11 +18,13 @@ import {
TextInput,
} from '@tih/ui';
+import type { Filter } from '~/components/resumes/browse/resumeFilters';
import {
EXPERIENCE,
LOCATION,
ROLE,
-} from '~/components/resumes/browse/resumeConstants';
+} from '~/components/resumes/browse/resumeFilters';
+import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { trpc } from '~/utils/trpc';
@@ -43,122 +47,173 @@ type IFormInput = {
title: string;
};
-export default function SubmitResumeForm() {
- const { data: session, status } = useSession();
- const resumeCreateMutation = trpc.useMutation('resumes.resume.user.create');
- const router = useRouter();
+const selectors: Array
= [
+ { id: 'role', label: 'Role', options: ROLE },
+ { id: 'experience', label: 'Experience Level', options: EXPERIENCE },
+ { id: 'location', label: 'Location', options: LOCATION },
+];
+
+type InitFormDetails = {
+ additionalInfo?: string;
+ experience: string;
+ location: string;
+ resumeId: string;
+ role: string;
+ title: string;
+ url: string;
+};
- const [resumeFile, setResumeFile] = useState();
+type Props = Readonly<{
+ initFormDetails?: InitFormDetails | null;
+ onClose: () => void;
+}>;
+
+export default function SubmitResumeForm({
+ initFormDetails,
+ onClose = () => undefined,
+}: Props) {
const [isLoading, setIsLoading] = useState(false);
const [invalidFileUploadError, setInvalidFileUploadError] = useState<
string | null
>(null);
const [isDialogShown, setIsDialogShown] = useState(false);
- useEffect(() => {
- if (status !== 'loading') {
- if (session?.user?.id == null) {
- router.push('/api/auth/signin');
- }
- }
- }, [router, session, status]);
+ const { data: session, status } = useSession();
+ const router = useRouter();
+ const trpcContext = trpc.useContext();
+ const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert');
+ const isNewForm = initFormDetails == null;
const {
register,
handleSubmit,
setValue,
reset,
- formState: { errors, isDirty },
+ watch,
+ formState: { errors, isDirty, dirtyFields },
} = useForm({
defaultValues: {
isChecked: false,
+ ...initFormDetails,
},
});
- const onSubmit: SubmitHandler = async (data) => {
- if (resumeFile == null) {
- console.error('Resume file is empty');
- return;
+ const resumeFile = watch('file');
+
+ const onFileDrop = useCallback(
+ (acceptedFiles: Array, fileRejections: Array) => {
+ if (fileRejections.length === 0) {
+ setInvalidFileUploadError('');
+ setValue('file', acceptedFiles[0], {
+ shouldDirty: true,
+ });
+ } else {
+ setInvalidFileUploadError(FILE_UPLOAD_ERROR);
+ }
+ },
+ [setValue],
+ );
+
+ const { getRootProps, getInputProps } = useDropzone({
+ accept: {
+ 'application/pdf': ['.pdf'],
+ },
+ maxFiles: 1,
+ maxSize: FILE_SIZE_LIMIT_BYTES,
+ noClick: isLoading,
+ noDrag: isLoading,
+ onDrop: onFileDrop,
+ });
+
+ // Route user to sign in if not logged in
+ useEffect(() => {
+ if (status !== 'loading') {
+ if (session?.user?.id == null) {
+ router.push('/api/auth/signin');
+ }
}
+ }, [router, session, status]);
+
+ const onSubmit: SubmitHandler = async (data) => {
setIsLoading(true);
+ let fileUrl = initFormDetails?.url ?? '';
- const formData = new FormData();
- formData.append('key', RESUME_STORAGE_KEY);
- formData.append('file', resumeFile);
+ // Only update file in fs when it changes
+ if (dirtyFields.file) {
+ const formData = new FormData();
+ formData.append('key', RESUME_STORAGE_KEY);
+ formData.append('file', resumeFile);
- const res = await axios.post('/api/file-storage', formData, {
- headers: {
- 'Content-Type': 'multipart/form-data',
- },
- });
- const { url } = res.data;
+ const res = await axios.post('/api/file-storage', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ });
+ fileUrl = res.data.url;
+ }
- resumeCreateMutation.mutate(
+ resumeUpsertMutation.mutate(
{
additionalInfo: data.additionalInfo,
experience: data.experience,
+ id: initFormDetails?.resumeId,
location: data.location,
role: data.role,
title: data.title,
- url,
+ url: fileUrl,
},
{
- onError: (error) => {
+ onError(error) {
console.error(error);
},
- onSettled: () => {
+ onSettled() {
setIsLoading(false);
},
- onSuccess: () => {
- router.push('/resumes');
+ onSuccess() {
+ if (isNewForm) {
+ trpcContext.invalidateQueries('resumes.resume.findAll');
+ router.push('/resumes/browse');
+ } else {
+ onClose();
+ }
},
},
);
};
- const onUploadFile = (event: React.ChangeEvent) => {
- const file = event.target.files?.item(0);
- if (file == null) {
- return;
- }
- if (file.type !== 'application/pdf' || file.size > FILE_SIZE_LIMIT_BYTES) {
- setInvalidFileUploadError(FILE_UPLOAD_ERROR);
- return;
- }
- setInvalidFileUploadError('');
- setResumeFile(file);
- };
-
- const onClickReset = () => {
- if (isDirty || resumeFile != null) {
+ const onClickClear = () => {
+ if (isDirty) {
setIsDialogShown(true);
+ } else {
+ onClose();
}
};
- const onClickProceedDialog = () => {
+ const onClickResetDialog = () => {
+ onClose();
setIsDialogShown(false);
reset();
- setResumeFile(null);
+ setInvalidFileUploadError(null);
};
- const onClickDownload = async () => {
- if (resumeFile == null) {
- return;
- }
+ const onClickDownload = async (
+ event: React.MouseEvent,
+ ) => {
+ // Prevent click event from propagating up to dropzone
+ event.stopPropagation();
const url = window.URL.createObjectURL(resumeFile);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', resumeFile.name);
-
- // Append to html link element page
document.body.appendChild(link);
// Start download
link.click();
- // Clean up and remove the link
+ // Clean up and remove the link and object URL
link.remove();
+ URL.revokeObjectURL(url);
};
const fileUploadError = useMemo(() => {
@@ -175,10 +230,11 @@ export default function SubmitResumeForm() {
Upload a Resume
-
+
+ {/* Reset Dialog component */}
}
secondaryButton={
@@ -197,13 +253,18 @@ export default function SubmitResumeForm() {
onClick={() => setIsDialogShown(false)}
/>
}
- title="Are you sure you want to clear?"
+ title={
+ isNewForm
+ ? 'Are you sure you want to clear?'
+ : 'Are you sure you want to leave?'
+ }
onClose={() => setIsDialogShown(false)}>
Note that your current input will not be saved!
Upload a resume
+ {/* Title Section */}
setValue('title', val)}
/>
-
- setValue('role', val)}
- />
-
-
- setValue('experience', val)}
- />
-
-
- setValue('location', val)}
- />
-
-
- Upload resume (PDF format)
-
- {' '}
- *
-
-
-
-
-
-
- {resumeFile == null ? (
-
- ) : (
-
+ {/* Selectors */}
+ {selectors.map((item) => (
+
+ setValue(item.id, val)}
+ />
+
+ ))}
+ {/* Upload resume form */}
+ {isNewForm && (
+ <>
+
+ Upload resume (PDF format)
+
+ {' '}
+ *
+
+
+
+
+
+ {resumeFile == null ? (
+
+ ) : (
{resumeFile.name}
+ )}
+
+
+
+ Drop file here
+
+ or
+
+ {resumeFile == null
+ ? 'Select file'
+ : 'Replace file'}
+
+
+
- )}
-
-
-
-
- {resumeFile == null
- ? 'Upload a file'
- : 'Replace file'}
+
+ PDF up to {FILE_SIZE_LIMIT_MB}MB
-
-
+
-
- PDF up to {FILE_SIZE_LIMIT_MB}MB
-
+ {fileUploadError && (
+
+ {fileUploadError}
+
+ )}
-
- {fileUploadError && (
-
{fileUploadError}
- )}
-
+ >
+ )}
+ {/* Additional Info Section */}
setValue('additionalInfo', val)}
/>
-
-
- Submission Guidelines
-
-
- Before you submit, please review and acknolwedge our
- submission guidelines
- stated below.
-
-
- •
- Ensure that you do not divulge any of your
- personal particulars .
-
-
- •
- Ensure that you do not divulge any
-
- {' '}
- company's proprietary and confidential information
-
- .
-
-
- •
- Proof-read your resumes to look for grammatical/spelling
- errors.
-
-
+ {/* Submission Guidelines */}
+
setValue('isChecked', val)}
/>
+ {/* Clear and Submit Buttons */}
diff --git a/apps/portal/src/pages/test__.tsx b/apps/portal/src/pages/test__.tsx
new file mode 100644
index 00000000..26859c56
--- /dev/null
+++ b/apps/portal/src/pages/test__.tsx
@@ -0,0 +1,51 @@
+import { useState } from 'react';
+import type { TypeaheadOption } from '@tih/ui';
+import { Button } from '@tih/ui';
+import { useToast } from '@tih/ui';
+import { HorizontalDivider } from '@tih/ui';
+
+import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
+import type { Month, MonthYear } from '~/components/shared/MonthYearPicker';
+import MonthYearPicker from '~/components/shared/MonthYearPicker';
+
+export default function HomePage() {
+ const [selectedCompany, setSelectedCompany] =
+ useState
(null);
+ const [monthYear, setMonthYear] = useState({
+ month: (new Date().getMonth() + 1) as Month,
+ year: new Date().getFullYear(),
+ });
+
+ const { showToast } = useToast();
+
+ return (
+
+
+
+
+ Test Page
+
+
setSelectedCompany(option)}
+ />
+ {JSON.stringify(selectedCompany, null, 2)}
+
+
+
+ {
+ showToast({
+ // Duration: 10000 (optional)
+ subtitle: `Some optional subtitle ${Date.now()}`,
+ title: `Hello World ${Date.now()}`,
+ variant: 'success',
+ });
+ }}
+ />
+
+
+
+ );
+}
diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts
index 43fcbd8d..c3046659 100644
--- a/apps/portal/src/server/router/index.ts
+++ b/apps/portal/src/server/router/index.ts
@@ -3,6 +3,7 @@ import superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context';
import { offersRouter } from './offers/offers';
+import { offersAnalysisRouter } from './offers/offers-analysis-router';
import { offersCommentsRouter } from './offers/offers-comments-router';
import { offersProfileRouter } from './offers/offers-profile-router';
import { protectedExampleRouter } from './protected-example-router';
@@ -12,6 +13,8 @@ import { questionsQuestionCommentRouter } from './questions-question-comment-rou
import { questionsQuestionRouter } from './questions-question-router';
import { resumeCommentsRouter } from './resumes/resumes-comments-router';
import { resumesCommentsUserRouter } from './resumes/resumes-comments-user-router';
+import { resumesCommentsVotesRouter } from './resumes/resumes-comments-votes-router';
+import { resumesCommentsVotesUserRouter } from './resumes/resumes-comments-votes-user-router';
import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
@@ -32,12 +35,15 @@ export const appRouter = createRouter()
.merge('resumes.resume.', resumesStarUserRouter)
.merge('resumes.comments.', resumeCommentsRouter)
.merge('resumes.comments.user.', resumesCommentsUserRouter)
+ .merge('resumes.comments.votes.', resumesCommentsVotesRouter)
+ .merge('resumes.comments.votes.user.', resumesCommentsVotesUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.', questionsQuestionRouter)
.merge('offers.', offersRouter)
.merge('offers.profile.', offersProfileRouter)
+ .merge('offers.analysis.', offersAnalysisRouter)
.merge('offers.comments.', offersCommentsRouter);
// Export type definition of API
diff --git a/apps/portal/src/server/router/offers/offers-analysis-router.ts b/apps/portal/src/server/router/offers/offers-analysis-router.ts
new file mode 100644
index 00000000..973f3136
--- /dev/null
+++ b/apps/portal/src/server/router/offers/offers-analysis-router.ts
@@ -0,0 +1,478 @@
+import { z } from 'zod';
+import type {
+ Company,
+ OffersBackground,
+ OffersCurrency,
+ OffersFullTime,
+ OffersIntern,
+ OffersOffer,
+ OffersProfile,
+} from '@prisma/client';
+import { TRPCError } from '@trpc/server';
+
+import { profileAnalysisDtoMapper } from '~/mappers/offers-mappers';
+
+import { createRouter } from '../context';
+
+const searchOfferPercentile = (
+ offer: OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & {
+ baseSalary: OffersCurrency;
+ bonus: OffersCurrency;
+ stocks: OffersCurrency;
+ totalCompensation: OffersCurrency;
+ })
+ | null;
+ offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ profile: OffersProfile & { background: OffersBackground | null };
+ },
+ similarOffers: Array<
+ OffersOffer & {
+ company: Company;
+ offersFullTime:
+ | (OffersFullTime & {
+ totalCompensation: OffersCurrency;
+ })
+ | null;
+ offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
+ profile: OffersProfile & { background: OffersBackground | null };
+ }
+ >,
+) => {
+ for (let i = 0; i < similarOffers.length; i++) {
+ if (similarOffers[i].id === offer.id) {
+ return i;
+ }
+ }
+
+ return -1;
+};
+
+export const offersAnalysisRouter = createRouter()
+ .query('get', {
+ input: z.object({
+ profileId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const analysis = await ctx.prisma.offersAnalysis.findFirst({
+ include: {
+ overallHighestOffer: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: true,
+ },
+ },
+ },
+ },
+ topCompanyOffers: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ topOverallOffers: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ where: {
+ profileId: input.profileId,
+ },
+ });
+
+ if (!analysis) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'No analysis found on this profile',
+ });
+ }
+
+ return profileAnalysisDtoMapper(analysis);
+ },
+ })
+ .mutation('generate', {
+ input: z.object({
+ profileId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ await ctx.prisma.offersAnalysis.deleteMany({
+ where: {
+ profileId: input.profileId,
+ },
+ });
+
+ const offers = await ctx.prisma.offersOffer.findMany({
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ baseSalary: true,
+ bonus: true,
+ stocks: true,
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: true,
+ },
+ },
+ },
+ orderBy: [
+ {
+ offersFullTime: {
+ totalCompensation: {
+ baseValue: 'desc',
+ },
+ },
+ },
+ {
+ offersIntern: {
+ monthlySalary: {
+ baseValue: 'desc',
+ },
+ },
+ },
+ ],
+ where: {
+ profileId: input.profileId,
+ },
+ });
+
+ if (!offers || offers.length === 0) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'No offers found on this profile',
+ });
+ }
+
+ const overallHighestOffer = offers[0];
+
+ // TODO: Shift yoe out of background to make it mandatory
+ if (
+ !overallHighestOffer.profile.background ||
+ overallHighestOffer.profile.background.totalYoe == null
+ ) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'YOE not found',
+ });
+ }
+
+ const yoe = overallHighestOffer.profile.background.totalYoe as number;
+ const monthYearReceived = new Date(overallHighestOffer.monthYearReceived);
+ monthYearReceived.setFullYear(monthYearReceived.getFullYear() - 1);
+
+ let similarOffers = await ctx.prisma.offersOffer.findMany({
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ orderBy: [
+ {
+ offersFullTime: {
+ totalCompensation: {
+ baseValue: 'desc',
+ },
+ },
+ },
+ {
+ offersIntern: {
+ monthlySalary: {
+ baseValue: 'desc',
+ },
+ },
+ },
+ ],
+ where: {
+ AND: [
+ {
+ location: overallHighestOffer.location,
+ },
+ {
+ monthYearReceived: {
+ gte: monthYearReceived,
+ },
+ },
+ {
+ OR: [
+ {
+ offersFullTime: {
+ level: overallHighestOffer.offersFullTime?.level,
+ title: overallHighestOffer.offersFullTime?.title,
+ },
+ offersIntern: {
+ title: overallHighestOffer.offersIntern?.title,
+ },
+ },
+ ],
+ },
+ {
+ profile: {
+ background: {
+ AND: [
+ {
+ totalYoe: {
+ gte: Math.max(yoe - 1, 0),
+ lte: yoe + 1,
+ },
+ },
+ ],
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ let similarCompanyOffers = similarOffers.filter(
+ (offer) => offer.companyId === overallHighestOffer.companyId,
+ );
+
+ // CALCULATE PERCENTILES
+ const overallIndex = searchOfferPercentile(
+ overallHighestOffer,
+ similarOffers,
+ );
+ const overallPercentile =
+ similarOffers.length === 0
+ ? 100
+ : (100 * overallIndex) / similarOffers.length;
+
+ const companyIndex = searchOfferPercentile(
+ overallHighestOffer,
+ similarCompanyOffers,
+ );
+ const companyPercentile =
+ similarCompanyOffers.length === 0
+ ? 100
+ : (100 * companyIndex) / similarCompanyOffers.length;
+
+ // FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
+ // e.g. If there only 4 offers, it gives the 2nd and 3rd offer
+ similarOffers = similarOffers.filter(
+ (offer) => offer.id !== overallHighestOffer.id,
+ );
+ similarCompanyOffers = similarCompanyOffers.filter(
+ (offer) => offer.id !== overallHighestOffer.id,
+ );
+
+ const noOfSimilarOffers = similarOffers.length;
+ const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1);
+ const topPercentileOffers =
+ noOfSimilarOffers > 2
+ ? similarOffers.slice(
+ similarOffers90PercentileIndex,
+ similarOffers90PercentileIndex + 2,
+ )
+ : similarOffers;
+
+ const noOfSimilarCompanyOffers = similarCompanyOffers.length;
+ const similarCompanyOffers90PercentileIndex = Math.ceil(
+ noOfSimilarCompanyOffers * 0.1,
+ );
+ const topPercentileCompanyOffers =
+ noOfSimilarCompanyOffers > 2
+ ? similarCompanyOffers.slice(
+ similarCompanyOffers90PercentileIndex,
+ similarCompanyOffers90PercentileIndex + 2,
+ )
+ : similarCompanyOffers;
+
+ const analysis = await ctx.prisma.offersAnalysis.create({
+ data: {
+ companyPercentile,
+ noOfSimilarCompanyOffers,
+ noOfSimilarOffers,
+ overallHighestOffer: {
+ connect: {
+ id: overallHighestOffer.id,
+ },
+ },
+ overallPercentile,
+ profile: {
+ connect: {
+ id: input.profileId,
+ },
+ },
+ topCompanyOffers: {
+ connect: topPercentileCompanyOffers.map((offer) => {
+ return { id: offer.id };
+ }),
+ },
+ topOverallOffers: {
+ connect: topPercentileOffers.map((offer) => {
+ return { id: offer.id };
+ }),
+ },
+ },
+ include: {
+ overallHighestOffer: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: true,
+ },
+ },
+ },
+ },
+ topCompanyOffers: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ topOverallOffers: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ return profileAnalysisDtoMapper(analysis);
+ },
+ });
diff --git a/apps/portal/src/server/router/offers/offers-comments-router.ts b/apps/portal/src/server/router/offers/offers-comments-router.ts
index e8308320..f2160243 100644
--- a/apps/portal/src/server/router/offers/offers-comments-router.ts
+++ b/apps/portal/src/server/router/offers/offers-comments-router.ts
@@ -1,225 +1,336 @@
import { z } from 'zod';
import * as trpc from '@trpc/server';
-import { createProtectedRouter } from '../context';
-
-export const offersCommentsRouter = createProtectedRouter()
- .query('getComments', {
- input: z.object({
- profileId: z.string()
- }),
- async resolve({ ctx, input }) {
- const result = await ctx.prisma.offersProfile.findFirst({
- include: {
- discussion: {
- include: {
- replies: true,
- replyingTo: true,
- user: true
- }
- }
- },
- where: {
- id: input.profileId
- }
- })
+import { createRouter } from '../context';
- if (result) {
- return result.discussion.filter((x) => x.replyingToId === null)
- }
+import type { OffersDiscussion, Reply } from '~/types/offers';
- return result
- }
- })
- .mutation("create", {
- input: z.object({
- message: z.string(),
- profileId: z.string(),
- replyingToId: z.string().optional(),
- userId: z.string().optional()
- }),
- async resolve({ ctx, input }) {
- const createdReply = await ctx.prisma.offersReply.create({
- data: {
- message: input.message,
- profile: {
- connect: {
- id: input.profileId
- }
- }
- }
- })
+export const offersCommentsRouter = createRouter()
+ .query('getComments', {
+ input: z.object({
+ profileId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
- if (input.replyingToId) {
- await ctx.prisma.offersReply.update({
- data: {
- replyingTo: {
- connect: {
- id: input.replyingToId
- }
- }
- },
- where: {
- id: createdReply.id
- }
- })
- }
-
- if (input.userId) {
- await ctx.prisma.offersReply.update({
- data: {
- user: {
- connect: {
- id: input.userId
- }
- }
- },
- where: {
- id: createdReply.id
- }
- })
- }
- // Get replies
- const result = await ctx.prisma.offersProfile.findFirst({
+ const result = await ctx.prisma.offersProfile.findFirst({
+ include: {
+ discussion: {
+ include: {
+ replies: {
include: {
- discussion: {
- include: {
- replies: true,
- replyingTo: true,
- user: true
- }
- }
+ user: true,
},
- where: {
- id: input.profileId
- }
- })
-
- if (result) {
- return result.discussion.filter((x) => x.replyingToId === null)
- }
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ replyingTo: true,
+ user: true,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ },
+ },
+ where: {
+ id: input.profileId,
+ },
+ });
- return result
- }
- })
- .mutation("update", {
- input: z.object({
- id: z.string(),
- message: z.string(),
- profileId: z.string(),
- // Have to pass in either userID or token for validation
- token: z.string().optional(),
- userId: z.string().optional(),
- }),
- async resolve({ ctx, input }) {
- const messageToUpdate = await ctx.prisma.offersReply.findFirst({
- where: {
- id: input.id
- }
+ const discussions: OffersDiscussion = {
+ data:
+ result?.discussion
+ .filter((x) => {
+ return x.replyingToId === null;
})
- const profile = await ctx.prisma.offersProfile.findFirst({
- where: {
- id: input.profileId,
- },
- });
-
- const profileEditToken = profile?.editToken;
-
- // To validate user editing, OP or correct user
- // TODO: improve validation process
- if (profileEditToken === input.token || messageToUpdate?.userId === input.userId) {
- await ctx.prisma.offersReply.update({
- data: {
- message: input.message
- },
- where: {
- id: input.id
- }
- })
-
- const result = await ctx.prisma.offersProfile.findFirst({
- include: {
- discussion: {
- include: {
- replies: true,
- replyingTo: true,
- user: true
- }
- }
- },
- where: {
- id: input.profileId
- }
- })
-
- if (result) {
- return result.discussion.filter((x) => x.replyingToId === null)
+ .map((x) => {
+ if (x.user == null) {
+ x.user = {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ };
+ }
+
+ x.replies?.map((y) => {
+ if (y.user == null) {
+ y.user = {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ };
}
+ });
- return result
- }
+ const replyType: Reply = {
+ createdAt: x.createdAt,
+ id: x.id,
+ message: x.message,
+ replies: x.replies.map((reply) => {
+ return {
+ createdAt: reply.createdAt,
+ id: reply.id,
+ message: reply.message,
+ replies: [],
+ replyingToId: reply.replyingToId,
+ user: reply.user,
+ };
+ }),
+ replyingToId: x.replyingToId,
+ user: x.user,
+ };
- throw new trpc.TRPCError({
- code: 'UNAUTHORIZED',
- message: 'Wrong userId or token.'
- })
+ return replyType;
+ }) ?? [],
+ };
+
+ return discussions;
+ },
+ })
+ .mutation('create', {
+ input: z.object({
+ message: z.string(),
+ profileId: z.string(),
+ replyingToId: z.string().optional(),
+ token: z.string().optional(),
+ userId: z.string().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const profileEditToken = profile?.editToken;
+
+ if (input.token === profileEditToken || input.userId) {
+ const createdReply = await ctx.prisma.offersReply.create({
+ data: {
+ message: input.message,
+ profile: {
+ connect: {
+ id: input.profileId,
+ },
+ },
+ },
+ });
+
+ if (input.replyingToId) {
+ await ctx.prisma.offersReply.update({
+ data: {
+ replyingTo: {
+ connect: {
+ id: input.replyingToId,
+ },
+ },
+ },
+ where: {
+ id: createdReply.id,
+ },
+ });
}
- })
- .mutation("delete", {
- input: z.object({
- id: z.string(),
- profileId: z.string(),
- // Have to pass in either userID or token for validation
- token: z.string().optional(),
- userId: z.string().optional(),
- }),
- async resolve({ ctx, input }) {
- const messageToDelete = await ctx.prisma.offersReply.findFirst({
- where: {
- id: input.id
- }
- })
- const profile = await ctx.prisma.offersProfile.findFirst({
- where: {
- id: input.profileId,
+
+ if (input.userId) {
+ await ctx.prisma.offersReply.update({
+ data: {
+ user: {
+ connect: {
+ id: input.userId,
},
- });
-
- const profileEditToken = profile?.editToken;
-
- // To validate user editing, OP or correct user
- // TODO: improve validation process
- if (profileEditToken === input.token || messageToDelete?.userId === input.userId) {
- await ctx.prisma.offersReply.delete({
- where: {
- id: input.id
- }
- })
- const result = await ctx.prisma.offersProfile.findFirst({
- include: {
- discussion: {
- include: {
- replies: true,
- replyingTo: true,
- user: true
- }
- }
- },
- where: {
- id: input.profileId
- }
- })
-
- if (result) {
- return result.discussion.filter((x) => x.replyingToId === null)
- }
+ },
+ },
+ where: {
+ id: createdReply.id,
+ },
+ });
+ }
- return result
- }
+ const created = await ctx.prisma.offersReply.findFirst({
+ include: {
+ user: true,
+ },
+ where: {
+ id: createdReply.id,
+ },
+ });
- throw new trpc.TRPCError({
- code: 'UNAUTHORIZED',
- message: 'Wrong userId or token.'
- })
- }
- })
\ No newline at end of file
+ const result: Reply = {
+ createdAt: created!.createdAt,
+ id: created!.id,
+ message: created!.message,
+ replies: [], // New message should have no replies
+ replyingToId: created!.replyingToId,
+ user: created!.user ?? {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ },
+ };
+
+ return result;
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Missing userId or wrong token.',
+ });
+ },
+ })
+ .mutation('update', {
+ input: z.object({
+ id: z.string(),
+ message: z.string(),
+ profileId: z.string(),
+ // Have to pass in either userID or token for validation
+ token: z.string().optional(),
+ userId: z.string().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const messageToUpdate = await ctx.prisma.offersReply.findFirst({
+ where: {
+ id: input.id,
+ },
+ });
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const profileEditToken = profile?.editToken;
+
+ // To validate user editing, OP or correct user
+ // TODO: improve validation process
+ if (
+ profileEditToken === input.token ||
+ messageToUpdate?.userId === input.userId
+ ) {
+ const updated = await ctx.prisma.offersReply.update({
+ data: {
+ message: input.message,
+ },
+ include: {
+ replies: {
+ include: {
+ user: true,
+ },
+ },
+ user: true,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+
+ const result: Reply = {
+ createdAt: updated!.createdAt,
+ id: updated!.id,
+ message: updated!.message,
+ replies: updated!.replies.map((x) => {
+ return {
+ createdAt: x.createdAt,
+ id: x.id,
+ message: x.message,
+ replies: [],
+ replyingToId: x.replyingToId,
+ user: x.user ?? {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ },
+ };
+ }),
+ replyingToId: updated!.replyingToId,
+ user: updated!.user ?? {
+ email: '',
+ emailVerified: null,
+ id: '',
+ image: '',
+ name: profile?.profileName ?? '',
+ },
+ };
+
+ return result;
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Wrong userId or token.',
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ profileId: z.string(),
+ // Have to pass in either userID or token for validation
+ token: z.string().optional(),
+ userId: z.string().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const messageToDelete = await ctx.prisma.offersReply.findFirst({
+ where: {
+ id: input.id,
+ },
+ });
+ const profile = await ctx.prisma.offersProfile.findFirst({
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ const profileEditToken = profile?.editToken;
+
+ // To validate user editing, OP or correct user
+ // TODO: improve validation process
+ if (
+ profileEditToken === input.token ||
+ messageToDelete?.userId === input.userId
+ ) {
+ await ctx.prisma.offersReply.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ await ctx.prisma.offersProfile.findFirst({
+ include: {
+ discussion: {
+ include: {
+ replies: true,
+ replyingTo: true,
+ user: true,
+ },
+ },
+ },
+ where: {
+ id: input.profileId,
+ },
+ });
+
+ // If (result) {
+ // return result.discussion.filter((x) => x.replyingToId === null);
+ // }
+
+ // return result;
+ }
+
+ throw new trpc.TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'Wrong userId or token.',
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/offers/offers-profile-router.ts b/apps/portal/src/server/router/offers/offers-profile-router.ts
index 2cce77fb..9aade630 100644
--- a/apps/portal/src/server/router/offers/offers-profile-router.ts
+++ b/apps/portal/src/server/router/offers/offers-profile-router.ts
@@ -1,10 +1,18 @@
import crypto, { randomUUID } from 'crypto';
import { z } from 'zod';
+import { JobType } from '@prisma/client';
import * as trpc from '@trpc/server';
-import { createRouter } from '../context';
+import {
+ addToProfileResponseMapper,
+ createOfferProfileResponseMapper,
+ profileDtoMapper,
+} from '~/mappers/offers-mappers';
+import { baseCurrencyString } from '~/utils/offers/currency';
+import { convert } from '~/utils/offers/currency/currencyExchange';
+import { createValidationRegex } from '~/utils/offers/zodRegex';
-import type { offersProfile } from '~/types/offers-profile';
+import { createRouter } from '../context';
const valuation = z.object({
currency: z.string(),
@@ -19,42 +27,46 @@ const company = z.object({
logoUrl: z.string().nullish(),
name: z.string(),
slug: z.string(),
- updatedAt: z.date()
-})
+ updatedAt: z.date(),
+});
const offer = z.object({
- OffersFullTime: z.object({
- baseSalary: valuation.nullish(),
- baseSalaryId: z.string().nullish(),
- bonus: valuation.nullish(),
- bonusId: z.string().nullish(),
- id: z.string().optional(),
- level: z.string().nullish(),
- specialization: z.string(),
- stocks: valuation.nullish(),
- stocksId: z.string().nullish(),
- title: z.string(),
- totalCompensation: valuation.nullish(),
- totalCompensationId: z.string().nullish(),
- }).nullish(),
- OffersIntern: z.object({
- id: z.string().optional(),
- internshipCycle: z.string().nullish(),
- monthlySalary: valuation.nullish(),
- specialization: z.string(),
- startYear: z.number().nullish(),
- title: z.string(),
- totalCompensation: valuation.nullish(), // Full time
- }).nullish(),
- comments: z.string().nullish(),
+ comments: z.string(),
company: company.nullish(),
companyId: z.string(),
id: z.string().optional(),
- jobType: z.string(),
+ jobType: z.string().regex(createValidationRegex(Object.keys(JobType), null)),
location: z.string(),
monthYearReceived: z.date(),
- negotiationStrategy: z.string().nullish(),
+ negotiationStrategy: z.string(),
+ offersFullTime: z
+ .object({
+ baseSalary: valuation.nullish(),
+ baseSalaryId: z.string().nullish(),
+ bonus: valuation.nullish(),
+ bonusId: z.string().nullish(),
+ id: z.string().optional(),
+ level: z.string().nullish(),
+ specialization: z.string(),
+ stocks: valuation.nullish(),
+ stocksId: z.string().nullish(),
+ title: z.string(),
+ totalCompensation: valuation.nullish(),
+ totalCompensationId: z.string().nullish(),
+ })
+ .nullish(),
offersFullTimeId: z.string().nullish(),
+ offersIntern: z
+ .object({
+ id: z.string().optional(),
+ internshipCycle: z.string().nullish(),
+ monthlySalary: valuation.nullish(),
+ specialization: z.string(),
+ startYear: z.number().nullish(),
+ title: z.string(),
+ totalCompensation: valuation.nullish(), // Full time
+ })
+ .nullish(),
offersInternId: z.string().nullish(),
profileId: z.string().nullish(),
});
@@ -65,14 +77,18 @@ const experience = z.object({
companyId: z.string().nullish(),
durationInMonths: z.number().nullish(),
id: z.string().optional(),
- jobType: z.string().nullish(),
+ jobType: z
+ .string()
+ .regex(createValidationRegex(Object.keys(JobType), null))
+ .nullish(),
level: z.string().nullish(),
+ location: z.string().nullish(),
monthlySalary: valuation.nullish(),
monthlySalaryId: z.string().nullish(),
specialization: z.string().nullish(),
title: z.string().nullish(),
totalCompensation: valuation.nullish(),
- totalCompensationId: z.string().nullish()
+ totalCompensationId: z.string().nullish(),
});
const education = z.object({
@@ -85,39 +101,6 @@ const education = z.object({
type: z.string().nullish(),
});
-const reply = z.object({
- createdAt: z.date().nullish(),
- id: z.string().optional(),
- messages: z.string().nullish(),
- profileId: z.string().nullish(),
- replyingToId: z.string().nullish(),
- userId: z.string().nullish()
-})
-
-type WithIsEditable = T & {
- isEditable: boolean;
-};
-
-function computeIsEditable(
- profileInput: offersProfile,
- editToken?: string,
-): WithIsEditable {
- return {
- ...profileInput,
- isEditable: profileInput.editToken === editToken,
- };
-}
-
-function exclude>(
- profile: WithIsEditable,
- ...keys: Array
-): Omit, Key> {
- for (const key of keys) {
- delete profile[key];
- }
- return profile;
-}
-
export const offersProfileRouter = createRouter()
.query('listOne', {
input: z.object({
@@ -127,6 +110,86 @@ export const offersProfileRouter = createRouter()
async resolve({ ctx, input }) {
const result = await ctx.prisma.offersProfile.findFirst({
include: {
+ analysis: {
+ include: {
+ overallHighestOffer: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: true,
+ },
+ },
+ },
+ },
+ topCompanyOffers: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ topOverallOffers: {
+ include: {
+ company: true,
+ offersFullTime: {
+ include: {
+ totalCompensation: true,
+ },
+ },
+ offersIntern: {
+ include: {
+ monthlySalary: true,
+ },
+ },
+ profile: {
+ include: {
+ background: {
+ include: {
+ experiences: {
+ include: {
+ company: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
background: {
include: {
educations: true,
@@ -144,12 +207,13 @@ export const offersProfileRouter = createRouter()
include: {
replies: true,
replyingTo: true,
- user: true
+ user: true,
},
},
offers: {
include: {
- OffersFullTime: {
+ company: true,
+ offersFullTime: {
include: {
baseSalary: true,
bonus: true,
@@ -157,12 +221,11 @@ export const offersProfileRouter = createRouter()
totalCompensation: true,
},
},
- OffersIntern: {
+ offersIntern: {
include: {
monthlySalary: true,
},
},
- company: true,
},
},
},
@@ -172,7 +235,7 @@ export const offersProfileRouter = createRouter()
});
if (result) {
- return exclude(computeIsEditable(result, input.token), 'editToken')
+ return profileDtoMapper(result, input.token);
}
throw new trpc.TRPCError({
@@ -217,11 +280,11 @@ export const offersProfileRouter = createRouter()
})),
},
experiences: {
- create: input.background.experiences.map((x) => {
+ create: input.background.experiences.map(async (x) => {
if (
- x.jobType === 'FULLTIME' &&
- x.totalCompensation?.currency !== undefined &&
- x.totalCompensation.value !== undefined
+ x.jobType === JobType.FULLTIME &&
+ x.totalCompensation?.currency != null &&
+ x.totalCompensation?.value != null
) {
if (x.companyId) {
return {
@@ -237,8 +300,14 @@ export const offersProfileRouter = createRouter()
title: x.title,
totalCompensation: {
create: {
- currency: x.totalCompensation?.currency,
- value: x.totalCompensation?.value,
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.totalCompensation.value,
+ x.totalCompensation.currency,
+ baseCurrencyString,
+ ),
+ currency: x.totalCompensation.currency,
+ value: x.totalCompensation.value,
},
},
};
@@ -247,20 +316,27 @@ export const offersProfileRouter = createRouter()
durationInMonths: x.durationInMonths,
jobType: x.jobType,
level: x.level,
+ location: x.location,
specialization: x.specialization,
title: x.title,
totalCompensation: {
create: {
- currency: x.totalCompensation?.currency,
- value: x.totalCompensation?.value,
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.totalCompensation.value,
+ x.totalCompensation.currency,
+ baseCurrencyString,
+ ),
+ currency: x.totalCompensation.currency,
+ value: x.totalCompensation.value,
},
},
};
}
if (
- x.jobType === 'INTERN' &&
- x.monthlySalary?.currency !== undefined &&
- x.monthlySalary.value !== undefined
+ x.jobType === JobType.INTERN &&
+ x.monthlySalary?.currency != null &&
+ x.monthlySalary?.value != null
) {
if (x.companyId) {
return {
@@ -273,8 +349,14 @@ export const offersProfileRouter = createRouter()
jobType: x.jobType,
monthlySalary: {
create: {
- currency: x.monthlySalary?.currency,
- value: x.monthlySalary?.value,
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.monthlySalary.value,
+ x.monthlySalary.currency,
+ baseCurrencyString,
+ ),
+ currency: x.monthlySalary.currency,
+ value: x.monthlySalary.value,
},
},
specialization: x.specialization,
@@ -286,8 +368,14 @@ export const offersProfileRouter = createRouter()
jobType: x.jobType,
monthlySalary: {
create: {
- currency: x.monthlySalary?.currency,
- value: x.monthlySalary?.value,
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.monthlySalary.value,
+ x.monthlySalary.currency,
+ baseCurrencyString,
+ ),
+ currency: x.monthlySalary.currency,
+ value: x.monthlySalary.value,
},
},
specialization: x.specialization,
@@ -314,144 +402,147 @@ export const offersProfileRouter = createRouter()
},
editToken: token,
offers: {
- create: input.offers.map((x) => {
- if (
- x.jobType === 'INTERN' &&
- x.OffersIntern &&
- x.OffersIntern.internshipCycle &&
- x.OffersIntern.monthlySalary?.currency &&
- x.OffersIntern.monthlySalary.value &&
- x.OffersIntern.startYear
- ) {
- return {
- OffersIntern: {
- create: {
- internshipCycle: x.OffersIntern.internshipCycle,
- monthlySalary: {
- create: {
- currency: x.OffersIntern.monthlySalary?.currency,
- value: x.OffersIntern.monthlySalary?.value,
+ create: await Promise.all(
+ input.offers.map(async (x) => {
+ if (
+ x.jobType === JobType.INTERN &&
+ x.offersIntern &&
+ x.offersIntern.internshipCycle != null &&
+ x.offersIntern.monthlySalary?.currency != null &&
+ x.offersIntern.monthlySalary?.value != null &&
+ x.offersIntern.startYear != null
+ ) {
+ return {
+ comments: x.comments,
+ company: {
+ connect: {
+ id: x.companyId,
+ },
+ },
+ jobType: x.jobType,
+ location: x.location,
+ monthYearReceived: x.monthYearReceived,
+ negotiationStrategy: x.negotiationStrategy,
+ offersIntern: {
+ create: {
+ internshipCycle: x.offersIntern.internshipCycle,
+ monthlySalary: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.offersIntern.monthlySalary.value,
+ x.offersIntern.monthlySalary.currency,
+ baseCurrencyString,
+ ),
+ currency: x.offersIntern.monthlySalary.currency,
+ value: x.offersIntern.monthlySalary.value,
+ },
},
+ specialization: x.offersIntern.specialization,
+ startYear: x.offersIntern.startYear,
+ title: x.offersIntern.title,
},
- specialization: x.OffersIntern.specialization,
- startYear: x.OffersIntern.startYear,
- title: x.OffersIntern.title,
},
- },
- comments: x.comments,
- company: {
- connect: {
- id: x.companyId,
+ };
+ }
+ if (
+ x.jobType === JobType.FULLTIME &&
+ x.offersFullTime &&
+ x.offersFullTime.baseSalary?.currency != null &&
+ x.offersFullTime.baseSalary?.value != null &&
+ x.offersFullTime.bonus?.currency != null &&
+ x.offersFullTime.bonus?.value != null &&
+ x.offersFullTime.stocks?.currency != null &&
+ x.offersFullTime.stocks?.value != null &&
+ x.offersFullTime.totalCompensation?.currency != null &&
+ x.offersFullTime.totalCompensation?.value != null &&
+ x.offersFullTime.level != null &&
+ x.offersFullTime.title != null &&
+ x.offersFullTime.specialization != null
+ ) {
+ return {
+ comments: x.comments,
+ company: {
+ connect: {
+ id: x.companyId,
+ },
},
- },
- jobType: x.jobType,
- location: x.location,
- monthYearReceived: x.monthYearReceived,
- negotiationStrategy: x.negotiationStrategy,
- };
- }
- if (
- x.jobType === 'FULLTIME' &&
- x.OffersFullTime &&
- x.OffersFullTime.baseSalary?.currency &&
- x.OffersFullTime.baseSalary?.value &&
- x.OffersFullTime.bonus?.currency &&
- x.OffersFullTime.bonus?.value &&
- x.OffersFullTime.stocks?.currency &&
- x.OffersFullTime.stocks?.value &&
- x.OffersFullTime.totalCompensation?.currency &&
- x.OffersFullTime.totalCompensation?.value &&
- x.OffersFullTime.level
- ) {
- return {
- OffersFullTime: {
- create: {
- baseSalary: {
- create: {
- currency: x.OffersFullTime.baseSalary?.currency,
- value: x.OffersFullTime.baseSalary?.value,
+ jobType: x.jobType,
+ location: x.location,
+ monthYearReceived: x.monthYearReceived,
+ negotiationStrategy: x.negotiationStrategy,
+ offersFullTime: {
+ create: {
+ baseSalary: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.offersFullTime.baseSalary.value,
+ x.offersFullTime.baseSalary.currency,
+ baseCurrencyString,
+ ),
+ currency: x.offersFullTime.baseSalary.currency,
+ value: x.offersFullTime.baseSalary.value,
+ },
},
- },
- bonus: {
- create: {
- currency: x.OffersFullTime.bonus?.currency,
- value: x.OffersFullTime.bonus?.value,
+ bonus: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.offersFullTime.bonus.value,
+ x.offersFullTime.bonus.currency,
+ baseCurrencyString,
+ ),
+ currency: x.offersFullTime.bonus.currency,
+ value: x.offersFullTime.bonus.value,
+ },
},
- },
- level: x.OffersFullTime.level,
- specialization: x.OffersFullTime.specialization,
- stocks: {
- create: {
- currency: x.OffersFullTime.stocks?.currency,
- value: x.OffersFullTime.stocks?.value,
+ level: x.offersFullTime.level,
+ specialization: x.offersFullTime.specialization,
+ stocks: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.offersFullTime.stocks.value,
+ x.offersFullTime.stocks.currency,
+ baseCurrencyString,
+ ),
+ currency: x.offersFullTime.stocks.currency,
+ value: x.offersFullTime.stocks.value,
+ },
},
- },
- title: x.OffersFullTime.title,
- totalCompensation: {
- create: {
- currency: x.OffersFullTime.totalCompensation?.currency,
- value: x.OffersFullTime.totalCompensation?.value,
+ title: x.offersFullTime.title,
+ totalCompensation: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ x.offersFullTime.totalCompensation.value,
+ x.offersFullTime.totalCompensation.currency,
+ baseCurrencyString,
+ ),
+ currency:
+ x.offersFullTime.totalCompensation.currency,
+ value: x.offersFullTime.totalCompensation.value,
+ },
},
},
},
- },
- comments: x.comments,
- company: {
- connect: {
- id: x.companyId,
- },
- },
- jobType: x.jobType,
- location: x.location,
- monthYearReceived: x.monthYearReceived,
- negotiationStrategy: x.negotiationStrategy,
- };
- }
+ };
+ }
- // Throw error
- throw new trpc.TRPCError({
- code: 'BAD_REQUEST',
- message: 'Missing fields.',
- });
- }),
+ // Throw error
+ throw new trpc.TRPCError({
+ code: 'BAD_REQUEST',
+ message: 'Missing fields.',
+ });
+ }),
+ ),
},
profileName: randomUUID().substring(0, 10),
},
- include: {
- background: {
- include: {
- educations: true,
- experiences: {
- include: {
- company: true,
- monthlySalary: true,
- totalCompensation: true,
- },
- },
- specificYoes: true,
- },
- },
- offers: {
- include: {
- OffersFullTime: {
- include: {
- baseSalary: true,
- bonus: true,
- stocks: true,
- totalCompensation: true,
- },
- },
- OffersIntern: {
- include: {
- monthlySalary: true,
- },
- },
- },
- },
- },
});
- // TODO: add analysis to profile object then return
- return profile;
+
+ return createOfferProfileResponseMapper(profile, token);
},
})
.mutation('delete', {
@@ -468,13 +559,15 @@ export const offersProfileRouter = createRouter()
const profileEditToken = profileToDelete?.editToken;
if (profileEditToken === input.token) {
- return await ctx.prisma.offersProfile.delete({
+ const deletedProfile = await ctx.prisma.offersProfile.delete({
where: {
id: input.profileId,
},
});
+
+ return deletedProfile.id;
}
- // TODO: Throw 401
+
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid token.',
@@ -493,19 +586,18 @@ export const offersProfileRouter = createRouter()
backgroundId: z.string().optional(),
domain: z.string(),
id: z.string().optional(),
- yoe: z.number()
+ yoe: z.number(),
}),
),
totalYoe: z.number(),
}),
createdAt: z.string().optional(),
- discussion: z.array(reply),
id: z.string(),
isEditable: z.boolean().nullish(),
offers: z.array(offer),
- profileName: z.string(),
+ profileName: z.string().optional(),
token: z.string(),
- userId: z.string().nullish()
+ userId: z.string().nullish(),
}),
async resolve({ ctx, input }) {
const profileToUpdate = await ctx.prisma.offersProfile.findFirst({
@@ -516,26 +608,48 @@ export const offersProfileRouter = createRouter()
const profileEditToken = profileToUpdate?.editToken;
if (profileEditToken === input.token) {
- await ctx.prisma.offersProfile.update({
- data: {
- profileName: input.profileName,
- },
- where: {
- id: input.id,
- }
- });
+ if (input.profileName) {
+ await ctx.prisma.offersProfile.update({
+ data: {
+ profileName: input.profileName,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ }
await ctx.prisma.offersBackground.update({
data: {
- totalYoe: input.background.totalYoe
+ totalYoe: input.background.totalYoe,
},
where: {
- id: input.background.id
+ id: input.background.id,
+ },
+ });
+
+ // Delete educations
+ const educationsId = (
+ await ctx.prisma.offersEducation.findMany({
+ where: {
+ backgroundId: input.background.id,
+ },
+ })
+ ).map((x) => x.id);
+
+ for (const id of educationsId) {
+ if (!input.background.educations.map((x) => x.id).includes(id)) {
+ await ctx.prisma.offersEducation.delete({
+ where: {
+ id,
+ },
+ });
}
- })
+ }
for (const edu of input.background.educations) {
if (edu.id) {
+ // Update existing education
await ctx.prisma.offersEducation.update({
data: {
endDate: edu.endDate,
@@ -545,32 +659,52 @@ export const offersProfileRouter = createRouter()
type: edu.type,
},
where: {
- id: edu.id
- }
- })
+ id: edu.id,
+ },
+ });
} else {
+ // Create new education
await ctx.prisma.offersBackground.update({
data: {
educations: {
- create:
- {
+ create: {
endDate: edu.endDate,
field: edu.field,
school: edu.school,
startDate: edu.startDate,
type: edu.type,
- }
- }
+ },
+ },
},
where: {
- id: input.background.id
- }
- })
+ id: input.background.id,
+ },
+ });
+ }
+ }
+
+ // Delete experiences
+ const experiencesId = (
+ await ctx.prisma.offersExperience.findMany({
+ where: {
+ backgroundId: input.background.id,
+ },
+ })
+ ).map((x) => x.id);
+
+ for (const id of experiencesId) {
+ if (!input.background.experiences.map((x) => x.id).includes(id)) {
+ await ctx.prisma.offersExperience.delete({
+ where: {
+ id,
+ },
+ });
}
}
for (const exp of input.background.experiences) {
if (exp.id) {
+ // Update existing experience
await ctx.prisma.offersExperience.update({
data: {
companyId: exp.companyId,
@@ -579,36 +713,117 @@ export const offersProfileRouter = createRouter()
specialization: exp.specialization,
},
where: {
- id: exp.id
- }
- })
+ id: exp.id,
+ },
+ });
if (exp.monthlySalary) {
await ctx.prisma.offersCurrency.update({
data: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ exp.monthlySalary.value,
+ exp.monthlySalary.currency,
+ baseCurrencyString,
+ ),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
where: {
- id: exp.monthlySalary.id
- }
- })
+ id: exp.monthlySalary.id,
+ },
+ });
}
if (exp.totalCompensation) {
await ctx.prisma.offersCurrency.update({
data: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ exp.totalCompensation.value,
+ exp.totalCompensation.currency,
+ baseCurrencyString,
+ ),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
where: {
- id: exp.totalCompensation.id
- }
- })
+ id: exp.totalCompensation.id,
+ },
+ });
}
} else if (!exp.id) {
- if (exp.jobType === 'FULLTIME' && exp.totalCompensation?.currency !== undefined && exp.totalCompensation.value !== undefined) {
- if (exp.companyId) {
+ // Create new experience
+ if (exp.jobType === JobType.FULLTIME) {
+ if (exp.totalCompensation?.currency != null &&
+ exp.totalCompensation?.value != null) {
+ if (exp.companyId) {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ experiences: {
+ create: {
+ company: {
+ connect: {
+ id: exp.companyId,
+ },
+ },
+ durationInMonths: exp.durationInMonths,
+ jobType: exp.jobType,
+ level: exp.level,
+ location: exp.location,
+ specialization: exp.specialization,
+ title: exp.title,
+ totalCompensation: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ exp.totalCompensation.value,
+ exp.totalCompensation.currency,
+ baseCurrencyString,
+ ),
+ currency: exp.totalCompensation.currency,
+ value: exp.totalCompensation.value,
+ },
+ },
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ } else {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ experiences: {
+ create: {
+ durationInMonths: exp.durationInMonths,
+ jobType: exp.jobType,
+ level: exp.level,
+ location: exp.location,
+ specialization: exp.specialization,
+ title: exp.title,
+ totalCompensation: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ exp.totalCompensation.value,
+ exp.totalCompensation.currency,
+ baseCurrencyString,
+ ),
+ currency: exp.totalCompensation.currency,
+ value: exp.totalCompensation.value,
+ },
+ },
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ }
+ } else if (exp.companyId) {
await ctx.prisma.offersBackground.update({
data: {
experiences: {
@@ -621,21 +836,16 @@ export const offersProfileRouter = createRouter()
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
level: exp.level,
+ location: exp.location,
specialization: exp.specialization,
title: exp.title,
- totalCompensation: {
- create: {
- currency: exp.totalCompensation?.currency,
- value: exp.totalCompensation?.value,
- },
- },
},
- }
+ },
},
where: {
- id: input.background.id
- }
- })
+ id: input.background.id,
+ },
+ });
} else {
await ctx.prisma.offersBackground.update({
data: {
@@ -644,29 +854,85 @@ export const offersProfileRouter = createRouter()
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
level: exp.level,
+ location: exp.location,
specialization: exp.specialization,
title: exp.title,
- totalCompensation: {
- create: {
- currency: exp.totalCompensation?.currency,
- value: exp.totalCompensation?.value,
- },
- },
- }
- }
+ },
+ },
},
where: {
- id: input.background.id
- }
- })
+ id: input.background.id,
+ },
+ });
}
- }
- else if (
- exp.jobType === 'INTERN' &&
- exp.monthlySalary?.currency !== undefined &&
- exp.monthlySalary.value !== undefined
- ) {
- if (exp.companyId) {
+ } else if (exp.jobType === JobType.INTERN) {
+ if (exp.monthlySalary?.currency != null &&
+ exp.monthlySalary?.value != null) {
+ if (exp.companyId) {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ experiences: {
+ create: {
+ company: {
+ connect: {
+ id: exp.companyId,
+ },
+ },
+ durationInMonths: exp.durationInMonths,
+ jobType: exp.jobType,
+ location: exp.location,
+ monthlySalary: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ exp.monthlySalary.value,
+ exp.monthlySalary.currency,
+ baseCurrencyString,
+ ),
+ currency: exp.monthlySalary.currency,
+ value: exp.monthlySalary.value,
+ },
+ },
+ specialization: exp.specialization,
+ title: exp.title,
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ } else {
+ await ctx.prisma.offersBackground.update({
+ data: {
+ experiences: {
+ create: {
+ durationInMonths: exp.durationInMonths,
+ jobType: exp.jobType,
+ location: exp.location,
+ monthlySalary: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ exp.monthlySalary.value,
+ exp.monthlySalary.currency,
+ baseCurrencyString,
+ ),
+ currency: exp.monthlySalary.currency,
+ value: exp.monthlySalary.value,
+ },
+ },
+ specialization: exp.specialization,
+ title: exp.title,
+ },
+ },
+ },
+ where: {
+ id: input.background.id,
+ },
+ });
+ }
+ } else if (exp.companyId) {
await ctx.prisma.offersBackground.update({
data: {
experiences: {
@@ -678,21 +944,16 @@ export const offersProfileRouter = createRouter()
},
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
- monthlySalary: {
- create: {
- currency: exp.monthlySalary?.currency,
- value: exp.monthlySalary?.value,
- },
- },
+ location: exp.location,
specialization: exp.specialization,
title: exp.title,
- }
- }
+ },
+ },
},
where: {
- id: input.background.id
- }
- })
+ id: input.background.id,
+ },
+ });
} else {
await ctx.prisma.offersBackground.update({
data: {
@@ -700,185 +961,232 @@ export const offersProfileRouter = createRouter()
create: {
durationInMonths: exp.durationInMonths,
jobType: exp.jobType,
- monthlySalary: {
- create: {
- currency: exp.monthlySalary?.currency,
- value: exp.monthlySalary?.value,
- },
- },
+ location: exp.location,
specialization: exp.specialization,
title: exp.title,
- }
- }
+ },
+ },
},
where: {
- id: input.background.id
- }
- })
+ id: input.background.id,
+ },
+ });
}
}
}
+ }
+
+ // Delete specific yoes
+ const yoesId = (
+ await ctx.prisma.offersSpecificYoe.findMany({
+ where: {
+ backgroundId: input.background.id,
+ },
+ })
+ ).map((x) => x.id);
+ for (const id of yoesId) {
+ if (!input.background.specificYoes.map((x) => x.id).includes(id)) {
+ await ctx.prisma.offersSpecificYoe.delete({
+ where: {
+ id,
+ },
+ });
+ }
}
for (const yoe of input.background.specificYoes) {
if (yoe.id) {
+ // Update existing yoe
await ctx.prisma.offersSpecificYoe.update({
data: {
- ...yoe
+ ...yoe,
},
where: {
- id: yoe.id
- }
- })
+ id: yoe.id,
+ },
+ });
} else {
+ // Create new yoe
await ctx.prisma.offersBackground.update({
data: {
specificYoes: {
- create:
- {
+ create: {
domain: yoe.domain,
yoe: yoe.yoe,
- }
- }
+ },
+ },
},
where: {
- id: input.background.id
- }
- })
+ id: input.background.id,
+ },
+ });
}
}
+ // Delete specific offers
+ const offers = (
+ await ctx.prisma.offersOffer.findMany({
+ where: {
+ profileId: input.id,
+ },
+ })
+ ).map((x) => x.id);
+
+ for (const id of offers) {
+ if (!input.offers.map((x) => x.id).includes(id)) {
+ await ctx.prisma.offersOffer.delete({
+ where: {
+ id,
+ },
+ });
+ }
+ }
+
+ // Update remaining offers
for (const offerToUpdate of input.offers) {
if (offerToUpdate.id) {
+ // Update existing offer
await ctx.prisma.offersOffer.update({
data: {
comments: offerToUpdate.comments,
companyId: offerToUpdate.companyId,
+ jobType:
+ offerToUpdate.jobType === JobType.FULLTIME
+ ? JobType.FULLTIME
+ : JobType.INTERN,
location: offerToUpdate.location,
monthYearReceived: offerToUpdate.monthYearReceived,
negotiationStrategy: offerToUpdate.negotiationStrategy,
},
where: {
- id: offerToUpdate.id
- }
- })
-
- if (offerToUpdate.jobType === "INTERN" || offerToUpdate.jobType === "FULLTIME") {
- await ctx.prisma.offersOffer.update({
- data: {
- jobType: offerToUpdate.jobType
- },
- where: {
- id: offerToUpdate.id
- }
- })
- }
+ id: offerToUpdate.id,
+ },
+ });
- if (offerToUpdate.OffersIntern?.monthlySalary) {
+ if (offerToUpdate.offersIntern?.monthlySalary != null) {
await ctx.prisma.offersIntern.update({
data: {
- internshipCycle: offerToUpdate.OffersIntern.internshipCycle ?? undefined,
- specialization: offerToUpdate.OffersIntern.specialization,
- startYear: offerToUpdate.OffersIntern.startYear ?? undefined,
- title: offerToUpdate.OffersIntern.title,
+ internshipCycle:
+ offerToUpdate.offersIntern.internshipCycle ?? undefined,
+ specialization: offerToUpdate.offersIntern.specialization,
+ startYear: offerToUpdate.offersIntern.startYear ?? undefined,
+ title: offerToUpdate.offersIntern.title,
},
where: {
- id: offerToUpdate.OffersIntern.id,
- }
- })
+ id: offerToUpdate.offersIntern.id,
+ },
+ });
await ctx.prisma.offersCurrency.update({
data: {
- currency: offerToUpdate.OffersIntern.monthlySalary.currency,
- value: offerToUpdate.OffersIntern.monthlySalary.value
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersIntern.monthlySalary.value,
+ offerToUpdate.offersIntern.monthlySalary.currency,
+ baseCurrencyString,
+ ),
+ currency: offerToUpdate.offersIntern.monthlySalary.currency,
+ value: offerToUpdate.offersIntern.monthlySalary.value,
},
where: {
- id: offerToUpdate.OffersIntern.monthlySalary.id
- }
- })
+ id: offerToUpdate.offersIntern.monthlySalary.id,
+ },
+ });
}
- if (offerToUpdate.OffersFullTime?.totalCompensation) {
+ if (offerToUpdate.offersFullTime?.totalCompensation != null) {
await ctx.prisma.offersFullTime.update({
data: {
- level: offerToUpdate.OffersFullTime.level ?? undefined,
- specialization: offerToUpdate.OffersFullTime.specialization,
- title: offerToUpdate.OffersFullTime.title,
+ level: offerToUpdate.offersFullTime.level ?? undefined,
+ specialization: offerToUpdate.offersFullTime.specialization,
+ title: offerToUpdate.offersFullTime.title,
},
where: {
- id: offerToUpdate.OffersFullTime.id,
- }
- })
- if (offerToUpdate.OffersFullTime.baseSalary) {
+ id: offerToUpdate.offersFullTime.id,
+ },
+ });
+ if (offerToUpdate.offersFullTime.baseSalary != null) {
await ctx.prisma.offersCurrency.update({
data: {
- currency: offerToUpdate.OffersFullTime.baseSalary.currency,
- value: offerToUpdate.OffersFullTime.baseSalary.value
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersFullTime.baseSalary.value,
+ offerToUpdate.offersFullTime.baseSalary.currency,
+ baseCurrencyString,
+ ),
+ currency: offerToUpdate.offersFullTime.baseSalary.currency,
+ value: offerToUpdate.offersFullTime.baseSalary.value,
},
where: {
- id: offerToUpdate.OffersFullTime.baseSalary.id
- }
- })
+ id: offerToUpdate.offersFullTime.baseSalary.id,
+ },
+ });
}
- if (offerToUpdate.OffersFullTime.bonus) {
+ if (offerToUpdate.offersFullTime.bonus) {
await ctx.prisma.offersCurrency.update({
data: {
- currency: offerToUpdate.OffersFullTime.bonus.currency,
- value: offerToUpdate.OffersFullTime.bonus.value
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersFullTime.bonus.value,
+ offerToUpdate.offersFullTime.bonus.currency,
+ baseCurrencyString,
+ ),
+ currency: offerToUpdate.offersFullTime.bonus.currency,
+ value: offerToUpdate.offersFullTime.bonus.value,
},
where: {
- id: offerToUpdate.OffersFullTime.bonus.id
- }
- })
+ id: offerToUpdate.offersFullTime.bonus.id,
+ },
+ });
}
- if (offerToUpdate.OffersFullTime.stocks) {
+ if (offerToUpdate.offersFullTime.stocks) {
await ctx.prisma.offersCurrency.update({
data: {
- currency: offerToUpdate.OffersFullTime.stocks.currency,
- value: offerToUpdate.OffersFullTime.stocks.value
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersFullTime.stocks.value,
+ offerToUpdate.offersFullTime.stocks.currency,
+ baseCurrencyString,
+ ),
+ currency: offerToUpdate.offersFullTime.stocks.currency,
+ value: offerToUpdate.offersFullTime.stocks.value,
},
where: {
- id: offerToUpdate.OffersFullTime.stocks.id
- }
- })
+ id: offerToUpdate.offersFullTime.stocks.id,
+ },
+ });
}
await ctx.prisma.offersCurrency.update({
data: {
- currency: offerToUpdate.OffersFullTime.totalCompensation.currency,
- value: offerToUpdate.OffersFullTime.totalCompensation.value
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersFullTime.totalCompensation.value,
+ offerToUpdate.offersFullTime.totalCompensation.currency,
+ baseCurrencyString,
+ ),
+ currency:
+ offerToUpdate.offersFullTime.totalCompensation.currency,
+ value: offerToUpdate.offersFullTime.totalCompensation.value,
},
where: {
- id: offerToUpdate.OffersFullTime.totalCompensation.id
- }
- })
+ id: offerToUpdate.offersFullTime.totalCompensation.id,
+ },
+ });
}
} else {
+ // Create new offer
if (
- offerToUpdate.jobType === "INTERN" &&
- offerToUpdate.OffersIntern &&
- offerToUpdate.OffersIntern.internshipCycle &&
- offerToUpdate.OffersIntern.monthlySalary?.currency &&
- offerToUpdate.OffersIntern.monthlySalary.value &&
- offerToUpdate.OffersIntern.startYear
+ offerToUpdate.jobType === JobType.INTERN &&
+ offerToUpdate.offersIntern &&
+ offerToUpdate.offersIntern.internshipCycle != null &&
+ offerToUpdate.offersIntern.monthlySalary?.currency != null &&
+ offerToUpdate.offersIntern.monthlySalary?.value != null &&
+ offerToUpdate.offersIntern.startYear != null
) {
await ctx.prisma.offersProfile.update({
data: {
offers: {
create: {
- OffersIntern: {
- create: {
- internshipCycle: offerToUpdate.OffersIntern.internshipCycle,
- monthlySalary: {
- create: {
- currency: offerToUpdate.OffersIntern.monthlySalary?.currency,
- value: offerToUpdate.OffersIntern.monthlySalary?.value,
- },
- },
- specialization: offerToUpdate.OffersIntern.specialization,
- startYear: offerToUpdate.OffersIntern.startYear,
- title: offerToUpdate.OffersIntern.title,
- },
- },
comments: offerToUpdate.comments,
company: {
connect: {
@@ -889,131 +1197,155 @@ export const offersProfileRouter = createRouter()
location: offerToUpdate.location,
monthYearReceived: offerToUpdate.monthYearReceived,
negotiationStrategy: offerToUpdate.negotiationStrategy,
- }
- }
+ offersIntern: {
+ create: {
+ internshipCycle:
+ offerToUpdate.offersIntern.internshipCycle,
+ monthlySalary: {
+ create: {
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersIntern.monthlySalary.value,
+ offerToUpdate.offersIntern.monthlySalary
+ .currency,
+ baseCurrencyString,
+ ),
+ currency:
+ offerToUpdate.offersIntern.monthlySalary
+ .currency,
+ value:
+ offerToUpdate.offersIntern.monthlySalary.value,
+ },
+ },
+ specialization:
+ offerToUpdate.offersIntern.specialization,
+ startYear: offerToUpdate.offersIntern.startYear,
+ title: offerToUpdate.offersIntern.title,
+ },
+ },
+ },
+ },
},
where: {
id: input.id,
- }
- })
+ },
+ });
}
if (
- offerToUpdate.jobType === 'FULLTIME' &&
- offerToUpdate.OffersFullTime &&
- offerToUpdate.OffersFullTime.baseSalary?.currency &&
- offerToUpdate.OffersFullTime.baseSalary?.value &&
- offerToUpdate.OffersFullTime.bonus?.currency &&
- offerToUpdate.OffersFullTime.bonus?.value &&
- offerToUpdate.OffersFullTime.stocks?.currency &&
- offerToUpdate.OffersFullTime.stocks?.value &&
- offerToUpdate.OffersFullTime.totalCompensation?.currency &&
- offerToUpdate.OffersFullTime.totalCompensation?.value &&
- offerToUpdate.OffersFullTime.level
+ offerToUpdate.jobType === JobType.FULLTIME &&
+ offerToUpdate.offersFullTime &&
+ offerToUpdate.offersFullTime.baseSalary?.currency != null &&
+ offerToUpdate.offersFullTime.baseSalary?.value != null &&
+ offerToUpdate.offersFullTime.bonus?.currency != null &&
+ offerToUpdate.offersFullTime.bonus?.value != null &&
+ offerToUpdate.offersFullTime.stocks?.currency != null &&
+ offerToUpdate.offersFullTime.stocks?.value != null &&
+ offerToUpdate.offersFullTime.totalCompensation?.currency !=
+ null &&
+ offerToUpdate.offersFullTime.totalCompensation?.value != null &&
+ offerToUpdate.offersFullTime.level != null
) {
await ctx.prisma.offersProfile.update({
data: {
offers: {
create: {
- OffersFullTime: {
+ comments: offerToUpdate.comments,
+ company: {
+ connect: {
+ id: offerToUpdate.companyId,
+ },
+ },
+ jobType: offerToUpdate.jobType,
+ location: offerToUpdate.location,
+ monthYearReceived: offerToUpdate.monthYearReceived,
+ negotiationStrategy: offerToUpdate.negotiationStrategy,
+ offersFullTime: {
create: {
baseSalary: {
create: {
- currency: offerToUpdate.OffersFullTime.baseSalary?.currency,
- value: offerToUpdate.OffersFullTime.baseSalary?.value,
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersFullTime.baseSalary.value,
+ offerToUpdate.offersFullTime.baseSalary
+ .currency,
+ baseCurrencyString,
+ ),
+ currency:
+ offerToUpdate.offersFullTime.baseSalary
+ .currency,
+ value:
+ offerToUpdate.offersFullTime.baseSalary.value,
},
},
bonus: {
create: {
- currency: offerToUpdate.OffersFullTime.bonus?.currency,
- value: offerToUpdate.OffersFullTime.bonus?.value,
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersFullTime.bonus.value,
+ offerToUpdate.offersFullTime.bonus.currency,
+ baseCurrencyString,
+ ),
+ currency:
+ offerToUpdate.offersFullTime.bonus.currency,
+ value: offerToUpdate.offersFullTime.bonus.value,
},
},
- level: offerToUpdate.OffersFullTime.level,
- specialization: offerToUpdate.OffersFullTime.specialization,
+ level: offerToUpdate.offersFullTime.level,
+ specialization:
+ offerToUpdate.offersFullTime.specialization,
stocks: {
create: {
- currency: offerToUpdate.OffersFullTime.stocks?.currency,
- value: offerToUpdate.OffersFullTime.stocks?.value,
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersFullTime.stocks.value,
+ offerToUpdate.offersFullTime.stocks.currency,
+ baseCurrencyString,
+ ),
+ currency:
+ offerToUpdate.offersFullTime.stocks.currency,
+ value: offerToUpdate.offersFullTime.stocks.value,
},
},
- title: offerToUpdate.OffersFullTime.title,
+ title: offerToUpdate.offersFullTime.title,
totalCompensation: {
create: {
- currency: offerToUpdate.OffersFullTime.totalCompensation?.currency,
- value: offerToUpdate.OffersFullTime.totalCompensation?.value,
+ baseCurrency: baseCurrencyString,
+ baseValue: await convert(
+ offerToUpdate.offersFullTime.totalCompensation
+ .value,
+ offerToUpdate.offersFullTime.totalCompensation
+ .currency,
+ baseCurrencyString,
+ ),
+ currency:
+ offerToUpdate.offersFullTime.totalCompensation
+ .currency,
+ value:
+ offerToUpdate.offersFullTime.totalCompensation
+ .value,
},
},
},
},
- comments: offerToUpdate.comments,
- company: {
- connect: {
- id: offerToUpdate.companyId,
- },
- },
- jobType: offerToUpdate.jobType,
- location: offerToUpdate.location,
- monthYearReceived: offerToUpdate.monthYearReceived,
- negotiationStrategy: offerToUpdate.negotiationStrategy,
- }
- }
+ },
+ },
},
where: {
id: input.id,
- }
- })
+ },
+ });
}
}
}
- // TODO: add analysis to profile object then return
+
const result = await ctx.prisma.offersProfile.findFirst({
- include: {
- background: {
- include: {
- educations: true,
- experiences: {
- include: {
- company: true,
- monthlySalary: true,
- totalCompensation: true,
- },
- },
- specificYoes: true,
- },
- },
- discussion: {
- include: {
- replies: true,
- replyingTo: true,
- user: true
- },
- },
- offers: {
- include: {
- OffersFullTime: {
- include: {
- baseSalary: true,
- bonus: true,
- stocks: true,
- totalCompensation: true,
- },
- },
- OffersIntern: {
- include: {
- monthlySalary: true,
- },
- },
- company: true,
- },
- },
- },
where: {
id: input.id,
},
});
if (result) {
- return exclude(computeIsEditable(result, input.token), 'editToken')
+ return createOfferProfileResponseMapper(result, input.token);
}
throw new trpc.TRPCError({
@@ -1036,9 +1368,9 @@ export const offersProfileRouter = createRouter()
}),
async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({
- where: {
- id: input.profileId,
- },
+ where: {
+ id: input.profileId,
+ },
});
const profileEditToken = profile?.editToken;
@@ -1048,25 +1380,21 @@ export const offersProfileRouter = createRouter()
data: {
user: {
connect: {
- id: input.userId
- }
- }
+ id: input.userId,
+ },
+ },
},
where: {
- id: input.profileId
- }
- })
+ id: input.profileId,
+ },
+ });
- return {
- id: updated.id,
- profileName: updated.profileName,
- userId: updated.userId
- }
+ return addToProfileResponseMapper(updated);
}
throw new trpc.TRPCError({
code: 'UNAUTHORIZED',
message: 'Invalid token.',
});
- }
+ },
});
diff --git a/apps/portal/src/server/router/offers/offers.ts b/apps/portal/src/server/router/offers/offers.ts
index 9aaeb487..8b2321e6 100644
--- a/apps/portal/src/server/router/offers/offers.ts
+++ b/apps/portal/src/server/router/offers/offers.ts
@@ -1,8 +1,29 @@
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
+import {
+ dashboardOfferDtoMapper,
+ getOffersResponseMapper,
+} from '~/mappers/offers-mappers';
+import { convertWithDate } from '~/utils/offers/currency/currencyExchange';
+import { Currency } from '~/utils/offers/currency/CurrencyEnum';
+import { createValidationRegex } from '~/utils/offers/zodRegex';
+
import { createRouter } from '../context';
+const getOrder = (prefix: string) => {
+ if (prefix === '+') {
+ return 'asc';
+ }
+ return 'desc';
+};
+
+const sortingKeysMap = {
+ monthYearReceived: 'monthYearReceived',
+ totalCompensation: 'totalCompensation',
+ totalYoe: 'totalYoe',
+};
+
const yoeCategoryMap: Record = {
0: 'Internship',
1: 'Fresh Grad',
@@ -12,46 +33,52 @@ const yoeCategoryMap: Record = {
const getYoeRange = (yoeCategory: number) => {
return yoeCategoryMap[yoeCategory] === 'Fresh Grad'
- ? { maxYoe: 3, minYoe: 0 }
+ ? { maxYoe: 2, minYoe: 0 }
: yoeCategoryMap[yoeCategory] === 'Mid'
- ? { maxYoe: 7, minYoe: 4 }
+ ? { maxYoe: 5, minYoe: 3 }
: yoeCategoryMap[yoeCategory] === 'Senior'
- ? { maxYoe: null, minYoe: 8 }
- : null;
-};
-
-const ascOrder = '+';
-const descOrder = '-';
-const sortingKeys = ['monthYearReceived', 'totalCompensation', 'totalYoe'];
-
-const createSortByValidationRegex = () => {
- const startsWithPlusOrMinusOnly = '^[+-]{1}';
- const sortingKeysRegex = sortingKeys.join('|');
- return new RegExp(startsWithPlusOrMinusOnly + '(' + sortingKeysRegex + ')');
+ ? { maxYoe: 100, minYoe: 6 }
+ : null; // Internship
};
export const offersRouter = createRouter().query('list', {
input: z.object({
companyId: z.string().nullish(),
+ currency: z.string().nullish(),
dateEnd: z.date().nullish(),
dateStart: z.date().nullish(),
- limit: z.number().nonnegative(),
+ limit: z.number().positive(),
location: z.string(),
offset: z.number().nonnegative(),
- salaryMax: z.number().nullish(),
+ salaryMax: z.number().nonnegative().nullish(),
salaryMin: z.number().nonnegative().nullish(),
- sortBy: z.string().regex(createSortByValidationRegex()).nullish(),
+ sortBy: z
+ .string()
+ .regex(createValidationRegex(Object.keys(sortingKeysMap), '[+-]{1}'))
+ .nullish(),
title: z.string().nullish(),
yoeCategory: z.number().min(0).max(3),
+ yoeMax: z.number().max(100).nullish(),
+ yoeMin: z.number().min(0).nullish(),
}),
async resolve({ ctx, input }) {
const yoeRange = getYoeRange(input.yoeCategory);
+ const yoeMin = input.yoeMin ? input.yoeMin : yoeRange?.minYoe;
+ const yoeMax = input.yoeMax ? input.yoeMax : yoeRange?.maxYoe;
+
+ if (!input.sortBy) {
+ input.sortBy = '-' + sortingKeysMap.monthYearReceived;
+ }
+
+ const order = getOrder(input.sortBy.charAt(0));
+ const sortingKey = input.sortBy.substring(1);
let data = !yoeRange
? await ctx.prisma.offersOffer.findMany({
// Internship
include: {
- OffersFullTime: {
+ company: true,
+ offersFullTime: {
include: {
baseSalary: true,
bonus: true,
@@ -59,100 +86,103 @@ export const offersRouter = createRouter().query('list', {
totalCompensation: true,
},
},
- OffersIntern: {
+ offersIntern: {
include: {
monthlySalary: true,
},
},
- company: true,
profile: {
include: {
background: true,
},
},
},
+ orderBy:
+ sortingKey === sortingKeysMap.monthYearReceived
+ ? {
+ monthYearReceived: order,
+ }
+ : sortingKey === sortingKeysMap.totalCompensation
+ ? {
+ offersIntern: {
+ monthlySalary: {
+ baseValue: order,
+ },
+ },
+ }
+ : sortingKey === sortingKeysMap.totalYoe
+ ? {
+ profile: {
+ background: {
+ totalYoe: order,
+ },
+ },
+ }
+ : undefined,
where: {
AND: [
{
- location: input.location,
+ location:
+ input.location.length === 0 ? undefined : input.location,
},
{
- OffersIntern: {
+ offersIntern: {
isNot: null,
},
},
{
- OffersFullTime: {
- is: null,
+ offersIntern: {
+ title:
+ input.title && input.title.length !== 0
+ ? input.title
+ : undefined,
},
},
- ],
- },
- })
- : yoeRange.maxYoe
- ? await ctx.prisma.offersOffer.findMany({
- // Junior, Mid
- include: {
- OffersFullTime: {
- include: {
- baseSalary: true,
- bonus: true,
- stocks: true,
- totalCompensation: true,
- },
- },
- OffersIntern: {
- include: {
- monthlySalary: true,
- },
- },
- company: true,
- profile: {
- include: {
- background: true,
- },
- },
- },
- where: {
- AND: [
{
- location: input.location,
+ offersIntern: {
+ monthlySalary: {
+ baseValue: {
+ gte: input.salaryMin ?? undefined,
+ lte: input.salaryMax ?? undefined,
+ },
+ },
+ },
},
{
- OffersIntern: {
+ offersFullTime: {
is: null,
},
},
{
- OffersFullTime: {
- isNot: null,
- },
+ companyId:
+ input.companyId && input.companyId.length !== 0
+ ? input.companyId
+ : undefined,
},
{
profile: {
background: {
totalYoe: {
- gte: yoeRange.minYoe,
+ gte: yoeMin,
+ lte: yoeMax,
},
},
},
},
{
- profile: {
- background: {
- totalYoe: {
- gte: yoeRange.maxYoe,
- },
- },
+ monthYearReceived: {
+ gte: input.dateStart ?? undefined,
+ lte: input.dateEnd ?? undefined,
},
},
],
},
})
: await ctx.prisma.offersOffer.findMany({
- // Senior
+ // Junior, Mid, Senior
include: {
- OffersFullTime: {
+ company: true,
+ offersFullTime: {
include: {
baseSalary: true,
bonus: true,
@@ -160,192 +190,153 @@ export const offersRouter = createRouter().query('list', {
totalCompensation: true,
},
},
- OffersIntern: {
+ offersIntern: {
include: {
monthlySalary: true,
},
},
- company: true,
profile: {
include: {
background: true,
},
},
},
+ orderBy:
+ sortingKey === sortingKeysMap.monthYearReceived
+ ? {
+ monthYearReceived: order,
+ }
+ : sortingKey === sortingKeysMap.totalCompensation
+ ? {
+ offersFullTime: {
+ totalCompensation: {
+ baseValue: order,
+ },
+ },
+ }
+ : sortingKey === sortingKeysMap.totalYoe
+ ? {
+ profile: {
+ background: {
+ totalYoe: order,
+ },
+ },
+ }
+ : undefined,
where: {
AND: [
{
- location: input.location,
+ location:
+ input.location.length === 0 ? undefined : input.location,
},
{
- OffersIntern: {
+ offersIntern: {
is: null,
},
},
{
- OffersFullTime: {
+ offersFullTime: {
isNot: null,
},
},
+ {
+ offersFullTime: {
+ title:
+ input.title && input.title.length !== 0
+ ? input.title
+ : undefined,
+ },
+ },
+ {
+ offersFullTime: {
+ totalCompensation: {
+ baseValue: {
+ gte: input.salaryMin ?? undefined,
+ lte: input.salaryMax ?? undefined,
+ },
+ },
+ },
+ },
+ {
+ companyId:
+ input.companyId && input.companyId.length !== 0
+ ? input.companyId
+ : undefined,
+ },
{
profile: {
background: {
totalYoe: {
- gte: yoeRange.minYoe,
+ gte: yoeMin,
+ lte: yoeMax,
},
},
},
},
+ {
+ monthYearReceived: {
+ gte: input.dateStart ?? undefined,
+ lte: input.dateEnd ?? undefined,
+ },
+ },
],
},
});
- // FILTERING
- data = data.filter((offer) => {
- let validRecord = true;
-
- if (input.companyId) {
- validRecord = validRecord && offer.company.id === input.companyId;
- }
-
- if (input.title) {
- validRecord =
- validRecord &&
- (offer.OffersFullTime?.title === input.title ||
- offer.OffersIntern?.title === input.title);
- }
-
- if (input.dateStart && input.dateEnd) {
- validRecord =
- validRecord &&
- offer.monthYearReceived.getTime() >= input.dateStart.getTime() &&
- offer.monthYearReceived.getTime() <= input.dateEnd.getTime();
- }
-
- if (input.salaryMin && input.salaryMax) {
- const salary = offer.OffersFullTime?.totalCompensation.value
- ? offer.OffersFullTime?.totalCompensation.value
- : offer.OffersIntern?.monthlySalary.value;
-
- if (!salary) {
- throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'Total Compensation or Salary not found',
- });
- }
-
- validRecord =
- validRecord && salary >= input.salaryMin && salary <= input.salaryMax;
- }
-
- return validRecord;
- });
-
- // SORTING
- data = data.sort((offer1, offer2) => {
- const defaultReturn =
- offer2.monthYearReceived.getTime() - offer1.monthYearReceived.getTime();
-
- if (!input.sortBy) {
- return defaultReturn;
- }
-
- const order = input.sortBy.charAt(0);
- const sortingKey = input.sortBy.substring(1);
-
- if (order === ascOrder) {
- return (() => {
- if (sortingKey === 'monthYearReceived') {
- return (
- offer1.monthYearReceived.getTime() -
- offer2.monthYearReceived.getTime()
+ // CONVERTING
+ const currency = input.currency?.toUpperCase();
+ if (currency != null && currency in Currency) {
+ data = await Promise.all(
+ data.map(async (offer) => {
+ if (offer.offersFullTime?.totalCompensation != null) {
+ offer.offersFullTime.totalCompensation.value =
+ await convertWithDate(
+ offer.offersFullTime.totalCompensation.value,
+ offer.offersFullTime.totalCompensation.currency,
+ currency,
+ offer.offersFullTime.totalCompensation.updatedAt,
+ );
+ offer.offersFullTime.totalCompensation.currency = currency;
+ offer.offersFullTime.baseSalary.value = await convertWithDate(
+ offer.offersFullTime.baseSalary.value,
+ offer.offersFullTime.baseSalary.currency,
+ currency,
+ offer.offersFullTime.baseSalary.updatedAt,
);
- }
-
- if (sortingKey === 'totalCompensation') {
- const salary1 = offer1.OffersFullTime?.totalCompensation.value
- ? offer1.OffersFullTime?.totalCompensation.value
- : offer1.OffersIntern?.monthlySalary.value;
-
- const salary2 = offer2.OffersFullTime?.totalCompensation.value
- ? offer2.OffersFullTime?.totalCompensation.value
- : offer2.OffersIntern?.monthlySalary.value;
-
- if (!salary1 || !salary2) {
- throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'Total Compensation or Salary not found',
- });
- }
-
- return salary1 - salary2;
- }
-
- if (sortingKey === 'totalYoe') {
- const yoe1 = offer1.profile.background?.totalYoe;
- const yoe2 = offer2.profile.background?.totalYoe;
-
- if (!yoe1 || !yoe2) {
- throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'Total years of experience not found',
- });
- }
-
- return yoe1 - yoe2;
- }
-
- return defaultReturn;
- })();
- }
-
- if (order === descOrder) {
- return (() => {
- if (sortingKey === 'monthYearReceived') {
- return (
- offer2.monthYearReceived.getTime() -
- offer1.monthYearReceived.getTime()
+ offer.offersFullTime.baseSalary.currency = currency;
+ offer.offersFullTime.stocks.value = await convertWithDate(
+ offer.offersFullTime.stocks.value,
+ offer.offersFullTime.stocks.currency,
+ currency,
+ offer.offersFullTime.stocks.updatedAt,
);
+ offer.offersFullTime.stocks.currency = currency;
+ offer.offersFullTime.bonus.value = await convertWithDate(
+ offer.offersFullTime.bonus.value,
+ offer.offersFullTime.bonus.currency,
+ currency,
+ offer.offersFullTime.bonus.updatedAt,
+ );
+ offer.offersFullTime.bonus.currency = currency;
+ } else if (offer.offersIntern?.monthlySalary != null) {
+ offer.offersIntern.monthlySalary.value = await convertWithDate(
+ offer.offersIntern.monthlySalary.value,
+ offer.offersIntern.monthlySalary.currency,
+ currency,
+ offer.offersIntern.monthlySalary.updatedAt,
+ );
+ offer.offersIntern.monthlySalary.currency = currency;
+ } else {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Total Compensation or Salary not found',
+ });
}
- if (sortingKey === 'totalCompensation') {
- const salary1 = offer1.OffersFullTime?.totalCompensation.value
- ? offer1.OffersFullTime?.totalCompensation.value
- : offer1.OffersIntern?.monthlySalary.value;
-
- const salary2 = offer2.OffersFullTime?.totalCompensation.value
- ? offer2.OffersFullTime?.totalCompensation.value
- : offer2.OffersIntern?.monthlySalary.value;
-
- if (!salary1 || !salary2) {
- throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'Total Compensation or Salary not found',
- });
- }
-
- return salary2 - salary1;
- }
-
- if (sortingKey === 'totalYoe') {
- const yoe1 = offer1.profile.background?.totalYoe;
- const yoe2 = offer2.profile.background?.totalYoe;
-
- if (!yoe1 || !yoe2) {
- throw new TRPCError({
- code: 'NOT_FOUND',
- message: 'Total years of experience not found',
- });
- }
-
- return yoe2 - yoe1;
- }
-
- return defaultReturn;
- })();
- }
- return defaultReturn;
- });
+ return offer;
+ }),
+ );
+ }
const startRecordIndex: number = input.limit * input.offset;
const endRecordIndex: number =
@@ -354,14 +345,14 @@ export const offersRouter = createRouter().query('list', {
: data.length;
const paginatedData = data.slice(startRecordIndex, endRecordIndex);
- return {
- data: paginatedData,
- paging: {
- currPage: input.offset,
- numOfItemsInPage: paginatedData.length,
+ return getOffersResponseMapper(
+ paginatedData.map((offer) => dashboardOfferDtoMapper(offer)),
+ {
+ currentPage: input.offset,
+ numOfItems: paginatedData.length,
numOfPages: Math.ceil(data.length / input.limit),
- totalNumberOfOffers: data.length,
+ totalItems: data.length,
},
- };
+ );
},
});
diff --git a/apps/portal/src/server/router/questions-answer-comment-router.ts b/apps/portal/src/server/router/questions-answer-comment-router.ts
index f75195c7..7ed3e6e2 100644
--- a/apps/portal/src/server/router/questions-answer-comment-router.ts
+++ b/apps/portal/src/server/router/questions-answer-comment-router.ts
@@ -27,7 +27,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
createdAt: 'desc',
},
where: {
- ...input,
+ answerId : input.answerId,
},
});
return questionAnswerCommentsData.map((data) => {
@@ -69,9 +69,12 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { answerId, content } = input;
+
return await ctx.prisma.questionsAnswerComment.create({
data: {
- ...input,
+ answerId,
+ content,
userId,
},
});
@@ -99,9 +102,10 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
});
}
+ const { content } = input;
return await ctx.prisma.questionsAnswerComment.update({
data: {
- ...input,
+ content,
},
where: {
id: input.id,
@@ -160,10 +164,13 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { answerCommentId, vote } = input;
+
return await ctx.prisma.questionsAnswerCommentVote.create({
data: {
- ...input,
+ answerCommentId,
userId,
+ vote,
},
});
},
diff --git a/apps/portal/src/server/router/questions-answer-router.ts b/apps/portal/src/server/router/questions-answer-router.ts
index 7dadbeb4..5d386854 100644
--- a/apps/portal/src/server/router/questions-answer-router.ts
+++ b/apps/portal/src/server/router/questions-answer-router.ts
@@ -12,6 +12,8 @@ export const questionsAnswerRouter = createProtectedRouter()
questionId: z.string(),
}),
async resolve({ ctx, input }) {
+ const { questionId } = input;
+
const answersData = await ctx.prisma.questionsAnswer.findMany({
include: {
_count: {
@@ -31,7 +33,7 @@ export const questionsAnswerRouter = createProtectedRouter()
createdAt: 'desc',
},
where: {
- ...input,
+ questionId,
},
});
return answersData.map((data) => {
@@ -132,9 +134,12 @@ export const questionsAnswerRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { content, questionId } = input;
+
return await ctx.prisma.questionsAnswer.create({
data: {
- ...input,
+ content,
+ questionId,
userId,
},
});
@@ -222,10 +227,13 @@ export const questionsAnswerRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { answerId, vote } = input;
+
return await ctx.prisma.questionsAnswerVote.create({
data: {
- ...input,
+ answerId,
userId,
+ vote,
},
});
},
diff --git a/apps/portal/src/server/router/questions-question-comment-router.ts b/apps/portal/src/server/router/questions-question-comment-router.ts
index f3985f31..e2f786f9 100644
--- a/apps/portal/src/server/router/questions-question-comment-router.ts
+++ b/apps/portal/src/server/router/questions-question-comment-router.ts
@@ -12,6 +12,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
questionId: z.string(),
}),
async resolve({ ctx, input }) {
+ const { questionId } = input;
const questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({
include: {
@@ -27,7 +28,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
createdAt: 'desc',
},
where: {
- ...input,
+ questionId,
},
});
return questionCommentsData.map((data) => {
@@ -68,9 +69,12 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { content, questionId } = input;
+
return await ctx.prisma.questionsQuestionComment.create({
data: {
- ...input,
+ content,
+ questionId,
userId,
},
});
@@ -84,6 +88,8 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { content } = input;
+
const questionCommentToUpdate =
await ctx.prisma.questionsQuestionComment.findUnique({
where: {
@@ -100,7 +106,7 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
return await ctx.prisma.questionsQuestionComment.update({
data: {
- ...input,
+ content,
},
where: {
id: input.id,
@@ -158,11 +164,13 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { questionCommentId, vote } = input;
return await ctx.prisma.questionsQuestionCommentVote.create({
data: {
- ...input,
+ questionCommentId,
userId,
+ vote,
},
});
},
diff --git a/apps/portal/src/server/router/questions-question-encounter-router.ts b/apps/portal/src/server/router/questions-question-encounter-router.ts
new file mode 100644
index 00000000..2894fdf4
--- /dev/null
+++ b/apps/portal/src/server/router/questions-question-encounter-router.ts
@@ -0,0 +1,135 @@
+import { z } from 'zod';
+import { TRPCError } from '@trpc/server';
+
+import { createProtectedRouter } from './context';
+
+import type { AggregatedQuestionEncounter } from '~/types/questions';
+
+export const questionsQuestionEncounterRouter = createProtectedRouter()
+ .query('getAggregatedEncounters', {
+ input: z.object({
+ questionId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const questionEncountersData = await ctx.prisma.questionsQuestionEncounter.findMany({
+ include: {
+ company : true,
+ },
+ where: {
+ ...input,
+ },
+ });
+
+ const companyCounts: Record = {};
+ const locationCounts: Record = {};
+ const roleCounts:Record = {};
+
+ for (let i = 0; i < questionEncountersData.length; i++) {
+ const encounter = questionEncountersData[i];
+
+ if (!(encounter.company!.name in companyCounts)) {
+ companyCounts[encounter.company!.name] = 1;
+ }
+ companyCounts[encounter.company!.name] += 1;
+
+ if (!(encounter.location in locationCounts)) {
+ locationCounts[encounter.location] = 1;
+ }
+ locationCounts[encounter.location] += 1;
+
+ if (!(encounter.role in roleCounts)) {
+ roleCounts[encounter.role] = 1;
+ }
+ roleCounts[encounter.role] += 1;
+
+ }
+
+ const questionEncounter:AggregatedQuestionEncounter = {
+ companyCounts,
+ locationCounts,
+ roleCounts,
+ }
+ return questionEncounter;
+ }
+ })
+ .mutation('create', {
+ input: z.object({
+ companyId: z.string(),
+ location: z.string(),
+ questionId: z.string(),
+ role: z.string(),
+ seenAt: z.date()
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ return await ctx.prisma.questionsQuestionEncounter.create({
+ data: {
+ ...input,
+ userId,
+ },
+ });
+ },
+ })
+ .mutation('update', {
+ //
+ input: z.object({
+ companyId: z.string().optional(),
+ id: z.string(),
+ location: z.string().optional(),
+ role: z.string().optional(),
+ seenAt: z.date().optional(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const questionEncounterToUpdate = await ctx.prisma.questionsQuestionEncounter.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (questionEncounterToUpdate?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsQuestionEncounter.update({
+ data: {
+ ...input,
+ },
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+
+ const questionEncounterToDelete = await ctx.prisma.questionsQuestionEncounter.findUnique({
+ where: {
+ id: input.id,
+ },
+ });
+
+ if (questionEncounterToDelete?.id !== userId) {
+ throw new TRPCError({
+ code: 'UNAUTHORIZED',
+ message: 'User have no authorization to record.',
+ });
+ }
+
+ return await ctx.prisma.questionsQuestionEncounter.delete({
+ where: {
+ id: input.id,
+ },
+ });
+ },
+ });
\ No newline at end of file
diff --git a/apps/portal/src/server/router/questions-question-router.ts b/apps/portal/src/server/router/questions-question-router.ts
index 353ac4a3..dafb8ae5 100644
--- a/apps/portal/src/server/router/questions-question-router.ts
+++ b/apps/portal/src/server/router/questions-question-router.ts
@@ -5,18 +5,33 @@ import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import type { Question } from '~/types/questions';
+import { SortOrder, SortType } from '~/types/questions.d';
+
+const TWO_WEEK_IN_MS = 12096e5;
export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', {
input: z.object({
- companies: z.string().array(),
- endDate: z.date(),
+ companyNames: z.string().array(),
+ endDate: z.date().default(new Date()),
locations: z.string().array(),
+ pageSize: z.number().default(50),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
- startDate: z.date().optional(),
+ sortOrder: z.nativeEnum(SortOrder),
+ sortType: z.nativeEnum(SortType),
+ startDate: z.date().default(new Date(Date.now() - TWO_WEEK_IN_MS)),
}),
async resolve({ ctx, input }) {
+ const sortCondition =
+ input.sortType === SortType.TOP
+ ? {
+ upvotes: input.sortOrder,
+ }
+ : {
+ lastSeenAt: input.sortOrder,
+ };
+
const questionsData = await ctx.prisma.questionsQuestion.findMany({
include: {
_count: {
@@ -41,7 +56,7 @@ export const questionsQuestionRouter = createProtectedRouter()
votes: true,
},
orderBy: {
- createdAt: 'desc',
+ ...sortCondition,
},
where: {
...(input.questionTypes.length > 0
@@ -51,63 +66,73 @@ export const questionsQuestionRouter = createProtectedRouter()
},
}
: {}),
+ encounters: {
+ some: {
+ seenAt: {
+ gte: input.startDate,
+ lte: input.endDate,
+ },
+ ...(input.companyNames.length > 0
+ ? {
+ company: {
+ name: {
+ in: input.companyNames,
+ },
+ },
+ }
+ : {}),
+ ...(input.locations.length > 0
+ ? {
+ location: {
+ in: input.locations,
+ },
+ }
+ : {}),
+ ...(input.roles.length > 0
+ ? {
+ role: {
+ in: input.roles,
+ },
+ }
+ : {}),
+ },
+ },
},
});
- return questionsData
- .filter((data) => {
- for (let i = 0; i < data.encounters.length; i++) {
- const encounter = data.encounters[i];
- const matchCompany =
- input.companies.length === 0 ||
- input.companies.includes(encounter.company);
- const matchLocation =
- input.locations.length === 0 ||
- input.locations.includes(encounter.location);
- const matchRole =
- input.roles.length === 0 || input.roles.includes(encounter.role);
- const matchDate =
- (!input.startDate || encounter.seenAt >= input.startDate) &&
- encounter.seenAt <= input.endDate;
- if (matchCompany && matchLocation && matchRole && matchDate) {
- return true;
- }
- }
- return false;
- })
- .map((data) => {
- const votes: number = data.votes.reduce(
- (previousValue: number, currentValue) => {
- let result: number = previousValue;
+ return questionsData.map((data) => {
+ const votes: number = data.votes.reduce(
+ (previousValue: number, currentValue) => {
+ let result: number = previousValue;
- switch (currentValue.vote) {
- case Vote.UPVOTE:
- result += 1;
- break;
- case Vote.DOWNVOTE:
- result -= 1;
- break;
- }
- return result;
- },
- 0,
- );
+ switch (currentValue.vote) {
+ case Vote.UPVOTE:
+ result += 1;
+ break;
+ case Vote.DOWNVOTE:
+ result -= 1;
+ break;
+ }
+ return result;
+ },
+ 0,
+ );
- const question: Question = {
- company: data.encounters[0].company,
- content: data.content,
- id: data.id,
- location: data.encounters[0].location ?? 'Unknown location',
- numAnswers: data._count.answers,
- numComments: data._count.comments,
- numVotes: votes,
- role: data.encounters[0].role ?? 'Unknown role',
- seenAt: data.encounters[0].seenAt,
- type: data.questionType,
- updatedAt: data.updatedAt,
- user: data.user?.name ?? '',
- };
- return question;
- });
+ const question: Question = {
+ company: data.encounters[0].company!.name ?? 'Unknown company',
+ content: data.content,
+ id: data.id,
+ location: data.encounters[0].location ?? 'Unknown location',
+ numAnswers: data._count.answers,
+ numComments: data._count.comments,
+ numVotes: votes,
+ role: data.encounters[0].role ?? 'Unknown role',
+ seenAt: data.encounters[0].seenAt,
+ type: data.questionType,
+ updatedAt: data.updatedAt,
+ user: data.user?.name ?? '',
+ };
+ return question;
+ });
},
})
.query('getQuestionById', {
@@ -166,7 +191,7 @@ export const questionsQuestionRouter = createProtectedRouter()
);
const question: Question = {
- company: questionData.encounters[0].company,
+ company: questionData.encounters[0].company!.name ?? 'Unknown company',
content: questionData.content,
id: questionData.id,
location: questionData.encounters[0].location ?? 'Unknown location',
@@ -209,7 +234,7 @@ export const questionsQuestionRouter = createProtectedRouter()
})
.mutation('create', {
input: z.object({
- company: z.string(),
+ companyId: z.string(),
content: z.string(),
location: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType),
@@ -219,38 +244,31 @@ export const questionsQuestionRouter = createProtectedRouter()
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
- const question = await ctx.prisma.questionsQuestion.create({
+ return await ctx.prisma.questionsQuestion.create({
data: {
content: input.content,
encounters: {
- create: [
- {
- company: input.company,
- location: input.location,
- role: input.role,
- seenAt: input.seenAt,
- userId,
+ create: {
+ company: {
+ connect: {
+ id: input.companyId,
+ },
+ },
+ location: input.location,
+ role: input.role,
+ seenAt: input.seenAt,
+ user: {
+ connect: {
+ id: userId,
+ },
},
- ],
+ },
},
+ lastSeenAt: input.seenAt,
questionType: input.questionType,
userId,
},
});
-
- // Create question encounter
- await ctx.prisma.questionsQuestionEncounter.create({
- data: {
- company: input.company,
- location: input.location,
- questionId: question.id,
- role: input.role,
- seenAt: input.seenAt,
- userId,
- },
- });
-
- return question;
},
})
.mutation('update', {
@@ -276,9 +294,12 @@ export const questionsQuestionRouter = createProtectedRouter()
});
}
+ const { content, questionType } = input;
+
return await ctx.prisma.questionsQuestion.update({
data: {
- ...input,
+ content,
+ questionType,
},
where: {
id: input.id,
@@ -336,13 +357,30 @@ export const questionsQuestionRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
+ const { questionId, vote } = input;
- return await ctx.prisma.questionsQuestionVote.create({
- data: {
- ...input,
- userId,
- },
- });
+ const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
+
+ const [questionVote] = await ctx.prisma.$transaction([
+ ctx.prisma.questionsQuestionVote.create({
+ data: {
+ questionId,
+ userId,
+ vote,
+ },
+ }),
+ ctx.prisma.questionsQuestion.update({
+ data: {
+ upvotes: {
+ increment: incrementValue,
+ },
+ },
+ where: {
+ id: questionId,
+ },
+ }),
+ ]);
+ return questionVote;
},
})
.mutation('updateVote', {
@@ -367,14 +405,30 @@ export const questionsQuestionRouter = createProtectedRouter()
});
}
- return await ctx.prisma.questionsQuestionVote.update({
- data: {
- vote,
- },
- where: {
- id,
- },
- });
+ const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
+
+ const [questionVote] = await ctx.prisma.$transaction([
+ ctx.prisma.questionsQuestionVote.update({
+ data: {
+ vote,
+ },
+ where: {
+ id,
+ },
+ }),
+ ctx.prisma.questionsQuestion.update({
+ data: {
+ upvotes: {
+ increment: incrementValue,
+ },
+ },
+ where: {
+ id: voteToUpdate.questionId,
+ },
+ }),
+ ]);
+
+ return questionVote;
},
})
.mutation('deleteVote', {
@@ -397,10 +451,25 @@ export const questionsQuestionRouter = createProtectedRouter()
});
}
- return await ctx.prisma.questionsQuestionVote.delete({
- where: {
- id: input.id,
- },
- });
+ const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
+
+ const [questionVote] = await ctx.prisma.$transaction([
+ ctx.prisma.questionsQuestionVote.delete({
+ where: {
+ id: input.id,
+ },
+ }),
+ ctx.prisma.questionsQuestion.update({
+ data: {
+ upvotes: {
+ increment: incrementValue,
+ },
+ },
+ where: {
+ id: voteToDelete.questionId,
+ },
+ }),
+ ]);
+ return questionVote;
},
});
diff --git a/apps/portal/src/server/router/resumes/resumes-comments-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-router.ts
index fb9840ac..548b4a06 100644
--- a/apps/portal/src/server/router/resumes/resumes-comments-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-comments-router.ts
@@ -9,17 +9,23 @@ export const resumeCommentsRouter = createRouter().query('list', {
resumeId: z.string(),
}),
async resolve({ ctx, input }) {
- const userId = ctx.session?.user?.id;
const { resumeId } = input;
// For this resume, we retrieve every comment's information, along with:
// The user's name and image to render
- // Number of votes, and whether the user (if-any) has voted
const comments = await ctx.prisma.resumesComment.findMany({
include: {
- _count: {
- select: {
- votes: true,
+ children: {
+ include: {
+ user: {
+ select: {
+ image: true,
+ name: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'asc',
},
},
user: {
@@ -28,31 +34,40 @@ export const resumeCommentsRouter = createRouter().query('list', {
name: true,
},
},
- votes: {
- take: 1,
- where: {
- userId,
- },
- },
},
orderBy: {
createdAt: 'desc',
},
where: {
- resumeId,
+ AND: [{ resumeId }, { parentId: null }],
},
});
return comments.map((data) => {
- const hasVoted = data.votes.length > 0;
- const numVotes = data._count.votes;
+ const children: Array = data.children.map((child) => {
+ return {
+ children: [],
+ createdAt: child.createdAt,
+ description: child.description,
+ id: child.id,
+ parentId: data.id,
+ resumeId: child.resumeId,
+ section: child.section,
+ updatedAt: child.updatedAt,
+ user: {
+ image: child.user.image,
+ name: child.user.name,
+ userId: child.userId,
+ },
+ };
+ });
const comment: ResumeComment = {
+ children,
createdAt: data.createdAt,
description: data.description,
- hasVoted,
id: data.id,
- numVotes,
+ parentId: data.parentId,
resumeId: data.resumeId,
section: data.section,
updatedAt: data.updatedAt,
diff --git a/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
index 479c9390..d061a4c2 100644
--- a/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-comments-user-router.ts
@@ -10,9 +10,8 @@ type ResumeCommentInput = Readonly<{
userId: string;
}>;
-export const resumesCommentsUserRouter = createProtectedRouter().mutation(
- 'create',
- {
+export const resumesCommentsUserRouter = createProtectedRouter()
+ .mutation('create', {
input: z.object({
education: z.string(),
experience: z.string(),
@@ -22,7 +21,7 @@ export const resumesCommentsUserRouter = createProtectedRouter().mutation(
skills: z.string(),
}),
async resolve({ ctx, input }) {
- const userId = ctx.session?.user?.id;
+ const userId = ctx.session.user.id;
const { resumeId, education, experience, general, projects, skills } =
input;
@@ -50,5 +49,44 @@ export const resumesCommentsUserRouter = createProtectedRouter().mutation(
data: comments,
});
},
- },
-);
+ })
+ .mutation('update', {
+ input: z.object({
+ description: z.string(),
+ id: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const { id, description } = input;
+
+ return await ctx.prisma.resumesComment.update({
+ data: {
+ description,
+ },
+ where: {
+ id,
+ },
+ });
+ },
+ })
+ .mutation('reply', {
+ input: z.object({
+ description: z.string(),
+ parentId: z.string(),
+ resumeId: z.string(),
+ section: z.nativeEnum(ResumesSection),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session.user.id;
+ const { description, parentId, resumeId, section } = input;
+
+ return await ctx.prisma.resumesComment.create({
+ data: {
+ description,
+ parentId,
+ resumeId,
+ section,
+ userId,
+ },
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts
new file mode 100644
index 00000000..5d508c35
--- /dev/null
+++ b/apps/portal/src/server/router/resumes/resumes-comments-votes-router.ts
@@ -0,0 +1,38 @@
+import { z } from 'zod';
+import type { ResumesCommentVote } from '@prisma/client';
+import { Vote } from '@prisma/client';
+
+import { createRouter } from '../context';
+
+import type { ResumeCommentVote } from '~/types/resume-comments';
+
+export const resumesCommentsVotesRouter = createRouter().query('list', {
+ input: z.object({
+ commentId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session?.user?.id;
+ const { commentId } = input;
+
+ const votes = await ctx.prisma.resumesCommentVote.findMany({
+ where: {
+ commentId,
+ },
+ });
+
+ let userVote: ResumesCommentVote | null = null;
+ let numVotes = 0;
+
+ votes.forEach((vote) => {
+ numVotes += vote.value === Vote.UPVOTE ? 1 : -1;
+ userVote = vote.userId === userId ? vote : null;
+ });
+
+ const resumeCommentVote: ResumeCommentVote = {
+ numVotes,
+ userVote,
+ };
+
+ return resumeCommentVote;
+ },
+});
diff --git a/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts b/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts
new file mode 100644
index 00000000..7dbeec77
--- /dev/null
+++ b/apps/portal/src/server/router/resumes/resumes-comments-votes-user-router.ts
@@ -0,0 +1,45 @@
+import { z } from 'zod';
+import { Vote } from '@prisma/client';
+
+import { createProtectedRouter } from '../context';
+
+export const resumesCommentsVotesUserRouter = createProtectedRouter()
+ .mutation('upsert', {
+ input: z.object({
+ commentId: z.string(),
+ value: z.nativeEnum(Vote),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session.user.id;
+ const { commentId, value } = input;
+
+ await ctx.prisma.resumesCommentVote.upsert({
+ create: {
+ commentId,
+ userId,
+ value,
+ },
+ update: {
+ value,
+ },
+ where: {
+ userId_commentId: { commentId, userId },
+ },
+ });
+ },
+ })
+ .mutation('delete', {
+ input: z.object({
+ commentId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session.user.id;
+ const { commentId } = input;
+
+ await ctx.prisma.resumesCommentVote.delete({
+ where: {
+ userId_commentId: { commentId, userId },
+ },
+ });
+ },
+ });
diff --git a/apps/portal/src/server/router/resumes/resumes-resume-router.ts b/apps/portal/src/server/router/resumes/resumes-resume-router.ts
index 34d7d6f0..fe3b1d57 100644
--- a/apps/portal/src/server/router/resumes/resumes-resume-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-resume-router.ts
@@ -1,4 +1,5 @@
import { z } from 'zod';
+import { Vote } from '@prisma/client';
import { createRouter } from '../context';
@@ -6,7 +7,38 @@ import type { Resume } from '~/types/resume';
export const resumesRouter = createRouter()
.query('findAll', {
- async resolve({ ctx }) {
+ input: z.object({
+ experienceFilters: z.string().array(),
+ locationFilters: z.string().array(),
+ numComments: z.number().optional(),
+ roleFilters: z.string().array(),
+ searchValue: z.string(),
+ skip: z.number(),
+ sortOrder: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const {
+ roleFilters,
+ locationFilters,
+ experienceFilters,
+ sortOrder,
+ numComments,
+ skip,
+ searchValue,
+ } = input;
+ const userId = ctx.session?.user?.id;
+ const totalRecords = await ctx.prisma.resumesResume.count({
+ where: {
+ ...(numComments === 0 && {
+ comments: {
+ none: {},
+ },
+ }),
+ experience: { in: experienceFilters },
+ location: { in: locationFilters },
+ role: { in: roleFilters },
+ },
+ });
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
@@ -15,22 +47,53 @@ export const resumesRouter = createRouter()
stars: true,
},
},
+ comments: true,
+ stars: {
+ where: {
+ OR: {
+ userId,
+ },
+ },
+ },
user: {
select: {
name: true,
},
},
},
- orderBy: {
- createdAt: 'desc',
+ orderBy:
+ sortOrder === 'latest'
+ ? {
+ createdAt: 'desc',
+ }
+ : sortOrder === 'popular'
+ ? {
+ stars: {
+ _count: 'desc',
+ },
+ }
+ : { comments: { _count: 'desc' } },
+ skip,
+ take: 10,
+ where: {
+ ...(numComments === 0 && {
+ comments: {
+ none: {},
+ },
+ }),
+ experience: { in: experienceFilters },
+ location: { in: locationFilters },
+ role: { in: roleFilters },
+ title: { contains: searchValue, mode: 'insensitive' },
},
});
- return resumesData.map((r) => {
+ const mappedResumeData = resumesData.map((r) => {
const resume: Resume = {
additionalInfo: r.additionalInfo,
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
+ isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
numStars: r._count.stars,
@@ -41,6 +104,7 @@ export const resumesRouter = createRouter()
};
return resume;
});
+ return { mappedResumeData, totalRecords };
},
})
.query('findOne', {
@@ -78,4 +142,109 @@ export const resumesRouter = createRouter()
},
});
},
+ })
+ .query('findUserReviewedResumeCount', {
+ input: z.object({
+ userId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ return await ctx.prisma.resumesResume.count({
+ where: {
+ // User has commented on this resume
+ comments: {
+ some: {
+ userId: input.userId,
+ },
+ },
+ // Not user's own resume
+ userId: {
+ not: input.userId,
+ },
+ },
+ });
+ },
+ })
+ .query('findUserMaxResumeUpvoteCount', {
+ input: z.object({
+ userId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const highestUpvotedResume = await ctx.prisma.resumesResume.findFirst({
+ orderBy: {
+ stars: {
+ _count: 'desc',
+ },
+ },
+ select: {
+ _count: {
+ select: {
+ stars: true,
+ },
+ },
+ },
+ where: {
+ userId: input.userId,
+ },
+ });
+
+ return highestUpvotedResume?._count?.stars ?? 0;
+ },
+ })
+ .query('findUserTopUpvotedCommentCount', {
+ input: z.object({
+ userId: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const resumes = await ctx.prisma.resumesResume.findMany({
+ select: {
+ comments: {
+ select: {
+ userId: true,
+ votes: {
+ select: {
+ value: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ let topUpvotedCommentCount = 0;
+
+ for (const resume of resumes) {
+ // Set minimum upvote count >= 5 to qualify
+ let highestVoteCount = 5;
+
+ // Get Map of {userId, voteCount} for each comment
+ const commentUpvotePairs = [];
+ for (const comment of resume.comments) {
+ const { userId, votes } = comment;
+ let voteCount = 0;
+ for (const vote of votes) {
+ if (vote.value === Vote.UPVOTE) {
+ voteCount++;
+ } else {
+ voteCount--;
+ }
+ }
+ if (voteCount >= highestVoteCount) {
+ highestVoteCount = voteCount;
+ commentUpvotePairs.push({ userId, voteCount });
+ }
+ }
+
+ // Filter to get the userIds with the highest vote counts
+ const userIds = commentUpvotePairs
+ .filter((pair) => pair.voteCount === highestVoteCount)
+ .map((pair) => pair.userId);
+
+ // Increment if input userId is the highest voted comment
+ if (userIds.includes(input.userId)) {
+ topUpvotedCommentCount++;
+ }
+ }
+
+ return topUpvotedCommentCount;
+ },
});
diff --git a/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts b/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
index b443c3e2..10bf5c81 100644
--- a/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
+++ b/apps/portal/src/server/router/resumes/resumes-resume-user-router.ts
@@ -5,29 +5,81 @@ import { createProtectedRouter } from '../context';
import type { Resume } from '~/types/resume';
export const resumesResumeUserRouter = createProtectedRouter()
- .mutation('create', {
+ .mutation('upsert', {
// TODO: Use enums for experience, location, role
input: z.object({
additionalInfo: z.string().optional(),
experience: z.string(),
+ id: z.string().optional(),
location: z.string(),
role: z.string(),
title: z.string(),
url: z.string(),
}),
async resolve({ ctx, input }) {
- const userId = ctx.session?.user.id;
- return await ctx.prisma.resumesResume.create({
- data: {
- ...input,
+ const userId = ctx.session.user.id;
+
+ return await ctx.prisma.resumesResume.upsert({
+ create: {
+ additionalInfo: input.additionalInfo,
+ experience: input.experience,
+ location: input.location,
+ role: input.role,
+ title: input.title,
+ url: input.url,
+ userId,
+ },
+ update: {
+ additionalInfo: input.additionalInfo,
+ experience: input.experience,
+ location: input.location,
+ role: input.role,
+ title: input.title,
+ url: input.url,
userId,
},
+ where: {
+ id: input.id ?? '',
+ },
});
},
})
.query('findUserStarred', {
- async resolve({ ctx }) {
- const userId = ctx.session?.user?.id;
+ input: z.object({
+ experienceFilters: z.string().array(),
+ locationFilters: z.string().array(),
+ numComments: z.number().optional(),
+ roleFilters: z.string().array(),
+ searchValue: z.string(),
+ skip: z.number(),
+ sortOrder: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session.user.id;
+ const {
+ roleFilters,
+ locationFilters,
+ experienceFilters,
+ searchValue,
+ sortOrder,
+ numComments,
+ skip,
+ } = input;
+ const totalRecords = await ctx.prisma.resumesStar.count({
+ where: {
+ resume: {
+ ...(numComments === 0 && {
+ comments: {
+ none: {},
+ },
+ }),
+ experience: { in: experienceFilters },
+ location: { in: locationFilters },
+ role: { in: roleFilters },
+ },
+ userId,
+ },
+ });
const resumeStarsData = await ctx.prisma.resumesStar.findMany({
include: {
resume: {
@@ -46,19 +98,53 @@ export const resumesResumeUserRouter = createProtectedRouter()
},
},
},
- orderBy: {
- createdAt: 'desc',
- },
+ orderBy:
+ sortOrder === 'latest'
+ ? {
+ resume: {
+ createdAt: 'desc',
+ },
+ }
+ : sortOrder === 'popular'
+ ? {
+ resume: {
+ stars: {
+ _count: 'desc',
+ },
+ },
+ }
+ : {
+ resume: {
+ comments: {
+ _count: 'desc',
+ },
+ },
+ },
+ skip,
+ take: 10,
where: {
+ resume: {
+ ...(numComments === 0 && {
+ comments: {
+ none: {},
+ },
+ }),
+ experience: { in: experienceFilters },
+ location: { in: locationFilters },
+ role: { in: roleFilters },
+ title: { contains: searchValue, mode: 'insensitive' },
+ },
userId,
},
});
- return resumeStarsData.map((rs) => {
+
+ const mappedResumeData = resumeStarsData.map((rs) => {
const resume: Resume = {
additionalInfo: rs.resume.additionalInfo,
createdAt: rs.resume.createdAt,
experience: rs.resume.experience,
id: rs.resume.id,
+ isStarredByUser: true,
location: rs.resume.location,
numComments: rs.resume._count.comments,
numStars: rs.resume._count.stars,
@@ -69,11 +155,43 @@ export const resumesResumeUserRouter = createProtectedRouter()
};
return resume;
});
+ return { mappedResumeData, totalRecords };
},
})
.query('findUserCreated', {
- async resolve({ ctx }) {
- const userId = ctx.session?.user?.id;
+ input: z.object({
+ experienceFilters: z.string().array(),
+ locationFilters: z.string().array(),
+ numComments: z.number().optional(),
+ roleFilters: z.string().array(),
+ searchValue: z.string(),
+ skip: z.number(),
+ sortOrder: z.string(),
+ }),
+ async resolve({ ctx, input }) {
+ const userId = ctx.session.user.id;
+ const {
+ roleFilters,
+ locationFilters,
+ experienceFilters,
+ sortOrder,
+ searchValue,
+ numComments,
+ skip,
+ } = input;
+ const totalRecords = await ctx.prisma.resumesResume.count({
+ where: {
+ ...(numComments === 0 && {
+ comments: {
+ none: {},
+ },
+ }),
+ experience: { in: experienceFilters },
+ location: { in: locationFilters },
+ role: { in: roleFilters },
+ userId,
+ },
+ });
const resumesData = await ctx.prisma.resumesResume.findMany({
include: {
_count: {
@@ -82,25 +200,51 @@ export const resumesResumeUserRouter = createProtectedRouter()
stars: true,
},
},
+ stars: {
+ where: {
+ userId,
+ },
+ },
user: {
select: {
name: true,
},
},
},
- orderBy: {
- createdAt: 'desc',
- },
+ orderBy:
+ sortOrder === 'latest'
+ ? {
+ createdAt: 'desc',
+ }
+ : sortOrder === 'popular'
+ ? {
+ stars: {
+ _count: 'desc',
+ },
+ }
+ : { comments: { _count: 'desc' } },
+ skip,
+ take: 10,
where: {
+ ...(numComments === 0 && {
+ comments: {
+ none: {},
+ },
+ }),
+ experience: { in: experienceFilters },
+ location: { in: locationFilters },
+ role: { in: roleFilters },
+ title: { contains: searchValue, mode: 'insensitive' },
userId,
},
});
- return resumesData.map((r) => {
+ const mappedResumeData = resumesData.map((r) => {
const resume: Resume = {
additionalInfo: r.additionalInfo,
createdAt: r.createdAt,
experience: r.experience,
id: r.id,
+ isStarredByUser: r.stars.length > 0,
location: r.location,
numComments: r._count.comments,
numStars: r._count.stars,
@@ -111,19 +255,6 @@ export const resumesResumeUserRouter = createProtectedRouter()
};
return resume;
});
- },
- })
- .query('isResumeStarred', {
- input: z.object({
- resumeId: z.string(),
- }),
- async resolve({ ctx, input }) {
- const userId = ctx.session?.user?.id;
- const { resumeId } = input;
- return await ctx.prisma.resumesStar.findUnique({
- where: {
- userId_resumeId: { resumeId, userId },
- },
- });
+ return { mappedResumeData, totalRecords };
},
});
diff --git a/apps/portal/src/types/offers-profile.d.ts b/apps/portal/src/types/offers-profile.d.ts
deleted file mode 100644
index 0990612a..00000000
--- a/apps/portal/src/types/offers-profile.d.ts
+++ /dev/null
@@ -1,113 +0,0 @@
-export type offersProfile = {
- background?: background | null;
- createdAt: Date;
-// Discussions: Array;
- editToken: string;
- id: string;
- offers: Array;
- profileName: string;
- userId?: string | null;
-};
-
-export type background = {
- educations: Array;
- experiences: Array;
- id: string;
- offersProfileId: string;
- specificYoes: Array;
- totalYoe?: number | null;
-}
-
-export type experience = {
- backgroundId: string;
- company?: company | null;
- companyId?: string | null;
- durationInMonths?: number | null;
- id: string;
- jobType?: string | null;
- level?: string | null;
- monthlySalary?: valuation | null;
- monthlySalaryId?: string | null;
- specialization?: string | null;
- title?: string | null;
- totalCompensation?: valuation | null;
- totalCompensationId?: string | null;
-}
-
-export type company = {
- createdAt: Date;
- description: string | null;
- id: string;
- logoUrl: string | null;
- name: string;
- slug: string;
- updatedAt: Date
-}
-
-export type valuation = {
- currency: string;
- id: string;
- value: number;
-}
-
-export type education = {
- backgroundId: string;
- endDate?: Date | null;
- field?: string | null;
- id: string;
- school?: string | null;
- startDate?: Date | null;
- type?: string | null;
-}
-
-export type specificYoe = {
- backgroundId: string;
- domain: string;
- id: string;
- yoe: number;
-}
-
-export type offers = {
- OffersFullTime?: offersFullTime | null;
- OffersIntern?: offersIntern | null;
- comments?: string | null;
- company: company;
- companyId: string;
- id: string;
- jobType: string;
- location: string;
- monthYearReceived: string;
- negotiationStrategy?: string | null;
- offersFullTimeId?: string | null;
- offersInternId?: string | null;
- profileId: string;
-}
-
-export type offersFullTime = {
- baseSalary: valuation;
- baseSalaryId: string;
- bonus: valuation;
- bonusId: string;
- id: string;
- level: string;
- specialization: string;
- stocks: valuation;
- stocksId: string;
- title?: string | null;
- totalCompensation: valuation;
- totalCompensationId: string;
-}
-
-export type offersIntern = {
- id: string;
- internshipCycle: string;
- monthlySalary: valuation;
- monthlySalaryId: string;
- specialization: string;
- startYear: number;
-}
-
-// TODO: fill in next time
-export type discussion = {
- id: string;
-}
\ No newline at end of file
diff --git a/apps/portal/src/types/offers.d.ts b/apps/portal/src/types/offers.d.ts
new file mode 100644
index 00000000..e2ca2d2f
--- /dev/null
+++ b/apps/portal/src/types/offers.d.ts
@@ -0,0 +1,189 @@
+import type { JobType } from '@prisma/client';
+
+export type Profile = {
+ analysis: ProfileAnalysis?;
+ background: Background?;
+ editToken: string?;
+ id: string;
+ isEditable: boolean;
+ offers: Array;
+ profileName: string;
+};
+
+export type Background = {
+ educations: Array;
+ experiences: Array;
+ id: string;
+ specificYoes: Array;
+ totalYoe: number;
+};
+
+export type Experience = {
+ company: OffersCompany?;
+ durationInMonths: number?;
+ id: string;
+ jobType: JobType?;
+ level: string?;
+ location: string?;
+ monthlySalary: Valuation?;
+ specialization: string?;
+ title: string?;
+ totalCompensation: Valuation?;
+};
+
+export type OffersCompany = {
+ createdAt: Date;
+ description: string;
+ id: string;
+ logoUrl: string;
+ name: string;
+ slug: string;
+ updatedAt: Date;
+};
+
+export type Valuation = {
+ baseCurrency: string;
+ baseValue: number;
+ currency: string;
+ value: number;
+};
+
+export type Education = {
+ endDate: Date?;
+ field: string?;
+ id: string;
+ school: string?;
+ startDate: Date?;
+ type: string?;
+};
+
+export type SpecificYoe = {
+ domain: string;
+ id: string;
+ yoe: number;
+};
+
+export type DashboardOffer = {
+ company: OffersCompany;
+ id: string;
+ income: Valuation;
+ monthYearReceived: Date;
+ profileId: string;
+ title: string;
+ totalYoe: number;
+};
+
+export type ProfileOffer = {
+ comments: string;
+ company: OffersCompany;
+ id: string;
+ jobType: JobType;
+ location: string;
+ monthYearReceived: Date;
+ negotiationStrategy: string;
+ offersFullTime: FullTime?;
+ offersIntern: Intern?;
+};
+
+export type FullTime = {
+ baseSalary: Valuation;
+ bonus: Valuation;
+ id: string;
+ level: string;
+ specialization: string;
+ stocks: Valuation;
+ title: string;
+ totalCompensation: Valuation;
+};
+
+export type Intern = {
+ id: string;
+ internshipCycle: string;
+ monthlySalary: Valuation;
+ specialization: string;
+ startYear: number;
+ title: string;
+};
+
+export type Reply = {
+ createdAt: Date;
+ id: string;
+ message: string;
+ replies: Array?;
+ replyingToId: string?;
+ user: User?;
+};
+
+export type User = {
+ email: string?;
+ emailVerified: Date?;
+ id: string;
+ image: string?;
+ name: string?;
+};
+
+export type GetOffersResponse = {
+ data: Array;
+ paging: Paging;
+};
+
+export type Paging = {
+ currentPage: number;
+ numOfItems: number;
+ numOfPages: number;
+ totalItems: number;
+};
+
+export type CreateOfferProfileResponse = {
+ id: string;
+ token: string;
+};
+
+export type OffersDiscussion = {
+ data: Array;
+};
+
+export type ProfileAnalysis = {
+ companyAnalysis: Array;
+ id: string;
+ overallAnalysis: Analysis;
+ overallHighestOffer: AnalysisHighestOffer;
+ profileId: string;
+};
+
+export type Analysis = {
+ noOfOffers: number;
+ percentile: number;
+ topPercentileOffers: Array;
+};
+
+export type AnalysisHighestOffer = {
+ company: OffersCompany;
+ id: string;
+ level: string;
+ location: string;
+ specialization: string;
+ totalYoe: number;
+};
+
+export type AnalysisOffer = {
+ company: OffersCompany;
+ id: string;
+ income: Valuation;
+ jobType: JobType;
+ level: string;
+ location: string;
+ monthYearReceived: Date;
+ negotiationStrategy: string;
+ previousCompanies: Array;
+ profileName: string;
+ specialization: string;
+ title: string;
+ totalYoe: number;
+};
+
+export type AddToProfileResponse = {
+ id: string;
+ profileName: string;
+ userId: string;
+};
diff --git a/apps/portal/src/types/questions.d.ts b/apps/portal/src/types/questions.d.ts
index 41286249..d75d82de 100644
--- a/apps/portal/src/types/questions.d.ts
+++ b/apps/portal/src/types/questions.d.ts
@@ -1,3 +1,5 @@
+import type { QuestionsQuestionType } from '@prisma/client';
+
export type Question = {
// TODO: company, location, role maps
company: string;
@@ -9,11 +11,17 @@ export type Question = {
numVotes: number;
role: string;
seenAt: Date;
- type: stringl;
+ type: QuestionsQuestionType;
updatedAt: Date;
user: string;
};
+export type AggregatedQuestionEncounter = {
+ companyCounts: Record;
+ locationCounts: Record;
+ roleCounts: Record;
+};
+
export type AnswerComment = {
content: string;
createdAt: Date;
@@ -42,3 +50,13 @@ export type QuestionComment = {
user: string;
userImage: string;
};
+
+export enum SortOrder {
+ ASC = 'asc',
+ DESC = 'desc',
+}
+
+export enum SortType {
+ TOP,
+ NEW,
+}
diff --git a/apps/portal/src/types/resume-comments.d.ts b/apps/portal/src/types/resume-comments.d.ts
index c0e181fb..f953c827 100644
--- a/apps/portal/src/types/resume-comments.d.ts
+++ b/apps/portal/src/types/resume-comments.d.ts
@@ -1,15 +1,15 @@
-import type { ResumesSection } from '@prisma/client';
+import type { ResumesCommentVote, ResumesSection } from '@prisma/client';
/**
* Returned by `resumeCommentsRouter` (query for 'resumes.comments.list') and received as prop by `Comment` in `CommentsList`
* frontend-friendly representation of the query
*/
export type ResumeComment = Readonly<{
+ children: Array;
createdAt: Date;
description: string;
- hasVoted: boolean;
id: string;
- numVotes: number;
+ parentId: string?;
resumeId: string;
section: ResumesSection;
updatedAt: Date;
@@ -19,3 +19,8 @@ export type ResumeComment = Readonly<{
userId: string;
};
}>;
+
+export type ResumeCommentVote = Readonly<{
+ numVotes: number;
+ userVote: ResumesCommentVote?;
+}>;
diff --git a/apps/portal/src/types/resume.d.ts b/apps/portal/src/types/resume.d.ts
index 5b2a33a9..39e782bb 100644
--- a/apps/portal/src/types/resume.d.ts
+++ b/apps/portal/src/types/resume.d.ts
@@ -3,6 +3,7 @@ export type Resume = {
createdAt: Date;
experience: string;
id: string;
+ isStarredByUser: boolean;
location: string;
numComments: number;
numStars: number;
diff --git a/apps/portal/src/utils/offers/currency/CurrencyEnum.tsx b/apps/portal/src/utils/offers/currency/CurrencyEnum.tsx
index 7cfbc0cb..88efa1c1 100644
--- a/apps/portal/src/utils/offers/currency/CurrencyEnum.tsx
+++ b/apps/portal/src/utils/offers/currency/CurrencyEnum.tsx
@@ -1,167 +1,170 @@
// eslint-disable-next-line no-shadow
-export enum Currency {
- AED = 'AED', // United Arab Emirates Dirham
- AFN = 'AFN', // Afghanistan Afghani
- ALL = 'ALL', // Albania Lek
- AMD = 'AMD', // Armenia Dram
- ANG = 'ANG', // Netherlands Antilles Guilder
- AOA = 'AOA', // Angola Kwanza
- ARS = 'ARS', // Argentina Peso
- AUD = 'AUD', // Australia Dollar
- AWG = 'AWG', // Aruba Guilder
- AZN = 'AZN', // Azerbaijan New Manat
- BAM = 'BAM', // Bosnia and Herzegovina Convertible Marka
- BBD = 'BBD', // Barbados Dollar
- BDT = 'BDT', // Bangladesh Taka
- BGN = 'BGN', // Bulgaria Lev
- BHD = 'BHD', // Bahrain Dinar
- BIF = 'BIF', // Burundi Franc
- BMD = 'BMD', // Bermuda Dollar
- BND = 'BND', // Brunei Darussalam Dollar
- BOB = 'BOB', // Bolivia Bolíviano
- BRL = 'BRL', // Brazil Real
- BSD = 'BSD', // Bahamas Dollar
- BTN = 'BTN', // Bhutan Ngultrum
- BWP = 'BWP', // Botswana Pula
- BYR = 'BYR', // Belarus Ruble
- BZD = 'BZD', // Belize Dollar
- CAD = 'CAD', // Canada Dollar
- CDF = 'CDF', // Congo/Kinshasa Franc
- CHF = 'CHF', // Switzerland Franc
- CLP = 'CLP', // Chile Peso
- CNY = 'CNY', // China Yuan Renminbi
- COP = 'COP', // Colombia Peso
- CRC = 'CRC', // Costa Rica Colon
- CUC = 'CUC', // Cuba Convertible Peso
- CUP = 'CUP', // Cuba Peso
- CVE = 'CVE', // Cape Verde Escudo
- CZK = 'CZK', // Czech Republic Koruna
- DJF = 'DJF', // Djibouti Franc
- DKK = 'DKK', // Denmark Krone
- DOP = 'DOP', // Dominican Republic Peso
- DZD = 'DZD', // Algeria Dinar
- EGP = 'EGP', // Egypt Pound
- ERN = 'ERN', // Eritrea Nakfa
- ETB = 'ETB', // Ethiopia Birr
- EUR = 'EUR', // Euro Member Countries
- FJD = 'FJD', // Fiji Dollar
- FKP = 'FKP', // Falkland Islands (Malvinas) Pound
- GBP = 'GBP', // United Kingdom Pound
- GEL = 'GEL', // Georgia Lari
- GGP = 'GGP', // Guernsey Pound
- GHS = 'GHS', // Ghana Cedi
- GIP = 'GIP', // Gibraltar Pound
- GMD = 'GMD', // Gambia Dalasi
- GNF = 'GNF', // Guinea Franc
- GTQ = 'GTQ', // Guatemala Quetzal
- GYD = 'GYD', // Guyana Dollar
- HKD = 'HKD', // Hong Kong Dollar
- HNL = 'HNL', // Honduras Lempira
- HRK = 'HRK', // Croatia Kuna
- HTG = 'HTG', // Haiti Gourde
- HUF = 'HUF', // Hungary Forint
- IDR = 'IDR', // Indonesia Rupiah
- ILS = 'ILS', // Israel Shekel
- IMP = 'IMP', // Isle of Man Pound
- INR = 'INR', // India Rupee
- IQD = 'IQD', // Iraq Dinar
- IRR = 'IRR', // Iran Rial
- ISK = 'ISK', // Iceland Krona
- JEP = 'JEP', // Jersey Pound
- JMD = 'JMD', // Jamaica Dollar
- JOD = 'JOD', // Jordan Dinar
- JPY = 'JPY', // Japan Yen
- KES = 'KES', // Kenya Shilling
- KGS = 'KGS', // Kyrgyzstan Som
- KHR = 'KHR', // Cambodia Riel
- KMF = 'KMF', // Comoros Franc
- KPW = 'KPW', // Korea (North) Won
- KRW = 'KRW', // Korea (South) Won
- KWD = 'KWD', // Kuwait Dinar
- KYD = 'KYD', // Cayman Islands Dollar
- KZT = 'KZT', // Kazakhstan Tenge
- LAK = 'LAK', // Laos Kip
- LBP = 'LBP', // Lebanon Pound
- LKR = 'LKR', // Sri Lanka Rupee
- LRD = 'LRD', // Liberia Dollar
- LSL = 'LSL', // Lesotho Loti
- LYD = 'LYD', // Libya Dinar
- MAD = 'MAD', // Morocco Dirham
- MDL = 'MDL', // Moldova Leu
- MGA = 'MGA', // Madagascar Ariary
- MKD = 'MKD', // Macedonia Denar
- MMK = 'MMK', // Myanmar (Burma) Kyat
- MNT = 'MNT', // Mongolia Tughrik
- MOP = 'MOP', // Macau Pataca
- MRO = 'MRO', // Mauritania Ouguiya
- MUR = 'MUR', // Mauritius Rupee
- MVR = 'MVR', // Maldives (Maldive Islands) Rufiyaa
- MWK = 'MWK', // Malawi Kwacha
- MXN = 'MXN', // Mexico Peso
- MYR = 'MYR', // Malaysia Ringgit
- MZN = 'MZN', // Mozambique Metical
- NAD = 'NAD', // Namibia Dollar
- NGN = 'NGN', // Nigeria Naira
- NIO = 'NIO', // Nicaragua Cordoba
- NOK = 'NOK', // Norway Krone
- NPR = 'NPR', // Nepal Rupee
- NZD = 'NZD', // New Zealand Dollar
- OMR = 'OMR', // Oman Rial
- PAB = 'PAB', // Panama Balboa
- PEN = 'PEN', // Peru Sol
- PGK = 'PGK', // Papua New Guinea Kina
- PHP = 'PHP', // Philippines Peso
- PKR = 'PKR', // Pakistan Rupee
- PLN = 'PLN', // Poland Zloty
- PYG = 'PYG', // Paraguay Guarani
- QAR = 'QAR', // Qatar Riyal
- RON = 'RON', // Romania New Leu
- RSD = 'RSD', // Serbia Dinar
- RUB = 'RUB', // Russia Ruble
- RWF = 'RWF', // Rwanda Franc
- SAR = 'SAR', // Saudi Arabia Riyal
- SBD = 'SBD', // Solomon Islands Dollar
- SCR = 'SCR', // Seychelles Rupee
- SDG = 'SDG', // Sudan Pound
- SEK = 'SEK', // Sweden Krona
- SGD = 'SGD', // Singapore Dollar
- SHP = 'SHP', // Saint Helena Pound
- SLL = 'SLL', // Sierra Leone Leone
- SOS = 'SOS', // Somalia Shilling
- SPL = 'SPL', // Seborga Luigino
- SRD = 'SRD', // Suriname Dollar
- STD = 'STD', // São Tomé and Príncipe Dobra
- SVC = 'SVC', // El Salvador Colon
- SYP = 'SYP', // Syria Pound
- SZL = 'SZL', // Swaziland Lilangeni
- THB = 'THB', // Thailand Baht
- TJS = 'TJS', // Tajikistan Somoni
- TMT = 'TMT', // Turkmenistan Manat
- TND = 'TND', // Tunisia Dinar
- TOP = 'TOP', // Tonga Pa'anga
- TRY = 'TRY', // Turkey Lira
- TTD = 'TTD', // Trinidad and Tobago Dollar
- TVD = 'TVD', // Tuvalu Dollar
- TWD = 'TWD', // Taiwan New Dollar
- TZS = 'TZS', // Tanzania Shilling
- UAH = 'UAH', // Ukraine Hryvnia
- UGX = 'UGX', // Uganda Shilling
- USD = 'USD', // United States Dollar
- UYU = 'UYU', // Uruguay Peso
- UZS = 'UZS', // Uzbekistan Som
- VEF = 'VEF', // Venezuela Bolivar
- VND = 'VND', // Viet Nam Dong
- VUV = 'VUV', // Vanuatu Vatu
- WST = 'WST', // Samoa Tala
- XAF = 'XAF', // Communauté Financière Africaine (BEAC) CFA Franc BEAC
- XCD = 'XCD', // East Caribbean Dollar
- XDR = 'XDR', // International Monetary Fund (IMF) Special Drawing Rights
- XOF = 'XOF', // Communauté Financière Africaine (BCEAO) Franc
- XPF = 'XPF', // Comptoirs Français du Pacifique (CFP) Franc
- YER = 'YER', // Yemen Rial
- ZAR = 'ZAR', // South Africa Rand
- ZMW = 'ZMW', // Zambia Kwacha
- ZWD = 'ZWD', // Zimbabwe Dollar
+export enum Currency {
+ AED = "AED", // 'UNITED ARAB EMIRATES DIRHAM'
+ AFN = "AFN", // 'AFGHAN AFGHANI'
+ ALL = "ALL", // 'ALBANIAN LEK'
+ AMD = "AMD", // 'ARMENIAN DRAM'
+ ANG = "ANG", // 'NETHERLANDS ANTILLEAN GUILDER'
+ AOA = "AOA", // 'ANGOLAN KWANZA'
+ ARS = "ARS", // 'ARGENTINE PESO'
+ AUD = "AUD", // 'AUSTRALIAN DOLLAR'
+ AWG = "AWG", // 'ARUBAN FLORIN'
+ AZN = "AZN", // 'AZERBAIJANI MANAT'
+ BAM = "BAM", // 'BOSNIA-HERZEGOVINA CONVERTIBLE MARK'
+ BBD = "BBD", // 'BAJAN DOLLAR'
+ BDT = "BDT", // 'BANGLADESHI TAKA'
+ BGN = "BGN", // 'BULGARIAN LEV'
+ BHD = "BHD", // 'BAHRAINI DINAR'
+ BIF = "BIF", // 'BURUNDIAN FRANC'
+ BMD = "BMD", // 'BERMUDAN DOLLAR'
+ BND = "BND", // 'BRUNEI DOLLAR'
+ BOB = "BOB", // 'BOLIVIAN BOLIVIANO'
+ BRL = "BRL", // 'BRAZILIAN REAL'
+ BSD = "BSD", // 'BAHAMIAN DOLLAR'
+ BTN = "BTN", // 'BHUTAN CURRENCY'
+ BWP = "BWP", // 'BOTSWANAN PULA'
+ BYN = "BYN", // 'NEW BELARUSIAN RUBLE'
+ BYR = "BYR", // 'BELARUSIAN RUBLE'
+ BZD = "BZD", // 'BELIZE DOLLAR'
+ CAD = "CAD", // 'CANADIAN DOLLAR'
+ CDF = "CDF", // 'CONGOLESE FRANC'
+ CHF = "CHF", // 'SWISS FRANC'
+ CLF = "CLF", // 'CHILEAN UNIT OF ACCOUNT (UF)'
+ CLP = "CLP", // 'CHILEAN PESO'
+ CNY = "CNY", // 'CHINESE YUAN'
+ COP = "COP", // 'COLOMBIAN PESO'
+ CRC = "CRC", // 'COSTA RICAN COLÓN'
+ CUC = "CUC", // 'CUBAN CONVERTIBLE PESO'
+ CUP = "CUP", // 'CUBAN PESO'
+ CVE = "CVE", // 'CAPE VERDEAN ESCUDO'
+ CVX = "CVX", // 'CONVEX FINANCE'
+ CZK = "CZK", // 'CZECH KORUNA'
+ DJF = "DJF", // 'DJIBOUTIAN FRANC'
+ DKK = "DKK", // 'DANISH KRONE'
+ DOP = "DOP", // 'DOMINICAN PESO'
+ DZD = "DZD", // 'ALGERIAN DINAR'
+ EGP = "EGP", // 'EGYPTIAN POUND'
+ ERN = "ERN", // 'ERITREAN NAKFA'
+ ETB = "ETB", // 'ETHIOPIAN BIRR'
+ ETC = "ETC", // 'ETHEREUM CLASSIC'
+ EUR = "EUR", // 'EURO'
+ FEI = "FEI", // 'FEI USD'
+ FJD = "FJD", // 'FIJIAN DOLLAR'
+ FKP = "FKP", // 'FALKLAND ISLANDS POUND'
+ GBP = "GBP", // 'POUND STERLING'
+ GEL = "GEL", // 'GEORGIAN LARI'
+ GHS = "GHS", // 'GHANAIAN CEDI'
+ GIP = "GIP", // 'GIBRALTAR POUND'
+ GMD = "GMD", // 'GAMBIAN DALASI'
+ GNF = "GNF", // 'GUINEAN FRANC'
+ GTQ = "GTQ", // 'GUATEMALAN QUETZAL'
+ GYD = "GYD", // 'GUYANAESE DOLLAR'
+ HKD = "HKD", // 'HONG KONG DOLLAR'
+ HNL = "HNL", // 'HONDURAN LEMPIRA'
+ HRK = "HRK", // 'CROATIAN KUNA'
+ HTG = "HTG", // 'HAITIAN GOURDE'
+ HUF = "HUF", // 'HUNGARIAN FORINT'
+ ICP = "ICP", // 'INTERNET COMPUTER'
+ IDR = "IDR", // 'INDONESIAN RUPIAH'
+ ILS = "ILS", // 'ISRAELI NEW SHEKEL'
+ INR = "INR", // 'INDIAN RUPEE'
+ IQD = "IQD", // 'IRAQI DINAR'
+ IRR = "IRR", // 'IRANIAN RIAL'
+ ISK = "ISK", // 'ICELANDIC KRÓNA'
+ JEP = "JEP", // 'JERSEY POUND'
+ JMD = "JMD", // 'JAMAICAN DOLLAR'
+ JOD = "JOD", // 'JORDANIAN DINAR'
+ JPY = "JPY", // 'JAPANESE YEN'
+ KES = "KES", // 'KENYAN SHILLING'
+ KGS = "KGS", // 'KYRGYSTANI SOM'
+ KHR = "KHR", // 'CAMBODIAN RIEL'
+ KMF = "KMF", // 'COMORIAN FRANC'
+ KPW = "KPW", // 'NORTH KOREAN WON'
+ KRW = "KRW", // 'SOUTH KOREAN WON'
+ KWD = "KWD", // 'KUWAITI DINAR'
+ KYD = "KYD", // 'CAYMAN ISLANDS DOLLAR'
+ KZT = "KZT", // 'KAZAKHSTANI TENGE'
+ LAK = "LAK", // 'LAOTIAN KIP'
+ LBP = "LPB", // 'LEBANESE POUND'
+ LKR = "LKR", // 'SRI LANKAN RUPEE'
+ LRD = "LRD", // 'LIBERIAN DOLLAR'
+ LSL = "LSL", // 'LESOTHO LOTI'
+ LTL = "LTL", // 'LITHUANIAN LITAS'
+ LVL = "LVL", // 'LATVIAN LATS'
+ LYD = "LYD", // 'LIBYAN DINAR'
+ MAD = "MAD", // 'MOROCCAN DIRHAM'
+ MDL = "MDL", // 'MOLDOVAN LEU'
+ MGA = "MGA", // 'MALAGASY ARIARY'
+ MKD = "MKD", // 'MACEDONIAN DENAR'
+ MMK = "MMK", // 'MYANMAR KYAT'
+ MNT = "MNT", // 'MONGOLIAN TUGRIK'
+ MOP = "MOP", // 'MACANESE PATACA'
+ MRO = "MRO", // 'MAURITANIAN OUGUIYA'
+ MUR = "MUR", // 'MAURITIAN RUPEE'
+ MVR = "MVR", // 'MALDIVIAN RUFIYAA'
+ MWK = "MWK", // 'MALAWIAN KWACHA'
+ MXN = "MXN", // 'MEXICAN PESO'
+ MYR = "MYR", // 'MALAYSIAN RINGGIT'
+ MZN = "MZN", // 'MOZAMBICAN METICAL'
+ NAD = "NAD", // 'NAMIBIAN DOLLAR'
+ NGN = "NGN", // 'NIGERIAN NAIRA'
+ NIO = "NIO", // 'NICARAGUAN CÓRDOBA'
+ NOK = "NOK", // 'NORWEGIAN KRONE'
+ NPR = "NPR", // 'NEPALESE RUPEE'
+ NZD = "NZD", // 'NEW ZEALAND DOLLAR'
+ OMR = "OMR", // 'OMANI RIAL'
+ ONE = "ONE", // 'MENLO ONE'
+ PAB = "PAB", // 'PANAMANIAN BALBOA'
+ PGK = "PGK", // 'PAPUA NEW GUINEAN KINA'
+ PHP = "PHP", // 'PHILIPPINE PESO'
+ PKR = "PKR", // 'PAKISTANI RUPEE'
+ PLN = "PLN", // 'POLAND ZŁOTY'
+ PYG = "PYG", // 'PARAGUAYAN GUARANI'
+ QAR = "QAR", // 'QATARI RIAL'
+ RON = "RON", // 'ROMANIAN LEU'
+ RSD = "RSD", // 'SERBIAN DINAR'
+ RUB = "RUB", // 'RUSSIAN RUBLE'
+ RWF = "RWF", // 'RWANDAN FRANC'
+ SAR = "SAR", // 'SAUDI RIYAL'
+ SBD = "SBD", // 'SOLOMON ISLANDS DOLLAR'
+ SCR = "SCR", // 'SEYCHELLOIS RUPEE'
+ SDG = "SDG", // 'SUDANESE POUND'
+ SEK = "SEK", // 'SWEDISH KRONA'
+ SGD = "SGD", // 'SINGAPORE DOLLAR'
+ SHIB = "SHIB", // 'SHIBA INU'
+ SHP = "SHP", // 'SAINT HELENA POUND'
+ SLL = "SLL", // 'SIERRA LEONEAN LEONE'
+ SOS = "SOS", // 'SOMALI SHILLING'
+ SRD = "SRD", // 'SURINAMESE DOLLAR'
+ STD = "STD", // 'SÃO TOMÉ AND PRÍNCIPE DOBRA (PRE-2018)'
+ SVC = "SVC", // 'SALVADORAN COLÓN'
+ SYP = "SYP", // 'SYRIAN POUND'
+ SZL = "SZL", // 'SWAZI LILANGENI'
+ THB = "THB", // 'THAI BAHT'
+ TJS = "TJS", // 'TAJIKISTANI SOMONI'
+ TMT = "TMT", // 'TURKMENISTANI MANAT'
+ TND = "TND", // 'TUNISIAN DINAR'
+ TOP = "TOP", // "TONGAN PA'ANGA"
+ TRY = "TRY", // 'TURKISH LIRA'
+ TTD = "TTD", // 'TRINIDAD & TOBAGO DOLLAR'
+ TWD = "TWD", // 'NEW TAIWAN DOLLAR'
+ TZS = "TZS", // 'TANZANIAN SHILLING'
+ UAH = "UAH", // 'UKRAINIAN HRYVNIA'
+ UGX = "UGX", // 'UGANDAN SHILLING'
+ USD = "USD", // 'UNITED STATES DOLLAR'
+ UYU = "UYU", // 'URUGUAYAN PESO'
+ UZS = "UZS", // 'UZBEKISTANI SOM'
+ VND = "VND", // 'VIETNAMESE DONG'
+ VUV = "VUV", // 'VANUATU VATU'
+ WST = "WST", // 'SAMOAN TALA'
+ XAF = "XAF", // 'CENTRAL AFRICAN CFA FRANC'
+ XCD = "XCD", // 'EAST CARIBBEAN DOLLAR'
+ XOF = "XOF", // 'WEST AFRICAN CFA FRANC'
+ XPF = "XPF", // 'CFP FRANC'
+ YER = "YER", // 'YEMENI RIAL'
+ ZAR = "ZAR", // 'SOUTH AFRICAN RAND'
+ ZMW = "ZMW", // 'ZAMBIAN KWACHA'
+ ZWL = "ZWL", // 'ZIMBABWEAN DOLLAR'
}
export const CURRENCY_OPTIONS = Object.entries(Currency).map(
diff --git a/apps/portal/src/utils/offers/currency/currencyExchange.ts b/apps/portal/src/utils/offers/currency/currencyExchange.ts
new file mode 100644
index 00000000..0f642100
--- /dev/null
+++ b/apps/portal/src/utils/offers/currency/currencyExchange.ts
@@ -0,0 +1,49 @@
+// API from https://github.com/fawazahmed0/currency-api#readme
+
+export const convert = async (
+ value: number,
+ fromCurrency: string,
+ toCurrency: string,
+) => {
+ fromCurrency = fromCurrency.trim().toLowerCase();
+ toCurrency = toCurrency.trim().toLowerCase();
+ const url = [
+ 'https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies',
+ fromCurrency,
+ toCurrency,
+ ].join('/');
+
+ return await fetch(url + '.json')
+ .then((res) => res.json())
+ .then((data) => value * data[toCurrency]);
+};
+// https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@{apiVersion}/{date}/{endpoint}
+
+export const convertWithDate = async (
+ value: number,
+ fromCurrency: string,
+ toCurrency: string,
+ date: Date,
+) => {
+ if (new Date().toDateString === date.toDateString) {
+ return await convert(value, fromCurrency, toCurrency);
+ }
+
+ fromCurrency = fromCurrency.trim().toLowerCase();
+ toCurrency = toCurrency.trim().toLowerCase();
+
+ // Format date to YYYY-MM-DD
+ const formattedDate = date.toJSON().substring(0, 10);
+
+ const url = [
+ 'https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1',
+ formattedDate,
+ 'currencies',
+ fromCurrency,
+ toCurrency,
+ ].join('/');
+
+ return await fetch(url + '.json')
+ .then((res) => res.json())
+ .then((data) => value * data[toCurrency]);
+};
diff --git a/apps/portal/src/utils/offers/currency/index.tsx b/apps/portal/src/utils/offers/currency/index.tsx
index c2cfcb05..1e219c45 100644
--- a/apps/portal/src/utils/offers/currency/index.tsx
+++ b/apps/portal/src/utils/offers/currency/index.tsx
@@ -1,6 +1,10 @@
import type { Money } from '~/components/offers/types';
-export function convertCurrencyToString({ currency, value }: Money) {
+import { Currency } from './CurrencyEnum';
+
+export const baseCurrencyString = Currency.USD.toString();
+
+export function convertMoneyToString({ currency, value }: Money) {
if (!value) {
return '-';
}
@@ -10,5 +14,5 @@ export function convertCurrencyToString({ currency, value }: Money) {
minimumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
style: 'currency',
});
- return `${formatter.format(10000)}`; /* $2,500.00 */
+ return `${formatter.format(value)}`;
}
diff --git a/apps/portal/src/utils/offers/link.tsx b/apps/portal/src/utils/offers/link.tsx
new file mode 100644
index 00000000..9e111aad
--- /dev/null
+++ b/apps/portal/src/utils/offers/link.tsx
@@ -0,0 +1,19 @@
+export function getProfileLink(profileId: string, token?: string) {
+ return `${window.location.origin}${getProfilePath(profileId, token)}`;
+}
+
+export function copyProfileLink(profileId: string, token?: string) {
+ // TODO: Add notification
+ navigator.clipboard.writeText(getProfileLink(profileId, token));
+}
+
+export function getProfilePath(profileId: string, token?: string) {
+ if (token) {
+ return `/offers/profile/${profileId}?token=${token}`;
+ }
+ return `/offers/profile/${profileId}`;
+}
+
+export function getProfileEditPath(profileId: string, token: string) {
+ return `/offers/profile/edit/${profileId}?token=${token}`;
+}
diff --git a/apps/portal/src/utils/offers/time.tsx b/apps/portal/src/utils/offers/time.tsx
index c13a6efe..4f68adeb 100644
--- a/apps/portal/src/utils/offers/time.tsx
+++ b/apps/portal/src/utils/offers/time.tsx
@@ -2,24 +2,50 @@ import { getMonth, getYear } from 'date-fns';
import type { MonthYear } from '~/components/shared/MonthYearPicker';
+export function timeSinceNow(date: Date | number | string) {
+ const seconds = Math.floor(
+ new Date().getTime() / 1000 - new Date(date).getTime() / 1000,
+ );
+ let interval = seconds / 31536000;
+
+ if (interval > 1) {
+ return `${Math.floor(interval)} years`;
+ }
+ interval = seconds / 2592000;
+ if (interval > 1) {
+ return `${Math.floor(interval)} months`;
+ }
+ interval = seconds / 86400;
+ if (interval > 1) {
+ return `${Math.floor(interval)} days`;
+ }
+ interval = seconds / 3600;
+ if (interval > 1) {
+ return `${Math.floor(interval)} hours`;
+ }
+ interval = seconds / 60;
+ if (interval > 1) {
+ return `${Math.floor(interval)} minutes`;
+ }
+ return `${Math.floor(interval)} seconds`;
+}
+
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}`;
}
-export function formatMonthYear({ month, year }: MonthYear) {
- const monthString = month < 10 ? month.toString() : `0${month}`;
- const yearString = year.toString();
- return `${monthString}/${yearString}`;
-}
-
export function getCurrentMonth() {
- return getMonth(Date.now());
+ // `getMonth` returns a zero-based month index
+ return getMonth(Date.now()) + 1;
}
export function getCurrentYear() {
return getYear(Date.now());
}
+
+export function convertToMonthYear(date: Date) {
+ return { month: date.getMonth() + 1, year: date.getFullYear() } as MonthYear;
+}
diff --git a/apps/portal/src/utils/offers/zodRegex.ts b/apps/portal/src/utils/offers/zodRegex.ts
new file mode 100644
index 00000000..614b76d4
--- /dev/null
+++ b/apps/portal/src/utils/offers/zodRegex.ts
@@ -0,0 +1,8 @@
+export const createValidationRegex = (
+ keywordArray: Array,
+ prepend: string | null | undefined,
+) => {
+ const sortingKeysRegex = keywordArray.join('|');
+ prepend = prepend != null ? prepend : '';
+ return new RegExp('^' + prepend + '(' + sortingKeysRegex + ')$');
+};
diff --git a/apps/portal/src/utils/resumes/useDebounceValue.ts b/apps/portal/src/utils/resumes/useDebounceValue.ts
new file mode 100644
index 00000000..230a9726
--- /dev/null
+++ b/apps/portal/src/utils/resumes/useDebounceValue.ts
@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+
+export default function useDebounceValue(value: string, delay: number) {
+ const [debouncedValue, setDebouncedValue] = useState(value);
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
+
+ return () => {
+ clearTimeout(handler);
+ };
+ }, [value, delay]);
+ return debouncedValue;
+}
diff --git a/apps/storybook/stories/typeahead.stories.tsx b/apps/storybook/stories/typeahead.stories.tsx
index bd0c0a1f..defffbf4 100644
--- a/apps/storybook/stories/typeahead.stories.tsx
+++ b/apps/storybook/stories/typeahead.stories.tsx
@@ -20,6 +20,9 @@ export default {
placeholder: {
control: 'text',
},
+ required: {
+ control: 'boolean',
+ },
},
component: Typeahead,
parameters: {
@@ -80,3 +83,39 @@ Basic.args = {
isLabelHidden: false,
label: 'Author',
};
+
+export function Required() {
+ const people = [
+ { id: '1', label: 'Wade Cooper', value: '1' },
+ { id: '2', label: 'Arlene Mccoy', value: '2' },
+ { id: '3', label: 'Devon Webb', value: '3' },
+ { id: '4', label: 'Tom Cook', value: '4' },
+ { id: '5', label: 'Tanya Fox', value: '5' },
+ { id: '6', label: 'Hellen Schmidt', value: '6' },
+ ];
+ const [selectedEntry, setSelectedEntry] = useState(
+ people[0],
+ );
+ const [query, setQuery] = useState('');
+
+ const filteredPeople =
+ query === ''
+ ? people
+ : people.filter((person) =>
+ person.label
+ .toLowerCase()
+ .replace(/\s+/g, '')
+ .includes(query.toLowerCase().replace(/\s+/g, '')),
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/website/contents/choosing-between-companies.md b/apps/website/contents/choosing-between-companies.md
index 47801ad1..c00e97d7 100644
--- a/apps/website/contents/choosing-between-companies.md
+++ b/apps/website/contents/choosing-between-companies.md
@@ -15,7 +15,7 @@ First and foremost, compensation. Most technical roles at tech companies would r
Not all stock grants are equal as well. Some companies have linear vesting cycles (you vest the same amount every year), some companies like Amazon and Snap have backloaded schemes (you vest less in the earlier years, more later), and there are pay attention to cliffs as well. [Stripe and Lyft](https://www.theinformation.com/articles/stripe-and-lyft-speed-up-equity-payouts-to-first-year) recently changed their stock structure and announced that they will speed up equity payouts to the first year. This sounds good initially, [but in reality there are some nuances](https://tanay.substack.com/p/employee-compensation-and-one-year).
-Regardless of company, **always negotiate** your offer, especially if you have multiple offers to choose from! Having multiple offers in hand is the best bargaining chip you can have for negotiation and you should leverage it. We go into this more in the [Negotiation](./negotiation.md) section. Use [Moonchaser](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_choosing_between_companies) for risk-free negotiation services.
+Regardless of company, **always negotiate** your offer, especially if you have multiple offers to choose from! Having multiple offers in hand is the best bargaining chip you can have for negotiation and you should leverage it. We go into this more in the [Negotiation](./negotiation.md) section. Use [Rora](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_choosing_between_companies) for risk-free negotiation services.
## Products
diff --git a/apps/website/contents/negotiation-rules.md b/apps/website/contents/negotiation-rules.md
index 8f8d59e6..f1deb8f4 100644
--- a/apps/website/contents/negotiation-rules.md
+++ b/apps/website/contents/negotiation-rules.md
@@ -110,6 +110,6 @@ Don't waste their time or play games for your own purposes. Even if the company
:::tip Expert tip
-Get paid more. Receive salary negotiation help from [**Moonchaser**](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
+Get paid more. Receive salary negotiation help from [**Rora**](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
:::
diff --git a/apps/website/contents/negotiation.md b/apps/website/contents/negotiation.md
index 57fefabd..d98a6abd 100644
--- a/apps/website/contents/negotiation.md
+++ b/apps/website/contents/negotiation.md
@@ -32,11 +32,11 @@ If you've received an offer (or even better, offers), congratulations! You may h
If you haven't been negotiating your past offers, or are new to the negotiation game, worry not! There are multiple negotiation services that can help you out. Typically, they'd be well-worth the cost. Had I know about negotiation services in the past, I'd have leveraged them!
-### Moonchaser
+### Rora
-How Moonchaser works is that you will be guided by their experienced team of professionals throughout the entire salary negotiation process. It's also risk-free because you don't have to pay anything unless you have an increased offer. It's a **no-brainer decision** to get the help of Moonchaser during the offer process - some increase is better than no increase. Don't leave money on the table! Check out [Moonchaser](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation).
+How Rora works is that you will be guided by their experienced team of professionals throughout the entire salary negotiation process. It's also risk-free because you don't have to pay anything unless you have an increased offer. It's a **no-brainer decision** to get the help of Rora during the offer process - some increase is better than no increase. Don't leave money on the table! Check out [Rora](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation).
-Things Moonchaser can do for you:
+Things Rora can do for you:
- Help you to negotiate increases even without competing offers
- Provide tailored advice through their knowledge of compensation ranges at many companies
@@ -45,18 +45,18 @@ Things Moonchaser can do for you:
- Provide you with live guidance during the recruiter call through chat
- Introduce you to recruiters at other companies
-Book a free consultation with Moonchaser →
+Book a free consultation with Rora →
### Levels.fyi
-[Levels.fyi](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) is most famously known for being a salary database but they also offer complementary services such as salary negotiation where you will be put in-touch with experienced recruiters to help you in the process. How Levels.fyi differs from Moonchaser is that Levels.fyi charges a flat fee whereas Moonchaser takes a percentage of the negotiated difference.
+[Levels.fyi](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) is most famously known for being a salary database but they also offer complementary services such as salary negotiation where you will be put in-touch with experienced recruiters to help you in the process. How Levels.fyi differs from Rora is that Levels.fyi charges a flat fee whereas Rora takes a percentage of the negotiated difference.
:::tip Expert tip
-Get paid more. Receive salary negotiation advice from [**Moonchaser**](https://www.moonchaser.io/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
+Get paid more. Receive salary negotiation advice from [**Rora**](https://www.teamrora.com/?utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) (risk-free) or [**Levels.fyi**](https://www.levels.fyi/services/?ref=TechInterviewHandbook&utm_source=techinterviewhandbook&utm_medium=referral&utm_content=website_negotiation) and their team of experienced recruiters. Don't leave money on the table 💰!
:::
diff --git a/apps/website/src/components/SidebarAd/index.js b/apps/website/src/components/SidebarAd/index.js
index 711910df..0e250e20 100644
--- a/apps/website/src/components/SidebarAd/index.js
+++ b/apps/website/src/components/SidebarAd/index.js
@@ -69,23 +69,23 @@ function AlgoMonster({ position }) {
);
}
-function Moonchaser({ position }) {
+function Rora({ position }) {
return (
{
- window.gtag('event', `moonchaser.${position}.click`);
+ window.gtag('event', `rora.${position}.click`);
}}>
Risk-free salary negotiation help
{' '}
- Receive risk-free salary negotiation advice from Moonchaser . You
- pay nothing unless your offer is increased.{' '}
+ Receive risk-free salary negotiation advice from Rora . You pay
+ nothing unless your offer is increased.{' '}
Book your free consultation today!
@@ -210,7 +210,7 @@ export default React.memo(function SidebarAd({ position }) {
}
if (path.includes('negotiation') || path.includes('compensation')) {
- return ;
+ return ;
}
if (path.includes('system-design')) {
diff --git a/apps/website/src/components/SidebarAd/styles.module.css b/apps/website/src/components/SidebarAd/styles.module.css
index d7fc116e..dded48cc 100644
--- a/apps/website/src/components/SidebarAd/styles.module.css
+++ b/apps/website/src/components/SidebarAd/styles.module.css
@@ -37,7 +37,7 @@
background-color: #58527b;
}
-.backgroundMoonchaser {
+.backgroundRora {
background-color: #1574f9;
}
diff --git a/apps/website/src/pages/index.js b/apps/website/src/pages/index.js
index 858d4a4f..0da57814 100755
--- a/apps/website/src/pages/index.js
+++ b/apps/website/src/pages/index.js
@@ -222,7 +222,7 @@ function WhatIsThisSection() {
);
}
-function MoonchaserSection() {
+function RoraSection() {
// Because the SSR and client output can differ and hydration doesn't patch attribute differences,
// we'll render this on the browser only.
return (
@@ -237,18 +237,18 @@ function MoonchaserSection() {
Get paid more. Receive risk-free salary negotiation
- advice from Moonchaser. You pay nothing unless your
- offer is increased.
+ advice from Rora. You pay nothing unless your offer is
+ increased.
{
- window.gtag('event', 'moonchaser.homepage.click');
+ window.gtag('event', 'rora.homepage.click');
}}>
Get risk-free negotiation advice →
@@ -504,7 +504,7 @@ function GreatFrontEndSection() {
return (
+ style={{ backgroundColor: 'rgb(79, 70, 229)' }}>
@@ -517,13 +517,13 @@ function GreatFrontEndSection() {
+ style={{ fontSize: 'var(--ifm-h2-font-size)' }}>
Spend less time but prepare better for your Front End
Interviews with{' '}
+ style={{ color: '#fff', textDecoration: 'underline' }}>
Great Front End's
{' '}
large pool of high quality practice questions and solutions.
diff --git a/packages/tailwind-config/package.json b/packages/tailwind-config/package.json
index 71087c12..616cdfe1 100644
--- a/packages/tailwind-config/package.json
+++ b/packages/tailwind-config/package.json
@@ -8,6 +8,7 @@
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.7",
+ "tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss": "^3.1.8"
}
}
diff --git a/packages/tailwind-config/tailwind.config.js b/packages/tailwind-config/tailwind.config.js
index 8fa4940f..38f84e49 100644
--- a/packages/tailwind-config/tailwind.config.js
+++ b/packages/tailwind-config/tailwind.config.js
@@ -23,5 +23,6 @@ module.exports = {
require('@tailwindcss/forms'),
require('@tailwindcss/line-clamp'),
require('@tailwindcss/typography'),
+ require('tailwind-scrollbar-hide'),
],
};
diff --git a/packages/ui/src/Toast/Toast.tsx b/packages/ui/src/Toast/Toast.tsx
new file mode 100644
index 00000000..4d6efe51
--- /dev/null
+++ b/packages/ui/src/Toast/Toast.tsx
@@ -0,0 +1,108 @@
+import { Fragment, useEffect, useRef } from 'react';
+import { Transition } from '@headlessui/react';
+import { CheckIcon } from '@heroicons/react/24/outline';
+import { XMarkIcon } from '@heroicons/react/24/solid';
+
+type ToastVariant = 'failure' | 'success';
+
+export type ToastMessage = {
+ duration?: number;
+ subtitle?: string;
+ title: string;
+ variant: ToastVariant;
+};
+
+type Props = Readonly<{
+ duration?: number;
+ onClose: () => void;
+ subtitle?: string;
+ title: string;
+ variant: ToastVariant;
+}>;
+
+const DEFAULT_DURATION = 5000;
+
+function ToastIcon({ variant }: Readonly<{ variant: ToastVariant }>) {
+ switch (variant) {
+ case 'success':
+ return (
+
+ );
+ case 'failure':
+ return (
+
+ );
+ }
+}
+
+export default function Toast({
+ duration = DEFAULT_DURATION,
+ title,
+ subtitle,
+ variant,
+ onClose,
+}: Props) {
+ const timer = useRef(null);
+
+ function clearTimer() {
+ if (timer.current == null) {
+ return;
+ }
+
+ window.clearTimeout(timer.current);
+ timer.current = null;
+ }
+
+ function close() {
+ onClose();
+ clearTimer();
+ }
+
+ useEffect(() => {
+ timer.current = window.setTimeout(() => {
+ close();
+ }, duration);
+
+ return () => {
+ clearTimer();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
{title}
+ {subtitle && (
+
{subtitle}
+ )}
+
+
+
+ Close
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/Toast/ToastsProvider.tsx b/packages/ui/src/Toast/ToastsProvider.tsx
new file mode 100644
index 00000000..88ea14ee
--- /dev/null
+++ b/packages/ui/src/Toast/ToastsProvider.tsx
@@ -0,0 +1,72 @@
+import React, { createContext, useContext, useState } from 'react';
+
+import type { ToastMessage } from './Toast';
+import Toast from './Toast';
+
+type Context = Readonly<{
+ showToast: (message: ToastMessage) => void;
+}>;
+
+export const ToastContext = createContext({
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ showToast: (_: ToastMessage) => {},
+});
+
+const getID = (() => {
+ let id = 0;
+
+ return () => {
+ return id++;
+ };
+})();
+
+type ToastData = ToastMessage & {
+ id: number;
+};
+
+type Props = Readonly<{
+ children: React.ReactNode;
+}>;
+
+export function useToast() {
+ return useContext(ToastContext);
+}
+
+export default function ToastsProvider({ children }: Props) {
+ const [toasts, setToasts] = useState>([]);
+
+ function showToast({ title, subtitle, variant }: ToastMessage) {
+ setToasts([{ id: getID(), subtitle, title, variant }, ...toasts]);
+ }
+
+ function closeToast(id: number) {
+ setToasts((oldToasts) => {
+ const newToasts = oldToasts.filter((toast) => toast.id !== id);
+ return newToasts;
+ });
+ }
+
+ return (
+
+ {children}
+
+
+ {/* Notification panel, dynamically insert this into the live region when it needs to be displayed */}
+ {toasts.map(({ id, title, subtitle, variant }) => (
+ {
+ closeToast(id);
+ }}
+ />
+ ))}
+
+
+
+ );
+}
diff --git a/packages/ui/src/Typeahead/Typeahead.tsx b/packages/ui/src/Typeahead/Typeahead.tsx
index 0eb4b257..e84d03a3 100644
--- a/packages/ui/src/Typeahead/Typeahead.tsx
+++ b/packages/ui/src/Typeahead/Typeahead.tsx
@@ -1,4 +1,5 @@
import clsx from 'clsx';
+import type { InputHTMLAttributes } from 'react';
import { Fragment, useState } from 'react';
import { Combobox, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
@@ -10,8 +11,18 @@ export type TypeaheadOption = Readonly<{
value: string;
}>;
+type Attributes = Pick<
+ InputHTMLAttributes,
+ | 'disabled'
+ | 'name'
+ | 'onBlur'
+ | 'onFocus'
+ | 'pattern'
+ | 'placeholder'
+ | 'required'
+>;
+
type Props = Readonly<{
- disabled?: boolean;
isLabelHidden?: boolean;
label: string;
noResultsMessage?: string;
@@ -22,9 +33,9 @@ type Props = Readonly<{
) => void;
onSelect: (option: TypeaheadOption) => void;
options: ReadonlyArray;
- placeholder?: string;
value?: TypeaheadOption;
-}>;
+}> &
+ Readonly;
export default function Typeahead({
disabled = false,
@@ -34,9 +45,10 @@ export default function Typeahead({
nullable = false,
options,
onQueryChange,
+ required,
value,
onSelect,
- placeholder,
+ ...props
}: Props) {
const [query, setQuery] = useState('');
return (
@@ -68,6 +80,12 @@ export default function Typeahead({
: 'mb-1 block text-sm font-medium text-slate-700',
)}>
{label}
+ {required && (
+
+ {' '}
+ *
+
+ )}
@@ -79,11 +97,12 @@ export default function Typeahead({
displayValue={(option) =>
(option as unknown as TypeaheadOption)?.label
}
- placeholder={placeholder}
+ required={required}
onChange={(event) => {
setQuery(event.target.value);
onQueryChange(event.target.value, event);
}}
+ {...props}
/>