Merge branch 'hongpo/update-question-filter' of https://github.com/yangshun/tech-interview-handbook into hongpo/update-question-filter

pull/384/head
hpkoh 3 years ago
commit 3b1828ef3f

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "ResumesComment" ADD COLUMN "parentId" TEXT;
-- AddForeignKey
ALTER TABLE "ResumesComment" ADD CONSTRAINT "ResumesComment_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "ResumesComment"("id") ON DELETE SET NULL ON UPDATE CASCADE;

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OffersExperience" ADD COLUMN "location" TEXT;

@ -140,6 +140,7 @@ model ResumesComment {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
resumeId String resumeId String
parentId String?
description String @db.Text description String @db.Text
section ResumesSection section ResumesSection
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -147,6 +148,8 @@ model ResumesComment {
resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade) resume ResumesResume @relation(fields: [resumeId], references: [id], onDelete: Cascade)
votes ResumesCommentVote[] votes ResumesCommentVote[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parent ResumesComment? @relation("parentComment", fields: [parentId], references: [id])
children ResumesComment[] @relation("parentComment")
} }
enum ResumesSection { enum ResumesSection {
@ -232,6 +235,7 @@ model OffersExperience {
// Add more fields // Add more fields
durationInMonths Int? durationInMonths Int?
specialization String? specialization String?
location String?
// FULLTIME fields // FULLTIME fields
level String? level String?

@ -35,34 +35,6 @@ const COMPANIES = [
}, },
]; ];
const OFFER_PROFILES = [
{
id: 'cl91v97ex000109mt7fka5rto',
profileName: 'battery-horse-stable-cow',
editToken: 'cl91ulmhg000009l86o45aspt',
},
{
id: 'cl91v9iw2000209mtautgdnxq',
profileName: 'house-zebra-fast-giraffe',
editToken: 'cl91umigc000109l80f1tcqe8',
},
{
id: 'cl91v9m3y000309mt1ctw55wi',
profileName: 'keyboard-mouse-lazy-cat',
editToken: 'cl91ummoa000209l87q2b8hl7',
},
{
id: 'cl91v9p09000409mt5rvoasf1',
profileName: 'router-hen-bright-pig',
editToken: 'cl91umqa3000309l87jyefe9k',
},
{
id: 'cl91v9uda000509mt5i5fez3v',
profileName: 'screen-ant-dirty-bird',
editToken: 'cl91umuj9000409l87ez85vmg',
},
];
async function main() { async function main() {
console.log('Seeding started...'); console.log('Seeding started...');
await Promise.all([ await Promise.all([
@ -73,13 +45,6 @@ async function main() {
create: company, create: company,
}); });
}), }),
OFFER_PROFILES.map(async (offerProfile) => {
await prisma.offersProfile.upsert({
where: { profileName: offerProfile.profileName },
update: offerProfile,
create: offerProfile,
});
}),
]); ]);
console.log('Seeding completed.'); console.log('Seeding completed.');
} }

@ -5,20 +5,20 @@ export const emptyOption = '----';
// TODO: use enums // TODO: use enums
export const titleOptions = [ export const titleOptions = [
{ {
label: 'Software engineer', label: 'Software Engineer',
value: 'Software engineer', value: 'Software Engineer',
}, },
{ {
label: 'Frontend engineer', label: 'Frontend Engineer',
value: 'Frontend engineer', value: 'Frontend Engineer',
}, },
{ {
label: 'Backend engineer', label: 'Backend Engineer',
value: 'Backend engineer', value: 'Backend Engineer',
}, },
{ {
label: 'Full-stack engineer', label: 'Full-stack Engineer',
value: 'Full-stack engineer', value: 'Full-stack Engineer',
}, },
]; ];
@ -95,10 +95,18 @@ export const educationFieldOptions = [
label: 'Information Security', label: 'Information Security',
value: 'Information Security', value: 'Information Security',
}, },
{
label: 'Information Systems',
value: 'Information Systems',
},
{ {
label: 'Business Analytics', label: 'Business Analytics',
value: 'Business Analytics', value: 'Business Analytics',
}, },
{
label: 'Data Science and Analytics',
value: 'Data Science and Analytics',
},
]; ];
export enum FieldError { export enum FieldError {

@ -1,13 +1,30 @@
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { setTimeout } from 'timers'; import { setTimeout } from 'timers';
import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid'; import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline'; import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui'; import { Button, TextInput } from '@tih/ui';
export default function OfferProfileSave() { import {
copyProfileLink,
getProfileLink,
getProfilePath,
} from '~/utils/offers/link';
type OfferProfileSaveProps = Readonly<{
profileId: string;
token?: string;
}>;
export default function OfferProfileSave({
profileId,
token,
}: OfferProfileSaveProps) {
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const [isSaving, setSaving] = useState(false); const [isSaving, setSaving] = useState(false);
const [isSaved, setSaved] = useState(false); const [isSaved, setSaved] = useState(false);
const router = useRouter();
const saveProfile = () => { const saveProfile = () => {
setSaving(true); setSaving(true);
setTimeout(() => { setTimeout(() => {
@ -27,13 +44,13 @@ export default function OfferProfileSave() {
To keep you offer profile strictly anonymous, only people who have the To keep you offer profile strictly anonymous, only people who have the
link below can edit it. link below can edit it.
</p> </p>
<div className="mb-20 grid grid-cols-12 gap-4"> <div className="mb-5 grid grid-cols-12 gap-4">
<div className="col-span-11"> <div className="col-span-11">
<TextInput <TextInput
disabled={true} disabled={true}
isLabelHidden={true} isLabelHidden={true}
label="Edit link" label="Edit link"
value="link.myprofile-auto-generate..." value={getProfileLink(profileId, token)}
/> />
</div> </div>
<Button <Button
@ -41,10 +58,12 @@ export default function OfferProfileSave() {
isLabelHidden={true} isLabelHidden={true}
label="Copy" label="Copy"
variant="primary" variant="primary"
onClick={() => setLinkCopied(true)} onClick={() => {
copyProfileLink(profileId, token), setLinkCopied(true);
}}
/> />
</div> </div>
<div className="mb-5"> <div className="mb-20">
{linkCopied && ( {linkCopied && (
<p className="text-purple-700">Link copied to clipboard!</p> <p className="text-purple-700">Link copied to clipboard!</p>
)} )}
@ -60,13 +79,18 @@ export default function OfferProfileSave() {
disabled={isSaved} disabled={isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon} icon={isSaved ? CheckIcon : BookmarkSquareIcon}
isLoading={isSaving} isLoading={isSaving}
label="Save to user profile" label={isSaved ? 'Saved to user profile' : 'Save to user profile'}
variant="primary" variant="primary"
onClick={saveProfile} onClick={saveProfile}
/> />
</div> </div>
<div className="mb-10"> <div className="mb-10">
<Button icon={EyeIcon} label="View your profile" variant="special" /> <Button
icon={EyeIcon}
label="View your profile"
variant="special"
onClick={() => router.push(getProfilePath(profileId, token))}
/>
</div> </div>
</div> </div>
</div> </div>

@ -0,0 +1,243 @@
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 OfferAnalysis from '~/components/offers/offersSubmission/analysis/OfferAnalysis';
import OfferProfileSave from '~/components/offers/offersSubmission/OfferProfileSave';
import BackgroundForm from '~/components/offers/offersSubmission/submissionForm/BackgroundForm';
import OfferDetailsForm from '~/components/offers/offersSubmission/submissionForm/OfferDetailsForm';
import type {
OfferFormData,
OffersProfileFormData,
} from '~/components/offers/types';
import { 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';
import type { CreateOfferProfileResponse } from '~/types/offers';
const defaultOfferValues = {
comments: '',
companyId: '',
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
month: getCurrentMonth() as Month,
year: getCurrentYear(),
},
negotiationStrategy: '',
};
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FullTime,
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
specificYoes: [],
totalYoe: 0,
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
type Props = Readonly<{
initialOfferProfileValues?: OffersProfileFormData;
profileId?: string;
token?: string;
}>;
export default function OffersSubmissionForm({
initialOfferProfileValues = defaultOfferProfileValues,
profileId,
token,
}: Props) {
const [formStep, setFormStep] = useState(0);
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>({
id: profileId || '',
token: token || '',
});
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: initialOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: <OfferAnalysis key={2} profileId={createProfileResponse.id} />,
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: (
<OfferProfileSave
key={3}
profileId={createProfileResponse.id || ''}
token={createProfileResponse.token}
/>
),
hasNext: false,
hasPrevious: false,
label: 'Save',
},
];
const formStepsLabels = formSteps.map((step) => step.label);
const nextStep = async (currStep: number) => {
if (currStep === 0) {
const result = await trigger('offers');
if (!result) {
return;
}
}
setFormStep(formStep + 1);
scrollToTop();
};
const previousStep = () => {
setFormStep(formStep - 1);
scrollToTop();
};
const generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const mutationpath =
profileId && token ? 'offers.profile.update' : 'offers.profile.create';
const createOrUpdateMutation = trpc.useMutation([mutationpath], {
onError(error) {
console.error(error.message);
},
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
}
data = removeInvalidMoneyData(data);
const background = cleanObject(data.background);
background.specificYoes = data.background.specificYoes.filter(
(specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
);
if (Object.entries(background.experiences[0]).length === 1) {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,
offer.monthYearReceived.month - 1, // Convert month to monthIndex
),
}));
if (profileId && token) {
createOrUpdateMutation.mutate({
background,
id: profileId,
offers,
token,
});
} else {
createOrUpdateMutation.mutate({ background, offers });
}
};
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
}

@ -1,13 +1,12 @@
import Error from 'next/error';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui'; import { HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import OfferPercentileAnalysis from '../analysis/OfferPercentileAnalysis'; import OfferPercentileAnalysis from './OfferPercentileAnalysis';
import OfferProfileCard from '../analysis/OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
import { OVERALL_TAB } from '../constants'; import { OVERALL_TAB } from '../../constants';
import type { import type {
Analysis, Analysis,
@ -105,34 +104,32 @@ export default function OfferAnalysis({ profileId }: OfferAnalysisProps) {
]; ];
return ( return (
<> analysis && (
{getAnalysisResult.isError && ( <div>
<Error <h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
statusCode={404} Result
title="An error occurred while generating profile analysis." </h5>
/> {getAnalysisResult.isError && (
)} <p className="m-10 text-center">
{!getAnalysisResult.isError && analysis && ( An error occurred while generating profile analysis.
<div> </p>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900"> )}
Result {getAnalysisResult.isLoading && (
</h5> <Spinner className="m-10" display="block" size="lg" />
{getAnalysisResult.isLoading ? ( )}
<Spinner className="m-10" display="block" size="lg" /> {!getAnalysisResult.isError && !getAnalysisResult.isLoading && (
) : ( <div>
<div> <Tabs
<Tabs label="Result Navigation"
label="Result Navigation" tabs={tabOptions}
tabs={tabOptions} value={tab}
value={tab} onChange={setTab}
onChange={setTab} />
/> <HorizontalDivider className="mb-5" />
<HorizontalDivider className="mb-5" /> <OfferAnalysisContent analysis={analysis} tab={tab} />
<OfferAnalysisContent analysis={analysis} tab={tab} /> </div>
</div> )}
)} </div>
</div> )
)}
</>
); );
} }

@ -3,7 +3,7 @@ import { UserCircleIcon } from '@heroicons/react/24/outline';
import { HorizontalDivider } from '~/../../../packages/ui/dist'; import { HorizontalDivider } from '~/../../../packages/ui/dist';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import { JobType } from '../types'; import { JobType } from '../../types';
import type { AnalysisOffer } from '~/types/offers'; import type { AnalysisOffer } from '~/types/offers';

@ -15,9 +15,9 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum';
import FormRadioList from '../forms/FormRadioList'; import FormRadioList from '../../forms/FormRadioList';
import FormSelect from '../forms/FormSelect'; import FormSelect from '../../forms/FormSelect';
import FormTextInput from '../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
function YoeSection() { function YoeSection() {
const { register, formState } = useFormContext<{ const { register, formState } = useFormContext<{

@ -16,8 +16,7 @@ import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import { import {
defaultFullTimeOfferValues, defaultFullTimeOfferValues,
defaultInternshipOfferValues, defaultInternshipOfferValues,
} from '~/pages/offers/submit'; } from '../OffersSubmissionForm';
import { import {
emptyOption, emptyOption,
FieldError, FieldError,
@ -25,15 +24,15 @@ import {
locationOptions, locationOptions,
titleOptions, titleOptions,
yearOptions, yearOptions,
} from '../constants'; } from '../../constants';
import FormMonthYearPicker from '../forms/FormMonthYearPicker'; import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormSelect from '../forms/FormSelect'; import FormSelect from '../../forms/FormSelect';
import FormTextArea from '../forms/FormTextArea'; import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
import type { OfferFormData } from '../types'; import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../types'; import { JobTypeLabel } from '../../types';
import { JobType } from '../types'; import { JobType } from '../../types';
import { CURRENCY_OPTIONS } from '../../../utils/offers/currency/CurrencyEnum'; import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{ type FullTimeOfferDetailsFormProps = Readonly<{
index: number; index: number;

@ -5,6 +5,7 @@ import { Button, HorizontalDivider, Spinner, TextArea } from '@tih/ui';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard'; import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import { copyProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { OffersDiscussion, Reply } from '~/types/offers'; import type { OffersDiscussion, Reply } from '~/types/offers';
@ -84,19 +85,6 @@ export default function ProfileComments({
} }
} }
function handleCopyEditLink() {
// TODO: Add notification
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}?token=${token}`,
);
}
function handleCopyPublicLink() {
navigator.clipboard.writeText(
`${window.location.origin}/offers/profile/${profileId}`,
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
@ -116,7 +104,7 @@ export default function ProfileComments({
label="Copy profile edit link" label="Copy profile edit link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={handleCopyEditLink} onClick={() => copyProfileLink(profileId, token)}
/> />
)} )}
<Button <Button
@ -127,7 +115,7 @@ export default function ProfileComments({
label="Copy public link" label="Copy public link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={handleCopyPublicLink} onClick={() => copyProfileLink(profileId)}
/> />
</div> </div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2> <h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>

@ -27,10 +27,10 @@ export default function ProfileDetails({
); );
} }
if (selectedTab === 'offers') { if (selectedTab === 'offers') {
if (offers && offers.length !== 0) { if (offers.length !== 0) {
return ( return (
<> <>
{[...offers].map((offer) => ( {offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} /> <OfferCard key={offer.id} offer={offer} />
))} ))}
</> </>

@ -1,3 +1,4 @@
import { useRouter } from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
BookmarkSquareIcon, BookmarkSquareIcon,
@ -11,6 +12,8 @@ import { Button, Dialog, Spinner, Tabs } from '@tih/ui';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder'; import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import type { BackgroundCard } from '~/components/offers/types'; import type { BackgroundCard } from '~/components/offers/types';
import { getProfileEditPath } from '~/utils/offers/link';
type ProfileHeaderProps = Readonly<{ type ProfileHeaderProps = Readonly<{
background?: BackgroundCard; background?: BackgroundCard;
handleDelete: () => void; handleDelete: () => void;
@ -29,6 +32,12 @@ export default function ProfileHeader({
setSelectedTab, setSelectedTab,
}: ProfileHeaderProps) { }: ProfileHeaderProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const router = useRouter();
const { offerProfileId = '', token = '' } = router.query;
const handleEditClick = () => {
router.push(getProfileEditPath(offerProfileId as string, token as string));
};
function renderActionList() { function renderActionList() {
return ( return (
@ -48,6 +57,7 @@ export default function ProfileHeader({
label="Edit" label="Edit"
size="md" size="md"
variant="tertiary" variant="tertiary"
onClick={handleEditClick}
/> />
<Button <Button
disabled={isLoading} disabled={isLoading}
@ -119,9 +129,11 @@ export default function ProfileHeader({
<div className="flex flex-row"> <div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" /> <BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span> <span className="mr-2 font-bold">Current:</span>
<span>{`${background?.experiences[0].companyName ?? '-'} ${ <span>
background?.experiences[0].jobLevel {`${background?.experiences[0]?.companyName ?? '-'} ${
} ${background?.experiences[0].jobTitle}`}</span> background?.experiences[0]?.jobLevel || ''
} ${background?.experiences[0]?.jobTitle || ''}`}
</span>
</div> </div>
<div className="flex flex-row"> <div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" /> <CalendarDaysIcon className="mr-2.5 h-5" />

@ -1,6 +1,6 @@
import Link from 'next/link'; import Link from 'next/link';
import { convertCurrencyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
import type { DashboardOffer } from '~/types/offers'; import type { DashboardOffer } from '~/types/offers';
@ -21,7 +21,7 @@ export default function OfferTableRow({
</th> </th>
<td className="py-4 px-6">{title}</td> <td className="py-4 px-6">{title}</td>
<td className="py-4 px-6">{totalYoe}</td> <td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertCurrencyToString(income)}</td> <td className="py-4 px-6">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td> <td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="space-x-4 py-4 px-6"> <td className="space-x-4 py-4 px-6">
<Link <Link

@ -2,7 +2,12 @@ import { useEffect, useState } from 'react';
import { HorizontalDivider, Select, Spinner, Tabs } from '@tih/ui'; import { HorizontalDivider, Select, Spinner, Tabs } from '@tih/ui';
import OffersTablePagination from '~/components/offers/table/OffersTablePagination'; import OffersTablePagination from '~/components/offers/table/OffersTablePagination';
import { YOE_CATEGORY } from '~/components/offers/table/types'; import {
OfferTableFilterOptions,
OfferTableSortBy,
OfferTableTabOptions,
YOE_CATEGORY,
} from '~/components/offers/table/types';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector'; import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -29,7 +34,9 @@ export default function OffersTable({
totalItems: 0, totalItems: 0,
}); });
const [offers, setOffers] = useState<Array<DashboardOffer>>([]); const [offers, setOffers] = useState<Array<DashboardOffer>>([]);
const [selectedFilter, setSelectedFilter] = useState(
OfferTableFilterOptions[0].value,
);
useEffect(() => { useEffect(() => {
setPagination({ setPagination({
currentPage: 0, currentPage: 0,
@ -45,13 +52,16 @@ export default function OffersTable({
companyId: companyFilter, companyId: companyFilter,
limit: NUMBER_OF_OFFERS_IN_PAGE, limit: NUMBER_OF_OFFERS_IN_PAGE,
location: 'Singapore, Singapore', // TODO: Geolocation location: 'Singapore, Singapore', // TODO: Geolocation
offset: 0, offset: pagination.currentPage,
sortBy: '-monthYearReceived', sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
title: jobTitleFilter, title: jobTitleFilter,
yoeCategory: selectedTab, yoeCategory: selectedTab,
}, },
], ],
{ {
onError: (err) => {
alert(err);
},
onSuccess: (response: GetOffersResponse) => { onSuccess: (response: GetOffersResponse) => {
setOffers(response.data); setOffers(response.data);
setPagination(response.paging); setPagination(response.paging);
@ -65,24 +75,7 @@ export default function OffersTable({
<div className="w-fit"> <div className="w-fit">
<Tabs <Tabs
label="Table Navigation" label="Table Navigation"
tabs={[ tabs={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,
},
]}
value={selectedTab} value={selectedTab}
onChange={(value) => setSelectedTab(value)} onChange={(value) => setSelectedTab(value)}
/> />
@ -102,16 +95,11 @@ export default function OffersTable({
/> />
</div> </div>
<Select <Select
disabled={true}
isLabelHidden={true} isLabelHidden={true}
label="" label=""
options={[ options={OfferTableFilterOptions}
{ value={selectedFilter}
label: 'Latest Submitted', onChange={(value) => setSelectedFilter(value)}
value: 'latest-submitted',
},
]}
value="latest-submitted"
/> />
</div> </div>
); );
@ -139,7 +127,9 @@ export default function OffersTable({
} }
const handlePageChange = (currPage: number) => { const handlePageChange = (currPage: number) => {
setPagination({ ...pagination, currentPage: currPage }); if (0 < currPage && currPage < pagination.numOfPages) {
setPagination({ ...pagination, currentPage: currPage });
}
}; };
return ( return (

@ -33,7 +33,7 @@ export default function OffersTablePagination({
current={pagination.currentPage + 1} current={pagination.currentPage + 1}
end={pagination.numOfPages} end={pagination.numOfPages}
label="Pagination" label="Pagination"
pagePadding={1} pagePadding={2}
start={1} start={1}
onSelect={(currPage) => { onSelect={(currPage) => {
handlePageChange(currPage - 1); handlePageChange(currPage - 1);

@ -5,3 +5,48 @@ export enum YOE_CATEGORY {
MID = 2, MID = 2,
SENIOR = 3, SENIOR = 3,
} }
export const OfferTableTabOptions = [
{
label: 'Fresh Grad (0-2 YOE)',
value: YOE_CATEGORY.ENTRY,
},
{
label: 'Mid (3-5 YOE)',
value: YOE_CATEGORY.MID,
},
{
label: 'Senior (6+ YOE)',
value: YOE_CATEGORY.SENIOR,
},
{
label: 'Internship',
value: YOE_CATEGORY.INTERN,
},
];
export const OfferTableFilterOptions = [
{
label: 'Latest Submitted',
value: 'latest-submitted',
},
{
label: 'Highest Salary',
value: 'highest-salary',
},
{
label: 'Highest YOE first',
value: 'highest-yoe-first',
},
{
label: 'Lowest YOE first',
value: 'lowest-yoe-first',
},
];
export const OfferTableSortBy: Record<string, string> = {
'highest-salary': '-totalCompensation',
'highest-yoe-first': '-totalYoe',
'latest-submitted': '-monthYearReceived',
'lowest-yoe-first': '+totalYoe',
};

@ -0,0 +1,68 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeCoolIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 511.999 511.999"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<circle cx="247.796" cy="255.997" fill="#FFDB6C" r="247.796" />
<path
d="M300.895,467.216c-136.853,0-247.794-110.941-247.794-247.794c0-73.116,31.673-138.825,82.04-184.181
C54.919,76.258,0,159.716,0,256.003c0,136.853,110.941,247.794,247.794,247.794c63.738,0,121.848-24.073,165.754-63.612
C379.75,457.466,341.462,467.216,300.895,467.216z"
fill="#FCC56B"
/>
<g>
<path
d="M141.308,259.555c-18.402,0-33.321,14.918-33.321,33.32h66.641
C174.628,274.473,159.71,259.555,141.308,259.555z"
fill="#F9A880"
/>
<path
d="M431.948,259.555c-18.402,0-33.321,14.918-33.321,33.32h66.641
C465.269,274.473,450.349,259.555,431.948,259.555z"
fill="#F9A880"
/>
</g>
<path
d="M105.165,121.895c64.702-14.849,117.079-9.739,175.098,3.782c8.604,2.004,17.692,4.239,27.29,4.532
c15.985,0.489,33.956-3.489,49.449-7.382c61.168-15.366,108.95-7.374,154.996,2.465l-3.402,27.211
c-7.188,0.159-9.449,3.511-11.503,10.054c-10.747,34.242-1.594,93.16-81.048,86.233c-52.27-4.558-67.239-18.879-92.152-81.847
c-2.12-5.356-3.497-14.207-15.602-13.88c-6.835,0.184-12.948,1.392-15.079,13.267c-3.973,22.126-34.188,82.245-95.535,82.179
c-54.185-0.058-74.855-28.184-77.323-90.159c-0.306-7.695-7.012-9.156-11.035-9.246L105.165,121.895L105.165,121.895z"
fill="#56586F"
/>
<g>
<path
d="M199.128,113.331l-37.84,129.044c9.958,4.097,21.979,6.12,36.392,6.134
c0.254,0,0.504-0.009,0.758-0.011l38.499-131.292C224.347,115.304,211.809,113.972,199.128,113.331z"
fill="#737891"
/>
<path
d="M434.438,114.376c-12.593-0.403-25.665,0-39.395,1.534l-33.781,115.202
c9.238,7.758,20.144,12.263,34.543,15.016L434.438,114.376z"
fill="#737891"
/>
</g>
<path
d="M319.673,395.914c-16.785,0-33.382-5.73-46.784-16.718c-4.305-3.53-4.933-9.882-1.403-14.187
c3.53-4.306,9.882-4.933,14.188-1.403c15.016,12.314,35.551,15.539,53.597,8.423c17.582-6.937,30.535-23.491,33.802-43.202
c0.913-5.492,6.101-9.207,11.594-8.296c5.493,0.911,9.207,6.102,8.297,11.594c-4.422,26.66-22.161,49.137-46.296,58.657
C337.935,394.228,328.776,395.914,319.673,395.914z"
fill="#7F184C"
/>
<ellipse
cx="298.209"
cy="78.261"
fill="#FCEB88"
rx="28.897"
ry="51.747"
transform="matrix(0.2723 -0.9622 0.9622 0.2723 141.702 343.89)"
/>
</svg>
);
}

@ -0,0 +1,78 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeRocketIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 496.158 496.158"
x="36px"
xmlns="http://www.w3.org/2000/svg"
y="36px">
<path
d="M248.082,0.003C111.07,0.003,0,111.063,0,248.085c0,137.001,111.07,248.07,248.082,248.07 c137.006,0,248.076-111.069,248.076-248.07C496.158,111.062,385.088,0.003,248.082,0.003z"
fill="#334D5C"
/>
<g>
<polygon
fill="#DBBB00"
points="130.14,198.865 112.329,198.237 106.733,181.859 101.138,198.237 83.327,198.865 97.68,208.88 92.267,226.381 106.733,215.458 121.199,226.381 115.787,208.88 "
/>
<polygon
fill="#DBBB00"
points="112.416,202.889 115.484,191.248 105.788,198.382 95.265,191.881 99.455,203.294 89.618,211.306 102.168,210.835 106.348,222.679 110.18,210.584 122.334,210.282 "
/>
<polygon
fill="#DBBB00"
points="357.01,69.501 339.199,68.873 333.603,52.496 328.008,68.873 310.197,69.501 324.55,79.516 319.138,97.017 333.603,86.094 348.069,97.017 342.657,79.516 "
/>
<polygon
fill="#DBBB00"
points="339.286,73.525 342.354,61.884 332.658,69.018 322.135,62.517 326.325,73.93 316.488,81.942 329.038,81.472 333.218,93.315 337.05,81.221 349.204,80.918 "
/>
<polygon
fill="#DBBB00"
points="429.005,224.008 411.194,223.38 405.599,207.003 400.003,223.38 382.192,224.008 396.545,234.023 391.133,251.524 405.599,240.601 420.064,251.524 414.652,234.023 "
/>
<polygon
fill="#DBBB00"
points="411.281,228.032 414.35,216.392 404.653,223.526 394.13,217.024 398.32,228.437 388.483,236.449 401.033,235.979 405.213,247.822 409.045,235.728 421.199,235.426 "
/>
</g>
<path
d="M383.34,314.795c-5.941-14.345-21.202-36.571-46.212-55.931 c-19.131-14.808-50.218-32.46-89.678-32.46c-39.018,0-69.746,16.634-88.654,30.588c-25.352,18.71-40.673,40.56-46.559,54.769 c-4.417,10.663-4.502,18.883-0.239,23.145c3.465,3.465,7.585,5.079,12.965,5.079c6.495,0,14.247-2.294,24.975-5.469 c20.098-5.947,50.469-14.936,97.513-14.936c48.545,0,80.322,8.617,101.35,14.318c10.673,2.894,18.384,4.985,24.472,4.986h0.003 c4.713,0,8.172-1.264,10.886-3.979C387.635,331.431,387.35,324.477,383.34,314.795z"
fill="#EA6307"
/>
<path
d="M286.255,121.222c-14.873-40.687-31.176-66.481-38.176-66.481c-6.988,0-23.253,25.596-38.118,66.13 c-15.702,42.815-29.844,102.297-29.844,165.89c0,40.446,6.193,56.536,6.193,56.536s25.869,13.801,62.818,13.801 s60.716-13.801,60.716-13.801s6.101-16.404,6.101-57.03C315.945,223.234,301.891,163.997,286.255,121.222z"
fill="#DFEADC"
/>
<path
d="M248.166,54.741c-8.74,0-24.42,24.539-38.204,66.13c10.715,2.375,24.12,4.325,39.314,4.325 c14.394,0,26.884-1.749,36.92-3.953C272.454,79.654,256.87,54.741,248.166,54.741z"
fill="#CE5800"
/>
<path
d="M248.165,54.741c-8.343,0-23.005,22.365-36.309,60.561c10.384,2.186,23.106,3.916,37.418,3.916 c13.501,0,25.329-1.54,35.026-3.549C271.044,77.446,256.471,54.741,248.165,54.741z"
fill="#EA6307"
/>
<circle cx="248.079" cy="183.889" fill="#DBBB00" r="30.677" />
<circle cx="248.079" cy="183.889" fill="#FFDB29" r="25.486" />
<path
d="M262.936,167.597c-8.602-8.601-22.547-8.602-31.148,0s-8.602,22.547,0,31.149 S271.538,176.199,262.936,167.597z"
fill="#FFE36E"
/>
<path
d="M249.007,368.151c-16.392,0.012-32.76,0.337-32.76,8.403c0,16.16,32.564,81.608,32.564,81.608 s33.101-65.882,33.101-81.608C281.912,368.464,265.447,368.139,249.007,368.151z"
fill="#E17A2D"
/>
<path
d="M249.079,371.948c-11.66,0-23.32-0.845-23.32,4.894c0,11.479,23.131,57.964,23.131,57.964 s23.51-46.794,23.51-57.964C272.399,371.103,260.739,371.948,249.079,371.948z"
fill="#F4E028"
/>
<path
d="M249.079,376.829c-7.005,0-14.011-1.99-14.011,1.458c0,6.896,13.897,34.824,13.897,34.824 s14.124-28.113,14.124-34.824C263.09,374.839,256.084,376.829,249.079,376.829z"
fill="#FFFFFF"
/>
</svg>
);
}

@ -0,0 +1,198 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeTreasureIcon({
className,
}: ResumeBadgeProps) {
return (
<svg
className={className}
viewBox="0 0 511.672 511.672"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<path
d="M473.853,222.264l37.757-57.073c0,0,4.779-141.878-85.273-149.217h-170.47h-0.078H85.32
C-4.731,23.313,0.047,165.19,0.047,165.19l37.929,56.526L0,325.057l42.629,170.579l426.398,0.062l42.645-170.595L473.853,222.264z"
fill="#A85D5D"
/>
<g opacity={0.2}>
<path
d="M0.593,165.987C3.186,126.403,16.771,42.878,85.32,37.273h170.469h0.078h170.469
c68.551,5.606,82.15,89.162,84.728,128.73l0.546-0.812c0,0,4.779-141.878-85.273-149.217h-170.47h-0.078H85.32
C-4.731,23.313,0.047,165.19,0.047,165.19L0.593,165.987z"
fill="#FFFFFF"
/>
</g>
<polygon
fill="#723F3F"
points="511.672,325.104 0,325.057 37.976,221.717 473.853,222.264 "
/>
<polygon
fill="#8C4C4C"
points="473.853,222.264 37.976,221.717 0.047,165.19 511.609,165.19 "
/>
<path
d="M266.485,410.315c0,5.887-4.778,10.665-10.649,10.665c-5.887,0-10.665-4.778-10.665-10.665
c0-5.888,4.778-10.649,10.665-10.649C261.707,399.666,266.485,404.428,266.485,410.315z"
/>
<g>
<path
d="M170.251,295.045c-12.258,0-24.079-2.701-32.448-7.401c-6.387-3.591-10.181-8.042-10.181-11.914
c0-7.87,16.615-19.315,42.629-19.315c12.257,0,24.094,2.701,32.463,7.417c6.371,3.575,10.181,8.026,10.181,11.898
C212.895,283.615,196.28,295.045,170.251,295.045z"
fill="#FFCE54"
/>
<path
d="M255.524,295.045c-12.258,0-24.079-2.701-32.464-7.401c-6.371-3.591-10.165-8.042-10.165-11.914
c0-7.87,16.599-19.315,42.629-19.315c12.257,0,24.078,2.701,32.463,7.417c6.371,3.575,10.165,8.026,10.165,11.898
C298.152,283.615,281.555,295.045,255.524,295.045z"
fill="#FFCE54"
/>
<path
d="M340.797,295.045c-12.258,0-24.094-2.701-32.463-7.401c-6.371-3.591-10.182-8.042-10.182-11.914
c0-7.87,16.615-19.315,42.645-19.315c12.258,0,24.078,2.701,32.448,7.417c6.371,3.575,10.181,8.026,10.181,11.898
C383.426,283.615,366.813,295.045,340.797,295.045z"
fill="#FFCE54"
/>
<path
d="M212.895,265.065c-12.258,0-24.094-2.686-32.464-7.401c-6.371-3.592-10.181-8.026-10.181-11.899
c0-7.885,16.614-19.332,42.645-19.332c12.242,0,24.078,2.717,32.448,7.417c6.371,3.576,10.181,8.042,10.181,11.915
C255.524,253.634,238.909,265.065,212.895,265.065z"
fill="#FFCE54"
/>
<path
d="M298.152,265.065c-12.242,0-24.078-2.686-32.447-7.401c-6.371-3.592-10.181-8.026-10.181-11.899
c0-7.885,16.615-19.332,42.628-19.332c12.258,0,24.094,2.717,32.464,7.417c6.371,3.576,10.181,8.042,10.181,11.915
C340.797,253.634,324.184,265.065,298.152,265.065z"
fill="#FFCE54"
/>
<path
d="M255.524,235.099c-12.258,0-24.079-2.702-32.464-7.417c-6.371-3.575-10.165-8.026-10.165-11.898
c0-7.87,16.599-19.315,42.629-19.315c12.257,0,24.078,2.701,32.463,7.417c6.371,3.576,10.165,8.026,10.165,11.898
C298.152,223.653,281.555,235.099,255.524,235.099z"
fill="#FFCE54"
/>
<path
d="M91.629,325.104h72.516c4.153-3.123,6.417-6.511,6.417-9.415c0-3.873-3.81-8.308-10.181-11.899
c-8.37-4.715-20.206-7.401-32.463-7.401c-26.015,0-42.629,11.431-42.629,19.301C85.289,318.718,87.6,322.074,91.629,325.104z"
fill="#FFCE54"
/>
<path
d="M176.902,325.104h72.516c4.153-3.123,6.402-6.511,6.402-9.415c0-3.873-3.794-8.308-10.165-11.899
c-8.37-4.715-20.206-7.401-32.464-7.401c-26.03,0-42.629,11.431-42.629,19.301C170.563,318.718,172.874,322.074,176.902,325.104z"
fill="#FFCE54"
/>
</g>
<rect
fill="#CCD1D9"
height="98.75"
width="96.27"
x="206.586"
y="325.106"
/>
<g>
<path
d="M262.16,325.104h72.516c4.154-3.123,6.418-6.511,6.418-9.415c0-3.873-3.795-8.308-10.165-11.899
c-8.386-4.715-20.206-7.401-32.464-7.401c-26.029,0-42.645,11.431-42.645,19.301C255.82,318.718,258.147,322.074,262.16,325.104z"
fill="#FFCE54"
/>
<path
d="M347.434,325.104h72.516c4.154-3.123,6.418-6.511,6.418-9.415c0-3.873-3.81-8.308-10.181-11.899
c-8.37-4.715-20.206-7.401-32.448-7.401c-26.029,0-42.645,11.431-42.645,19.301C341.094,318.718,343.404,322.074,347.434,325.104z"
fill="#FFCE54"
/>
</g>
<path
d="M434.331,325.104c1.733-2.951,2.702-6.121,2.702-9.415c0-15.179-20.098-27.732-46.158-29.7
c2.076-3.186,3.217-6.652,3.217-10.259c0-14.507-18.332-26.593-42.66-29.372c0-0.203,0.016-0.406,0.016-0.593
c0-14.522-18.316-26.608-42.66-29.387c0.016-0.188,0.031-0.391,0.031-0.594c0-16.552-23.86-29.98-53.294-29.98
c-29.435,0-53.294,13.429-53.294,29.98c0,0.203,0.016,0.406,0.031,0.594c-24.344,2.779-42.661,14.865-42.661,29.387
c0,0.188,0.016,0.39,0.016,0.593c-24.328,2.779-42.66,14.865-42.66,29.372c0,3.622,1.14,7.104,3.248,10.321
c-25.78,2.093-45.58,14.568-45.58,29.638c0,3.294,0.968,6.464,2.701,9.415H434.331z M236.989,319.998
c-6.605,2.811-15.053,4.356-23.797,4.356s-17.192-1.546-23.782-4.356c-3.654-1.562-5.934-3.154-7.198-4.31
c1.265-1.124,3.544-2.733,7.198-4.278c6.59-2.812,15.038-4.373,23.782-4.373c8.745,0,17.192,1.562,23.797,4.373
c3.638,1.545,5.918,3.154,7.199,4.278C242.907,316.844,240.627,318.437,236.989,319.998z M231.727,280.024
c-3.638-1.546-5.918-3.154-7.199-4.294c0.297-0.266,0.656-0.547,1.062-0.858c9.322-1.281,17.676-3.936,24.344-7.59
c1.843-0.125,3.716-0.203,5.59-0.203s3.748,0.078,5.574,0.203c6.684,3.654,15.038,6.309,24.359,7.59
c0.406,0.312,0.766,0.593,1.062,0.858c-1.28,1.14-3.561,2.748-7.199,4.294c-6.604,2.811-15.053,4.372-23.796,4.372
C246.779,284.396,238.332,282.834,231.727,280.024z M322.246,319.998c-6.589,2.811-15.037,4.356-23.781,4.356
s-17.191-1.546-23.797-4.356c-3.639-1.562-5.918-3.154-7.199-4.31c1.281-1.124,3.561-2.733,7.199-4.278
c6.605-2.812,15.053-4.373,23.797-4.373s17.192,1.562,23.781,4.373c3.654,1.545,5.934,3.154,7.199,4.278
C328.18,316.844,325.9,318.437,322.246,319.998z M407.52,311.41c3.654,1.545,5.935,3.154,7.199,4.278
c-1.265,1.155-3.545,2.748-7.199,4.31c-6.589,2.811-15.053,4.356-23.781,4.356c-8.744,0-17.191-1.546-23.797-4.356
c-3.654-1.562-5.934-3.154-7.199-4.31c1.266-1.124,3.545-2.733,7.199-4.278c6.605-2.812,15.053-4.373,23.797-4.373
C392.467,307.037,400.931,308.599,407.52,311.41z M340.797,267.078c8.744,0,17.192,1.547,23.782,4.357
c3.653,1.562,5.934,3.154,7.198,4.294c-1.265,1.14-3.545,2.748-7.198,4.294c-6.59,2.811-15.038,4.372-23.782,4.372
s-17.191-1.562-23.797-4.372c-3.654-1.546-5.934-3.154-7.198-4.294c0.296-0.266,0.655-0.547,1.062-0.858
c9.322-1.281,17.676-3.936,24.344-7.59C337.05,267.156,338.908,267.078,340.797,267.078z M298.152,237.098
c8.744,0,17.192,1.546,23.798,4.356c3.653,1.562,5.934,3.154,7.198,4.31c-0.297,0.25-0.656,0.546-1.062,0.859
c-9.322,1.28-17.677,3.95-24.345,7.573c-1.842,0.141-3.7,0.219-5.59,0.219c-1.873,0-3.731-0.078-5.574-0.219
c-6.684-3.623-15.037-6.293-24.359-7.573c-0.406-0.312-0.75-0.609-1.047-0.859c0.297-0.281,0.641-0.562,1.047-0.875
c9.322-1.28,17.676-3.935,24.359-7.573C294.405,237.176,296.279,237.098,298.152,237.098z M231.727,211.489
c6.605-2.811,15.053-4.356,23.797-4.356s17.192,1.546,23.796,4.356c3.639,1.546,5.919,3.154,7.199,4.294
c-0.297,0.266-0.656,0.562-1.062,0.875c-9.321,1.281-17.676,3.935-24.359,7.558c-1.826,0.156-3.7,0.218-5.574,0.218
s-3.748-0.062-5.59-0.218c-6.667-3.623-15.021-6.277-24.344-7.558c-0.406-0.312-0.765-0.609-1.062-0.875
C225.809,214.644,228.088,213.035,231.727,211.489z M189.098,241.454c6.605-2.811,15.053-4.356,23.797-4.356
c1.874,0,3.732,0.078,5.574,0.219c6.684,3.639,15.038,6.293,24.359,7.573c0.406,0.312,0.75,0.594,1.046,0.875
c-0.297,0.25-0.64,0.546-1.046,0.859c-9.322,1.28-17.691,3.95-24.359,7.573c-1.842,0.141-3.701,0.219-5.574,0.219
c-1.89,0-3.748-0.078-5.59-0.219c-6.667-3.623-15.021-6.293-24.344-7.573c-0.406-0.312-0.765-0.609-1.062-0.859
C183.164,244.608,185.444,243.016,189.098,241.454z M146.469,271.436c6.589-2.811,15.037-4.357,23.782-4.357
c1.889,0,3.748,0.078,5.59,0.203c6.667,3.654,15.021,6.309,24.344,7.59c0.406,0.312,0.765,0.593,1.062,0.858
c-1.28,1.14-3.544,2.748-7.199,4.294c-6.605,2.811-15.053,4.372-23.797,4.372c-8.745,0-17.192-1.562-23.782-4.372
c-3.654-1.546-5.934-3.154-7.199-4.294C140.535,274.59,142.815,272.997,146.469,271.436z M104.136,311.41
c6.59-2.812,15.053-4.373,23.782-4.373c8.745,0,17.192,1.562,23.797,4.373c3.654,1.545,5.934,3.154,7.198,4.278
c-1.265,1.155-3.544,2.748-7.198,4.31c-6.605,2.811-15.053,4.356-23.797,4.356s-17.192-1.546-23.782-4.356
c-3.654-1.562-5.934-3.154-7.198-4.31C98.203,314.565,100.482,312.955,104.136,311.41z"
fill="#F6BB42"
/>
<rect
fill="#AAB2BC"
height="26.702"
width="21.331"
x="244.856"
y="383.616"
/>
<path
d="M270.936,369.982c0,8.448-6.855,15.287-15.287,15.287c-8.448,0-15.303-6.839-15.303-15.287
c0-8.447,6.855-15.287,15.303-15.287C264.08,354.694,270.936,361.534,270.936,369.982z"
fill="#434A54"
/>
<path
d="M319.467,165.19c0,0,0.016-0.016,0.016-0.031V122.53c0-5.871-4.777-10.649-10.664-10.649H202.23
c-5.887,0-10.665,4.778-10.665,10.649v42.629c0,0.016,0,0.031,0,0.031H319.467z"
fill="#CCD1D9"
/>
<g>
<path
d="M212.895,165.19v-31.995h85.257v31.995h21.314c0-21.798,0.016-42.66,0.016-42.66
c0-5.871-4.777-10.649-10.664-10.649H202.23c-5.887,0-10.665,4.778-10.665,10.649c0,0,0,20.862,0,42.66H212.895z"
fill="#AAB2BC"
/>
<polygon
fill="#AAB2BC"
points="55.152,325.057 92.894,495.651 114.723,495.651 76.998,325.057 "
/>
<polygon
fill="#AAB2BC"
points="434.159,325.088 396.387,495.698 418.217,495.698 455.989,325.088 "
/>
<path
d="M298.152,325.088v95.893h-85.257v-95.908h-21.33v106.573c0,5.871,4.778,10.649,10.665,10.649
h106.588c5.887,0,10.664-4.778,10.664-10.649V325.088H298.152z"
fill="#AAB2BC"
/>
<path
d="M255.836,335.707c-17.661,0-31.979,14.318-31.979,31.979c0,17.66,14.319,31.979,31.979,31.979
c17.645,0,31.964-14.319,31.964-31.979C287.8,350.025,273.481,335.707,255.836,335.707z M255.836,378.336
c-5.887,0-10.665-4.778-10.665-10.649c0-5.872,4.778-10.665,10.665-10.665c5.871,0,10.649,4.793,10.649,10.665
C266.485,373.558,261.707,378.336,255.836,378.336z"
fill="#AAB2BC"
/>
</g>
<polygon
opacity={0.1}
points="0,325.057 7.823,303.727 503.803,303.727 511.672,325.057"
/>
</svg>
);
}

@ -0,0 +1,3 @@
export type ResumeBadgeProps = Readonly<{
className: string;
}>;

@ -1,7 +1,12 @@
export default function SilverReviewerBadgeIcon() { import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeDetectiveIcon({
className,
}: ResumeBadgeProps) {
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
className={className}
height="36px" height="36px"
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
role="img" role="img"

@ -1,7 +1,10 @@
export default function BronzeReviewerBadgeIcon() { import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeEagleIcon({ className }: ResumeBadgeProps) {
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
className={className}
height="36px" height="36px"
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
viewBox="0 0 36 36" viewBox="0 0 36 36"

@ -1,7 +1,12 @@
export default function GoldReviewerBadgeIcon() { import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeSuperheroIcon({
className,
}: ResumeBadgeProps) {
return ( return (
<svg <svg
aria-hidden="true" aria-hidden="true"
className={className}
height="36px" height="36px"
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
role="img" role="img"

@ -0,0 +1,135 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeBookIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 512 512"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<path
d="M8.17,90.446v350.268H224.7c2.367,0,3.648,1.543,4.089,2.206l9.701,11.315l35.025-0.003l9.699-11.316
c0.439-0.661,1.719-2.202,4.086-2.202H503.83V90.446H8.17z"
fill="#FF7226"
/>
<path
d="M224.699,57.766H40.851v350.268h183.848c13.061,0,24.571,6.669,31.301,16.786l21.787-175.126
L256,74.567C249.271,64.442,237.767,57.766,224.699,57.766z"
fill="#F7EBD4"
/>
<path
d="M287.301,57.766c-13.068,0-24.573,6.677-31.301,16.801v350.252
c6.729-10.119,18.238-16.786,31.301-16.786h183.848V57.766H287.301z"
fill="#D2F0E7"
/>
<rect fill="#F99FB6" height="67.028" width="128" x="84.426" y="297.428" />
<g>
<path
d="M256,148.099c4.513,0,8.17-3.658,8.17-8.17v-32.681c0-4.512-3.657-8.17-8.17-8.17
s-8.17,3.658-8.17,8.17v32.681C247.83,144.441,251.487,148.099,256,148.099z"
fill="#3E0412"
/>
<path
d="M256,390.861c4.513,0,8.17-3.658,8.17-8.17V172.609c0-4.512-3.657-8.17-8.17-8.17
s-8.17,3.658-8.17,8.17v210.081C247.83,387.203,251.487,390.861,256,390.861z"
fill="#3E0412"
/>
<path
d="M503.83,82.276c-4.513,0-8.17,3.658-8.17,8.17v342.098H287.3c-4.182,0-8.077,1.987-10.54,5.346
l-7.004,8.172l-27.511,0.002l-7.007-8.172c-2.467-3.36-6.363-5.348-10.541-5.348H16.34V90.446c0-4.512-3.657-8.17-8.17-8.17
S0,85.934,0,90.446v350.268c0,4.512,3.657,8.17,8.17,8.17h214.971l9.146,10.668c1.552,1.81,3.818,2.852,6.203,2.852l35.025-0.003
c2.385,0,4.652-1.043,6.203-2.854l9.139-10.664H503.83c4.513,0,8.17-3.658,8.17-8.17V90.446
C512,85.934,508.343,82.276,503.83,82.276z"
fill="#3E0412"
/>
<path
d="M40.851,416.204H224.7c9.866,0,19.024,4.912,24.498,13.141c1.515,2.277,4.068,3.645,6.803,3.645
c2.734,0,5.288-1.368,6.802-3.646c5.471-8.228,14.629-13.14,24.496-13.14h183.849c4.513,0,8.17-3.658,8.17-8.17V57.766
c0-4.512-3.657-8.17-8.17-8.17H287.3c-11.783,0-22.915,4.503-31.3,12.389c-8.386-7.885-19.517-12.389-31.3-12.389H40.851
c-4.513,0-8.17,3.658-8.17,8.17v350.268C32.681,412.546,36.338,416.204,40.851,416.204z M49.021,65.936H224.7
c9.865,0,19.022,4.917,24.495,13.154c1.514,2.279,4.068,3.648,6.804,3.648c2.736,0,5.29-1.369,6.805-3.648
c5.472-8.237,14.629-13.153,24.494-13.153h175.679v333.927H287.3c-11.784,0-22.915,4.5-31.301,12.378
c-8.386-7.878-19.517-12.378-31.298-12.378H49.021V65.936z"
fill="#3E0412"
/>
<path
d="M212.426,93.17h-128c-4.513,0-8.17,3.658-8.17,8.17c0,4.512,3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17C220.596,96.828,216.939,93.17,212.426,93.17z"
fill="#3E0412"
/>
<path
d="M212.426,125.851h-128c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17C220.596,129.509,216.939,125.851,212.426,125.851z"
fill="#3E0412"
/>
<path
d="M212.426,158.532h-128c-4.513,0-8.17,3.658-8.17,8.17c0,4.512,3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17C220.596,162.19,216.939,158.532,212.426,158.532z"
fill="#3E0412"
/>
<path
d="M212.426,191.212h-128c-4.513,0-8.17,3.658-8.17,8.17c0,4.512,3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17C220.596,194.87,216.939,191.212,212.426,191.212z"
fill="#3E0412"
/>
<path
d="M212.426,223.893h-128c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17S216.939,223.893,212.426,223.893z"
fill="#3E0412"
/>
<path
d="M84.426,272.914h64c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17h-64
c-4.513,0-8.17,3.658-8.17,8.17C76.255,269.256,79.912,272.914,84.426,272.914z"
fill="#3E0412"
/>
<path
d="M212.426,289.255h-128c-4.513,0-8.17,3.658-8.17,8.17v67.034c0,4.512,3.657,8.17,8.17,8.17h128
c4.513,0,8.17-3.658,8.17-8.17v-67.034C220.596,292.913,216.939,289.255,212.426,289.255z M204.255,356.289H92.596v-50.693h111.66
V356.289z"
fill="#3E0412"
/>
<path
d="M299.574,241.906h128c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17h-128
c-4.513,0-8.17,3.658-8.17,8.17C291.404,238.248,295.061,241.906,299.574,241.906z"
fill="#3E0412"
/>
<path
d="M299.574,274.587h128c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17h-128
c-4.513,0-8.17,3.658-8.17,8.17C291.404,270.929,295.061,274.587,299.574,274.587z"
fill="#3E0412"
/>
<path
d="M299.574,307.268h128c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17h-128
c-4.513,0-8.17,3.658-8.17,8.17S295.061,307.268,299.574,307.268z"
fill="#3E0412"
/>
<path
d="M299.574,339.948h128c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17h-128
c-4.513,0-8.17,3.658-8.17,8.17S295.061,339.948,299.574,339.948z"
fill="#3E0412"
/>
<path
d="M299.574,372.629h64c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17h-64
c-4.513,0-8.17,3.658-8.17,8.17C291.404,368.971,295.061,372.629,299.574,372.629z"
fill="#3E0412"
/>
<path
d="M299.574,192.885c-4.513,0-8.17,3.658-8.17,8.17s3.657,8.17,8.17,8.17
c11.714,0,21.543-2.95,30.036-7.774c0.43-0.204,0.84-0.44,1.226-0.712c13.658-8.171,23.803-21.223,32.739-34.574
c8.933,13.346,19.073,26.393,32.723,34.564c0.398,0.282,0.821,0.528,1.268,0.736c8.486,4.814,18.308,7.758,30.01,7.758
c4.513,0,8.17-3.658,8.17-8.17s-3.657-8.17-8.17-8.17c-6.915,0-12.958-1.35-18.383-3.759v-75.856
c5.425-2.41,11.468-3.759,18.383-3.759c4.513,0,8.17-3.658,8.17-8.17c0-4.512-3.657-8.17-8.17-8.17
c-11.706,0-21.531,2.947-30.021,7.764c-0.439,0.207-0.858,0.449-1.251,0.727c-13.654,8.171-23.795,21.22-32.729,34.568
c-8.938-13.353-19.083-26.406-32.745-34.577c-0.38-0.268-0.784-0.501-1.208-0.702c-8.495-4.827-18.327-7.779-30.047-7.779
c-4.513,0-8.17,3.658-8.17,8.17c0,4.512,3.657,8.17,8.17,8.17c6.915,0,12.958,1.35,18.383,3.759v75.856
C312.532,191.535,306.49,192.885,299.574,192.885z M392.851,125.053v52.288c-7.034-7.18-13.2-16.294-19.562-26.144
C379.651,141.347,385.817,132.232,392.851,125.053z M353.86,151.197c-6.363,9.85-12.528,18.965-19.562,26.144v-52.288
C341.332,132.232,347.498,141.347,353.86,151.197z"
fill="#3E0412"
/>
</g>
</svg>
);
}

@ -0,0 +1,125 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeOwlIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 511.988 511.988"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<g>
<path
d="M366.867,149.324c-5.891,0-10.672-4.766-10.672-10.656s4.781-10.672,10.672-10.672
c16.781,0,23.312-4.469,25.719-7.141c1.562-1.718,2.062-3.515,2.062-3.53c0-5.891,4.781-10.656,10.688-10.656
c5.875,0,10.656,4.766,10.656,10.656c0,1.578-0.375,9.843-7.562,17.812C399.961,144.557,385.961,149.324,366.867,149.324z"
fill="#434A54"
/>
<path
d="M145.122,127.995c-11.554,0-20.382-2.234-24.866-6.281c-2.438-2.202-2.859-4.296-2.93-4.765
c0-0.031,0.008-0.062,0.008-0.078c0-5.891-4.781-10.672-10.672-10.672c-5.89,0-10.664,4.781-10.664,10.672
c0,0.156,0.016,0.297,0.023,0.453h-0.023c0,1.578,0.367,9.843,7.555,17.812c8.484,9.422,22.476,14.188,41.569,14.188
c5.891,0,10.672-4.766,10.672-10.656S151.013,127.995,145.122,127.995z M117.334,117.325h-0.023c0-0.109,0.016-0.234,0.016-0.344
C117.342,117.2,117.334,117.325,117.334,117.325z"
fill="#434A54"
/>
</g>
<path
d="M255.995,63.998c-76.459,0-138.661,62.201-138.661,138.669c0,17.906,5.039,42.093,14.57,69.936
c8.781,25.641,20.773,52.921,34.687,78.936c14.227,26.578,29.188,49.39,43.273,65.969c17.437,20.515,32.522,30.483,46.131,30.483
s28.687-9.969,46.124-30.483c14.094-16.579,29.062-39.391,43.28-65.969c13.905-26.015,25.905-53.295,34.687-78.936
c9.531-27.843,14.562-52.029,14.562-69.936C394.648,126.199,332.462,63.998,255.995,63.998z"
fill="#A85D5D"
/>
<path
d="M501.334,362.663H10.664C4.774,362.663,0,357.882,0,351.991c0-5.89,4.773-10.671,10.664-10.671
h490.669c5.874,0,10.655,4.781,10.655,10.671C511.989,357.882,507.208,362.663,501.334,362.663z"
fill="#FFD2A6"
/>
<g>
<path
d="M213.332,362.663c-5.891,0-10.672-4.781-10.672-10.672V341.32c0-5.891,4.781-10.656,10.672-10.656
c5.89,0,10.664,4.766,10.664,10.656v10.671C223.996,357.882,219.223,362.663,213.332,362.663z"
fill="#F6BB42"
/>
<path
d="M234.66,362.663c-5.891,0-10.664-4.781-10.664-10.672V341.32c0-5.891,4.773-10.656,10.664-10.656
c5.89,0,10.671,4.766,10.671,10.656v10.671C245.331,357.882,240.55,362.663,234.66,362.663z"
fill="#F6BB42"
/>
<path
d="M277.338,362.663c-5.897,0-10.679-4.781-10.679-10.672V341.32c0-5.891,4.781-10.656,10.679-10.656
c5.875,0,10.656,4.766,10.656,10.656v10.671C287.994,357.882,283.213,362.663,277.338,362.663z"
fill="#F6BB42"
/>
<path
d="M298.65,362.663c-5.875,0-10.656-4.781-10.656-10.672V341.32c0-5.891,4.781-10.656,10.656-10.656
c5.906,0,10.688,4.766,10.688,10.656v10.671C309.338,357.882,304.557,362.663,298.65,362.663z"
fill="#F6BB42"
/>
</g>
<path
d="M255.995,234.665c-5.89,0-10.664-4.781-10.664-10.671v-21.328c0-5.891,4.773-10.672,10.664-10.672
c5.891,0,10.664,4.781,10.664,10.672v21.328C266.659,229.885,261.886,234.665,255.995,234.665z"
fill="#FFCE54"
/>
<g>
<path
d="M387.914,159.947c-18.047-55.623-70.357-95.95-131.919-95.95
c-61.295,0-113.419,39.983-131.685,95.231l0.148,0.766c35.375-71.326,131.537-42.67,131.537,21.328
C255.995,117.34,352.523,88.684,387.914,159.947z"
fill="#7F4545"
/>
<path
d="M255.995,383.99c-5.89,0-10.664,4.781-10.664,10.672v51.391c3.656,1.297,7.211,1.938,10.664,1.938
s7.008-0.641,10.664-1.938v-51.391C266.659,388.771,261.886,383.99,255.995,383.99z"
fill="#7F4545"
/>
</g>
<path
d="M255.995,63.998c-1.828,0-3.656,0.031-5.468,0.109
c74.061,2.75,133.465,63.842,133.465,138.56c0,17.906-5.031,42.093-14.578,69.936c-8.766,25.641-20.765,52.921-34.687,78.936
c-14.218,26.578-29.187,49.39-43.265,65.969c-15.188,17.874-28.601,27.733-40.803,29.983c1.805,0.328,3.586,0.5,5.335,0.5
c13.609,0,28.687-9.969,46.124-30.483c14.094-16.579,29.062-39.391,43.28-65.969c13.905-26.015,25.905-53.295,34.687-78.936
c9.531-27.843,14.562-52.029,14.562-69.936C394.648,126.199,332.462,63.998,255.995,63.998z"
fill="#FFFFFF"
opacity={0.1}
/>
<g>
<path
d="M219.98,193.322c0,15.094-13.187,27.344-29.444,27.344c-16.266,0-29.445-12.25-29.445-27.344
s13.179-27.328,29.445-27.328C206.793,165.995,219.98,178.229,219.98,193.322z"
fill="#F6BB42"
/>
<path
d="M344.649,191.214c0,14.672-12.297,26.562-27.452,26.562c-15.156,0-27.453-11.891-27.453-26.562
c0-14.656,12.297-26.547,27.453-26.547C332.352,164.667,344.649,176.557,344.649,191.214z"
fill="#F6BB42"
/>
</g>
<path
d="M191.997,181.322c-5.891,0-10.664,4.781-10.664,10.672s4.773,10.672,10.664,10.672
s10.664-4.781,10.664-10.672S197.888,181.322,191.997,181.322z"
fill="#434A54"
/>
<path
d="M191.997,149.324c-23.523,0-42.664,19.14-42.664,42.671s19.14,42.671,42.664,42.671
c23.523,0,42.663-19.14,42.663-42.671S215.52,149.324,191.997,149.324z M191.997,213.322c-11.766,0-21.336-9.562-21.336-21.328
s9.57-21.328,21.336-21.328c11.765,0,21.335,9.562,21.335,21.328S203.762,213.322,191.997,213.322z"
fill="#FFCE54"
/>
<path
d="M319.994,181.322c-5.891,0-10.656,4.781-10.656,10.672s4.766,10.672,10.656,10.672
s10.656-4.781,10.656-10.672S325.885,181.322,319.994,181.322z"
fill="#434A54"
/>
<path
d="M319.994,149.324c-23.531,0-42.656,19.14-42.656,42.671s19.125,42.671,42.656,42.671
c23.53,0,42.654-19.14,42.654-42.671S343.524,149.324,319.994,149.324z M319.994,213.322c-11.766,0-21.344-9.562-21.344-21.328
s9.578-21.328,21.344-21.328s21.343,9.562,21.343,21.328S331.76,213.322,319.994,213.322z"
fill="#FFCE54"
/>
</svg>
);
}

@ -0,0 +1,103 @@
import type { ResumeBadgeProps } from '../resume-badge';
export default function ResumeBadgeSageIcon({ className }: ResumeBadgeProps) {
return (
<svg
aria-hidden="true"
className={className}
viewBox="0 0 512 512"
x="0px"
xmlns="http://www.w3.org/2000/svg"
y="0px">
<g>
<path
d="M252.356,4.59c-2.05-4.1-7.035-5.762-11.14-3.713c-4.1,2.051-5.763,7.037-3.713,11.14l16.605,33.211
l14.852-7.427L252.356,4.59z"
fill="#FFF3D4"
/>
<path
d="M219.145,4.59c-2.051-4.1-7.035-5.762-11.14-3.713c-4.1,2.051-5.763,7.037-3.713,11.14
l18.714,37.429l14.852-7.427L219.145,4.59z"
fill="#FFF3D4"
/>
</g>
<path
d="M106.661,141.146h-0.111c-18.342,0-33.211,14.868-33.211,33.211s14.868,33.211,33.211,33.211h149.448
v-66.421C255.999,141.146,106.661,141.146,106.661,141.146z"
fill="#FFCDC1"
/>
<path
d="M405.448,141.146h-0.111H256v66.421h149.448c18.342,0,33.211-14.868,33.211-33.211
S423.789,141.146,405.448,141.146z"
fill="#FFAB97"
/>
<path
d="M482.94,334.876c0-24.455-19.825-44.281-44.281-44.281l-33.211,94.097l33.211,94.097h44.281V334.876z
"
fill="#7F7774"
/>
<path
d="M73.341,290.595c-24.456,0-44.281,19.826-44.281,44.281v143.913h409.599V290.595H73.341z"
fill="#A99E9B"
/>
<path
d="M386.948,74.725h-44.281L372.237,512c18.342,0,33.211-14.868,33.211-33.211V146.682
C405.448,120.611,398.731,96.084,386.948,74.725z"
fill="#FFEAB2"
/>
<path
d="M342.667,74.725H125.051c-11.783,21.359-18.501,45.886-18.501,71.957v332.107
c0,18.342,14.868,33.211,33.211,33.211c12.507,0,23.396-6.918,29.059-17.132C174.484,505.082,185.373,512,197.88,512
s23.396-6.918,29.059-17.132C232.603,505.082,243.492,512,255.999,512c12.507,0,23.396-6.918,29.059-17.132
C290.722,505.082,301.61,512,314.118,512s23.396-6.918,29.059-17.132C348.84,505.082,359.729,512,372.236,512V146.682
L342.667,74.725z"
fill="#FFF3D4"
/>
<path
d="M256,30.444l83.027,149.448h33.211v-33.211C372.237,82.485,320.195,30.444,256,30.444z"
fill="#FFAB97"
/>
<path
d="M256,30.444c-64.196,0-116.238,52.041-116.238,116.238v33.211h199.264v-33.211
C339.027,82.485,301.854,30.444,256,30.444z"
fill="#FFCDC1"
/>
<g>
<path
d="M172.973,298.897c-4.586,0-8.303-3.716-8.303-8.303v-11.07c0-4.586,3.716-8.303,8.303-8.303
s8.303,3.716,8.303,8.303v11.07C181.276,295.181,177.558,298.897,172.973,298.897z"
fill="#FFD159"
/>
<path
d="M339.027,387.459c-4.586,0-8.303-3.716-8.303-8.303v-11.07c0-4.586,3.716-8.303,8.303-8.303
c4.586,0,8.303,3.716,8.303,8.303v11.07C347.329,383.743,343.612,387.459,339.027,387.459z"
fill="#FFD159"
/>
<path
d="M305.816,420.67c-4.586,0-8.303-3.716-8.303-8.303v-11.07c0-4.586,3.716-8.303,8.303-8.303
c4.586,0,8.303,3.716,8.303,8.303v11.07C314.119,416.954,310.401,420.67,305.816,420.67z"
fill="#FFD159"
/>
</g>
<path
d="M256,234.297c-11.69,0-22.174-7.399-26.088-18.412c-1.537-4.321,0.721-9.069,5.042-10.604
c4.315-1.54,9.068,0.72,10.603,5.041c1.568,4.408,5.764,7.369,10.444,7.369c4.679,0,8.876-2.961,10.444-7.369
c1.535-4.32,6.285-6.58,10.603-5.041c4.321,1.535,6.578,6.283,5.042,10.604C278.174,226.898,267.689,234.297,256,234.297z"
fill="#E26142"
/>
<g>
<path
d="M225.003,154.984c-3.616,0-6.691-2.31-7.83-5.535h-10.989c-4.586,0-8.303-3.716-8.303-8.303
c0-4.586,3.716-8.303,8.303-8.303h18.819c4.586,0,8.303,3.716,8.303,8.303v5.535C233.306,151.268,229.588,154.984,225.003,154.984z
"
fill="#554F4E"
/>
<path
d="M286.997,154.984c-4.586,0-8.303-3.716-8.303-8.303v-5.535c0-4.586,3.716-8.303,8.303-8.303h18.819
c4.586,0,8.303,3.716,8.303,8.303c0,4.586-3.716,8.303-8.303,8.303h-10.989C293.686,152.674,290.611,154.984,286.997,154.984z"
fill="#554F4E"
/>
</g>
</svg>
);
}

@ -0,0 +1,30 @@
import type { BadgeIcon } from './resumeBadgeConstants';
type Props = Readonly<{
description: string;
icon: BadgeIcon;
title: string;
}>;
export default function ResumeUserBadge({
description,
icon: Icon,
title,
}: Props) {
return (
<div className="group relative flex items-center justify-center">
<div
className="absolute -top-3 hidden w-48 -translate-y-full flex-col
justify-center gap-1 rounded-lg bg-white px-2 py-2 text-center drop-shadow-xl
after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2
after:border-8 after:border-x-transparent after:border-b-transparent
after:border-t-white after:drop-shadow-lg after:content-['']
group-hover:flex">
<Icon className="h-12 w-12 self-center" />
<p className="font-medium">{title}</p>
<p className="text-sm">{description}.</p>
</div>
<Icon className="h-4 w-4" />
</div>
);
}

@ -0,0 +1,45 @@
import { trpc } from '~/utils/trpc';
import type { BadgePayload } from './resumeBadgeConstants';
import { RESUME_USER_BADGES } from './resumeBadgeConstants';
import ResumeUserBadge from './ResumeUserBadge';
type Props = Readonly<{
userId: string;
}>;
export default function ResumeUserBadges({ userId }: Props) {
const userReviewedResumeCountQuery = trpc.useQuery([
'resumes.resume.findUserReviewedResumeCount',
{ userId },
]);
const userMaxResumeUpvoteCountQuery = trpc.useQuery([
'resumes.resume.findUserMaxResumeUpvoteCount',
{ userId },
]);
const userTopUpvotedCommentCountQuery = trpc.useQuery([
'resumes.resume.findUserTopUpvotedCommentCount',
{ userId },
]);
const payload: BadgePayload = {
maxResumeUpvoteCount: userMaxResumeUpvoteCountQuery.data ?? 0,
reviewedResumesCount: userReviewedResumeCountQuery.data ?? 0,
topUpvotedCommentCount: userTopUpvotedCommentCountQuery.data ?? 0,
};
return (
<div className="flex items-center justify-center gap-1">
{RESUME_USER_BADGES.filter((badge) => badge.isValid(payload)).map(
(badge) => (
<ResumeUserBadge
key={badge.id}
description={badge.description}
icon={badge.icon}
title={badge.title}
/>
),
)}
</div>
);
}

@ -0,0 +1,113 @@
import ResumeBadgeCoolIcon from '../badgeIcons/popularResumes/ResumeBadgeCoolIcon';
import ResumeBadgeRocketIcon from '../badgeIcons/popularResumes/ResumeBadgeRocketIcon';
import ResumeBadgeTreasureIcon from '../badgeIcons/popularResumes/ResumeBadgeTreasureIcon';
import ResumeBadgeDetectiveIcon from '../badgeIcons/reviewer/ResumeBadgeDetectiveIcon';
import ResumeBadgeEagleIcon from '../badgeIcons/reviewer/ResumeBadgeEagleIcon';
import ResumeBadgeSuperheroIcon from '../badgeIcons/reviewer/ResumeBadgeSuperheroIcon';
import ResumeBadgeBookIcon from '../badgeIcons/topComment/ResumeBadgeBookIcon';
import ResumeBadgeOwlIcon from '../badgeIcons/topComment/ResumeBadgeOwlIcon';
import ResumeBadgeSageIcon from '../badgeIcons/topComment/ResumeBadgeSageIcon';
export type BadgeIcon = (
props: React.ComponentProps<typeof ResumeBadgeDetectiveIcon>,
) => JSX.Element;
export type BadgeInfo = {
description: string;
icon: BadgeIcon;
id: string;
isValid: (payload: BadgePayload) => boolean;
title: string;
};
// TODO: Add other badges in
export type BadgePayload = {
maxResumeUpvoteCount: number;
reviewedResumesCount: number;
topUpvotedCommentCount: number;
};
const TIER_THREE = 20;
const TIER_TWO = 10;
const TIER_ONE = 5;
export const RESUME_USER_BADGES: Array<BadgeInfo> = [
{
description: `Reviewed over ${TIER_THREE} resumes`,
icon: ResumeBadgeSuperheroIcon,
id: 'Superhero',
isValid: (payload: BadgePayload) =>
payload.reviewedResumesCount >= TIER_THREE,
title: 'True saviour of the people',
},
{
description: `Reviewed over ${TIER_TWO} resumes`,
icon: ResumeBadgeDetectiveIcon,
id: 'Detective',
isValid: (payload: BadgePayload) =>
payload.reviewedResumesCount >= TIER_TWO &&
payload.reviewedResumesCount < TIER_THREE,
title: 'Keen eye for details like a private eye',
},
{
description: `Reviewed over ${TIER_ONE} resumes`,
icon: ResumeBadgeEagleIcon,
id: 'Eagle',
isValid: (payload: BadgePayload) =>
payload.reviewedResumesCount >= TIER_ONE &&
payload.reviewedResumesCount < TIER_TWO,
title: 'As sharp as an eagle',
},
{
description: `${TIER_THREE} upvotes on a resume`,
icon: ResumeBadgeRocketIcon,
id: 'Rocket',
isValid: (payload: BadgePayload) =>
payload.maxResumeUpvoteCount >= TIER_THREE,
title: 'To the moon!',
},
{
description: `${TIER_TWO} upvotes on a resume`,
icon: ResumeBadgeTreasureIcon,
id: 'Treasure',
isValid: (payload: BadgePayload) =>
payload.maxResumeUpvoteCount >= TIER_TWO &&
payload.maxResumeUpvoteCount < TIER_THREE,
title: "Can't get enough of this!",
},
{
description: `${TIER_ONE} upvotes on a resume`,
icon: ResumeBadgeCoolIcon,
id: 'Cool',
isValid: (payload: BadgePayload) =>
payload.maxResumeUpvoteCount >= TIER_ONE &&
payload.maxResumeUpvoteCount < TIER_TWO,
title: 'Like the cool kids',
},
{
description: `${TIER_THREE} top upvoted comment`,
icon: ResumeBadgeSageIcon,
id: 'Sage',
isValid: (payload: BadgePayload) =>
payload.topUpvotedCommentCount >= TIER_THREE,
title: 'I am wisdom',
},
{
description: `${TIER_TWO} top upvoted comment`,
icon: ResumeBadgeBookIcon,
id: 'Book',
isValid: (payload: BadgePayload) =>
payload.topUpvotedCommentCount >= TIER_TWO &&
payload.topUpvotedCommentCount < TIER_THREE,
title: 'The walking encyclopaedia',
},
{
description: `${TIER_ONE} top upvoted comment`,
icon: ResumeBadgeOwlIcon,
id: 'Owl',
isValid: (payload: BadgePayload) =>
payload.topUpvotedCommentCount >= TIER_ONE &&
payload.topUpvotedCommentCount < TIER_TWO,
title: 'Wise as an owl',
},
];

@ -41,7 +41,9 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
<div className="mt-4 flex justify-start text-xs text-slate-500"> <div className="mt-4 flex justify-start text-xs text-slate-500">
<div className="flex gap-2 pr-4"> <div className="flex gap-2 pr-4">
<ChatBubbleLeftIcon className="w-4" /> <ChatBubbleLeftIcon className="w-4" />
{resumeInfo.numComments} comments {`${resumeInfo.numComments} comment${
resumeInfo.numComments > 0 ? 's' : ''
}`}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
{resumeInfo.isStarredByUser ? ( {resumeInfo.isStarredByUser ? (

@ -139,3 +139,13 @@ export const SHORTCUTS: Array<Shortcut> = [
sortOrder: 'latest', sortOrder: 'latest',
}, },
]; ];
export const isInitialFilterState = (filters: FilterState) =>
Object.keys(filters).every((filter) => {
if (!['experience', 'location', 'role'].includes(filter)) {
return true;
}
return INITIAL_FILTER_STATE[filter as FilterId].every((value) =>
filters[filter as FilterId].includes(value),
);
});

@ -1,18 +1,12 @@
import clsx from 'clsx'; import clsx from 'clsx';
import type { Dispatch, SetStateAction } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form'; import { ChevronUpIcon } from '@heroicons/react/20/solid';
import { useForm } from 'react-hook-form';
import {
ArrowDownCircleIcon,
ArrowUpCircleIcon,
} from '@heroicons/react/20/solid';
import { FaceSmileIcon } from '@heroicons/react/24/outline'; import { FaceSmileIcon } from '@heroicons/react/24/outline';
import { Vote } from '@prisma/client';
import { Button, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc';
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 ResumeExpandableText from '../shared/ResumeExpandableText';
import type { ResumeComment } from '~/types/resume-comments'; import type { ResumeComment } from '~/types/resume-comments';
@ -22,143 +16,59 @@ type ResumeCommentListItemProps = {
userId: string | undefined; userId: string | undefined;
}; };
type ICommentInput = {
description: string;
};
export default function ResumeCommentListItem({ export default function ResumeCommentListItem({
comment, comment,
userId, userId,
}: ResumeCommentListItemProps) { }: ResumeCommentListItemProps) {
const isCommentOwner = userId === comment.user.userId; const isCommentOwner = userId === comment.user.userId;
const [isEditingComment, setIsEditingComment] = useState(false); const [isEditingComment, setIsEditingComment] = useState(false);
const [isReplyingComment, setIsReplyingComment] = useState(false);
const [upvoteAnimation, setUpvoteAnimation] = useState(false); const [showReplies, setShowReplies] = useState(true);
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
const {
register,
handleSubmit,
setValue,
formState: { errors, isDirty },
reset,
} = useForm<ICommentInput>({
defaultValues: {
description: comment.description,
},
});
const trpcContext = trpc.useContext();
const commentUpdateMutation = trpc.useMutation(
'resumes.comments.user.update',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
},
},
);
// COMMENT VOTES
const commentVotesQuery = trpc.useQuery([
'resumes.comments.votes.list',
{ commentId: comment.id },
]);
const commentVotesUpsertMutation = trpc.useMutation(
'resumes.comments.votes.user.upsert',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
},
},
);
const commentVotesDeleteMutation = trpc.useMutation(
'resumes.comments.votes.user.delete',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
},
},
);
// FORM ACTIONS
const onCancel = () => {
reset({ description: comment.description });
setIsEditingComment(false);
};
const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
const { id } = comment;
return commentUpdateMutation.mutate(
{
id,
...data,
},
{
onSuccess: () => {
setIsEditingComment(false);
},
},
);
};
const setFormValue = (value: string) => {
setValue('description', value.trim(), { shouldDirty: true });
};
const onVote = async (
value: Vote,
setAnimation: Dispatch<SetStateAction<boolean>>,
) => {
setAnimation(true);
if (commentVotesQuery.data?.userVote?.value === value) {
return commentVotesDeleteMutation.mutate(
{
commentId: comment.id,
},
{
onSettled: async () => setAnimation(false),
},
);
}
return commentVotesUpsertMutation.mutate(
{
commentId: comment.id,
value,
},
{
onSettled: async () => setAnimation(false),
},
);
};
return ( return (
<div className="border-primary-300 w-11/12 min-w-fit rounded-md border-2 bg-white p-2 drop-shadow-md"> <div
className={clsx(
'min-w-fit rounded-md bg-white ',
!comment.parentId &&
'w-11/12 border-2 border-indigo-300 p-2 drop-shadow-md',
)}>
<div className="flex flex-row space-x-2 p-1 align-top"> <div className="flex flex-row space-x-2 p-1 align-top">
{/* Image Icon */}
{comment.user.image ? ( {comment.user.image ? (
<img <img
alt={comment.user.name ?? 'Reviewer'} alt={comment.user.name ?? 'Reviewer'}
className="mt-1 h-8 w-8 rounded-full" className={clsx(
'mt-1 rounded-full',
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
)}
src={comment.user.image!} src={comment.user.image!}
/> />
) : ( ) : (
<FaceSmileIcon className="h-8 w-8 rounded-full" /> <FaceSmileIcon
className={clsx(
'mt-1 rounded-full',
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
)}
/>
)} )}
<div className="flex w-full flex-col space-y-1"> <div className="flex w-full flex-col space-y-1">
{/* Name and creation time */} {/* Name and creation time */}
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<div className="flex flex-row items-center space-x-1"> <div className="flex flex-row items-center space-x-1">
<div className="font-medium"> <p
className={clsx(
'font-medium text-black',
!!comment.parentId && 'text-sm',
)}>
{comment.user.name ?? 'Reviewer ABC'} {comment.user.name ?? 'Reviewer ABC'}
</div> </p>
<div className="text-primary-800 text-xs font-medium"> <p className="text-xs font-medium text-indigo-800">
{isCommentOwner ? '(Me)' : ''} {isCommentOwner ? '(Me)' : ''}
</div> </p>
<ResumeUserBadges userId={comment.user.userId} />
</div> </div>
<div className="px-2 text-xs text-gray-600"> <div className="px-2 text-xs text-gray-600">
@ -171,112 +81,90 @@ export default function ResumeCommentListItem({
{/* Description */} {/* Description */}
{isEditingComment ? ( {isEditingComment ? (
<form onSubmit={handleSubmit(onSubmit)}> <ResumeCommentEditForm
<div className="flex-column mt-1 space-y-2"> comment={comment}
<TextArea setIsEditingComment={setIsEditingComment}
{...(register('description', { />
required: 'Comments cannot be empty!',
}),
{})}
defaultValue={comment.description}
disabled={commentUpdateMutation.isLoading}
errorMessage={errors.description?.message}
label=""
placeholder="Leave your comment here"
onChange={setFormValue}
/>
<div className="flex-row space-x-2">
<Button
disabled={commentUpdateMutation.isLoading}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentUpdateMutation.isLoading}
isLoading={commentUpdateMutation.isLoading}
label="Confirm"
size="sm"
type="submit"
variant="primary"
/>
</div>
</div>
</form>
) : ( ) : (
<ResumeExpandableText text={comment.description} /> <ResumeExpandableText
key={comment.description}
text={comment.description}
/>
)} )}
{/* Upvote and edit */} {/* Upvote and edit */}
<div className="flex flex-row space-x-1 pt-1 align-middle"> <div className="flex flex-row space-x-1 pt-1 align-middle">
<button <ResumeCommentVoteButtons commentId={comment.id} userId={userId} />
disabled={
!userId || {/* Action buttons; only present when not editing/replying */}
commentVotesQuery.isLoading || {isCommentOwner && !isEditingComment && !isReplyingComment && (
commentVotesUpsertMutation.isLoading || <>
commentVotesDeleteMutation.isLoading <button
} className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
type="button" type="button"
onClick={() => onVote(Vote.UPVOTE, setUpvoteAnimation)}> onClick={() => setIsEditingComment(true)}>
<ArrowUpCircleIcon Edit
className={clsx( </button>
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE || {!comment.parentId && (
upvoteAnimation <button
? 'fill-indigo-500' className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
: 'fill-gray-400', type="button"
userId && onClick={() => setIsReplyingComment(true)}>
!downvoteAnimation && Reply
!upvoteAnimation && </button>
'hover:fill-indigo-500',
upvoteAnimation &&
'animate-[bounce_0.5s_infinite] cursor-default',
)} )}
/> </>
</button> )}
</div>
<div className="text-xs">
{commentVotesQuery.data?.numVotes ?? 0}
</div>
<button {/* Reply Form */}
disabled={ {isReplyingComment && (
!userId || <ResumeCommentReplyForm
commentVotesQuery.isLoading || parentId={comment.id}
commentVotesUpsertMutation.isLoading || resumeId={comment.resumeId}
commentVotesDeleteMutation.isLoading section={comment.section}
} setIsReplyingComment={setIsReplyingComment}
type="button" />
onClick={() => onVote(Vote.DOWNVOTE, setDownvoteAnimation)}> )}
<ArrowDownCircleIcon
className={clsx(
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
downvoteAnimation
? 'fill-red-500'
: 'fill-gray-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-red-500',
downvoteAnimation &&
'animate-[bounce_0.5s_infinite] cursor-default',
)}
/>
</button>
{isCommentOwner && !isEditingComment && ( {/* Replies */}
{comment.children.length > 0 && (
<div className="min-w-fit space-y-1 pt-2">
<button <button
className="text-primary-800 hover:text-primary-400 px-1 text-xs" className="flex items-center space-x-1 rounded-md text-xs font-medium text-indigo-800 hover:text-indigo-300"
type="button" type="button"
onClick={() => setIsEditingComment(true)}> onClick={() => setShowReplies(!showReplies)}>
Edit <ChevronUpIcon
className={clsx(
'h-5 w-5 ',
!showReplies && 'rotate-180 transform',
)}
/>
<span>{showReplies ? 'Hide replies' : 'Show replies'}</span>
</button> </button>
)}
</div> {showReplies && (
<div className="flex flex-row">
<div className="relative flex flex-col px-2 py-2">
<div className="flex-grow border-r border-gray-300" />
</div>
<div className="flex flex-col space-y-1">
{comment.children.map((child) => {
return (
<ResumeCommentListItem
key={child.id}
comment={child}
userId={userId}
/>
);
})}
</div>
</div>
)}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

@ -47,6 +47,9 @@ export default function ResumeCommentsForm({
onSuccess: () => { onSuccess: () => {
// New Comment added, invalidate query to trigger refetch // New Comment added, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']); trpcContext.invalidateQueries(['resumes.comments.list']);
trpcContext.invalidateQueries(['resumes.resume.findAll']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserStarred']);
trpcContext.invalidateQueries(['resumes.resume.user.findUserCreated']);
}, },
}, },
); );

@ -33,7 +33,7 @@ export default function ResumeCommentsList({
const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }]); const commentsQuery = trpc.useQuery(['resumes.comments.list', { resumeId }]);
const renderIcon = (section: ResumesSection) => { const renderIcon = (section: ResumesSection) => {
const className = 'h-8 w-8'; const className = 'h-7 w-7';
switch (section) { switch (section) {
case ResumesSection.GENERAL: case ResumesSection.GENERAL:
return <IdentificationIcon className={className} />; return <IdentificationIcon className={className} />;
@ -56,6 +56,7 @@ export default function ResumeCommentsList({
} }
return ( return (
<Button <Button
className="-mb-2"
display="block" display="block"
label="Add your review" label="Add your review"
variant="tertiary" variant="tertiary"
@ -73,7 +74,7 @@ export default function ResumeCommentsList({
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
</div> </div>
) : ( ) : (
<div className="m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-4 overflow-y-auto"> <div className="scrollbar-hide m-2 flow-root h-[calc(100vh-20rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pt-14 pb-6">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => { {RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => { ? commentsQuery.data.filter((comment: ResumeComment) => {
@ -83,11 +84,11 @@ export default function ResumeCommentsList({
const commentCount = comments.length; const commentCount = comments.length;
return ( return (
<div key={value} className="mb-4 space-y-3"> <div key={value} className="mb-4 space-y-4">
<div className="flex flex-row items-center space-x-2 text-indigo-800"> <div className="flex flex-row items-center space-x-2 text-indigo-800">
{renderIcon(value)} {renderIcon(value)}
<div className="w-fit text-xl font-medium">{label}</div> <div className="w-fit text-lg font-medium">{label}</div>
</div> </div>
{commentCount > 0 ? ( {commentCount > 0 ? (

@ -0,0 +1,106 @@
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Button, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentEditFormProps = {
comment: ResumeComment;
setIsEditingComment: (value: boolean) => void;
};
type ICommentInput = {
description: string;
};
export default function ResumeCommentEditForm({
comment,
setIsEditingComment,
}: ResumeCommentEditFormProps) {
const {
register,
handleSubmit,
setValue,
formState: { errors, isDirty },
reset,
} = useForm<ICommentInput>({
defaultValues: {
description: comment.description,
},
});
const trpcContext = trpc.useContext();
const commentUpdateMutation = trpc.useMutation(
'resumes.comments.user.update',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
},
},
);
const onCancel = () => {
reset({ description: comment.description });
setIsEditingComment(false);
};
const onSubmit: SubmitHandler<ICommentInput> = async (data) => {
const { id } = comment;
return commentUpdateMutation.mutate(
{
id,
...data,
},
{
onSuccess: () => {
setIsEditingComment(false);
},
},
);
};
const setFormValue = (value: string) => {
setValue('description', value.trim(), { shouldDirty: true });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex-column mt-1 space-y-2">
<TextArea
{...(register('description', {
required: 'Comments cannot be empty!',
}),
{})}
defaultValue={comment.description}
disabled={commentUpdateMutation.isLoading}
errorMessage={errors.description?.message}
label=""
placeholder="Leave your comment here"
onChange={setFormValue}
/>
<div className="flex-row space-x-2">
<Button
disabled={commentUpdateMutation.isLoading}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentUpdateMutation.isLoading}
isLoading={commentUpdateMutation.isLoading}
label="Confirm"
size="sm"
type="submit"
variant="primary"
/>
</div>
</div>
</form>
);
}

@ -0,0 +1,107 @@
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import type { ResumesSection } from '@prisma/client';
import { Button, TextArea } from '@tih/ui';
import { trpc } from '~/utils/trpc';
type ResumeCommentEditFormProps = {
parentId: string;
resumeId: string;
section: ResumesSection;
setIsReplyingComment: (value: boolean) => void;
};
type IReplyInput = {
description: string;
};
export default function ResumeCommentReplyForm({
parentId,
setIsReplyingComment,
resumeId,
section,
}: ResumeCommentEditFormProps) {
const {
register,
handleSubmit,
setValue,
formState: { errors, isDirty },
reset,
} = useForm<IReplyInput>({
defaultValues: {
description: '',
},
});
const trpcContext = trpc.useContext();
const commentReplyMutation = trpc.useMutation('resumes.comments.user.reply', {
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.list']);
},
});
const onCancel = () => {
reset({ description: '' });
setIsReplyingComment(false);
};
const onSubmit: SubmitHandler<IReplyInput> = async (data) => {
return commentReplyMutation.mutate(
{
parentId,
resumeId,
section,
...data,
},
{
onSuccess: () => {
setIsReplyingComment(false);
},
},
);
};
const setFormValue = (value: string) => {
setValue('description', value.trim(), { shouldDirty: true });
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex-column space-y-2 pt-2">
<TextArea
{...(register('description', {
required: 'Reply cannot be empty!',
}),
{})}
defaultValue=""
disabled={commentReplyMutation.isLoading}
errorMessage={errors.description?.message}
label=""
placeholder="Leave your reply here"
onChange={setFormValue}
/>
<div className="flex-row space-x-2">
<Button
disabled={commentReplyMutation.isLoading}
label="Cancel"
size="sm"
variant="tertiary"
onClick={onCancel}
/>
<Button
disabled={!isDirty || commentReplyMutation.isLoading}
isLoading={commentReplyMutation.isLoading}
label="Confirm"
size="sm"
type="submit"
variant="primary"
/>
</div>
</div>
</form>
);
}

@ -0,0 +1,131 @@
import clsx from 'clsx';
import { useState } from 'react';
import {
ArrowDownCircleIcon,
ArrowUpCircleIcon,
} from '@heroicons/react/20/solid';
import { Vote } from '@prisma/client';
import { trpc } from '~/utils/trpc';
type ResumeCommentVoteButtonsProps = {
commentId: string;
userId: string | undefined;
};
export default function ResumeCommentVoteButtons({
commentId,
userId,
}: ResumeCommentVoteButtonsProps) {
const [upvoteAnimation, setUpvoteAnimation] = useState(false);
const [downvoteAnimation, setDownvoteAnimation] = useState(false);
const trpcContext = trpc.useContext();
// COMMENT VOTES
const commentVotesQuery = trpc.useQuery([
'resumes.comments.votes.list',
{ commentId },
]);
const commentVotesUpsertMutation = trpc.useMutation(
'resumes.comments.votes.user.upsert',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
},
},
);
const commentVotesDeleteMutation = trpc.useMutation(
'resumes.comments.votes.user.delete',
{
onSuccess: () => {
// Comment updated, invalidate query to trigger refetch
trpcContext.invalidateQueries(['resumes.comments.votes.list']);
},
},
);
const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => {
setAnimation(true);
if (commentVotesQuery.data?.userVote?.value === value) {
return commentVotesDeleteMutation.mutate(
{
commentId,
},
{
onSettled: async () => setAnimation(false),
},
);
}
return commentVotesUpsertMutation.mutate(
{
commentId,
value,
},
{
onSettled: async () => setAnimation(false),
},
);
};
return (
<>
<button
disabled={
!userId ||
commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading
}
type="button"
onClick={() => onVote(Vote.UPVOTE, setUpvoteAnimation)}>
<ArrowUpCircleIcon
className={clsx(
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
upvoteAnimation
? 'fill-indigo-500'
: 'fill-gray-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-indigo-500',
upvoteAnimation && 'animate-[bounce_0.5s_infinite] cursor-default',
)}
/>
</button>
<div className="flex min-w-[1rem] justify-center text-xs">
{commentVotesQuery.data?.numVotes ?? 0}
</div>
<button
disabled={
!userId ||
commentVotesQuery.isLoading ||
commentVotesUpsertMutation.isLoading ||
commentVotesDeleteMutation.isLoading
}
type="button"
onClick={() => onVote(Vote.DOWNVOTE, setDownvoteAnimation)}>
<ArrowDownCircleIcon
className={clsx(
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
downvoteAnimation
? 'fill-red-500'
: 'fill-gray-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-red-500',
downvoteAnimation &&
'animate-[bounce_0.5s_infinite] cursor-default',
)}
/>
</button>
</>
);
}

@ -1,5 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useEffect, useState } from 'react'; import { useLayoutEffect, useRef, useState } from 'react';
type ResumeExpandableTextProps = Readonly<{ type ResumeExpandableTextProps = Readonly<{
text: string; text: string;
@ -8,17 +8,17 @@ type ResumeExpandableTextProps = Readonly<{
export default function ResumeExpandableText({ export default function ResumeExpandableText({
text, text,
}: ResumeExpandableTextProps) { }: ResumeExpandableTextProps) {
const ref = useRef<HTMLSpanElement>(null);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [descriptionOverflow, setDescriptionOverflow] = useState(false); const [descriptionOverflow, setDescriptionOverflow] = useState(false);
useEffect(() => { useLayoutEffect(() => {
const lines = text.split(/\r\n|\r|\n/); if (ref.current && ref.current.clientHeight < ref.current.scrollHeight) {
if (lines.length > 3) {
setDescriptionOverflow(true); setDescriptionOverflow(true);
} else { } else {
setDescriptionOverflow(false); setDescriptionOverflow(false);
} }
}, [text]); }, [ref]);
const onSeeActionClicked = () => { const onSeeActionClicked = () => {
setIsExpanded((prevExpanded) => !prevExpanded); setIsExpanded((prevExpanded) => !prevExpanded);
@ -27,6 +27,7 @@ export default function ResumeExpandableText({
return ( return (
<div> <div>
<span <span
ref={ref}
className={clsx( className={clsx(
'line-clamp-3 whitespace-pre-wrap text-sm', 'line-clamp-3 whitespace-pre-wrap text-sm',
isExpanded ? 'line-clamp-none' : '', isExpanded ? 'line-clamp-none' : '',

@ -1,12 +1,14 @@
import clsx from 'clsx';
import { signIn } from 'next-auth/react'; import { signIn } from 'next-auth/react';
type Props = Readonly<{ type Props = Readonly<{
className?: string;
text: string; text: string;
}>; }>;
export default function ResumeSignInButton({ text }: Props) { export default function ResumeSignInButton({ text, className }: Props) {
return ( return (
<div className="flex justify-center pt-4"> <div className={clsx('flex justify-center pt-4', className)}>
<p> <p>
<a <a
className="text-primary-800 hover:text-primary-500" className="text-primary-800 hover:text-primary-500"

@ -14,6 +14,7 @@ import type {
User, User,
} from '@prisma/client'; } from '@prisma/client';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import type { import type {
AddToProfileResponse, AddToProfileResponse,
@ -49,7 +50,7 @@ const analysisOfferDtoMapper = (
const analysisOfferDto: AnalysisOffer = { const analysisOfferDto: AnalysisOffer = {
company: offersCompanyDtoMapper(offer.company), company: offersCompanyDtoMapper(offer.company),
id: offer.id, id: offer.id,
income: -1, income: { currency: '', value: -1 },
jobType: offer.jobType, jobType: offer.jobType,
level: offer.offersFullTime?.level ?? '', level: offer.offersFullTime?.level ?? '',
location: offer.location, location: offer.location,
@ -69,9 +70,19 @@ const analysisOfferDtoMapper = (
}; };
if (offer.offersFullTime?.totalCompensation) { if (offer.offersFullTime?.totalCompensation) {
analysisOfferDto.income = offer.offersFullTime.totalCompensation.value; analysisOfferDto.income.value =
offer.offersFullTime.totalCompensation.value;
analysisOfferDto.income.currency =
offer.offersFullTime.totalCompensation.currency;
} else if (offer.offersIntern?.monthlySalary) { } else if (offer.offersIntern?.monthlySalary) {
analysisOfferDto.income = offer.offersIntern.monthlySalary.value; analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value;
analysisOfferDto.income.currency =
offer.offersIntern.monthlySalary.currency;
} else {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found',
});
} }
return analysisOfferDto; return analysisOfferDto;
@ -267,6 +278,7 @@ export const experienceDtoMapper = (
id: experience.id, id: experience.id,
jobType: experience.jobType, jobType: experience.jobType,
level: experience.level, level: experience.level,
location: experience.location,
monthlySalary: experience.monthlySalary monthlySalary: experience.monthlySalary
? valuationDtoMapper(experience.monthlySalary) ? valuationDtoMapper(experience.monthlySalary)
: experience.monthlySalary, : experience.monthlySalary,

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { Select } from '@tih/ui'; import { Select } from '@tih/ui';
import { titleOptions } from '~/components/offers/constants';
import OffersTitle from '~/components/offers/OffersTitle'; import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable'; import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -20,24 +21,7 @@ export default function OffersHomePage() {
<Select <Select
isLabelHidden={true} isLabelHidden={true}
label="Select a job title" label="Select a job title"
options={[ options={titleOptions}
{
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
]}
value={jobTitleFilter} value={jobTitleFilter}
onChange={setjobTitleFilter} onChange={setjobTitleFilter}
/> />

@ -7,7 +7,8 @@ import ProfileDetails from '~/components/offers/profile/ProfileDetails';
import ProfileHeader from '~/components/offers/profile/ProfileHeader'; import ProfileHeader from '~/components/offers/profile/ProfileHeader';
import type { BackgroundCard, OfferEntity } 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 { formatDate } from '~/utils/offers/time';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -38,7 +39,7 @@ export default function OfferProfile() {
} }
// If the profile is not editable with a wrong token, redirect to the profile page // If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') { if (!data?.isEditable && token !== '') {
router.push(`/offers/profile/${offerProfileId}`); router.push(getProfilePath(offerProfileId as string));
} }
setIsEditable(data?.isEditable ?? false); setIsEditable(data?.isEditable ?? false);
@ -48,10 +49,8 @@ export default function OfferProfile() {
? data?.offers.map((res: ProfileOffer) => { ? data?.offers.map((res: ProfileOffer) => {
if (res.offersFullTime) { if (res.offersFullTime) {
const filteredOffer: OfferEntity = { const filteredOffer: OfferEntity = {
base: convertCurrencyToString( base: convertMoneyToString(res.offersFullTime.baseSalary),
res.offersFullTime.baseSalary, bonus: convertMoneyToString(res.offersFullTime.bonus),
),
bonus: convertCurrencyToString(res.offersFullTime.bonus),
companyName: res.company.name, companyName: res.company.name,
id: res.offersFullTime.id, id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level, jobLevel: res.offersFullTime.level,
@ -60,12 +59,11 @@ export default function OfferProfile() {
negotiationStrategy: res.negotiationStrategy || '', negotiationStrategy: res.negotiationStrategy || '',
otherComment: res.comments || '', otherComment: res.comments || '',
receivedMonth: formatDate(res.monthYearReceived), receivedMonth: formatDate(res.monthYearReceived),
stocks: convertCurrencyToString(res.offersFullTime.stocks), stocks: convertMoneyToString(res.offersFullTime.stocks),
totalCompensation: convertCurrencyToString( totalCompensation: convertMoneyToString(
res.offersFullTime.totalCompensation, res.offersFullTime.totalCompensation,
), ),
}; };
return filteredOffer; return filteredOffer;
} }
const filteredOffer: OfferEntity = { const filteredOffer: OfferEntity = {
@ -73,7 +71,7 @@ export default function OfferProfile() {
id: res.offersIntern!.id, id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title, jobTitle: res.offersIntern!.title,
location: res.location, location: res.location,
monthlySalary: convertCurrencyToString( monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary, res.offersIntern!.monthlySalary,
), ),
negotiationStrategy: res.negotiationStrategy || '', negotiationStrategy: res.negotiationStrategy || '',
@ -88,46 +86,29 @@ export default function OfferProfile() {
if (data?.background) { if (data?.background) {
const transformedBackground = { const transformedBackground = {
educations: [ educations: data.background.educations.map((education) => ({
{ endDate: education.endDate ? formatDate(education.endDate) : '-',
endDate: data?.background.educations[0].endDate field: education.field || '-',
? formatDate(data.background.educations[0].endDate) school: education.school || '-',
: '-', startDate: education.startDate
field: data.background.educations[0].field || '-', ? formatDate(education.startDate)
school: data.background.educations[0].school || '-', : '-',
startDate: data.background.educations[0].startDate type: education.type || '-',
? formatDate(data.background.educations[0].startDate) })),
: '-', experiences: data.background.experiences.map((experience) => ({
type: data.background.educations[0].type || '-', companyName: experience.company?.name ?? '-',
}, duration: String(experience.durationInMonths) ?? '-',
], jobLevel: experience.level ?? '',
experiences: [ jobTitle: experience.title ?? '-',
data.background.experiences && monthlySalary: experience.monthlySalary
data.background.experiences.length > 0 ? convertMoneyToString(experience.monthlySalary)
? { : '-',
companyName: totalCompensation: experience.totalCompensation
data.background.experiences[0].company?.name ?? '-', ? convertMoneyToString(experience.totalCompensation)
duration: : '-',
String(data.background.experiences[0].durationInMonths) ?? })),
'-',
jobLevel: data.background.experiences[0].level ?? '',
jobTitle: data.background.experiences[0].title ?? '-',
monthlySalary: data.background.experiences[0].monthlySalary
? convertCurrencyToString(
data.background.experiences[0].monthlySalary,
)
: '-',
totalCompensation: data.background.experiences[0]
.totalCompensation
? convertCurrencyToString(
data.background.experiences[0].totalCompensation,
)
: '-',
}
: {},
],
profileName: data.profileName, profileName: data.profileName,
specificYoes: data.background.specificYoes ?? [], specificYoes: data.background.specificYoes,
totalYoe: String(data.background.totalYoe) || '-', totalYoe: String(data.background.totalYoe) || '-',
}; };
setBackground(transformedBackground); setBackground(transformedBackground);

@ -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<OffersProfileFormData>();
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 && (
<div className="flex w-full justify-center">
<Spinner className="m-10" display="block" size="lg" />
</div>
)}
{!getProfileResult.isLoading && (
<OffersSubmissionForm
initialOfferProfileValues={initialData}
profileId={profile?.id}
token={profile?.editToken || undefined}
/>
)}
</>
);
}

@ -1,215 +1,5 @@
import { useRef, useState } from 'react'; import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
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/offers-submission/BackgroundForm';
import OfferAnalysis from '~/components/offers/offers-submission/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/offers-submission/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/offers-submission/OfferProfileSave';
import type {
OfferFormData,
OffersProfileFormData,
} 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';
import type { CreateOfferProfileResponse } from '~/types/offers';
const defaultOfferValues = {
comments: '',
companyId: '',
jobType: JobType.FullTime,
location: '',
monthYearReceived: {
month: getCurrentMonth() as Month,
year: getCurrentYear(),
},
negotiationStrategy: '',
};
export const defaultFullTimeOfferValues = {
...defaultOfferValues,
jobType: JobType.FullTime,
};
export const defaultInternshipOfferValues = {
...defaultOfferValues,
jobType: JobType.Intern,
};
const defaultOfferProfileValues = {
background: {
educations: [],
experiences: [{ jobType: JobType.FullTime }],
specificYoes: [],
},
offers: [defaultOfferValues],
};
type FormStep = {
component: JSX.Element;
hasNext: boolean;
hasPrevious: boolean;
label: string;
};
export default function OffersSubmissionPage() { export default function OffersSubmissionPage() {
const [formStep, setFormStep] = useState(0); return <OffersSubmissionForm />;
const [createProfileResponse, setCreateProfileResponse] =
useState<CreateOfferProfileResponse>();
const pageRef = useRef<HTMLDivElement>(null);
const scrollToTop = () =>
pageRef.current?.scrollTo({ behavior: 'smooth', top: 0 });
const formMethods = useForm<OffersProfileFormData>({
defaultValues: defaultOfferProfileValues,
mode: 'all',
});
const { handleSubmit, trigger } = formMethods;
const formSteps: Array<FormStep> = [
{
component: <OfferDetailsForm key={0} />,
hasNext: true,
hasPrevious: false,
label: 'Offer details',
},
{
component: <BackgroundForm key={1} />,
hasNext: false,
hasPrevious: true,
label: 'Background',
},
{
component: (
<OfferAnalysis key={2} profileId={createProfileResponse?.id} />
),
hasNext: true,
hasPrevious: false,
label: 'Analysis',
},
{
component: <OfferProfileSave key={3} />,
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 generateAnalysisMutation = trpc.useMutation(
['offers.analysis.generate'],
{
onError(error) {
console.error(error.message);
},
},
);
const createMutation = trpc.useMutation(['offers.profile.create'], {
onError(error) {
console.error(error.message);
},
onSuccess(data) {
generateAnalysisMutation.mutate({
profileId: data?.id || '',
});
setCreateProfileResponse(data);
setFormStep(formStep + 1);
scrollToTop();
},
});
const onSubmit: SubmitHandler<OffersProfileFormData> = async (data) => {
const result = await trigger();
if (!result) {
return;
}
data = removeInvalidMoneyData(data);
const background = cleanObject(data.background);
background.specificYoes = data.background.specificYoes.filter(
(specificYoe) => specificYoe.domain && specificYoe.yoe > 0,
);
if (Object.entries(background.experiences[0]).length === 1) {
background.experiences = [];
}
const offers = data.offers.map((offer: OfferFormData) => ({
...offer,
monthYearReceived: new Date(
offer.monthYearReceived.year,
offer.monthYearReceived.month,
),
}));
const postData = { background, offers };
createMutation.mutate(postData);
};
return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<Breadcrumbs currentStep={formStep} stepLabels={formStepsLabels} />
</div>
<FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{formSteps[formStep].hasNext && (
<div className="flex justify-end">
<Button
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={() => nextStep(formStep)}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
} }

@ -77,7 +77,7 @@ function Test() {
message: 'wassup bro', message: 'wassup bro',
profileId: 'cl9efyn9p004ww3u42mjgl1vn', profileId: 'cl9efyn9p004ww3u42mjgl1vn',
replyingToId: 'cl9el4xj10001w3w21o3p2iny', replyingToId: 'cl9el4xj10001w3w21o3p2iny',
userId: 'cl9ehvpng0000w3ec2mpx0bdd' userId: 'cl9ehvpng0000w3ec2mpx0bdd',
}); });
}; };
@ -103,7 +103,7 @@ function Test() {
], ],
experiences: [ experiences: [
{ {
companyId: 'cl9ec1mgg0000w33hg1a3612r', companyId: 'cl9h0bqu50000txxwkhmshhxz',
durationInMonths: 24, durationInMonths: 24,
jobType: 'FULLTIME', jobType: 'FULLTIME',
level: 'Junior', level: 'Junior',
@ -132,7 +132,7 @@ function Test() {
{ {
comments: 'I am a Raffles Institution almumni', comments: 'I am a Raffles Institution almumni',
// Comments: '', // Comments: '',
companyId: 'cl98yuqk80007txhgjtjp8fk4', companyId: 'cl9h0bqu50000txxwkhmshhxz',
jobType: 'FULLTIME', jobType: 'FULLTIME',
location: 'Singapore, Singapore', location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'), monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
@ -161,7 +161,7 @@ function Test() {
}, },
{ {
comments: '', comments: '',
companyId: 'cl98yuqk80007txhgjtjp8fk4', companyId: 'cl9h0bqu50000txxwkhmshhxz',
jobType: 'FULLTIME', jobType: 'FULLTIME',
location: 'Singapore, Singapore', location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'), monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
@ -192,7 +192,7 @@ function Test() {
}); });
}; };
const profileId = 'cl9efyn9p004ww3u42mjgl1vn'; // Remember to change this filed after testing deleting const profileId = 'cl9i68fv60000tthj8t3zkox0'; // Remember to change this filed after testing deleting
const data = trpc.useQuery( const data = trpc.useQuery(
[ [
`offers.profile.listOne`, `offers.profile.listOne`,
@ -218,7 +218,6 @@ function Test() {
}, },
); );
// Console.log(replies.data?.data)
const deleteMutation = trpc.useMutation(['offers.profile.delete']); const deleteMutation = trpc.useMutation(['offers.profile.delete']);
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
@ -242,10 +241,10 @@ function Test() {
background: { background: {
educations: [ educations: [
{ {
backgroundId: 'cl96stky6002fw32g6vj4meyr', backgroundId: 'cl9i68fv60001tthj23g9tuv4',
endDate: new Date('2018-09-30T07:58:54.000Z'), endDate: new Date('2018-09-30T07:58:54.000Z'),
field: 'Computer Science', field: 'Computer Science',
id: 'cl96stky6002gw32gey2ffawd', id: 'cl9i87y7z004otthjmpsd48wo',
school: 'National University of Singapore', school: 'National University of Singapore',
startDate: new Date('2014-09-30T07:58:54.000Z'), startDate: new Date('2014-09-30T07:58:54.000Z'),
type: 'Bachelors', type: 'Bachelors',
@ -253,20 +252,20 @@ function Test() {
], ],
experiences: [ experiences: [
{ {
backgroundId: 'cl96stky6002fw32g6vj4meyr', backgroundId: 'cl9i68fv60001tthj23g9tuv4',
company: { company: {
createdAt: new Date('2022-10-12T16:19:05.196Z'), createdAt: new Date('2022-10-12T16:19:05.196Z'),
description: 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.', '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', id: 'cl9h0bqug0003txxwgkac0x40',
logoUrl: 'https://logo.clearbit.com/meta.com', logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta', name: 'Meta',
slug: 'meta', slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'), updatedAt: new Date('2022-10-12T16:19:05.196Z'),
}, },
companyId: 'cl9ec1mgg0000w33hg1a3612r', companyId: 'cl9h0bqug0003txxwgkac0x40',
durationInMonths: 24, durationInMonths: 24,
id: 'cl96stky6002iw32gpt6t87s2', // Id: 'cl9h0bqug0003txxwgkac0x40',
jobType: 'FULLTIME', jobType: 'FULLTIME',
level: 'Junior', level: 'Junior',
monthlySalary: null, monthlySalary: null,
@ -275,57 +274,33 @@ function Test() {
title: 'Software Engineer', title: 'Software Engineer',
totalCompensation: { totalCompensation: {
currency: 'SGD', currency: 'SGD',
id: 'cl96stky6002jw32g73svfacr', id: 'cl9i68fvc0005tthj7r1rhvb1',
value: 104100, value: 100,
}, },
totalCompensationId: 'cl96stky6002jw32g73svfacr', totalCompensationId: 'cl9i68fvc0005tthj7r1rhvb1',
}, },
], ],
id: 'cl96stky6002fw32g6vj4meyr', id: 'cl9i68fv60001tthj23g9tuv4',
offersProfileId: 'cl96stky5002ew32gx2kale2x', offersProfileId: 'cl9i68fv60000tthj8t3zkox0',
specificYoes: [ specificYoes: [
{ {
backgroundId: 'cl96stky6002fw32g6vj4meyr', backgroundId: 'cl9i68fv60001tthj23g9tuv4',
domain: 'Backend', domain: 'Backend',
id: 'cl96t7890004tw32g5in3px5j', id: 'cl9i68fvc0008tthjlxslzfo4',
yoe: 2, yoe: 5,
}, },
{ {
backgroundId: 'cl96stky6002fw32g6vj4meyr', backgroundId: 'cl9i68fv60001tthj23g9tuv4',
domain: 'Backend', domain: 'Backend',
id: 'cl96tb87x004xw32gnu17jbzv', id: 'cl9i68fvc0009tthjwol3285l',
yoe: 2, yoe: 4,
},
{
backgroundId: 'cl96stky6002fw32g6vj4meyr',
domain: 'Backend',
id: 'cl976t39z00007iygt3np3cgo',
yoe: 2,
},
{
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', createdAt: '2022-10-13T08:28:13.518Z',
discussion: [], // Discussion: [],
id: 'cl96stky5002ew32gx2kale2x', id: 'cl9i68fv60000tthj8t3zkox0',
isEditable: true, isEditable: true,
offers: [ offers: [
{ {
@ -334,14 +309,14 @@ function Test() {
createdAt: new Date('2022-10-12T16:19:05.196Z'), createdAt: new Date('2022-10-12T16:19:05.196Z'),
description: 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.', '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', id: 'cl9h0bqug0003txxwgkac0x40',
logoUrl: 'https://logo.clearbit.com/meta.com', logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta', name: 'Meta',
slug: 'meta', slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'), updatedAt: new Date('2022-10-12T16:19:05.196Z'),
}, },
companyId: 'cl9ec1mgg0000w33hg1a3612r', companyId: 'cl9h0bqug0003txxwgkac0x40',
id: 'cl976t4de00047iygl0zbce11', id: 'cl9i68fve000ntthj5h9yvqnh',
jobType: 'FULLTIME', jobType: 'FULLTIME',
location: 'Singapore, Singapore', location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'), monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
@ -349,253 +324,253 @@ function Test() {
offersFullTime: { offersFullTime: {
baseSalary: { baseSalary: {
currency: 'SGD', currency: 'SGD',
id: 'cl976t4de00067iyg3pjir7k9', id: 'cl9i68fve000ptthjn55hpoe4',
value: 1999999999, value: 1999999999,
}, },
baseSalaryId: 'cl976t4de00067iyg3pjir7k9', baseSalaryId: 'cl9i68fve000ptthjn55hpoe4',
bonus: { bonus: {
currency: 'SGD', currency: 'SGD',
id: 'cl976t4de00087iygcnlmh8aw', id: 'cl9i68fve000rtthjqo2ktljt',
value: 1410065407, value: 1410065407,
}, },
bonusId: 'cl976t4de00087iygcnlmh8aw', bonusId: 'cl9i68fve000rtthjqo2ktljt',
id: 'cl976t4de00057iygq3ktce3v', id: 'cl9i68fve000otthjqk0g01k0',
level: 'EXPERT', level: 'EXPERT',
specialization: 'FRONTEND', specialization: 'FRONTEND',
stocks: { stocks: {
currency: 'SGD', currency: 'SGD',
id: 'cl976t4df000a7iygkrsgr1xh', id: 'cl9i68fvf000ttthjt2ode0cc',
value: -558038585, value: -558038585,
}, },
stocksId: 'cl976t4df000a7iygkrsgr1xh', stocksId: 'cl9i68fvf000ttthjt2ode0cc',
title: 'Software Engineer', title: 'Software Engineer',
totalCompensation: { totalCompensation: {
currency: 'SGD', currency: 'SGD',
id: 'cl976t4df000c7iyg73ryf5uw', id: 'cl9i68fvf000vtthjg90s48nj',
value: 55555555, value: 55555555,
}, },
totalCompensationId: 'cl976t4df000c7iyg73ryf5uw', totalCompensationId: 'cl9i68fvf000vtthjg90s48nj',
}, },
offersFullTimeId: 'cl976t4de00057iygq3ktce3v', offersFullTimeId: 'cl9i68fve000otthjqk0g01k0',
offersIntern: null, offersIntern: null,
offersInternId: null, offersInternId: null,
profileId: 'cl96stky5002ew32gx2kale2x', 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: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
id: 'cl96stky80031w32gau9mu1gs',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Leveraged having million offers',
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',
},
offersFullTimeId: 'cl96stky80032w32gep9ovgj3',
offersIntern: null,
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: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
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: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
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: 'cl95u79f000007im531ysjg79',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9ec1mgg0000w33hg1a3612r',
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',
}, },
// {
// 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: 'cl9h0bqug0003txxwgkac0x40',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9h0bqug0003txxwgkac0x40',
// 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: 'cl9h0bqug0003txxwgkac0x40',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9h0bqug0003txxwgkac0x40',
// 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: 'cl9h0bqug0003txxwgkac0x40',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9h0bqug0003txxwgkac0x40',
// 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: 'cl9h0bqug0003txxwgkac0x40',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9h0bqug0003txxwgkac0x40',
// 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', // ProfileName: 'ailing bryann stuart ziqing',
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba', token: 'd3509cb890f0bae0a785afdd6c1c074a140706ab1d155ed338ec22dcca5c92f1',
userId: null, userId: null,
}); });
}; };

@ -8,7 +8,7 @@ function GenerateAnalysis() {
return ( return (
<div> <div>
{JSON.stringify( {JSON.stringify(
analysisMutation.mutate({ profileId: 'cl98ywtbv0000tx1s4p18eol1' }), analysisMutation.mutate({ profileId: 'cl9h23fb1002ftxysli5iziu2' }),
)} )}
</div> </div>
); );

@ -5,7 +5,7 @@ import { trpc } from '~/utils/trpc';
function GetAnalysis() { function GetAnalysis() {
const analysis = trpc.useQuery([ const analysis = trpc.useQuery([
'offers.analysis.get', 'offers.analysis.get',
{ profileId: 'cl98ywtbv0000tx1s4p18eol1' }, { profileId: 'cl9h23fb1002ftxysli5iziu2' },
]); ]);
return <div>{JSON.stringify(analysis.data)}</div>; return <div>{JSON.stringify(analysis.data)}</div>;

@ -9,8 +9,8 @@ function Test() {
limit: 100, limit: 100,
location: 'Singapore, Singapore', location: 'Singapore, Singapore',
offset: 0, offset: 0,
sortBy: '-totalYoe', sortBy: '+totalCompensation',
yoeCategory: 2, yoeCategory: 1,
}, },
]); ]);

@ -42,11 +42,17 @@ export default function ResumeReviewPage() {
const starMutation = trpc.useMutation('resumes.resume.star', { const starMutation = trpc.useMutation('resumes.resume.star', {
onSuccess() { onSuccess() {
utils.invalidateQueries(['resumes.resume.findOne']); 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', { const unstarMutation = trpc.useMutation('resumes.resume.unstar', {
onSuccess() { onSuccess() {
utils.invalidateQueries(['resumes.resume.findOne']); utils.invalidateQueries(['resumes.resume.findOne']);
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
}, },
}); });
const userIsOwner = const userIsOwner =
@ -89,6 +95,9 @@ export default function ResumeReviewPage() {
}} }}
onClose={() => { onClose={() => {
utils.invalidateQueries(['resumes.resume.findOne']); utils.invalidateQueries(['resumes.resume.findOne']);
utils.invalidateQueries(['resumes.resume.findAll']);
utils.invalidateQueries(['resumes.resume.user.findUserStarred']);
utils.invalidateQueries(['resumes.resume.user.findUserCreated']);
setIsEditMode(false); setIsEditMode(false);
}} }}
/> />
@ -189,7 +198,10 @@ export default function ResumeReviewPage() {
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/> />
<ResumeExpandableText text={detailsQuery.data.additionalInfo} /> <ResumeExpandableText
key={detailsQuery.data.additionalInfo}
text={detailsQuery.data.additionalInfo}
/>
</div> </div>
)} )}
<div className="flex w-full flex-col py-4 lg:flex-row"> <div className="flex w-full flex-col py-4 lg:flex-row">

@ -4,7 +4,10 @@ import { useSession } from 'next-auth/react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Disclosure } from '@headlessui/react'; import { Disclosure } from '@headlessui/react';
import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid'; import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; import {
MagnifyingGlassIcon,
NewspaperIcon,
} from '@heroicons/react/24/outline';
import { import {
CheckboxInput, CheckboxInput,
CheckboxList, CheckboxList,
@ -24,6 +27,7 @@ import {
BROWSE_TABS_VALUES, BROWSE_TABS_VALUES,
EXPERIENCE, EXPERIENCE,
INITIAL_FILTER_STATE, INITIAL_FILTER_STATE,
isInitialFilterState,
LOCATION, LOCATION,
ROLE, ROLE,
SHORTCUTS, SHORTCUTS,
@ -33,10 +37,12 @@ import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle'; import ResumeReviewsTitle from '~/components/resumes/ResumeReviewsTitle';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton'; import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import useDebounceValue from '~/utils/resumes/useDebounceValue';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import type { Resume } from '~/types/resume'; import type { FilterState } from '../../components/resumes/browse/resumeFilters';
const PAGE_LIMIT = 10;
const filters: Array<Filter> = [ const filters: Array<Filter> = [
{ {
id: 'role', id: 'role',
@ -55,6 +61,40 @@ const filters: Array<Filter> = [
}, },
]; ];
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 getEmptyDataText = (
tabsValue: string,
searchValue: string,
userFilters: FilterState,
) => {
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 '';
}
};
export default function ResumeHomePage() { export default function ResumeHomePage() {
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
const router = useRouter(); const router = useRouter();
@ -63,13 +103,8 @@ export default function ResumeHomePage() {
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE); const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
const [shortcutSelected, setShortcutSelected] = useState('All'); const [shortcutSelected, setShortcutSelected] = useState('All');
const [resumes, setResumes] = useState<Array<Resume>>([]);
const [renderSignInButton, setRenderSignInButton] = useState(false);
const [signInButtonText, setSignInButtonText] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const PAGE_LIMIT = 10;
const skip = (currentPage - 1) * PAGE_LIMIT; const skip = (currentPage - 1) * PAGE_LIMIT;
useEffect(() => { useEffect(() => {
@ -84,21 +119,14 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800),
skip, skip,
sortOrder, sortOrder,
}, },
], ],
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.ALL, enabled: tabsValue === BROWSE_TABS_VALUES.ALL,
onSuccess: (data) => { staleTime: 5 * 60 * 1000,
setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
setRenderSignInButton(false);
},
}, },
); );
const starredResumesQuery = trpc.useQuery( const starredResumesQuery = trpc.useQuery(
@ -109,26 +137,15 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800),
skip, skip,
sortOrder, sortOrder,
}, },
], ],
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.STARRED, enabled: tabsValue === BROWSE_TABS_VALUES.STARRED,
onError: () => {
setResumes([]);
setRenderSignInButton(true);
setSignInButtonText('to view starred resumes');
},
onSuccess: (data) => {
setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
},
retry: false, retry: false,
staleTime: 5 * 60 * 1000,
}, },
); );
const myResumesQuery = trpc.useQuery( const myResumesQuery = trpc.useQuery(
@ -139,34 +156,23 @@ export default function ResumeHomePage() {
locationFilters: userFilters.location, locationFilters: userFilters.location,
numComments: userFilters.numComments, numComments: userFilters.numComments,
roleFilters: userFilters.role, roleFilters: userFilters.role,
searchValue: useDebounceValue(searchValue, 800),
skip, skip,
sortOrder, sortOrder,
}, },
], ],
{ {
enabled: tabsValue === BROWSE_TABS_VALUES.MY, enabled: tabsValue === BROWSE_TABS_VALUES.MY,
onError: () => {
setResumes([]);
setRenderSignInButton(true);
setSignInButtonText('to view your submitted resumes');
},
onSuccess: (data) => {
setTotalPages(
data.totalRecords % PAGE_LIMIT === 0
? data.totalRecords / PAGE_LIMIT
: Math.floor(data.totalRecords / PAGE_LIMIT) + 1,
);
setResumes(data.mappedResumeData);
},
retry: false, retry: false,
staleTime: 5 * 60 * 1000,
}, },
); );
const onSubmitResume = () => { const onSubmitResume = () => {
if (sessionData?.user?.id) { if (sessionData === null) {
router.push('/resumes/submit');
} else {
router.push('/api/auth/signin'); router.push('/api/auth/signin');
} else {
router.push('/resumes/submit');
} }
}; };
@ -205,6 +211,30 @@ export default function ResumeHomePage() {
setCurrentPage(1); 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 ( return (
<> <>
<Head> <Head>
@ -274,7 +304,7 @@ export default function ResumeHomePage() {
</div> </div>
<div> <div>
<button <button
className="rounded-md bg-indigo-500 py-1 px-3 text-sm font-medium text-white" className="rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white"
type="button" type="button"
onClick={onSubmitResume}> onClick={onSubmitResume}>
Submit Resume Submit Resume
@ -368,29 +398,42 @@ export default function ResumeHomePage() {
</div> </div>
</div> </div>
<div className="col-span-10 mb-6"> <div className="col-span-10 mb-6">
{renderSignInButton && ( {sessionData === null &&
<ResumeSignInButton text={signInButtonText} /> tabsValue !== BROWSE_TABS_VALUES.ALL ? (
)} <ResumeSignInButton
{totalPages === 0 && ( className="mt-8"
<div className="mt-4">Nothing to see here.</div> text={getLoggedOutText(tabsValue)}
)}
<ResumeListItems
isLoading={
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching
}
resumes={resumes}
/>
<div className="my-4 flex justify-center">
<Pagination
current={currentPage}
end={totalPages}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/> />
</div> ) : getTabResumes().length === 0 ? (
<div className="mt-24 flex flex-wrap justify-center">
<NewspaperIcon
className="mb-12 basis-full"
height={196}
width={196}
/>
{getEmptyDataText(tabsValue, searchValue, userFilters)}
</div>
) : (
<>
<ResumeListItems
isLoading={
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching
}
resumes={getTabResumes()}
/>
<div className="my-4 flex justify-center">
<Pagination
current={currentPage}
end={getTabTotalPages()}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

@ -216,7 +216,7 @@ export const offersAnalysisRouter = createRouter()
// TODO: Shift yoe out of background to make it mandatory // TODO: Shift yoe out of background to make it mandatory
if ( if (
!overallHighestOffer.profile.background || !overallHighestOffer.profile.background ||
!overallHighestOffer.profile.background.totalYoe overallHighestOffer.profile.background.totalYoe === undefined
) { ) {
throw new TRPCError({ throw new TRPCError({
code: 'BAD_REQUEST', code: 'BAD_REQUEST',

@ -75,6 +75,7 @@ const experience = z.object({
id: z.string().optional(), id: z.string().optional(),
jobType: z.string().nullish(), jobType: z.string().nullish(),
level: z.string().nullish(), level: z.string().nullish(),
location: z.string().nullish(),
monthlySalary: valuation.nullish(), monthlySalary: valuation.nullish(),
monthlySalaryId: z.string().nullish(), monthlySalaryId: z.string().nullish(),
specialization: z.string().nullish(), specialization: z.string().nullish(),
@ -93,14 +94,14 @@ const education = z.object({
type: z.string().nullish(), type: z.string().nullish(),
}); });
const reply = z.object({ // Const reply = z.object({
createdAt: z.date().nullish(), // createdAt: z.date().nullish(),
id: z.string().optional(), // id: z.string().optional(),
messages: z.string().nullish(), // messages: z.string().nullish(),
profileId: z.string().nullish(), // profileId: z.string().nullish(),
replyingToId: z.string().nullish(), // replyingToId: z.string().nullish(),
userId: z.string().nullish(), // userId: z.string().nullish(),
}); // });
export const offersProfileRouter = createRouter() export const offersProfileRouter = createRouter()
.query('listOne', { .query('listOne', {
@ -534,11 +535,11 @@ export const offersProfileRouter = createRouter()
totalYoe: z.number(), totalYoe: z.number(),
}), }),
createdAt: z.string().optional(), createdAt: z.string().optional(),
discussion: z.array(reply), // Discussion: z.array(reply),
id: z.string(), id: z.string(),
isEditable: z.boolean().nullish(), isEditable: z.boolean().nullish(),
offers: z.array(offer), offers: z.array(offer),
profileName: z.string(), profileName: z.string().optional(),
token: z.string(), token: z.string(),
userId: z.string().nullish(), userId: z.string().nullish(),
}), }),
@ -551,14 +552,16 @@ export const offersProfileRouter = createRouter()
const profileEditToken = profileToUpdate?.editToken; const profileEditToken = profileToUpdate?.editToken;
if (profileEditToken === input.token) { if (profileEditToken === input.token) {
await ctx.prisma.offersProfile.update({ if (input.profileName) {
data: { await ctx.prisma.offersProfile.update({
profileName: input.profileName, data: {
}, profileName: input.profileName,
where: { },
id: input.id, where: {
}, id: input.id,
}); },
});
}
await ctx.prisma.offersBackground.update({ await ctx.prisma.offersBackground.update({
data: { data: {
@ -569,8 +572,26 @@ export const offersProfileRouter = createRouter()
}, },
}); });
// 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) { for (const edu of input.background.educations) {
if (edu.id) { if (edu.id) {
// Update existing education
await ctx.prisma.offersEducation.update({ await ctx.prisma.offersEducation.update({
data: { data: {
endDate: edu.endDate, endDate: edu.endDate,
@ -584,6 +605,7 @@ export const offersProfileRouter = createRouter()
}, },
}); });
} else { } else {
// Create new education
await ctx.prisma.offersBackground.update({ await ctx.prisma.offersBackground.update({
data: { data: {
educations: { educations: {
@ -603,8 +625,26 @@ export const offersProfileRouter = createRouter()
} }
} }
// 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) { for (const exp of input.background.experiences) {
if (exp.id) { if (exp.id) {
// Update existing experience
await ctx.prisma.offersExperience.update({ await ctx.prisma.offersExperience.update({
data: { data: {
companyId: exp.companyId, companyId: exp.companyId,
@ -641,6 +681,7 @@ export const offersProfileRouter = createRouter()
}); });
} }
} else if (!exp.id) { } else if (!exp.id) {
// Create new experience
if ( if (
exp.jobType === 'FULLTIME' && exp.jobType === 'FULLTIME' &&
exp.totalCompensation?.currency !== undefined && exp.totalCompensation?.currency !== undefined &&
@ -757,8 +798,26 @@ export const offersProfileRouter = createRouter()
} }
} }
// 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) { for (const yoe of input.background.specificYoes) {
if (yoe.id) { if (yoe.id) {
// Update existing yoe
await ctx.prisma.offersSpecificYoe.update({ await ctx.prisma.offersSpecificYoe.update({
data: { data: {
...yoe, ...yoe,
@ -768,6 +827,7 @@ export const offersProfileRouter = createRouter()
}, },
}); });
} else { } else {
// Create new yoe
await ctx.prisma.offersBackground.update({ await ctx.prisma.offersBackground.update({
data: { data: {
specificYoes: { specificYoes: {
@ -784,8 +844,27 @@ export const offersProfileRouter = createRouter()
} }
} }
// 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) { for (const offerToUpdate of input.offers) {
if (offerToUpdate.id) { if (offerToUpdate.id) {
// Update existing offer
await ctx.prisma.offersOffer.update({ await ctx.prisma.offersOffer.update({
data: { data: {
comments: offerToUpdate.comments, comments: offerToUpdate.comments,
@ -893,6 +972,7 @@ export const offersProfileRouter = createRouter()
}); });
} }
} else { } else {
// Create new offer
if ( if (
offerToUpdate.jobType === 'INTERN' && offerToUpdate.jobType === 'INTERN' &&
offerToUpdate.offersIntern && offerToUpdate.offersIntern &&

@ -17,11 +17,11 @@ const yoeCategoryMap: Record<number, string> = {
const getYoeRange = (yoeCategory: number) => { const getYoeRange = (yoeCategory: number) => {
return yoeCategoryMap[yoeCategory] === 'Fresh Grad' return yoeCategoryMap[yoeCategory] === 'Fresh Grad'
? { maxYoe: 3, minYoe: 0 } ? { maxYoe: 2, minYoe: 0 }
: yoeCategoryMap[yoeCategory] === 'Mid' : yoeCategoryMap[yoeCategory] === 'Mid'
? { maxYoe: 7, minYoe: 4 } ? { maxYoe: 5, minYoe: 3 }
: yoeCategoryMap[yoeCategory] === 'Senior' : yoeCategoryMap[yoeCategory] === 'Senior'
? { maxYoe: 100, minYoe: 8 } ? { maxYoe: 100, minYoe: 6 }
: null; // Internship : null; // Internship
}; };
@ -43,7 +43,7 @@ export const offersRouter = createRouter().query('list', {
limit: z.number().positive(), limit: z.number().positive(),
location: z.string(), location: z.string(),
offset: z.number().nonnegative(), offset: z.number().nonnegative(),
salaryMax: z.number().nullish(), salaryMax: z.number().nonnegative().nullish(),
salaryMin: z.number().nonnegative().nullish(), salaryMin: z.number().nonnegative().nullish(),
sortBy: z.string().regex(createSortByValidationRegex()).nullish(), sortBy: z.string().regex(createSortByValidationRegex()).nullish(),
title: z.string().nullish(), title: z.string().nullish(),
@ -154,38 +154,47 @@ export const offersRouter = createRouter().query('list', {
data = data.filter((offer) => { data = data.filter((offer) => {
let validRecord = true; let validRecord = true;
if (input.companyId) { if (input.companyId && input.companyId.length !== 0) {
validRecord = validRecord && offer.company.id === input.companyId; validRecord = validRecord && offer.company.id === input.companyId;
} }
if (input.title) { if (input.title && input.title.length !== 0) {
validRecord = validRecord =
validRecord && validRecord &&
(offer.offersFullTime?.title === input.title || (offer.offersFullTime?.title === input.title ||
offer.offersIntern?.title === input.title); offer.offersIntern?.title === input.title);
} }
if (input.dateStart && input.dateEnd) { if (
input.dateStart &&
input.dateEnd &&
input.dateStart.getTime() <= input.dateEnd.getTime()
) {
validRecord = validRecord =
validRecord && validRecord &&
offer.monthYearReceived.getTime() >= input.dateStart.getTime() && offer.monthYearReceived.getTime() >= input.dateStart.getTime() &&
offer.monthYearReceived.getTime() <= input.dateEnd.getTime(); offer.monthYearReceived.getTime() <= input.dateEnd.getTime();
} }
if (input.salaryMin && input.salaryMax) { if (input.salaryMin != null || input.salaryMax != null) {
const salary = offer.offersFullTime?.totalCompensation.value const salary = offer.offersFullTime?.totalCompensation.value
? offer.offersFullTime?.totalCompensation.value ? offer.offersFullTime?.totalCompensation.value
: offer.offersIntern?.monthlySalary.value; : offer.offersIntern?.monthlySalary.value;
if (!salary) { if (salary == null) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found', message: 'Total Compensation or Salary not found',
}); });
} }
validRecord = if (input.salaryMin != null) {
validRecord && salary >= input.salaryMin && salary <= input.salaryMax; validRecord = validRecord && salary >= input.salaryMin;
}
if (input.salaryMax != null) {
validRecord = validRecord && salary <= input.salaryMax;
}
} }
return validRecord; return validRecord;
@ -221,7 +230,7 @@ export const offersRouter = createRouter().query('list', {
? offer2.offersFullTime?.totalCompensation.value ? offer2.offersFullTime?.totalCompensation.value
: offer2.offersIntern?.monthlySalary.value; : offer2.offersIntern?.monthlySalary.value;
if (!salary1 || !salary2) { if (salary1 == null || salary2 == null) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found', message: 'Total Compensation or Salary not found',
@ -235,7 +244,7 @@ export const offersRouter = createRouter().query('list', {
const yoe1 = offer1.profile.background?.totalYoe; const yoe1 = offer1.profile.background?.totalYoe;
const yoe2 = offer2.profile.background?.totalYoe; const yoe2 = offer2.profile.background?.totalYoe;
if (!yoe1 || !yoe2) { if (yoe1 == null || yoe2 == null) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Total years of experience not found', message: 'Total years of experience not found',
@ -267,7 +276,7 @@ export const offersRouter = createRouter().query('list', {
? offer2.offersFullTime?.totalCompensation.value ? offer2.offersFullTime?.totalCompensation.value
: offer2.offersIntern?.monthlySalary.value; : offer2.offersIntern?.monthlySalary.value;
if (!salary1 || !salary2) { if (salary1 == null || salary2 == null) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found', message: 'Total Compensation or Salary not found',
@ -281,7 +290,7 @@ export const offersRouter = createRouter().query('list', {
const yoe1 = offer1.profile.background?.totalYoe; const yoe1 = offer1.profile.background?.totalYoe;
const yoe2 = offer2.profile.background?.totalYoe; const yoe2 = offer2.profile.background?.totalYoe;
if (!yoe1 || !yoe2) { if (yoe1 == null || yoe2 == null) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'Total years of experience not found', message: 'Total years of experience not found',

@ -13,9 +13,21 @@ export const resumeCommentsRouter = createRouter().query('list', {
// For this resume, we retrieve every comment's information, along with: // For this resume, we retrieve every comment's information, along with:
// The user's name and image to render // 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({ const comments = await ctx.prisma.resumesComment.findMany({
include: { include: {
children: {
include: {
user: {
select: {
image: true,
name: true,
},
},
},
orderBy: {
createdAt: 'asc',
},
},
user: { user: {
select: { select: {
image: true, image: true,
@ -27,15 +39,35 @@ export const resumeCommentsRouter = createRouter().query('list', {
createdAt: 'desc', createdAt: 'desc',
}, },
where: { where: {
resumeId, AND: [{ resumeId }, { parentId: null }],
}, },
}); });
return comments.map((data) => { return comments.map((data) => {
const children: Array<ResumeComment> = 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 = { const comment: ResumeComment = {
children,
createdAt: data.createdAt, createdAt: data.createdAt,
description: data.description, description: data.description,
id: data.id, id: data.id,
parentId: data.parentId,
resumeId: data.resumeId, resumeId: data.resumeId,
section: data.section, section: data.section,
updatedAt: data.updatedAt, updatedAt: data.updatedAt,

@ -67,4 +67,26 @@ export const resumesCommentsUserRouter = createProtectedRouter()
}, },
}); });
}, },
})
.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,
},
});
},
}); });

@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { Vote } from '@prisma/client';
import { createRouter } from '../context'; import { createRouter } from '../context';
@ -11,6 +12,7 @@ export const resumesRouter = createRouter()
locationFilters: z.string().array(), locationFilters: z.string().array(),
numComments: z.number().optional(), numComments: z.number().optional(),
roleFilters: z.string().array(), roleFilters: z.string().array(),
searchValue: z.string(),
skip: z.number(), skip: z.number(),
sortOrder: z.string(), sortOrder: z.string(),
}), }),
@ -22,6 +24,7 @@ export const resumesRouter = createRouter()
sortOrder, sortOrder,
numComments, numComments,
skip, skip,
searchValue,
} = input; } = input;
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const totalRecords = await ctx.prisma.resumesResume.count({ const totalRecords = await ctx.prisma.resumesResume.count({
@ -81,6 +84,7 @@ export const resumesRouter = createRouter()
experience: { in: experienceFilters }, experience: { in: experienceFilters },
location: { in: locationFilters }, location: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
}, },
}); });
const mappedResumeData = resumesData.map((r) => { const mappedResumeData = resumesData.map((r) => {
@ -138,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;
},
}); });

@ -50,6 +50,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
locationFilters: z.string().array(), locationFilters: z.string().array(),
numComments: z.number().optional(), numComments: z.number().optional(),
roleFilters: z.string().array(), roleFilters: z.string().array(),
searchValue: z.string(),
skip: z.number(), skip: z.number(),
sortOrder: z.string(), sortOrder: z.string(),
}), }),
@ -59,6 +60,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
roleFilters, roleFilters,
locationFilters, locationFilters,
experienceFilters, experienceFilters,
searchValue,
sortOrder, sortOrder,
numComments, numComments,
skip, skip,
@ -130,6 +132,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
experience: { in: experienceFilters }, experience: { in: experienceFilters },
location: { in: locationFilters }, location: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
}, },
userId, userId,
}, },
@ -161,6 +164,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
locationFilters: z.string().array(), locationFilters: z.string().array(),
numComments: z.number().optional(), numComments: z.number().optional(),
roleFilters: z.string().array(), roleFilters: z.string().array(),
searchValue: z.string(),
skip: z.number(), skip: z.number(),
sortOrder: z.string(), sortOrder: z.string(),
}), }),
@ -171,6 +175,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
locationFilters, locationFilters,
experienceFilters, experienceFilters,
sortOrder, sortOrder,
searchValue,
numComments, numComments,
skip, skip,
} = input; } = input;
@ -229,6 +234,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
experience: { in: experienceFilters }, experience: { in: experienceFilters },
location: { in: locationFilters }, location: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId, userId,
}, },
}); });

@ -24,6 +24,7 @@ export type Experience = {
id: string; id: string;
jobType: JobType?; jobType: JobType?;
level: string?; level: string?;
location: string?;
monthlySalary: Valuation?; monthlySalary: Valuation?;
specialization: string?; specialization: string?;
title: string?; title: string?;
@ -166,7 +167,7 @@ export type AnalysisHighestOffer = {
export type AnalysisOffer = { export type AnalysisOffer = {
company: OffersCompany; company: OffersCompany;
id: string; id: string;
income: number; income: Valuation;
jobType: JobType; jobType: JobType;
level: string; level: string;
location: string; location: string;

@ -5,9 +5,11 @@ import type { ResumesCommentVote, ResumesSection } from '@prisma/client';
* frontend-friendly representation of the query * frontend-friendly representation of the query
*/ */
export type ResumeComment = Readonly<{ export type ResumeComment = Readonly<{
children: Array<ResumeComment>;
createdAt: Date; createdAt: Date;
description: string; description: string;
id: string; id: string;
parentId: string?;
resumeId: string; resumeId: string;
section: ResumesSection; section: ResumesSection;
updatedAt: Date; updatedAt: Date;

@ -1,6 +1,6 @@
import type { Money } from '~/components/offers/types'; import type { Money } from '~/components/offers/types';
export function convertCurrencyToString({ currency, value }: Money) { export function convertMoneyToString({ currency, value }: Money) {
if (!value) { if (!value) {
return '-'; return '-';
} }
@ -10,5 +10,5 @@ export function convertCurrencyToString({ currency, value }: Money) {
minimumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501) minimumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501)
style: 'currency', style: 'currency',
}); });
return `${formatter.format(10000)}`; /* $2,500.00 */ return `${formatter.format(value)}`;
} }

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

@ -32,22 +32,20 @@ export function timeSinceNow(date: Date | number | string) {
export function formatDate(value: Date | number | string) { export function formatDate(value: Date | number | string) {
const date = new Date(value); const date = new Date(value);
// Const day = date.toLocaleString('default', { day: '2-digit' });
const month = date.toLocaleString('default', { month: 'short' }); const month = date.toLocaleString('default', { month: 'short' });
const year = date.toLocaleString('default', { year: 'numeric' }); const year = date.toLocaleString('default', { year: 'numeric' });
return `${month} ${year}`; 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() { export function getCurrentMonth() {
return getMonth(Date.now()); // `getMonth` returns a zero-based month index
return getMonth(Date.now()) + 1;
} }
export function getCurrentYear() { export function getCurrentYear() {
return getYear(Date.now()); return getYear(Date.now());
} }
export function convertToMonthYear(date: Date) {
return { month: date.getMonth() + 1, year: date.getFullYear() } as MonthYear;
}

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

@ -8,6 +8,7 @@
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@tailwindcss/line-clamp": "^0.4.2", "@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.7", "@tailwindcss/typography": "^0.5.7",
"tailwind-scrollbar-hide": "^1.1.7",
"tailwindcss": "^3.1.8" "tailwindcss": "^3.1.8"
} }
} }

@ -23,5 +23,6 @@ module.exports = {
require('@tailwindcss/forms'), require('@tailwindcss/forms'),
require('@tailwindcss/line-clamp'), require('@tailwindcss/line-clamp'),
require('@tailwindcss/typography'), require('@tailwindcss/typography'),
require('tailwind-scrollbar-hide'),
], ],
}; };

@ -13791,6 +13791,11 @@ synchronous-promise@^2.0.15:
resolved "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.16.tgz" resolved "https://registry.npmjs.org/synchronous-promise/-/synchronous-promise-2.0.16.tgz"
integrity sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A== integrity sha512-qImOD23aDfnIDNqlG1NOehdB9IYsn1V9oByPjKY1nakv2MQYCEMyX033/q+aEtYCpmYK1cv2+NTmlH+ra6GA5A==
tailwind-scrollbar-hide@^1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/tailwind-scrollbar-hide/-/tailwind-scrollbar-hide-1.1.7.tgz#90b481fb2e204030e3919427416650c54f56f847"
integrity sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==
tailwindcss@^3.1.8: tailwindcss@^3.1.8:
version "3.1.8" version "3.1.8"
resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.8.tgz" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.1.8.tgz"

Loading…
Cancel
Save