Merge branch 'main' into stuart/seed-db

* main: (33 commits)
  [offers][fix] fix create offer bug
  [offers][feat] add default filters and more income columns (#495)
  [portal][ui] allow customization of spacing within MonthYearPicker
  [resumes][feat] add logo
  [offers][feat] return base bonus stocks for dashboard
  [offers][chore] Change location filter from city to country
  [offers][chore] Make all filters optional (#493)
  [offers][chore] Change location fields (#491)
  [ui][typeahead] fix nullable prop (#492)
  [offers] tweak offer profiles UI
  [portal][ui] change app shell nav structure
  [offers][refactor] tweak submit and analysis steps UI
  [offers][refactor] improve offers table responsiveness
  [offers][fix] use upsert to remove id in valuation
  [offers][fix] fix unable to update BBS in offer bug and remove valuation id
  [offers][feat] tweak offer background submission form (#490)
  [offers][fix] fix profile page mobile compatible style (#489)
  [offers][feat] tweak offer details submission form (#488)
  [offers][fix] tweak submit offer job type selector (#487)
  [portal][misc] refactor typeahead props
  ...

# Conflicts:
#	apps/portal/src/components/offers/dashboard/DashboardOfferCard.tsx
#	apps/portal/src/components/offers/offersSubmission/OffersSubmissionForm.tsx
#	apps/portal/src/components/offers/offersSubmission/submissionForm/BackgroundForm.tsx
#	apps/portal/src/components/offers/offersSubmission/submissionForm/OfferDetailsForm.tsx
#	apps/portal/src/components/offers/table/OffersTable.tsx
#	apps/portal/src/pages/offers/index.tsx
#	apps/portal/src/pages/offers/test/generateAnalysis.tsx
#	apps/portal/src/pages/offers/test/getAnalysis.tsx
#	apps/portal/src/pages/offers/test/listOffers.tsx
#	apps/portal/src/server/router/offers/offers-profile-router.ts
#	apps/portal/src/server/router/offers/offers.ts
#	apps/portal/src/utils/offers/analysisGeneration.ts
pull/501/head^2
Bryann Yeap Kok Keong 3 years ago
commit 6c8d087bce

@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the column `location` on the `OffersExperience` table. All the data in the column will be lost.
- You are about to drop the column `location` on the `OffersOffer` table. All the data in the column will be lost.
- Added the required column `cityId` to the `OffersOffer` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "OffersExperience" DROP COLUMN "location",
ADD COLUMN "cityId" TEXT;
-- AlterTable
ALTER TABLE "OffersOffer" DROP COLUMN "location",
ADD COLUMN "cityId" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "OffersExperience" ADD CONSTRAINT "OffersExperience_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OffersOffer" ADD CONSTRAINT "OffersOffer_cityId_fkey" FOREIGN KEY ("cityId") REFERENCES "City"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 582.39 458.49">
<defs>
<style>
.cls-1 {
fill: #00e5a0;
}
.cls-2 {
fill: #eef2f5;
}
.cls-3 {
fill: #e6e6e6;
}
.cls-4 {
fill: #fee400;
}
.cls-5 {
fill: #fff;
}
.cls-6 {
fill: #ff4585;
}
.cls-7 {
fill: #7f7ff0;
}
.cls-8 {
fill: #363636;
}
</style>
</defs>
<ellipse class="cls-2" cx="291.19" cy="428.46" rx="291.19" ry="30.03"/>
<path class="cls-7" d="M94.28,226.37c0-108.75,88.16-196.92,196.92-196.92s196.92,88.16,196.92,196.92c0,75.78-42.81,141.57-105.57,174.49-27.3,14.32-58.38-12.43-91.35-12.43-22.94,0-44.96,30.93-65.44,23.72-76.58-26.98-131.47-99.97-131.47-185.78Z"/>
<rect class="cls-6" x="596.3" y="-67.3" width="59.5" height="90.16" rx="11.44" ry="11.44" transform="translate(328.22 528.9) rotate(-59.58)"/>
<g>
<rect class="cls-3" x="158.7" y="37.28" width="265.22" height="378.18" rx="30" ry="30" transform="translate(-10.62 14.38) rotate(-2.78)"/>
<rect class="cls-5" x="145.53" y="37.92" width="265.22" height="378.18" rx="30" ry="30" transform="translate(-10.67 13.74) rotate(-2.78)"/>
<path class="cls-6" d="M140.58,128.04l-2.6-53.51c-.8-16.55,11.96-30.62,28.51-31.42l204.98-9.94c16.55-.8,30.62,11.96,31.42,28.51l2.6,53.51-264.91,12.85Z"/>
<rect class="cls-8" x="161.75" y="143.74" width="128.01" height="12.88" rx="6.42" ry="6.42" transform="translate(-7.01 11.11) rotate(-2.78)"/>
<rect class="cls-3" x="164.92" y="208.64" width="160" height="12.88" rx="6.42" ry="6.42" transform="translate(-10.13 12.12) rotate(-2.78)"/>
<rect class="cls-8" x="163.38" y="178.26" width="58.72" height="12.88" rx="6.42" ry="6.42" transform="translate(-8.72 9.55) rotate(-2.78)"/>
<rect class="cls-3" x="166.54" y="242.71" width="108.99" height="12.88" rx="6.42" ry="6.42" transform="translate(-11.81 11) rotate(-2.78)"/>
<rect class="cls-3" x="170.18" y="317.14" width="160" height="12.88" rx="6.42" ry="6.42" transform="translate(-15.38 12.5) rotate(-2.78)"/>
<rect class="cls-3" x="168.62" y="285.54" width="108.99" height="12.88" rx="6.42" ry="6.42" transform="translate(-13.88 11.15) rotate(-2.78)"/>
<rect class="cls-3" x="171.8" y="351.21" width="108.99" height="12.88" rx="6.42" ry="6.42" transform="translate(-17.06 11.38) rotate(-2.78)"/>
<rect class="cls-4" x="319.94" y="77.98" width="60.57" height="63.56" rx="13.76" ry="13.76" transform="translate(-4.91 17.09) rotate(-2.78)"/>
</g>
<g>
<circle class="cls-1" cx="405.49" cy="348.96" r="51.89"/>
<path class="cls-5" d="M396.69,374.45l-24.26-23.7,10.85-11.13,13.74,13.42,31.52-28.99,10.51,11.44s-42.34,38.96-42.34,38.96Z"/>
</g>
<path class="cls-4" d="M141.58,319.12l12.69,22.46c.94,1.66,2.72,2.66,4.63,2.61l25.78-.8c5.36-.17,7.35,6.96,2.68,9.6l-22.46,12.69c-1.66,.94-2.66,2.72-2.61,4.63l.8,25.78c.17,5.36-6.96,7.35-9.6,2.68l-12.69-22.46c-.94-1.66-2.72-2.66-4.63-2.61l-25.78,.8c-5.36,.17-7.35-6.96-2.68-9.6l22.46-12.69c1.66-.94,2.66-2.72,2.61-4.63l-.8-25.78c-.17-5.36,6.96-7.35,9.6-2.68Z"/>
<path class="cls-4" d="M456.08,148.79l-.5,22.57c-.05,2.16,1.11,4.18,3,5.23l19.75,10.93c5.32,2.95,3.13,11.06-2.95,10.92l-22.57-.5c-2.16-.05-4.18,1.11-5.23,3l-10.93,19.75c-2.95,5.32-11.06,3.13-10.92-2.95l.5-22.57c.05-2.16-1.11-4.18-3-5.23l-19.75-10.93c-5.32-2.95-3.13-11.06,2.95-10.92l22.57,.5c2.16,.05,4.18-1.11,5.23-3l10.93-19.75c2.95-5.32,11.06-3.13,10.92,2.95Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@ -1,7 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { signIn, signOut, useSession } from 'next-auth/react'; import { signOut, useSession } from 'next-auth/react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Fragment, useState } from 'react'; import { Fragment, useState } from 'react';
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react';
@ -19,12 +19,14 @@ import GoogleAnalytics from './GoogleAnalytics';
import MobileNavigation from './MobileNavigation'; import MobileNavigation from './MobileNavigation';
import type { ProductNavigationItems } from './ProductNavigation'; import type { ProductNavigationItems } from './ProductNavigation';
import ProductNavigation from './ProductNavigation'; import ProductNavigation from './ProductNavigation';
import loginPageHref from '../shared/loginPageHref';
type Props = Readonly<{ type Props = Readonly<{
children: ReactNode; children: ReactNode;
}>; }>;
function ProfileJewel() { function ProfileJewel() {
const router = useRouter();
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const isSessionLoading = status === 'loading'; const isSessionLoading = status === 'loading';
@ -32,25 +34,20 @@ function ProfileJewel() {
return null; return null;
} }
const loginHref = loginPageHref();
if (session == null) { if (session == null) {
return ( return router.pathname !== loginHref.pathname ? (
<Link <Link className="text-base" href={loginHref}>
className="text-base" Log In
href="/api/auth/signin"
onClick={(event) => {
event.preventDefault();
signIn();
}}>
Sign in
</Link> </Link>
); ) : null;
} }
const userNavigation = [ const userNavigation = [
{ href: '/profile', name: 'Profile' }, { href: '/profile', name: 'Profile' },
{ {
href: '/api/auth/signout', href: '/api/auth/signout',
name: 'Sign out', name: 'Log out',
onClick: (event: MouseEvent) => { onClick: (event: MouseEvent) => {
event.preventDefault(); event.preventDefault();
signOut(); signOut();
@ -139,7 +136,7 @@ export default function AppShell({ children }: Props) {
return ( return (
<GoogleAnalytics <GoogleAnalytics
measurementID={currentProductNavigation.googleAnalyticsMeasurementID}> measurementID={currentProductNavigation.googleAnalyticsMeasurementID}>
<div className="flex h-full min-h-screen"> <div className="flex">
{/* Narrow sidebar */} {/* Narrow sidebar */}
{currentProductNavigation.showGlobalNav && ( {currentProductNavigation.showGlobalNav && (
<div className="hidden w-28 overflow-y-auto border-r border-slate-200 bg-white md:block"> <div className="hidden w-28 overflow-y-auto border-r border-slate-200 bg-white md:block">
@ -186,9 +183,10 @@ export default function AppShell({ children }: Props) {
setIsShown={setMobileMenuOpen} setIsShown={setMobileMenuOpen}
/> />
{/* Content area */} {/* Content area */}
<div className="flex h-screen flex-1 flex-col overflow-hidden"> <div className="w-full">
<header className="w-full"> {/* Navigation Bar */}
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-slate-200 bg-white shadow-sm"> <header className="sticky top-0 z-10 w-full">
<div className="relative flex h-16 flex-shrink-0 border-b border-slate-200 bg-white shadow-sm">
<button <button
className="focus:ring-primary-500 border-r border-slate-200 px-4 text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset md:hidden" className="focus:ring-primary-500 border-r border-slate-200 px-4 text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset md:hidden"
type="button" type="button"
@ -211,11 +209,8 @@ export default function AppShell({ children }: Props) {
</div> </div>
</div> </div>
</header> </header>
{/* Main Content */}
{/* Main content */} <div className="w-full">{children}</div>
<div className="flex flex-1 items-stretch overflow-hidden">
{children}
</div>
</div> </div>
</div> </div>
</GoogleAnalytics> </GoogleAnalytics>

@ -1,22 +1,21 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; // Not using this for now.
// const navigation: ProductNavigationItems = [
const navigation: ProductNavigationItems = [ // { href: '/offers', name: 'Offers' },
{ href: '/offers', name: 'Offers' }, // { href: '/questions', name: 'Question Bank' },
{ href: '/questions', name: 'Question Bank' }, // {
{ // children: [
children: [ // { href: '/resumes', name: 'View Resumes' },
{ href: '/resumes', name: 'View Resumes' }, // { href: '/resumes/submit', name: 'Submit Resume' },
{ href: '/resumes/submit', name: 'Submit Resume' }, // ],
], // href: '#',
href: '#', // name: 'Resumes',
name: 'Resumes', // },
}, // ];
];
const config = { const config = {
googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN', googleAnalyticsMeasurementID: 'G-DBLZDQ2ZZN',
navigation, navigation: [],
showGlobalNav: true, showGlobalNav: false,
title: 'Tech Interview Handbook', title: 'Tech Interview Handbook',
titleHref: '/', titleHref: '/',
}; };

@ -35,14 +35,16 @@ export default function ProductNavigation({
<Link <Link
className="hover:text-primary-700 flex items-center gap-2 text-base font-medium" className="hover:text-primary-700 flex items-center gap-2 text-base font-medium"
href={titleHref}> href={titleHref}>
{titleHref !== '/' && <div>
(logo ?? ( {titleHref !== '/' &&
<img (logo ?? (
alt="Tech Interview Handbook" <img
className="h-8 w-auto" alt="Tech Interview Handbook"
src="/logo.svg" className="h-8 w-auto"
/> src="/logo.svg"
))} />
))}
</div>
{title} {title}
</Link> </Link>
<div className="hidden h-full items-center space-x-8 md:flex"> <div className="hidden h-full items-center space-x-8 md:flex">

@ -1,45 +1,50 @@
export type BreadcrumbStep = { import clsx from 'clsx';
import { ChevronRightIcon } from '@heroicons/react/20/solid';
export type BreadcrumbStep = Readonly<{
label: string; label: string;
step?: number; step?: number;
}; }>;
type BreadcrumbsProps = Readonly<{ type BreadcrumbsProps = Readonly<{
currentStep: number; currentStep: number;
setStep: (nextStep: number) => void; setStep: (nextStep: number) => void;
steps: Array<BreadcrumbStep>; steps: ReadonlyArray<BreadcrumbStep>;
}>; }>;
function getPrimaryText(text: string) {
return <p className="text-primary-700 text-sm">{text}</p>;
}
function getSlateText(text: string) {
return <p className="text-sm text-slate-400">{text}</p>;
}
function getTextWithLink(text: string, onClickHandler: () => void) {
return (
<p
className="hover:text-primary-700 cursor-pointer text-sm text-slate-400 hover:underline hover:underline-offset-2"
onClick={onClickHandler}>
{text}
</p>
);
}
export function Breadcrumbs({ steps, currentStep, setStep }: BreadcrumbsProps) { export function Breadcrumbs({ steps, currentStep, setStep }: BreadcrumbsProps) {
return ( return (
<div className="flex space-x-1"> <nav aria-label="Submit offer stages" className="inline-flex">
{steps.map(({ label, step }, index) => ( <ol className="mx-auto flex w-full space-x-2 sm:space-x-4" role="list">
<div key={label} className="flex space-x-1"> {steps.map(({ label, step }, index) => (
{step === currentStep <li key={step} className="flex items-center">
? getPrimaryText(label) {index > 0 && (
: step !== undefined <ChevronRightIcon
? getTextWithLink(label, () => setStep(step)) aria-hidden="true"
: getSlateText(label)} className="h-5 w-5 flex-shrink-0 text-slate-400"
{index !== steps.length - 1 && getSlateText('>')} />
</div> )}
))} <button
</div> aria-current={step === currentStep ? 'page' : undefined}
className={clsx(
'text-xs font-medium text-slate-600 sm:text-sm',
index > 0 && 'ml-4',
step != null ? 'hover:text-primary-500' : 'cursor-default',
step === currentStep && 'text-primary-500',
)}
type="button"
{...(step != null
? {
onClick: () => {
setStep(step);
},
}
: {})}>
{label}
</button>
</li>
))}
</ol>
</nav>
); );
} }

@ -0,0 +1,50 @@
import clsx from 'clsx';
import { JobType } from '@prisma/client';
import { JobTypeLabel } from './types';
type Props = Readonly<{
onChange: (jobType: JobType) => void;
value: JobType;
}>;
const tabs = [
{
label: JobTypeLabel.FULLTIME,
value: JobType.FULLTIME,
},
{
label: JobTypeLabel.INTERN,
value: JobType.INTERN,
},
];
export default function JobTypeTabs({ value, onChange }: Props) {
return (
<div className="block">
<nav
aria-label="Job Types"
className="isolate flex divide-x divide-slate-200 rounded-lg border border-slate-200 bg-white">
{tabs.map((tab, tabIdx) => (
<button
key={tab.value}
aria-current={tab.value === value ? 'page' : undefined}
className={clsx(
tab.value === value
? 'bg-primary-100 text-primary-700 hover:bg-primary-200'
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-700',
tabIdx === 0 && 'rounded-l-lg',
tabIdx === tabs.length - 1 && 'rounded-r-lg',
'focus:ring-primary-500 group relative min-w-0 flex-1 overflow-hidden py-3 px-4 text-center font-medium focus:z-10',
)}
type="button"
onClick={() => {
onChange(tab.value);
}}>
<span>{tab.label}</span>
</button>
))}
</nav>
</div>
);
}

@ -1,5 +1,9 @@
import {
ArrowTrendingUpIcon,
BuildingOfficeIcon,
MapPinIcon,
} from '@heroicons/react/20/solid';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { HorizontalDivider } from '@tih/ui';
import type { JobTitleType } from '~/components/shared/JobTitles'; import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles'; import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
@ -10,12 +14,10 @@ import { formatDate } from '~/utils/offers/time';
import type { UserProfileOffer } from '~/types/offers'; import type { UserProfileOffer } from '~/types/offers';
type Props = Readonly<{ type Props = Readonly<{
disableTopDivider?: boolean;
offer: UserProfileOffer; offer: UserProfileOffer;
}>; }>;
export default function DashboardProfileCard({ export default function DashboardProfileCard({
disableTopDivider,
offer: { offer: {
company, company,
income, income,
@ -27,29 +29,53 @@ export default function DashboardProfileCard({
}, },
}: Props) { }: Props) {
return ( return (
<> <div className="px-4 py-4 sm:px-6">
{!disableTopDivider && <HorizontalDivider />}
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<div className="col-span-1 row-span-3"> <div className="col-span-1 row-span-3">
<p className="font-bold"> <h4 className="font-medium">
{getLabelForJobTitleType(title as JobTitleType)} {getLabelForJobTitleType(title as JobTitleType)}
</p> </h4>
<p> <div className="mt-1 flex flex-col sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-4">
{location {company?.name && (
? `Company: ${company.name}, ${location.cityName}` <div className="mt-2 flex items-center text-sm text-gray-500">
: `Company: ${company.name}`} <BuildingOfficeIcon
</p> aria-hidden="true"
{level && <p>Level: {level}</p>} className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{company.name}
</div>
)}
{location && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<MapPinIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{location.cityName}
</div>
)}
{level && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<ArrowTrendingUpIcon
aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400"
/>
{level}
</div>
)}
</div>
</div> </div>
<div className="col-span-1 row-span-3"> <div className="col-span-1 row-span-3">
<p className="text-end">{formatDate(monthYearReceived)}</p> <p className="text-end text-lg font-medium leading-6 text-slate-900">
<p className="text-end text-xl">
{jobType === JobType.FULLTIME {jobType === JobType.FULLTIME
? `${convertMoneyToString(income)} / year` ? `${convertMoneyToString(income)} / year`
: `${convertMoneyToString(income)} / month`} : `${convertMoneyToString(income)} / month`}
</p> </p>
<p className="text-end text-sm text-slate-500">
{formatDate(monthYearReceived)}
</p>
</div> </div>
</div> </div>
</> </div>
); );
} }

@ -1,5 +1,6 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { ArrowRightIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { BookmarkSlashIcon } from '@heroicons/react/20/solid';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import { Button, useToast } from '@tih/ui'; import { Button, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -43,54 +44,52 @@ export default function DashboardProfileCard({
); );
function handleRemoveProfile() { function handleRemoveProfile() {
// TODO(offers): Confirm before removal.
removeSavedProfileMutation.mutate({ profileId: id }); removeSavedProfileMutation.mutate({ profileId: id });
} }
return ( return (
<div className="space-y-4 bg-white px-4 pt-5 sm:px-4"> <div className="overflow-hidden bg-white sm:rounded-lg sm:shadow">
{/* Header */} {/* Header */}
<div className="-ml-4 -mt-2 flex flex-wrap items-center justify-between border-b border-gray-300 pb-4 sm:flex-nowrap"> <div className="border-b border-slate-200 bg-white px-4 py-5 sm:px-6">
<div className="flex items-center gap-x-5"> <div className="-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap">
<div> <div className="ml-4 mt-4">
<ProfilePhotoHolder size="sm" /> <div className="flex items-center">
</div> <div className="flex-shrink-0">
<div className="col-span-10"> <ProfilePhotoHolder size="sm" />
<p className="text-xl font-bold">{profileName}</p> </div>
<div className="ml-4">
<div className="flex flex-row"> <h2 className="text-lg font-medium leading-6 text-slate-900">
<span>Created at {formatDate(createdAt)}</span> {profileName}
</h2>
<p className="text-sm text-slate-500">
<span>Created at {formatDate(createdAt)}</span>
</p>
</div>
</div> </div>
</div> </div>
</div> <div className="ml-4 mt-4 flex flex-shrink-0">
<Button
<div className="flex self-start"> disabled={removeSavedProfileMutation.isLoading}
<Button icon={BookmarkSlashIcon}
disabled={removeSavedProfileMutation.isLoading} isLabelHidden={true}
icon={XMarkIcon} label="Remove Profile"
isLabelHidden={true} size="md"
label="Remove Profile" variant="tertiary"
size="md" onClick={handleRemoveProfile}
variant="tertiary"
onClick={handleRemoveProfile}
/>
</div>
</div>
{/* Offers */}
<div>
{offers.map((offer: UserProfileOffer, index) =>
index === 0 ? (
<DashboardOfferCard
key={offer.id}
disableTopDivider={true}
offer={offer}
/> />
) : ( </div>
<DashboardOfferCard key={offer.id} offer={offer} /> </div>
),
)}
</div> </div>
<div className="flex justify-end pt-1"> {/* List of Offers */}
<ul className="divide-y divide-slate-200" role="list">
{offers.map((offer: UserProfileOffer) => (
<li key={offer.id}>
<DashboardOfferCard offer={offer} />
</li>
))}
</ul>
<div className="flex justify-end border-t border-slate-200 px-4 py-5 sm:px-6">
<Button <Button
disabled={removeSavedProfileMutation.isLoading} disabled={removeSavedProfileMutation.isLoading}
icon={ArrowRightIcon} icon={ArrowRightIcon}

@ -28,6 +28,7 @@ function FormMonthYearPickerWithRef({
return ( return (
<MonthYearPicker <MonthYearPicker
className="space-x-6"
{...(rest as MonthYearPickerProps)} {...(rest as MonthYearPickerProps)}
value={value} value={value}
onChange={(val) => { onChange={(val) => {

@ -0,0 +1,18 @@
import { HorizontalDivider } from '@tih/ui';
export default function FormSection({
children,
title,
}: Readonly<{ children: React.ReactNode; title: string }>) {
return (
<div>
<div className="mb-4">
<h2 className="text-lg font-medium leading-6 text-slate-900">
{title}
</h2>
<HorizontalDivider />
</div>
<div className="space-y-4 sm:space-y-6">{children}</div>
</div>
);
}

@ -1,6 +1,6 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import { HorizontalDivider, Spinner, Tabs } from '@tih/ui'; import { Alert, HorizontalDivider, Spinner, Tabs } from '@tih/ui';
import OfferPercentileAnalysisText from './OfferPercentileAnalysisText'; import OfferPercentileAnalysisText from './OfferPercentileAnalysisText';
import OfferProfileCard from './OfferProfileCard'; import OfferProfileCard from './OfferProfileCard';
@ -22,17 +22,19 @@ function OfferAnalysisContent({
if (!analysis || analysis.noOfOffers === 0) { if (!analysis || analysis.noOfOffers === 0) {
if (tab === OVERALL_TAB) { if (tab === OVERALL_TAB) {
return ( return (
<p className="m-10"> <Alert title="Insufficient data to compare with" variant="info">
You are the first to submit an offer for your job title and YOE! Check You are among the first to submit an offer for your job title and
back later when there are more submissions. years of experience! Check back later when there are more submissions.
</p> </Alert>
); );
} }
return ( return (
<p className="m-10"> <Alert title="Insufficient data to compare with" variant="info">
You are the first to submit an offer for this company, job title and You are among the first to submit an offer for this company, job title
YOE! Check back later when there are more submissions. and years of experience! Check back later when there are more
</p> submissions.
</Alert>
); );
} }
return ( return (

@ -69,9 +69,7 @@ export default function OfferProfileCard({
{getLabelForJobTitleType(title as JobTitleType)}{' '} {getLabelForJobTitleType(title as JobTitleType)}{' '}
{`(${JobTypeLabel[jobType]})`} {`(${JobTypeLabel[jobType]})`}
</p> </p>
<p> <p>{`Company: ${company.name}, ${location}`}</p>
Company: {company.name}, {location}
</p>
{level && <p>Level: {level}</p>} {level && <p>Level: {level}</p>}
</div> </div>
<div className="col-span-1 row-span-3"> <div className="col-span-1 row-span-3">

@ -1,7 +1,8 @@
import { signIn, useSession } from 'next-auth/react'; import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { DocumentDuplicateIcon } from '@heroicons/react/20/solid'; import { DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, CheckIcon } from '@heroicons/react/24/outline'; import { BookmarkIcon as BookmarkOutlineIcon } from '@heroicons/react/24/outline';
import { BookmarkIcon as BookmarkSolidIcon } from '@heroicons/react/24/solid';
import { Button, TextInput, useToast } from '@tih/ui'; import { Button, TextInput, useToast } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -73,58 +74,67 @@ export default function OffersProfileSave({
}; };
return ( return (
<div className="flex w-full justify-center"> <div className="flex w-full justify-center pb-10">
<div className="max-w-2xl text-center"> <div className="max-w-2xl text-center">
<h5 className="mb-6 text-4xl font-bold text-slate-900"> <h2 className="block text-center text-3xl font-bold leading-8 tracking-tight text-gray-900 sm:text-4xl">
Save for future edits Save for future edits
</h5> </h2>
<p className="mb-2 text-slate-900">We value your privacy.</p> <p className="mt-4 text-xl leading-8 text-slate-500">
<p className="mb-5 text-slate-900"> We value your privacy
To keep you offer profile strictly anonymous, only people who have the
link below can edit it.
</p> </p>
<div className="mb-20 grid grid-cols-12 gap-4"> <div className="mt-6 max-w-md text-slate-500">
<div className="col-span-11"> <div className="bg-info-50 rounded-lg p-6">
<TextInput <p className="sm:tex-base text-sm">
disabled={true} To keep your offer profile strictly anonymous, it is not linked to
isLabelHidden={true} your user account. Only people who have the link below can edit
label="Edit link" it. If you want to edit the profile in future, store the link
value={getProfileLink(profileId, token)} somewhere.
</p>
<div className="mt-4 flex gap-4">
<div className="grow">
<TextInput
disabled={true}
isLabelHidden={true}
label="Edit link"
value={getProfileLink(profileId, token)}
/>
</div>
<Button
icon={DocumentDuplicateIcon}
isLabelHidden={true}
label="Copy"
variant="info"
onClick={() => {
copyProfileLink(profileId, token);
showToast({
title: `Profile edit link copied to clipboard!`,
variant: 'success',
});
gaEvent({
action: 'offers.profile_submission_copy_edit_profile_link',
category: 'engagement',
label: 'Copy Edit Profile Link in Profile Submission',
});
}}
/>
</div>
</div>
<p className="mt-6 text-xs sm:text-sm">
If you do not want to manually store the link somewhere else, you
can add this offers profile to your user account by clicking the
button below. It will still only be editable by you.
</p>
<div className="mt-6">
<Button
disabled={isSavedQuery.isLoading || isSaved}
icon={isSaved ? BookmarkSolidIcon : BookmarkOutlineIcon}
isLoading={saveMutation.isLoading || isSavedQuery.isLoading}
label={isSaved ? 'Added to account' : 'Add to your account'}
size="sm"
variant="secondary"
onClick={handleSave}
/> />
</div> </div>
<Button
icon={DocumentDuplicateIcon}
isLabelHidden={true}
label="Copy"
variant="primary"
onClick={() => {
copyProfileLink(profileId, token);
showToast({
title: `Profile edit link copied to clipboard!`,
variant: 'success',
});
gaEvent({
action: 'offers.profile_submission_copy_edit_profile_link',
category: 'engagement',
label: 'Copy Edit Profile Link in Profile Submission',
});
}}
/>
</div>
<p className="mb-5 text-slate-900">
If you do not want to keep the edit link, you can opt to save this
profile under your account's dashboard. It will still only be editable
by you.
</p>
<div className="mb-20">
<Button
disabled={isSavedQuery.isLoading || isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon}
isLoading={saveMutation.isLoading || isSavedQuery.isLoading}
label={isSaved ? 'Saved to user profile' : 'Save to user profile'}
variant="primary"
onClick={handleSave}
/>
</div> </div>
</div> </div>
</div> </div>

@ -15,10 +15,12 @@ export default function OffersSubmissionAnalysis({
return ( return (
<div className="mb-8"> <div className="mb-8">
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900"> <h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result Offer Analysis
</h5> </h5>
{!analysis && ( {!analysis && (
<p className="mb-8 text-center">Error generating analysis.</p> <p className="text-error-500 mb-8 text-center">
Error generating analysis.
</p>
)} )}
{analysis && ( {analysis && (
<OfferAnalysis <OfferAnalysis

@ -264,64 +264,69 @@ export default function OffersSubmissionForm({
}, []); }, []);
return ( return (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll"> <div ref={pageRef} className="w-full">
<div className="mb-20 flex justify-center"> <div className="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="block w-full max-w-screen-md overflow-hidden rounded-lg sm:shadow-lg md:my-10">
<div className="mb-4 flex justify-end"> <div className="flex justify-center bg-slate-100 px-4 py-4 sm:px-6 lg:px-8">
<Breadcrumbs <Breadcrumbs
currentStep={step} currentStep={step}
setStep={setStep} setStep={setStep}
steps={breadcrumbSteps} steps={breadcrumbSteps}
/> />
</div> </div>
<FormProvider {...formMethods}> <div className="bg-white p-6 sm:p-10">
<form className="text-sm" onSubmit={handleSubmit(onSubmit)}> <FormProvider {...formMethods}>
{steps[step]} <form
<pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> className="space-y-6 text-sm"
{step === 0 && ( onSubmit={handleSubmit(onSubmit)}>
<div className="flex justify-end"> {steps[step]}
<Button {step === 0 && (
disabled={false} <div className="flex justify-end">
icon={ArrowRightIcon} <Button
label="Next" disabled={false}
variant="secondary" icon={ArrowRightIcon}
onClick={() => { label="Next"
goToNextStep(step); variant="primary"
gaEvent({ onClick={() => {
action: 'offers.profile_submission_navigate_next', goToNextStep(step);
category: 'submission', gaEvent({
label: 'Navigate next', action: 'offers.profile_submission_navigate_next',
}); category: 'submission',
}} label: 'Navigate next',
/> });
</div> }}
)} />
{step === 1 && ( </div>
<div className="flex items-center justify-between"> )}
<Button {step === 1 && (
icon={ArrowLeftIcon} <div className="flex items-center justify-between">
label="Previous" <Button
variant="secondary" addonPosition="start"
onClick={() => { icon={ArrowLeftIcon}
setStep(step - 1); label="Previous"
gaEvent({ variant="secondary"
action: 'offers.profile_submission_navigation_back', onClick={() => {
category: 'submission', setStep(step - 1);
label: 'Navigate back', gaEvent({
}); action: 'offers.profile_submission_navigation_back',
}} category: 'submission',
/> label: 'Navigate back',
<Button });
disabled={isSubmitting || isSubmitSuccessful} }}
isLoading={isSubmitting || isSubmitSuccessful} />
label="Submit" <Button
type="submit" disabled={isSubmitting || isSubmitSuccessful}
variant="primary" icon={ArrowRightIcon}
/> isLoading={isSubmitting || isSubmitSuccessful}
</div> label="Submit"
)} type="submit"
</form> variant="primary"
</FormProvider> />
</div>
)}
</form>
</FormProvider>
</div>
</div> </div>
</div> </div>
</div> </div>

@ -21,6 +21,7 @@ import {
} from '~/utils/offers/currency/CurrencyEnum'; } from '~/utils/offers/currency/CurrencyEnum';
import FormRadioList from '../../forms/FormRadioList'; import FormRadioList from '../../forms/FormRadioList';
import FormSection from '../../forms/FormSection';
import FormSelect from '../../forms/FormSelect'; import FormSelect from '../../forms/FormSelect';
import FormTextInput from '../../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
@ -29,29 +30,26 @@ function YoeSection() {
background: BackgroundPostData; background: BackgroundPostData;
}>(); }>();
const backgroundFields = formState.errors.background; const backgroundFields = formState.errors.background;
return (
<>
<h6 className="mb-2 text-left text-xl font-medium text-slate-400">
Years of Experience (YOE)
</h6>
<div className="mb-5 rounded-lg border border-slate-200 px-10 py-5"> return (
<div className="mb-2 grid grid-cols-3 space-x-3"> <FormSection title="Years of Experience (YOE)">
<FormTextInput <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
errorMessage={backgroundFields?.totalYoe?.message} <FormTextInput
label="Total YOE" errorMessage={backgroundFields?.totalYoe?.message}
placeholder="0" label="Total YOE"
required={true} placeholder="0"
type="number" required={true}
{...register(`background.totalYoe`, { type="number"
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, {...register(`background.totalYoe`, {
required: FieldError.REQUIRED, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true, required: FieldError.REQUIRED,
})} valueAsNumber: true,
/> })}
</div> />
<Collapsible label="Add specific YOEs by domain"> </div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <Collapsible label="Add specific YOEs by domain">
<div className="space-y-4 sm:space-y-6">
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<FormTextInput <FormTextInput
errorMessage={backgroundFields?.specificYoes?.[0]?.yoe?.message} errorMessage={backgroundFields?.specificYoes?.[0]?.yoe?.message}
label="Specific YOE 1" label="Specific YOE 1"
@ -63,11 +61,11 @@ function YoeSection() {
/> />
<FormTextInput <FormTextInput
label="Specific Domain 1" label="Specific Domain 1"
placeholder="e.g. Frontend" placeholder="e.g. Front End"
{...register(`background.specificYoes.0.domain`)} {...register(`background.specificYoes.0.domain`)}
/> />
</div> </div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<FormTextInput <FormTextInput
errorMessage={backgroundFields?.specificYoes?.[1]?.yoe?.message} errorMessage={backgroundFields?.specificYoes?.[1]?.yoe?.message}
label="Specific YOE 2" label="Specific YOE 2"
@ -79,13 +77,13 @@ function YoeSection() {
/> />
<FormTextInput <FormTextInput
label="Specific Domain 2" label="Specific Domain 2"
placeholder="e.g. Backend" placeholder="e.g. Back End"
{...register(`background.specificYoes.1.domain`)} {...register(`background.specificYoes.1.domain`)}
/> />
</div> </div>
</Collapsible> </div>
</div> </Collapsible>
</> </FormSection>
); );
} }
@ -113,38 +111,34 @@ function FullTimeJobFields() {
return ( return (
<> <>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<div> <JobTitlesTypeahead
<JobTitlesTypeahead value={{
value={{ id: watchJobTitle,
id: watchJobTitle, label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
label: getLabelForJobTitleType(watchJobTitle as JobTitleType), value: watchJobTitle,
value: watchJobTitle, }}
}} onSelect={(option) => {
onSelect={(option) => { if (option) {
if (option) { setValue('background.experiences.0.title', option.value);
setValue('background.experiences.0.title', option.value); }
} }}
}} />
/> <CompaniesTypeahead
</div> value={{
<div> id: watchCompanyId,
<CompaniesTypeahead label: watchCompanyName,
value={{ value: watchCompanyId,
id: watchCompanyId, }}
label: watchCompanyName, onSelect={(option) => {
value: watchCompanyId, if (option) {
}} setValue('background.experiences.0.companyId', option.value);
onSelect={(option) => { setValue('background.experiences.0.companyName', option.label);
if (option) { }
setValue('background.experiences.0.companyId', option.value); }}
setValue('background.experiences.0.companyName', option.label); />
}
}}
/>
</div>
</div> </div>
<div className="mb-5 grid grid-cols-1 space-x-3"> <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<FormTextInput <FormTextInput
endAddOn={ endAddOn={
<FormSelect <FormSelect
@ -172,7 +166,7 @@ function FullTimeJobFields() {
/> />
</div> </div>
<Collapsible label="Add more details"> <Collapsible label="Add more details">
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<FormTextInput <FormTextInput
label="Level" label="Level"
placeholder="e.g. L4, Junior" placeholder="e.g. L4, Junior"
@ -195,8 +189,6 @@ function FullTimeJobFields() {
} }
}} }}
/> />
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput <FormTextInput
errorMessage={experiencesField?.durationInMonths?.message} errorMessage={experiencesField?.durationInMonths?.message}
label="Duration (months)" label="Duration (months)"
@ -236,82 +228,74 @@ function InternshipJobFields() {
return ( return (
<> <>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<div> <JobTitlesTypeahead
<JobTitlesTypeahead value={{
value={{ id: watchJobTitle,
id: watchJobTitle, label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
label: getLabelForJobTitleType(watchJobTitle as JobTitleType), value: watchJobTitle,
value: watchJobTitle, }}
}} onSelect={(option) => {
onSelect={(option) => { if (option) {
if (option) { setValue('background.experiences.0.title', option.value);
setValue('background.experiences.0.title', option.value); }
} }}
}} />
/> <CompaniesTypeahead
</div> value={{
<div> id: watchCompanyId,
<CompaniesTypeahead label: watchCompanyName,
value={{ value: watchCompanyId,
id: watchCompanyId, }}
label: watchCompanyName, onSelect={(option) => {
value: watchCompanyId, if (option) {
}} setValue('background.experiences.0.companyId', option.value);
onSelect={(option) => { setValue('background.experiences.0.companyName', option.label);
if (option) { }
setValue('background.experiences.0.companyId', option.value); }}
setValue('background.experiences.0.companyName', option.label);
}
}}
/>
</div>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`background.experiences.0.monthlySalary.currency`)}
/>
}
endAddOnType="element"
errorMessage={experiencesField?.monthlySalary?.value?.message}
label="Salary (Monthly)"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experiences.0.monthlySalary.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/> />
</div> </div>
<Collapsible label="Add more details"> <FormTextInput
<div className="mb-5 grid grid-cols-2 space-x-3"> endAddOn={
<CitiesTypeahead <FormSelect
label="Location" borderStyle="borderless"
value={{ defaultValue={Currency.SGD}
id: watchCityId, isLabelHidden={true}
label: watchCityName, label="Currency"
value: watchCityId, options={CURRENCY_OPTIONS}
}} {...register(`background.experiences.0.monthlySalary.currency`)}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.cityId', option.value);
setValue('background.experiences.0.cityName', option.label);
} else {
setValue('background.experiences.0.cityId', '');
setValue('background.experiences.0.cityName', '');
}
}}
/> />
</div> }
endAddOnType="element"
errorMessage={experiencesField?.monthlySalary?.value?.message}
label="Salary (Monthly)"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experiences.0.monthlySalary.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/>
<Collapsible label="Add more details">
<CitiesTypeahead
label="Location"
value={{
id: watchCityId,
label: watchCityName,
value: watchCityId,
}}
onSelect={(option) => {
if (option) {
setValue('background.experiences.0.cityId', option.value);
setValue('background.experiences.0.cityName', option.label);
} else {
setValue('background.experiences.0.cityId', '');
setValue('background.experiences.0.cityName', '');
}
}}
/>
</Collapsible> </Collapsible>
</> </>
); );
@ -324,85 +308,71 @@ function CurrentJobSection() {
}); });
return ( return (
<> <FormSection title="Current / Previous Job">
<h6 className="mb-2 text-left text-xl font-medium text-slate-400"> <FormRadioList
Current / Previous Job defaultValue={watchJobType}
</h6> isLabelHidden={true}
<div className="mb-5 rounded-lg border border-slate-200 px-10 py-5"> label="Job Type"
<div className="mb-5"> orientation="horizontal"
<FormRadioList {...register('background.experiences.0.jobType')}>
defaultValue={watchJobType} <RadioList.Item
isLabelHidden={true} key="Full-time"
label="Job Type" label="Full-time"
orientation="horizontal" value={JobType.FULLTIME}
{...register('background.experiences.0.jobType')}> />
<RadioList.Item <RadioList.Item
key="Full-time" key="Internship"
label="Full-time" label="Internship"
value={JobType.FULLTIME} value={JobType.INTERN}
/> />
<RadioList.Item </FormRadioList>
key="Internship" {watchJobType === JobType.FULLTIME ? (
label="Internship" <FullTimeJobFields />
value={JobType.INTERN} ) : (
/> <InternshipJobFields />
</FormRadioList> )}
</div> </FormSection>
{watchJobType === JobType.FULLTIME ? (
<FullTimeJobFields />
) : (
<InternshipJobFields />
)}
</div>
</>
); );
} }
function EducationSection() { function EducationSection() {
const { register } = useFormContext(); const { register } = useFormContext();
return ( return (
<> <FormSection title="Education">
<h6 className="mb-2 text-left text-xl font-medium text-slate-400"> <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
Education <FormSelect
</h6> display="block"
<div className="mb-5 rounded-lg border border-slate-200 px-10 py-5"> label="Education Level"
<div className="mb-5 grid grid-cols-2 space-x-3"> options={educationLevelOptions}
<FormSelect placeholder={emptyOption}
display="block" {...register(`background.educations.0.type`)}
label="Education Level" />
options={educationLevelOptions} <FormSelect
placeholder={emptyOption} display="block"
{...register(`background.educations.0.type`)} label="Field"
/> options={educationFieldOptions}
<FormSelect placeholder={emptyOption}
display="block" {...register(`background.educations.0.field`)}
label="Field" />
options={educationFieldOptions}
placeholder={emptyOption}
{...register(`background.educations.0.field`)}
/>
</div>
<Collapsible label="Add more details">
<div className="mb-5">
<FormTextInput
label="School"
placeholder="e.g. National University of Singapore"
{...register(`background.educations.0.school`)}
/>
</div>
</Collapsible>
</div> </div>
</> <Collapsible label="Add more details">
<FormTextInput
label="School"
placeholder="e.g. National University of Singapore"
{...register(`background.educations.0.school`)}
/>
</Collapsible>
</FormSection>
); );
} }
export default function BackgroundForm() { export default function BackgroundForm() {
return ( return (
<div> <div className="space-y-6">
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900"> <h2 className="mb-8 text-2xl font-bold text-slate-900 sm:text-center sm:text-4xl">
Help us better gauge your offers Help us better gauge your offers
</h5> </h2>
<div> <div className="space-y-8 rounded-lg border border-slate-200 p-6 sm:space-y-16 sm:p-8">
<YoeSection /> <YoeSection />
<CurrentJobSection /> <CurrentJobSection />
<EducationSection /> <EducationSection />

@ -10,7 +10,7 @@ import { useFieldArray } from 'react-hook-form';
import { PlusIcon } from '@heroicons/react/20/solid'; import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline'; import { TrashIcon } from '@heroicons/react/24/outline';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import { Button, Dialog } from '@tih/ui'; import { Button, Dialog, HorizontalDivider } from '@tih/ui';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead'; import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -29,9 +29,11 @@ import {
yearOptions, yearOptions,
} from '../../constants'; } from '../../constants';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker'; import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormSection from '../../forms/FormSection';
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 JobTypeTabs from '../../JobTypeTabs';
import type { OfferFormData } from '../../types'; import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../../types'; import { JobTypeLabel } from '../../types';
import { import {
@ -82,9 +84,9 @@ function FullTimeOfferDetailsForm({
}, [watchCurrency, index, setValue]); }, [watchCurrency, index, setValue]);
return ( return (
<div className="my-5 rounded-lg border border-slate-200 px-10 py-5"> <div className="space-y-8 rounded-lg border border-slate-200 p-6 sm:space-y-16 sm:p-8">
<div className="mb-5 grid grid-cols-2 space-x-3"> <FormSection title="Company & Title Information">
<div> <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead <JobTitlesTypeahead
required={true} required={true}
value={{ value={{
@ -98,197 +100,200 @@ function FullTimeOfferDetailsForm({
} }
}} }}
/> />
<FormTextInput
errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.REQUIRED,
})}
/>
</div> </div>
<FormTextInput <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
errorMessage={offerFields?.offersFullTime?.level?.message} <CompaniesTypeahead
label="Level" required={true}
placeholder="e.g. L4, Junior" value={{
required={true} id: watchCompanyId,
{...register(`offers.${index}.offersFullTime.level`, { label: watchCompanyName,
required: FieldError.REQUIRED, value: watchCompanyId,
})} }}
/> onSelect={(option) => {
</div> if (option) {
<div className="mb-5 flex grid grid-cols-2 space-x-3"> setValue(`offers.${index}.companyId`, option.value);
<CompaniesTypeahead setValue(`offers.${index}.companyName`, option.label);
required={true} }
value={{ }}
id: watchCompanyId, />
label: watchCompanyName, <CitiesTypeahead
value: watchCompanyId, label="Location"
}} required={true}
onSelect={(option) => { value={{
if (option) { id: watchCityId,
setValue(`offers.${index}.companyId`, option.value); label: watchCityName,
setValue(`offers.${index}.companyName`, option.label); value: watchCityId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.cityId`, option.value);
setValue(`offers.${index}.cityName`, option.label);
} else {
setValue(`offers.${index}.cityId`, '');
setValue(`offers.${index}.cityName`, '');
}
}}
/>
</div>
</FormSection>
<FormSection title="Compensation Details">
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<FormMonthYearPicker
monthLabel="Date Received"
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.REQUIRED,
})}
/>
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(
`offers.${index}.offersFullTime.totalCompensation.currency`,
{
required: FieldError.REQUIRED,
},
)}
/>
} }
}} endAddOnType="element"
/> errorMessage={
<CitiesTypeahead offerFields?.offersFullTime?.totalCompensation?.value?.message
label="Location"
required={true}
value={{
id: watchCityId,
label: watchCityName,
value: watchCityId,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.cityId`, option.value);
setValue(`offers.${index}.cityName`, option.label);
} else {
setValue(`offers.${index}.cityId`, '');
setValue(`offers.${index}.cityName`, '');
} }
}} label="Total Compensation (Annual)"
/> placeholder="0"
</div> required={true}
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3"> startAddOn="$"
<FormMonthYearPicker startAddOnType="label"
monthLabel="Date Received" type="number"
monthRequired={true} {...register(
yearLabel="" `offers.${index}.offersFullTime.totalCompensation.value`,
{...register(`offers.${index}.monthYearReceived`, { {
required: FieldError.REQUIRED, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
})} required: FieldError.REQUIRED,
/> valueAsNumber: true,
</div> },
<div className="mb-5"> )}
<FormTextInput />
endAddOn={ </div>
<FormSelect <div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
borderStyle="borderless" <FormTextInput
defaultValue={Currency.SGD} endAddOn={
isLabelHidden={true} <FormSelect
label="Currency" borderStyle="borderless"
options={CURRENCY_OPTIONS} defaultValue={Currency.SGD}
{...register( isLabelHidden={true}
`offers.${index}.offersFullTime.totalCompensation.currency`, label="Currency"
{ options={CURRENCY_OPTIONS}
required: FieldError.REQUIRED, {...register(
}, `offers.${index}.offersFullTime.baseSalary.currency`,
)} )}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={ errorMessage={
offerFields?.offersFullTime?.totalCompensation?.value?.message offerFields?.offersFullTime?.baseSalary?.value?.message
} }
label="Total Compensation (Annual)" label="Base Salary (Annual)"
placeholder="0" placeholder="0"
required={true} startAddOn="$"
startAddOn="$" startAddOnType="label"
startAddOnType="label" type="number"
type="number" {...register(`offers.${index}.offersFullTime.baseSalary.value`, {
{...register(
`offers.${index}.offersFullTime.totalCompensation.value`,
{
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
}, })}
)} />
/> <FormTextInput
</div> endAddOn={
<div className="mb-5 grid grid-cols-2 space-x-3"> <FormSelect
<FormTextInput borderStyle="borderless"
endAddOn={ defaultValue={Currency.SGD}
<FormSelect isLabelHidden={true}
borderStyle="borderless" label="Currency"
defaultValue={Currency.SGD} options={CURRENCY_OPTIONS}
isLabelHidden={true} {...register(`offers.${index}.offersFullTime.bonus.currency`)}
label="Currency" />
options={CURRENCY_OPTIONS} }
{...register( endAddOnType="element"
`offers.${index}.offersFullTime.baseSalary.currency`, errorMessage={offerFields?.offersFullTime?.bonus?.value?.message}
)} label="Bonus (Annual)"
/> placeholder="0"
} startAddOn="$"
endAddOnType="element" startAddOnType="label"
errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message} type="number"
label="Base Salary (Annual)" {...register(`offers.${index}.offersFullTime.bonus.value`, {
placeholder="0" min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
startAddOn="$" valueAsNumber: true,
startAddOnType="label" })}
type="number" />
{...register(`offers.${index}.offersFullTime.baseSalary.value`, { <FormTextInput
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, endAddOn={
valueAsNumber: true, <FormSelect
})} borderStyle="borderless"
/> defaultValue={Currency.SGD}
<FormTextInput isLabelHidden={true}
endAddOn={ label="Currency"
<FormSelect options={CURRENCY_OPTIONS}
borderStyle="borderless" {...register(`offers.${index}.offersFullTime.stocks.currency`)}
defaultValue={Currency.SGD} />
isLabelHidden={true} }
label="Currency" endAddOnType="element"
options={CURRENCY_OPTIONS} errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
{...register(`offers.${index}.offersFullTime.bonus.currency`)} label="Stocks (Annual)"
/> placeholder="0"
} startAddOn="$"
endAddOnType="element" startAddOnType="label"
errorMessage={offerFields?.offersFullTime?.bonus?.value?.message} type="number"
label="Bonus (Annual)" {...register(`offers.${index}.offersFullTime.stocks.value`, {
placeholder="0" min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
startAddOn="$" valueAsNumber: true,
startAddOnType="label" })}
type="number" />
{...register(`offers.${index}.offersFullTime.bonus.value`, { </div>
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, </FormSection>
valueAsNumber: true, <FormSection title="Additional Information">
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.stocks.currency`)}
/>
}
endAddOnType="element"
errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
label="Stocks (Annual)"
placeholder="0"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
valueAsNumber: true,
})}
/>
</div>
<div className="mb-5">
<FormTextArea <FormTextArea
label="Negotiation Strategy / Interview Performance" label="Negotiation Strategy / Interview Performance"
placeholder="e.g. Did well in the behavioral interview / Used competing offers to negotiate for a higher salary" placeholder="e.g. Did well in the behavioral interview / Used competing offers to negotiate for a higher salary"
{...register(`offers.${index}.negotiationStrategy`)} {...register(`offers.${index}.negotiationStrategy`)}
/> />
</div>
<div className="mb-5">
<FormTextArea <FormTextArea
label="Comments" label="Comments"
placeholder="e.g. Benefits offered by the company" placeholder="e.g. Benefits offered by the company"
{...register(`offers.${index}.comments`)} {...register(`offers.${index}.comments`)}
/> />
</div>
<div className="flex justify-end">
{index > 0 && ( {index > 0 && (
<Button <div className="space-y-4 sm:space-y-6">
icon={TrashIcon} <HorizontalDivider />
label="Delete" <div className="flex justify-end">
variant="secondary" <Button
onClick={() => remove(index)} icon={TrashIcon}
/> label="Delete"
variant="tertiary"
onClick={() => {
remove(index);
}}
/>
</div>
</div>
)} )}
</div> </FormSection>
</div> </div>
); );
} }
@ -324,155 +329,153 @@ function InternshipOfferDetailsForm({
}); });
return ( return (
<div className="my-5 rounded-lg border border-slate-200 px-10 py-5"> <div className="space-y-8 rounded-lg border border-slate-200 p-6 sm:space-y-16 sm:p-8">
<div className="mb-5 grid grid-cols-2 space-x-3"> <FormSection title="Company & Title Information">
<div> <JobTitlesTypeahead
<JobTitlesTypeahead required={true}
value={{
id: watchJobTitle,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType),
value: watchJobTitle,
}}
onSelect={(option) => {
if (option) {
setValue(`offers.${index}.offersIntern.title`, option.value);
}
}}
/>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<CompaniesTypeahead
required={true} required={true}
value={{ value={{
id: watchJobTitle, id: watchCompanyId,
label: getLabelForJobTitleType(watchJobTitle as JobTitleType), label: watchCompanyName,
value: watchJobTitle, value: watchCompanyId,
}} }}
onSelect={(option) => { onSelect={(option) => {
if (option) { if (option) {
setValue(`offers.${index}.offersIntern.title`, option.value); setValue(`offers.${index}.companyId`, option.value);
setValue(`offers.${index}.companyName`, option.label);
} }
}} }}
/> />
</div> <CitiesTypeahead
</div> label="Location"
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<CompaniesTypeahead
required={true} required={true}
value={{ value={{
id: watchCompanyId, id: watchCityId,
label: watchCompanyName, label: watchCityName,
value: watchCompanyId, value: watchCityId,
}} }}
onSelect={(option) => { onSelect={(option) => {
if (option) { if (option) {
setValue(`offers.${index}.companyId`, option.value); setValue(`offers.${index}.cityId`, option.value);
setValue(`offers.${index}.companyName`, option.label); setValue(`offers.${index}.cityName`, option.label);
} else {
setValue(`offers.${index}.cityId`, '');
setValue(`offers.${index}.cityName`, '');
} }
}} }}
/> />
</div> </div>
<CitiesTypeahead <div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
label="Location" <FormSelect
required={true} display="block"
value={{ errorMessage={offerFields?.offersIntern?.internshipCycle?.message}
id: watchCityId, label="Internship Cycle"
label: watchCityName, options={internshipCycleOptions}
value: watchCityId, placeholder={emptyOption}
}} required={true}
onSelect={(option) => { {...register(`offers.${index}.offersIntern.internshipCycle`, {
if (option) { required: FieldError.REQUIRED,
setValue(`offers.${index}.cityId`, option.value); })}
setValue(`offers.${index}.cityName`, option.label); />
} else { <FormSelect
setValue(`offers.${index}.cityId`, ''); display="block"
setValue(`offers.${index}.cityName`, ''); errorMessage={offerFields?.offersIntern?.startYear?.message}
label="Internship Year"
options={yearOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
</div>
</FormSection>
<FormSection title="Compensation Details">
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<FormMonthYearPicker
monthLabel="Date Received"
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.REQUIRED,
})}
/>
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(
`offers.${index}.offersIntern.monthlySalary.currency`,
{
required: FieldError.REQUIRED,
},
)}
/>
} }
}} endAddOnType="element"
/> errorMessage={
</div> offerFields?.offersIntern?.monthlySalary?.value?.message
<div className="mb-5 grid grid-cols-2 space-x-3"> }
<FormSelect label="Salary (Monthly)"
display="block" placeholder="0"
errorMessage={offerFields?.offersIntern?.internshipCycle?.message} required={true}
label="Internship Cycle" startAddOn="$"
options={internshipCycleOptions} startAddOnType="label"
placeholder={emptyOption} type="number"
required={true} {...register(`offers.${index}.offersIntern.monthlySalary.value`, {
{...register(`offers.${index}.offersIntern.internshipCycle`, { min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED, required: FieldError.REQUIRED,
})} valueAsNumber: true,
/> })}
<FormSelect />
display="block" </div>
errorMessage={offerFields?.offersIntern?.startYear?.message} </FormSection>
label="Internship Year" <FormSection title="Additional Information">
options={yearOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.offersIntern.startYear`, {
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
</div>
<div className="mb-5">
<FormMonthYearPicker
monthLabel="Date Received"
monthRequired={true}
yearLabel=""
{...register(`offers.${index}.monthYearReceived`, {
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(
`offers.${index}.offersIntern.monthlySalary.currency`,
{
required: FieldError.REQUIRED,
},
)}
/>
}
endAddOnType="element"
errorMessage={
offerFields?.offersIntern?.monthlySalary?.value?.message
}
label="Salary (Monthly)"
placeholder="0"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.offersIntern.monthlySalary.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true,
})}
/>
</div>
<div className="mb-5">
<FormTextArea <FormTextArea
label="Negotiation Strategy / Interview Performance" label="Negotiation Strategy / Interview Performance"
placeholder="e.g. Did well in the behavioral interview. Used competing offers to negotiate for a higher salary." placeholder="e.g. Did well in the behavioral interview. Used competing offers to negotiate for a higher salary."
{...register(`offers.${index}.negotiationStrategy`)} {...register(`offers.${index}.negotiationStrategy`)}
/> />
</div>
<div className="mb-5">
<FormTextArea <FormTextArea
label="Comments" label="Comments"
placeholder="e.g. Encountered similar questions using the Technical Interview Handbook." placeholder="e.g. Encountered similar questions using the Technical Interview Handbook."
{...register(`offers.${index}.comments`)} {...register(`offers.${index}.comments`)}
/> />
</div> </FormSection>
<div className="flex justify-end"> {index > 0 && (
{index > 0 && ( <div className="space-y-4 sm:space-y-6">
<Button <HorizontalDivider />
icon={TrashIcon} <div className="flex justify-end">
label="Delete" <Button
variant="secondary" icon={TrashIcon}
onClick={() => { label="Delete"
remove(index); variant="tertiary"
}} onClick={() => {
/> remove(index);
)} }}
</div> />
</div>
</div>
)}
</div> </div>
); );
} }
@ -489,7 +492,7 @@ function OfferDetailsFormArray({
const { append, remove, fields } = fieldArrayValues; const { append, remove, fields } = fieldArrayValues;
return ( return (
<div> <div className="space-y-8">
{fields.map((item, index) => { {fields.map((item, index) => {
return ( return (
<div key={item.id}> <div key={item.id}>
@ -506,7 +509,7 @@ function OfferDetailsFormArray({
icon={PlusIcon} icon={PlusIcon}
label="Add another offer" label="Add another offer"
size="lg" size="lg"
variant="tertiary" variant="secondary"
onClick={() => onClick={() =>
append( append(
jobType === JobType.FULLTIME jobType === JobType.FULLTIME
@ -547,40 +550,20 @@ export default function OfferDetailsForm({
jobType === JobType.FULLTIME ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME; jobType === JobType.FULLTIME ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
return ( return (
<div className="mb-5"> <div className="space-y-6">
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900"> <h2 className="mb-8 text-2xl font-bold text-slate-900 sm:text-center sm:text-4xl">
Fill in your offer details Fill in your offer details
</h5> </h2>
<div className="flex w-full justify-center"> <JobTypeTabs
<div className="mx-5 w-1/3"> value={jobType}
<Button onChange={(newJobType) => {
display="block" if (newJobType === jobType) {
label={JobTypeLabel.FULLTIME} return;
size="md" }
variant={jobType === JobType.FULLTIME ? 'secondary' : 'tertiary'}
onClick={() => { setDialogOpen(true);
if (jobType === JobType.FULLTIME) { }}
return; />
}
setDialogOpen(true);
}}
/>
</div>
<div className="mx-5 w-1/3">
<Button
display="block"
label={JobTypeLabel.INTERN}
size="md"
variant={jobType === JobType.INTERN ? 'secondary' : 'tertiary'}
onClick={() => {
if (jobType === JobType.INTERN) {
return;
}
setDialogOpen(true);
}}
/>
</div>
</div>
<OfferDetailsFormArray <OfferDetailsFormArray
fieldArrayValues={fieldArrayValues} fieldArrayValues={fieldArrayValues}
jobType={jobType} jobType={jobType}

@ -128,7 +128,7 @@ export default function OfferCard({
); );
} }
return ( return (
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md"> <div className="mx-8 my-4 block rounded-md border-b border-gray-300 bg-white py-4">
<UpperSection /> <UpperSection />
<BottomSection /> <BottomSection />
</div> </div>

@ -12,6 +12,7 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard'; import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import Tooltip from '~/components/offers/util/Tooltip'; import Tooltip from '~/components/offers/util/Tooltip';
import loginPageHref from '~/components/shared/loginPageHref';
import { copyProfileLink } from '~/utils/offers/link'; import { copyProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -109,108 +110,115 @@ export default function ProfileComments({
); );
} }
return ( return (
<div className="m-4 h-full"> <div className="bh-white h-fit px-4 lg:h-[calc(100vh-4.5rem)] lg:overflow-y-auto">
<div className="flex-end flex justify-end space-x-4"> <div className="bg-white pt-4 lg:sticky lg:top-0">
{isEditable && ( <div className="flex justify-end">
<Tooltip tooltipContent="Copy this link to edit your profile later"> <div className="grid w-fit space-y-2 lg:grid-cols-1 lg:grid-cols-2 lg:space-y-0 lg:space-x-4">
<Button <div className="col-span-1 flex justify-end">
addonPosition="start" {isEditable && (
disabled={isDisabled} <Tooltip tooltipContent="Copy this link to edit your profile later">
icon={ClipboardDocumentIcon} <Button
isLabelHidden={false} addonPosition="start"
label="Copy profile edit link" disabled={isDisabled}
size="sm" icon={ClipboardDocumentIcon}
variant="secondary" isLabelHidden={false}
onClick={() => { label="Copy edit link"
copyProfileLink(profileId, token); size="sm"
gaEvent({ variant="secondary"
action: 'offers.copy_profile_edit_link', onClick={() => {
category: 'engagement', copyProfileLink(profileId, token);
label: 'Copy Profile Edit Link', gaEvent({
}); action: 'offers.copy_profile_edit_link',
showToast({ category: 'engagement',
title: `Profile edit link copied to clipboard!`, label: 'Copy Profile Edit Link',
variant: 'success', });
}); showToast({
}} title: `Profile edit link copied to clipboard!`,
variant: 'success',
});
}}
/>
</Tooltip>
)}
</div>
<div className="col-span-1 flex justify-end">
<Tooltip tooltipContent="Share this profile with your friends">
<Button
addonPosition="start"
disabled={isDisabled}
icon={ShareIcon}
isLabelHidden={false}
label="Copy public link"
size="sm"
variant="secondary"
onClick={() => {
copyProfileLink(profileId);
gaEvent({
action: 'offers.copy_profile_public_link',
category: 'engagement',
label: 'Copy Profile Public Link',
});
showToast({
title: `Public profile link copied to clipboard!`,
variant: 'success',
});
}}
/>
</Tooltip>
</div>
</div>
</div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
{isEditable || session?.user?.name ? (
<div>
<TextArea
label={`Comment as ${
isEditable ? profileName : session?.user?.name ?? 'anonymous'
}`}
placeholder="Type your comment here"
value={currentReply}
onChange={(value) => setCurrentReply(value)}
/> />
</Tooltip> <div className="mt-2 flex w-full justify-end">
)} <div className="w-fit">
<Tooltip tooltipContent="Share this profile with your friends"> <Button
disabled={
commentsQuery.isLoading ||
!currentReply.length ||
createCommentMutation.isLoading
}
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Comment"
size="sm"
variant="primary"
onClick={() => handleComment(currentReply)}
/>
</div>
</div>
<HorizontalDivider />
</div>
) : (
<Button <Button
addonPosition="start" className="mb-5"
disabled={isDisabled} display="block"
icon={ShareIcon} href={loginPageHref()}
isLabelHidden={false} label="Sign in to join discussion"
label="Copy public link" variant="tertiary"
size="sm"
variant="secondary"
onClick={() => {
copyProfileLink(profileId);
gaEvent({
action: 'offers.copy_profile_public_link',
category: 'engagement',
label: 'Copy Profile Public Link',
});
showToast({
title: `Public profile link copied to clipboard!`,
variant: 'success',
});
}}
/> />
</Tooltip> )}
</div> </div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2> <div className="w-full">
{isEditable || session?.user?.name ? ( {replies?.map((reply: Reply) => (
<div> <ExpandableCommentCard
<TextArea key={reply.id}
label={`Comment as ${ comment={reply}
isEditable ? profileName : session?.user?.name ?? 'anonymous' profileId={profileId}
}`} token={isEditable ? token : undefined}
placeholder="Type your comment here"
value={currentReply}
onChange={(value) => setCurrentReply(value)}
/> />
<div className="mt-2 flex w-full justify-end"> ))}
<div className="w-fit">
<Button
disabled={
commentsQuery.isLoading ||
!currentReply.length ||
createCommentMutation.isLoading
}
display="block"
isLabelHidden={false}
isLoading={createCommentMutation.isLoading}
label="Comment"
size="sm"
variant="primary"
onClick={() => handleComment(currentReply)}
/>
</div>
</div>
<HorizontalDivider />
</div>
) : (
<Button
className="mb-5"
display="block"
href="/api/auth/signin"
label="Sign in to join discussion"
variant="tertiary"
/>
)}
<div className="h-full overflow-y-auto">
<div className="h-content mb-96 w-full">
{replies?.map((reply: Reply) => (
<ExpandableCommentCard
key={reply.id}
comment={reply}
profileId={profileId}
token={isEditable ? token : undefined}
/>
))}
</div>
</div> </div>
</div> </div>
); );

@ -65,7 +65,7 @@ export default function ProfileHeader({
{ profileId: offerProfileId as string, userId: session?.user?.id }, { profileId: offerProfileId as string, userId: session?.user?.id },
], ],
{ {
onSuccess: (res) => { onSuccess: (res: boolean) => {
setSaved(res); setSaved(res);
}, },
}, },
@ -233,57 +233,60 @@ export default function ProfileHeader({
const { experiences, totalYoe, specificYoes, profileName } = background; const { experiences, totalYoe, specificYoes, profileName } = background;
return ( return (
<div className="h-40 bg-white p-4"> <div className="grid-rows-2 bg-white p-4">
<div className="justify-left flex h-1/2"> <div className="flex grid grid-cols-5 md:grid-cols-7">
<div className="mx-4 mt-2"> <div className="jsutify-start col-span-5 flex">
<ProfilePhotoHolder /> <div className="ml-0 mr-2 mt-2 h-16 w-16 md:mx-4">
</div> <ProfilePhotoHolder />
<div className="w-full"> </div>
<div className="justify-left flex flex-1"> <div>
<h2 className="flex w-4/5 text-2xl font-bold"> <h2 className="flex text-2xl font-bold">
{profileName ?? 'anonymous'} {profileName ?? 'anonymous'}
</h2> </h2>
{isEditable && ( {(experiences[0]?.companyName ||
<div className="flex h-8 w-1/5 justify-end"> experiences[0]?.jobLevel ||
{renderActionList()} experiences[0]?.jobTitle) && (
<div className="flex flex-row">
<span>
<BuildingOffice2Icon className="mr-2.5 h-5 w-5" />
</span>
<p>
<span className="mr-2 font-bold">Current:</span>
{`${experiences[0].companyName || ''} ${
experiences[0].jobLevel || ''
} ${experiences[0].jobTitle || ''} ${
experiences[0].jobType
? `(${JobTypeLabel[experiences[0].jobType]})`
: ''
}`}
</p>
</div> </div>
)} )}
</div>
{(experiences[0]?.companyName ||
experiences[0]?.jobLevel ||
experiences[0]?.jobTitle) && (
<div className="flex flex-row"> <div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" /> <CalendarDaysIcon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span> <p>
<span> <span className="mr-2 font-bold">YOE:</span>
{`${experiences[0].companyName || ''} ${ <span className="mr-4">{totalYoe}</span>
experiences[0].jobLevel || '' {specificYoes &&
} ${experiences[0].jobTitle || ''} ${ specificYoes.length > 0 &&
experiences[0].jobType specificYoes.map(({ domain, yoe }) => {
? `(${JobTypeLabel[experiences[0].jobType]})` return (
: '' <span
}`} key={domain}
</span> className="mr-4">{`${domain}: ${yoe}`}</span>
);
})}
</p>
</div> </div>
)}
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span className="mr-4">{totalYoe}</span>
{specificYoes &&
specificYoes.length > 0 &&
specificYoes.map(({ domain, yoe }) => {
return (
<span
key={domain}
className="mr-4">{`${domain}: ${yoe}`}</span>
);
})}
</div> </div>
</div> </div>
{isEditable && (
<div className="col-span-2 col-end-6 flex h-8 justify-end md:col-end-8 md:pt-0">
{renderActionList()}
</div>
)}
</div> </div>
<div className="mt-4">
<div className="mt-8">
<Tabs <Tabs
label="Profile Detail Navigation" label="Profile Detail Navigation"
tabs={profileDetailTabs} tabs={profileDetailTabs}

@ -1,6 +1,6 @@
type ProfilePhotoHolderProps = { type ProfilePhotoHolderProps = Readonly<{
size?: 'lg' | 'sm'; size?: 'lg' | 'sm';
}; }>;
export default function ProfilePhotoHolder({ export default function ProfilePhotoHolder({
size = 'lg', size = 'lg',

@ -1,5 +1,6 @@
import clsx from 'clsx'; import clsx from 'clsx';
import Link from 'next/link'; import Link from 'next/link';
import { JobType } from '@prisma/client';
import type { JobTitleType } from '~/components/shared/JobTitles'; import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles'; import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
@ -9,25 +10,47 @@ import { formatDate } from '~/utils/offers/time';
import type { DashboardOffer } from '~/types/offers'; import type { DashboardOffer } from '~/types/offers';
export type OfferTableRowProps = Readonly<{ row: DashboardOffer }>; export type OfferTableRowProps = Readonly<{
jobType: JobType;
row: DashboardOffer;
}>;
export default function OfferTableRow({ export default function OfferTableRow({
row: { company, id, income, monthYearReceived, profileId, title, totalYoe }, jobType,
row: {
baseSalary,
bonus,
company,
id,
income,
monthYearReceived,
profileId,
stocks,
title,
totalYoe,
},
}: OfferTableRowProps) { }: OfferTableRowProps) {
return ( return (
<tr key={id} className="divide-x divide-slate-200 border-b bg-white"> <tr key={id} className="divide-x divide-slate-200 border-b bg-white">
<th className="whitespace-nowrap py-4 px-6 font-medium" scope="row"> <th className="whitespace-nowrap py-4 px-4 font-medium" scope="row">
{company.name} {company.name}
</th> </th>
<td className="py-4 px-6"> <td className="py-4 px-4">
{getLabelForJobTitleType(title as JobTitleType)} {getLabelForJobTitleType(title as JobTitleType)}
</td> </td>
<td className="py-4 px-6">{totalYoe}</td> <td className="py-4 px-4">{totalYoe}</td>
<td className="py-4 px-6">{convertMoneyToString(income)}</td> <td className="py-4 px-4">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td> {jobType === JobType.FULLTIME && (
<td className="py-4 px-4">
{`${baseSalary && convertMoneyToString(baseSalary)} / ${
bonus && convertMoneyToString(bonus)
} / ${stocks && convertMoneyToString(stocks)}`}
</td>
)}
<td className="py-4 px-4">{formatDate(monthYearReceived)}</td>
<td <td
className={clsx( className={clsx(
'sticky right-0 py-4 px-6 drop-shadow md:drop-shadow-none', 'sticky right-0 bg-white px-4 py-4 drop-shadow lg:drop-shadow-none',
)}> )}>
<Link <Link
className="text-primary-600 dark:text-primary-500 font-medium hover:underline" className="text-primary-600 dark:text-primary-500 font-medium hover:underline"

@ -1,5 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { JobType } from '@prisma/client';
import { DropdownMenu, Spinner } from '@tih/ui'; import { DropdownMenu, Spinner } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -7,8 +9,9 @@ import OffersTablePagination from '~/components/offers/table/OffersTablePaginati
import { import {
OfferTableFilterOptions, OfferTableFilterOptions,
OfferTableSortBy, OfferTableSortBy,
OfferTableTabOptions, OfferTableYoeOptions,
YOE_CATEGORY, YOE_CATEGORY,
YOE_CATEGORY_PARAM,
} from '~/components/offers/table/types'; } from '~/components/offers/table/types';
import { Currency } from '~/utils/offers/currency/CurrencyEnum'; import { Currency } from '~/utils/offers/currency/CurrencyEnum';
@ -21,17 +24,18 @@ import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
const NUMBER_OF_OFFERS_IN_PAGE = 10; const NUMBER_OF_OFFERS_IN_PAGE = 10;
export type OffersTableProps = Readonly<{ export type OffersTableProps = Readonly<{
cityFilter: string;
companyFilter: string; companyFilter: string;
countryFilter: string;
jobTitleFilter: string; jobTitleFilter: string;
}>; }>;
export default function OffersTable({ export default function OffersTable({
cityFilter, countryFilter,
companyFilter, companyFilter,
jobTitleFilter, jobTitleFilter,
}: OffersTableProps) { }: OffersTableProps) {
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
const [selectedTab, setSelectedTab] = useState(YOE_CATEGORY.ENTRY); const [selectedYoe, setSelectedYoe] = useState('');
const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME);
const [pagination, setPagination] = useState<Paging>({ const [pagination, setPagination] = useState<Paging>({
currentPage: 0, currentPage: 0,
numOfItems: 0, numOfItems: 0,
@ -43,6 +47,10 @@ export default function OffersTable({
OfferTableFilterOptions[0].value, OfferTableFilterOptions[0].value,
); );
const { event: gaEvent } = useGoogleAnalytics(); const { event: gaEvent } = useGoogleAnalytics();
const router = useRouter();
const { yoeCategory = '' } = router.query;
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
setPagination({ setPagination({
currentPage: 0, currentPage: 0,
@ -50,20 +58,26 @@ export default function OffersTable({
numOfPages: 0, numOfPages: 0,
totalItems: 0, totalItems: 0,
}); });
}, [selectedTab, currency]); setIsLoading(true);
const offersQuery = trpc.useQuery( }, [selectedYoe, currency, countryFilter, companyFilter, jobTitleFilter]);
useEffect(() => {
setSelectedYoe(yoeCategory as YOE_CATEGORY);
event?.preventDefault();
}, [yoeCategory]);
trpc.useQuery(
[ [
'offers.list', 'offers.list',
{ {
// Location: 'Singapore, Singapore', // TODO: Geolocation
cityId: cityFilter,
companyId: companyFilter, companyId: companyFilter,
countryId: countryFilter,
currency, currency,
limit: NUMBER_OF_OFFERS_IN_PAGE, limit: NUMBER_OF_OFFERS_IN_PAGE,
offset: pagination.currentPage, offset: pagination.currentPage,
sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived', sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
title: jobTitleFilter, title: jobTitleFilter,
yoeCategory: selectedTab, yoeCategory: YOE_CATEGORY_PARAM[yoeCategory as string] ?? undefined,
}, },
], ],
{ {
@ -73,28 +87,52 @@ export default function OffersTable({
onSuccess: (response: GetOffersResponse) => { onSuccess: (response: GetOffersResponse) => {
setOffers(response.data); setOffers(response.data);
setPagination(response.paging); setPagination(response.paging);
setJobType(response.jobType);
setIsLoading(false);
}, },
}, },
); );
function renderFilters() { function renderFilters() {
return ( return (
<div className="m-4 flex items-center justify-between"> <div className="flex items-center justify-between p-4 text-sm sm:grid-cols-4 md:text-base">
<DropdownMenu <DropdownMenu
align="start" align="start"
label={ label={
OfferTableTabOptions.filter( OfferTableYoeOptions.filter(
({ value: itemValue }) => itemValue === selectedTab, ({ value: itemValue }) => itemValue === selectedYoe,
)[0].label )[0].label
} }
size="inherit"> size="inherit">
{OfferTableTabOptions.map(({ label: itemLabel, value }) => ( {OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
<DropdownMenu.Item <DropdownMenu.Item
key={value} key={value}
isSelected={value === selectedTab} isSelected={value === selectedYoe}
label={itemLabel} label={itemLabel}
onClick={() => { onClick={() => {
setSelectedTab(value); if (value === '') {
router.replace(
{
pathname: router.pathname,
query: undefined,
},
undefined,
// Do not refresh the page
{ shallow: true },
);
} else {
const params = new URLSearchParams({
['yoeCategory']: value,
});
router.replace(
{
pathname: location.pathname,
search: params.toString(),
},
undefined,
{ shallow: true },
);
}
gaEvent({ gaEvent({
action: `offers.table_filter_yoe_category_${value}`, action: `offers.table_filter_yoe_category_${value}`,
category: 'engagement', category: 'engagement',
@ -104,9 +142,11 @@ export default function OffersTable({
/> />
))} ))}
</DropdownMenu> </DropdownMenu>
<div className="divide-x-slate-200 flex items-center space-x-4 divide-x"> <div className="divide-x-slate-200 col-span-3 flex items-center justify-end space-x-4 divide-x">
<div className="justify-left flex items-center space-x-2 font-medium text-slate-700"> <div className="justify-left flex items-center space-x-2 font-medium text-slate-700">
<span>Display offers in</span> <span className="sr-only sm:not-sr-only sm:inline">
Display offers in
</span>
<CurrencySelector <CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)} handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency} selectedCurrency={currency}
@ -139,14 +179,26 @@ export default function OffersTable({
} }
function renderHeader() { function renderHeader() {
const columns = [ let columns = [
'Company', 'Company',
'Title', 'Title',
'YOE', 'YOE',
selectedTab === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'Annual TC', selectedYoe === YOE_CATEGORY.INTERN ? 'Monthly Salary' : 'Annual TC',
'Date Offered', 'Date Offered',
'Actions', 'Actions',
]; ];
if (jobType === JobType.FULLTIME) {
columns = [
'Company',
'Title',
'YOE',
'Annual TC',
'Annual Base / Bonus / Stocks',
'Date Offered',
'Actions',
];
}
return ( return (
<thead className="text-slate-700"> <thead className="text-slate-700">
<tr className="divide-x divide-slate-200"> <tr className="divide-x divide-slate-200">
@ -154,7 +206,7 @@ export default function OffersTable({
<th <th
key={header} key={header}
className={clsx( className={clsx(
'bg-slate-100 py-3 px-6', 'bg-slate-100 py-3 px-4',
// Make last column sticky. // Make last column sticky.
index === columns.length - 1 && index === columns.length - 1 &&
'sticky right-0 drop-shadow md:drop-shadow-none', 'sticky right-0 drop-shadow md:drop-shadow-none',
@ -175,34 +227,41 @@ export default function OffersTable({
}; };
return ( return (
<div className="w-5/6"> <div className="relative w-full border border-slate-200">
<div className="relative w-full border border-slate-200"> {renderFilters()}
{renderFilters()} {isLoading ? (
{offersQuery.isLoading ? ( <div className="col-span-10 py-32">
<div className="col-span-10 pt-4"> <Spinner display="block" size="lg" />
<Spinner display="block" size="lg" /> </div>
</div> ) : (
) : ( <div className="overflow-x-auto text-slate-600">
<div className="overflow-x-auto"> <table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left">
<table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left text-slate-600"> {renderHeader()}
{renderHeader()} <tbody>
<tbody> {offers.map((offer) => (
{offers.map((offer) => ( <OffersRow key={offer.id} jobType={jobType} row={offer} />
<OffersRow key={offer.id} row={offer} /> ))}
))} </tbody>
</tbody> </table>
</table> {!offers ||
</div> (offers.length === 0 && (
)} <div className="py-16 text-lg">
<OffersTablePagination <div className="flex justify-center">No data yet🥺</div>
endNumber={ <div className="flex justify-center">
pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + offers.length Please try another set of filters.
} </div>
handlePageChange={handlePageChange} </div>
pagination={pagination} ))}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + 1} </div>
/> )}
</div> <OffersTablePagination
endNumber={
pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + offers.length
}
handlePageChange={handlePageChange}
pagination={pagination}
startNumber={pagination.currentPage * NUMBER_OF_OFFERS_IN_PAGE + 1}
/>
</div> </div>
); );
} }

@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import { Pagination } from '@tih/ui'; import { Pagination } from '@tih/ui';
import type { Paging } from '~/types/offers'; import type { Paging } from '~/types/offers';
@ -15,30 +16,36 @@ export default function OffersTablePagination({
startNumber, startNumber,
handlePageChange, handlePageChange,
}: OffersTablePaginationProps) { }: OffersTablePaginationProps) {
const [screenWidth, setScreenWidth] = useState(0);
useEffect(() => {
setScreenWidth(window.innerWidth);
}, []);
return ( return (
<nav <nav aria-label="Table navigation" className="p-4">
aria-label="Table navigation" <div className="flex grid grid-cols-1 items-center md:grid-cols-2">
className="flex items-center justify-between p-4"> <div className="mb-2 text-sm font-normal text-slate-500 md:mb-0">
<span className="text-sm font-normal text-slate-500"> Showing
Showing <span className="font-semibold text-slate-900">
<span className="font-semibold text-slate-900"> {` ${startNumber} - ${endNumber} `}
{` ${startNumber} - ${endNumber} `} </span>
</span> {`of `}
{`of `} <span className="font-semibold text-slate-900">
<span className="font-semibold text-slate-900"> {pagination.totalItems}
{pagination.totalItems} </span>
</span> </div>
</span> <div className="flex md:justify-end">
<Pagination <Pagination
current={pagination.currentPage + 1} current={pagination.currentPage + 1}
end={pagination.numOfPages} end={pagination.numOfPages}
label="Pagination" label="Pagination"
pagePadding={2} pagePadding={screenWidth > 500 ? 2 : 0}
start={1} start={1}
onSelect={(currPage) => { onSelect={(currPage) => {
handlePageChange(currPage - 1); handlePageChange(currPage - 1);
}} }}
/> />
</div>
</div>
</nav> </nav>
); );
} }

@ -1,12 +1,20 @@
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
export enum YOE_CATEGORY { export enum YOE_CATEGORY {
INTERN = 0, ENTRY = 'entry',
ENTRY = 1, INTERN = 'intern',
MID = 2, MID = 'mid',
SENIOR = 3, SENIOR = 'senior',
} }
export const OfferTableTabOptions = [ export const YOE_CATEGORY_PARAM: Record<string, number> = {
entry: 1,
intern: 0,
mid: 2,
senior: 3,
};
export const OfferTableYoeOptions = [
{ label: 'All Full Time YOE', value: '' },
{ {
label: 'Fresh Grad (0-2 YOE)', label: 'Fresh Grad (0-2 YOE)',
value: YOE_CATEGORY.ENTRY, value: YOE_CATEGORY.ENTRY,

@ -30,54 +30,56 @@ export default function ContributeQuestionCard({
}); });
return ( return (
<button <div className="w-full">
className="flex w-full flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100" <button
type="button" className="flex w-full flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
onClick={handleOpenContribute}> type="button"
<TextInput onClick={handleOpenContribute}>
disabled={true} <TextInput
isLabelHidden={true} disabled={true}
label="Question" isLabelHidden={true}
placeholder="Contribute a question" label="Question"
onChange={handleOpenContribute} placeholder="Contribute a question"
/> onChange={handleOpenContribute}
<div className="flex flex-wrap items-end justify-start gap-2"> />
<div className="min-w-[150px] flex-1"> <div className="flex flex-wrap items-end justify-start gap-2">
<TextInput <div className="min-w-[150px] flex-1">
disabled={true} <TextInput
label="Company" disabled={true}
startAddOn={BuildingOffice2Icon} label="Company"
startAddOnType="icon" startAddOn={BuildingOffice2Icon}
onChange={handleOpenContribute} startAddOnType="icon"
/> onChange={handleOpenContribute}
</div> />
<div className="min-w-[150px] flex-1"> </div>
<TextInput <div className="min-w-[150px] flex-1">
disabled={true} <TextInput
label="Question type" disabled={true}
startAddOn={QuestionMarkCircleIcon} label="Question type"
startAddOnType="icon" startAddOn={QuestionMarkCircleIcon}
onChange={handleOpenContribute} startAddOnType="icon"
/> onChange={handleOpenContribute}
</div> />
<div className="min-w-[150px] flex-1"> </div>
<TextInput <div className="min-w-[150px] flex-1">
disabled={true} <TextInput
label="Date" disabled={true}
startAddOn={CalendarDaysIcon} label="Date"
startAddOnType="icon" startAddOn={CalendarDaysIcon}
onChange={handleOpenContribute} startAddOnType="icon"
/> onChange={handleOpenContribute}
/>
</div>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
</div> </div>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white"> </button>
Contribute
</h1>
</div>
<ContributeQuestionDialog <ContributeQuestionDialog
show={showDraftDialog} show={showDraftDialog}
onCancel={handleDraftDialogCancel} onCancel={handleDraftDialogCancel}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
</button> </div>
); );
} }

@ -35,7 +35,12 @@ export default function ContributeQuestionDialog({
return ( return (
<div> <div>
<Transition.Root as={Fragment} show={show}> <Transition.Root as={Fragment} show={show}>
<Dialog as="div" className="relative z-10" onClose={onCancel}> <Dialog
as="div"
className="relative z-10"
onClose={() => {
onCancel();
}}>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="ease-out duration-300" enter="ease-out duration-300"

@ -1,9 +1,12 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { ArrowSmallRightIcon } from '@heroicons/react/24/outline'; import { ArrowSmallRightIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import type { TypeaheadOption } from '@tih/ui';
import { Button, Select } from '@tih/ui'; import { Button, Select } from '@tih/ui';
import { companyOptionToSlug } from '~/utils/questions/companySlug';
import { QUESTION_TYPES } from '~/utils/questions/constants'; import { QUESTION_TYPES } from '~/utils/questions/constants';
import { locationOptionToSlug } from '~/utils/questions/locationSlug';
import useDefaultCompany from '~/utils/questions/useDefaultCompany'; import useDefaultCompany from '~/utils/questions/useDefaultCompany';
import useDefaultLocation from '~/utils/questions/useDefaultLocation'; import useDefaultLocation from '~/utils/questions/useDefaultLocation';
@ -11,8 +14,10 @@ import type { FilterChoice } from './filter/FilterSection';
import CompanyTypeahead from './typeahead/CompanyTypeahead'; import CompanyTypeahead from './typeahead/CompanyTypeahead';
import LocationTypeahead from './typeahead/LocationTypeahead'; import LocationTypeahead from './typeahead/LocationTypeahead';
import type { Location } from '~/types/questions';
export type LandingQueryData = { export type LandingQueryData = {
company: string; companySlug: string;
location: string; location: string;
questionType: QuestionsQuestionType; questionType: QuestionsQuestionType;
}; };
@ -30,9 +35,9 @@ export default function LandingComponent({
const [company, setCompany] = useState<FilterChoice | undefined>( const [company, setCompany] = useState<FilterChoice | undefined>(
defaultCompany, defaultCompany,
); );
const [location, setLocation] = useState<FilterChoice | undefined>( const [location, setLocation] = useState<
defaultLocation, (Location & TypeaheadOption) | undefined
); >(defaultLocation);
const [questionType, setQuestionType] = const [questionType, setQuestionType] =
useState<QuestionsQuestionType>('CODING'); useState<QuestionsQuestionType>('CODING');
@ -41,7 +46,7 @@ export default function LandingComponent({
setCompany(newCompany); setCompany(newCompany);
}; };
const handleChangeLocation = (newLocation: FilterChoice) => { const handleChangeLocation = (newLocation: Location & TypeaheadOption) => {
setLocation(newLocation); setLocation(newLocation);
}; };
@ -71,7 +76,7 @@ export default function LandingComponent({
className="h-40 w-40" className="h-40 w-40"
src="/bank-logo.png" src="/bank-logo.png"
/> />
<h1 className="text-4xl font-bold text-slate-900 text-center"> <h1 className="text-center text-4xl font-bold text-slate-900">
Tech Interview Question Bank Tech Interview Question Bank
</h1> </h1>
</div> </div>
@ -101,7 +106,6 @@ export default function LandingComponent({
isLabelHidden={true} isLabelHidden={true}
value={company} value={company}
onSelect={(value) => { onSelect={(value) => {
// @ts-ignore TODO(questions): handle potentially null value.
handleChangeCompany(value); handleChangeCompany(value);
}} }}
/> />
@ -110,7 +114,6 @@ export default function LandingComponent({
isLabelHidden={true} isLabelHidden={true}
value={location} value={location}
onSelect={(value) => { onSelect={(value) => {
// @ts-ignore TODO(questions): handle potentially null value.
handleChangeLocation(value); handleChangeLocation(value);
}} }}
/> />
@ -124,8 +127,8 @@ export default function LandingComponent({
onClick={() => { onClick={() => {
if (company !== undefined && location !== undefined) { if (company !== undefined && location !== undefined) {
return handleLandingQuery({ return handleLandingQuery({
company: company.label, companySlug: companyOptionToSlug(company),
location: location.value, location: locationOptionToSlug(location),
questionType, questionType,
}); });
} }

@ -9,10 +9,14 @@ import SortOptionsSelect from './SortOptionsSelect';
export type QuestionSearchBarProps = SortOptionsSelectProps & { export type QuestionSearchBarProps = SortOptionsSelectProps & {
onFilterOptionsToggle: () => void; onFilterOptionsToggle: () => void;
onQueryChange: (query: string) => void;
query: string;
}; };
export default function QuestionSearchBar({ export default function QuestionSearchBar({
onFilterOptionsToggle, onFilterOptionsToggle,
onQueryChange,
query,
...sortOptionsSelectProps ...sortOptionsSelectProps
}: QuestionSearchBarProps) { }: QuestionSearchBarProps) {
return ( return (
@ -24,6 +28,10 @@ export default function QuestionSearchBar({
placeholder="Search by content" placeholder="Search by content"
startAddOn={MagnifyingGlassIcon} startAddOn={MagnifyingGlassIcon}
startAddOnType="icon" startAddOnType="icon"
value={query}
onChange={(value) => {
onQueryChange(value);
}}
/> />
</div> </div>
<div className="flex items-end justify-end gap-4"> <div className="flex items-end justify-end gap-4">

@ -216,7 +216,11 @@ export default function BaseQuestionCard({
/> />
)} )}
</div> </div>
<p className={clsx(truncateContent && 'line-clamp-2 text-ellipsis')}> <p
className={clsx(
'whitespace-pre-line font-semibold',
truncateContent && 'line-clamp-2 text-ellipsis',
)}>
{content} {content}
</p> </p>
{!showReceivedForm && {!showReceivedForm &&

@ -128,7 +128,6 @@ export default function ContributeQuestionForm({
{...field} {...field}
required={true} required={true}
onSelect={(option) => { onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option); field.onChange(option);
}} }}
/> />
@ -141,6 +140,7 @@ export default function ContributeQuestionForm({
name="date" name="date"
render={({ field }) => ( render={({ field }) => (
<MonthYearPicker <MonthYearPicker
className="space-x-2"
monthRequired={true} monthRequired={true}
value={{ value={{
month: ((field.value.getMonth() as number) + 1) as Month, month: ((field.value.getMonth() as number) + 1) as Month,
@ -164,7 +164,6 @@ export default function ContributeQuestionForm({
<CompanyTypeahead <CompanyTypeahead
{...field} {...field}
required={true} required={true}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ id }) => { onSelect={({ id }) => {
field.onChange(id); field.onChange(id);
}} }}
@ -181,7 +180,6 @@ export default function ContributeQuestionForm({
{...field} {...field}
required={true} required={true}
onSelect={(option) => { onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option); field.onChange(option);
}} }}
/> />
@ -278,6 +276,7 @@ export default function ContributeQuestionForm({
</button> </button>
<Button <Button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm" className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!checkedSimilar}
label="Contribute" label="Contribute"
type="submit" type="submit"
variant="primary"></Button> variant="primary"></Button>

@ -42,14 +42,16 @@ export default function CreateQuestionEncounterForm({
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-md text-md text-slate-600">I saw this question at</p> <p className="font-md text-md text-slate-600">
I saw this question {step <= 1 ? 'at' : step === 2 ? 'for' : 'on'}
</p>
{step === 0 && ( {step === 0 && (
<div> <div>
<CompanyTypeahead <CompanyTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="Other company" placeholder="Company"
suggestedCount={3} // TODO: Fix suggestions and set count back to 3
// @ts-ignore TODO(questions): handle potentially null value. suggestedCount={0}
onSelect={({ value: company }) => { onSelect={({ value: company }) => {
setSelectedCompany(company); setSelectedCompany(company);
}} }}
@ -64,9 +66,8 @@ export default function CreateQuestionEncounterForm({
<div> <div>
<LocationTypeahead <LocationTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="Other location" placeholder="Location"
suggestedCount={3} suggestedCount={0}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={(location) => { onSelect={(location) => {
setSelectedLocation(location); setSelectedLocation(location);
}} }}
@ -81,9 +82,8 @@ export default function CreateQuestionEncounterForm({
<div> <div>
<RoleTypeahead <RoleTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="Other role" placeholder="Role"
suggestedCount={3} suggestedCount={0}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ value: role }) => { onSelect={({ value: role }) => {
setSelectedRole(role); setSelectedRole(role);
}} }}
@ -96,6 +96,7 @@ export default function CreateQuestionEncounterForm({
)} )}
{step === 3 && ( {step === 3 && (
<MonthYearPicker <MonthYearPicker
className="space-x-2"
// TODO: Add label and hide label on Select instead. // TODO: Add label and hide label on Select instead.
monthLabel="" monthLabel=""
value={{ value={{

@ -8,7 +8,10 @@ import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
type TypeaheadProps = ComponentProps<typeof Typeahead>; type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number]; type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = Omit<TypeaheadProps, 'onSelect'> & export type ExpandedTypeaheadProps = Omit<
TypeaheadProps,
'nullable' | 'onSelect'
> &
RequireAllOrNone<{ RequireAllOrNone<{
clearOnSelect?: boolean; clearOnSelect?: boolean;
filterOption: (option: TypeaheadOption) => boolean; filterOption: (option: TypeaheadOption) => boolean;
@ -59,8 +62,7 @@ export default function ExpandedTypeahead({
if (clearOnSelect) { if (clearOnSelect) {
setKey((key + 1) % 2); setKey((key + 1) % 2);
} }
// TODO: Remove onSelect null coercion once onSelect prop is refactored onSelect(option);
onSelect(option!);
}} }}
/> />
</div> </div>

@ -22,6 +22,13 @@ const navigation: ProductNavigationItems = [
const config = { const config = {
googleAnalyticsMeasurementID: 'G-VFTWPMW1WK', googleAnalyticsMeasurementID: 'G-VFTWPMW1WK',
logo: (
<img
alt="Tech Resume Review"
className="h-8 w-auto"
src="/resumes-logo.svg"
/>
),
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Resumes', title: 'Resumes',

@ -8,6 +8,7 @@ import {
import { Vote } from '@prisma/client'; import { Vote } from '@prisma/client';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import loginPageHref from '~/components/shared/loginPageHref';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -63,7 +64,7 @@ export default function ResumeCommentVoteButtons({
const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => { const onVote = async (value: Vote, setAnimation: (_: boolean) => void) => {
if (!userId) { if (!userId) {
router.push('/api/auth/signin'); router.push(loginPageHref());
return; return;
} }

@ -1,5 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { signIn } from 'next-auth/react'; import Link from 'next/link';
import loginPageHref from '~/components/shared/loginPageHref';
type Props = Readonly<{ type Props = Readonly<{
className?: string; className?: string;
@ -10,15 +12,11 @@ export default function ResumeSignInButton({ text, className }: Props) {
return ( return (
<div className={clsx('flex justify-center', className)}> <div className={clsx('flex justify-center', className)}>
<p> <p>
<a <Link
className="text-indigo-500 hover:text-indigo-600" className="text-primary-500 hover:text-primary-600"
href="/api/auth/signin" href={loginPageHref()}>
onClick={(event) => { Log in
event.preventDefault(); </Link>{' '}
signIn();
}}>
Sign in
</a>{' '}
{text} {text}
</p> </p>
</div> </div>

@ -1,28 +1,32 @@
import type { ComponentProps } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui'; import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
type Props = Readonly<{ type BaseProps = Pick<
disabled?: boolean; ComponentProps<typeof Typeahead>,
errorMessage?: string; | 'disabled'
isLabelHidden?: boolean; | 'errorMessage'
label?: string; | 'isLabelHidden'
onSelect: (option: TypeaheadOption | null) => void; | 'placeholder'
placeholder?: string; | 'required'
required?: boolean; | 'textSize'
value?: TypeaheadOption | null; >;
}>;
type Props = BaseProps &
Readonly<{
label?: string;
onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null;
}>;
export default function CitiesTypeahead({ export default function CitiesTypeahead({
disabled,
label = 'City', label = 'City',
onSelect, onSelect,
isLabelHidden,
placeholder,
required,
value, value,
...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const cities = trpc.useQuery([ const cities = trpc.useQuery([
@ -36,8 +40,6 @@ export default function CitiesTypeahead({
return ( return (
<Typeahead <Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label={label} label={label}
noResultsMessage="No cities found" noResultsMessage="No cities found"
nullable={true} nullable={true}
@ -48,12 +50,10 @@ export default function CitiesTypeahead({
value: id, value: id,
})) ?? [] })) ?? []
} }
placeholder={placeholder}
required={required}
textSize="inherit"
value={value} value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}
{...props}
/> />
); );
} }

@ -1,26 +1,30 @@
import type { ComponentProps } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui'; import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
type Props = Readonly<{ type BaseProps = Pick<
disabled?: boolean; ComponentProps<typeof Typeahead>,
errorMessage?: string; | 'disabled'
isLabelHidden?: boolean; | 'errorMessage'
onSelect: (option: TypeaheadOption | null) => void; | 'isLabelHidden'
placeholder?: string; | 'placeholder'
required?: boolean; | 'required'
value?: TypeaheadOption | null; | 'textSize'
}>; >;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null;
}>;
export default function CompaniesTypeahead({ export default function CompaniesTypeahead({
disabled,
onSelect, onSelect,
isLabelHidden,
placeholder,
required,
value, value,
...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const companies = trpc.useQuery([ const companies = trpc.useQuery([
@ -34,8 +38,6 @@ export default function CompaniesTypeahead({
return ( return (
<Typeahead <Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label="Company" label="Company"
noResultsMessage="No companies found" noResultsMessage="No companies found"
nullable={true} nullable={true}
@ -46,12 +48,10 @@ export default function CompaniesTypeahead({
value: id, value: id,
})) ?? [] })) ?? []
} }
placeholder={placeholder}
required={required}
textSize="inherit"
value={value} value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}
{...props}
/> />
); );
} }

@ -0,0 +1,27 @@
import clsx from 'clsx';
import React from 'react';
type Props = Readonly<{
children: React.ReactNode;
className?: string;
variant?: 'md' | 'sm' | 'xs';
}>;
export default function Container({
children,
className,
variant = 'md',
}: Props) {
return (
<div
className={clsx(
'mx-auto px-4 sm:px-6 lg:px-8',
variant === 'md' && 'max-w-7xl',
variant === 'sm' && 'max-w-5xl',
variant === 'xs' && 'max-w-3xl',
className,
)}>
{children}
</div>
);
}

@ -1,26 +1,30 @@
import type { ComponentProps } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui'; import { Typeahead } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
type Props = Readonly<{ type BaseProps = Pick<
disabled?: boolean; ComponentProps<typeof Typeahead>,
errorMessage?: string; | 'disabled'
isLabelHidden?: boolean; | 'errorMessage'
onSelect: (option: TypeaheadOption | null) => void; | 'isLabelHidden'
placeholder?: string; | 'placeholder'
required?: boolean; | 'required'
value?: TypeaheadOption | null; | 'textSize'
}>; >;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null;
}>;
export default function CountriesTypeahead({ export default function CountriesTypeahead({
disabled,
onSelect, onSelect,
isLabelHidden,
placeholder,
required,
value, value,
...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const countries = trpc.useQuery([ const countries = trpc.useQuery([
@ -34,8 +38,6 @@ export default function CountriesTypeahead({
return ( return (
<Typeahead <Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label="Country" label="Country"
noResultsMessage="No countries found" noResultsMessage="No countries found"
nullable={true} nullable={true}
@ -46,12 +48,10 @@ export default function CountriesTypeahead({
value: id, value: id,
})) ?? [] })) ?? []
} }
placeholder={placeholder}
required={required}
textSize="inherit"
value={value} value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}
{...props}
/> />
); );
} }

@ -1,25 +1,30 @@
import type { ComponentProps } from 'react';
import { useState } from 'react'; import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui'; import type { TypeaheadOption } from '@tih/ui';
import { Typeahead } from '@tih/ui'; import { Typeahead } from '@tih/ui';
import { JobTitleLabels } from './JobTitles'; import { JobTitleLabels } from './JobTitles';
type Props = Readonly<{ type BaseProps = Pick<
disabled?: boolean; ComponentProps<typeof Typeahead>,
isLabelHidden?: boolean; | 'disabled'
onSelect: (option: TypeaheadOption | null) => void; | 'errorMessage'
placeholder?: string; | 'isLabelHidden'
required?: boolean; | 'placeholder'
value?: TypeaheadOption | null; | 'required'
}>; | 'textSize'
>;
type Props = BaseProps &
Readonly<{
onSelect: (option: TypeaheadOption | null) => void;
value?: TypeaheadOption | null;
}>;
export default function JobTitlesTypeahead({ export default function JobTitlesTypeahead({
disabled,
onSelect, onSelect,
isLabelHidden,
placeholder,
required,
value, value,
...props
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels) const options = Object.entries(JobTitleLabels)
@ -35,18 +40,14 @@ export default function JobTitlesTypeahead({
return ( return (
<Typeahead <Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label="Job Title" label="Job Title"
noResultsMessage="No available job titles." noResultsMessage="No available job titles."
nullable={true} nullable={true}
options={options} options={options}
placeholder={placeholder}
required={required}
textSize="inherit"
value={value} value={value}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}
{...props}
/> />
); );
} }

@ -1,3 +1,4 @@
import clsx from 'clsx';
import { useEffect, useId, useState } from 'react'; import { useEffect, useId, useState } from 'react';
import { Select } from '@tih/ui'; import { Select } from '@tih/ui';
@ -14,6 +15,7 @@ export type MonthYearOptional = Readonly<{
}>; }>;
type Props = Readonly<{ type Props = Readonly<{
className?: string;
errorMessage?: string; errorMessage?: string;
monthLabel?: string; monthLabel?: string;
monthRequired?: boolean; monthRequired?: boolean;
@ -84,6 +86,7 @@ const YEAR_OPTIONS = Array.from({ length: NUM_YEARS }, (_, i) => {
}); });
export default function MonthYearPicker({ export default function MonthYearPicker({
className,
errorMessage, errorMessage,
monthLabel = 'Month', monthLabel = 'Month',
value, value,
@ -109,29 +112,35 @@ export default function MonthYearPicker({
return ( return (
<div aria-describedby={hasError ? errorId : undefined}> <div aria-describedby={hasError ? errorId : undefined}>
<div className="flex items-end space-x-2"> <div className={clsx('flex items-end', className)}>
<Select <div className="grow">
key={`month:${monthCounter}`} <Select
label={monthLabel} key={`month:${monthCounter}`}
options={MONTH_OPTIONS} display="block"
placeholder="Select month" label={monthLabel}
required={monthRequired} options={MONTH_OPTIONS}
value={value.month} placeholder="Select month"
onChange={(newMonth) => required={monthRequired}
onChange({ month: Number(newMonth) as Month, year: value.year }) value={value.month}
} onChange={(newMonth) =>
/> onChange({ month: Number(newMonth) as Month, year: value.year })
<Select }
key={`year:${yearCounter}`} />
label={yearLabel} </div>
options={YEAR_OPTIONS} <div className="grow">
placeholder="Select year" <Select
required={yearRequired} key={`year:${yearCounter}`}
value={value.year} display="block"
onChange={(newYear) => label={yearLabel}
onChange({ month: value.month, year: Number(newYear) }) options={YEAR_OPTIONS}
} placeholder="Select year"
/> required={yearRequired}
value={value.year}
onChange={(newYear) =>
onChange({ month: value.month, year: Number(newYear) })
}
/>
</div>
</div> </div>
{errorMessage && ( {errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}> <p className="text-danger-600 mt-2 text-sm" id={errorId}>

@ -0,0 +1,15 @@
export default function GitHubIcon(props: React.ComponentProps<'svg'>) {
return (
<svg
fill="currentColor"
height="1em"
stroke="currentColor"
strokeWidth={0}
viewBox="0 0 496 512"
width="1em"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path>
</svg>
);
}

@ -0,0 +1,11 @@
export default function loginPageHref(redirectUrl?: string) {
return {
pathname: '/login',
query: {
redirect:
typeof window !== 'undefined'
? redirectUrl ?? window.location.href
: null,
},
};
}

@ -700,6 +700,25 @@ export const dashboardOfferDtoMapper = (
dashboardOfferDto.income = valuationDtoMapper( dashboardOfferDto.income = valuationDtoMapper(
offer.offersFullTime.totalCompensation, offer.offersFullTime.totalCompensation,
); );
if (offer.offersFullTime.baseSalary) {
dashboardOfferDto.baseSalary = valuationDtoMapper(
offer.offersFullTime.baseSalary
);
}
if (offer.offersFullTime.bonus) {
dashboardOfferDto.bonus = valuationDtoMapper(
offer.offersFullTime.bonus
);
}
if (offer.offersFullTime.stocks) {
dashboardOfferDto.stocks = valuationDtoMapper(
offer.offersFullTime.stocks
);
}
} else if (offer.offersIntern) { } else if (offer.offersIntern) {
dashboardOfferDto.income = valuationDtoMapper( dashboardOfferDto.income = valuationDtoMapper(
offer.offersIntern.monthlySalary, offer.offersIntern.monthlySalary,
@ -712,10 +731,12 @@ export const dashboardOfferDtoMapper = (
export const getOffersResponseMapper = ( export const getOffersResponseMapper = (
data: Array<DashboardOffer>, data: Array<DashboardOffer>,
paging: Paging, paging: Paging,
jobType: JobType
) => { ) => {
const getOffersResponse: GetOffersResponse = { const getOffersResponse: GetOffersResponse = {
data, data,
paging, jobType,
paging
}; };
return getOffersResponse; return getOffersResponse;
}; };

@ -2,9 +2,9 @@ import { Head, Html, Main, NextScript } from 'next/document';
export default function Document() { export default function Document() {
return ( return (
<Html className="h-full bg-slate-50"> <Html className="bg-slate-50">
<Head /> <Head />
<body className="h-full overflow-hidden"> <body>
<Main /> <Main />
<NextScript /> <NextScript />
</body> </body>

@ -0,0 +1,72 @@
import { useRouter } from 'next/router';
import type {
GetServerSideProps,
InferGetServerSidePropsType,
} from 'next/types';
import { getProviders, signIn } from 'next-auth/react';
import { Button } from '@tih/ui';
import GitHubIcon from '~/components/shared/icons/GitHubIcon';
export const getServerSideProps: GetServerSideProps<{
providers: Awaited<ReturnType<typeof getProviders>>;
}> = async () => {
const providers = await getProviders();
return {
props: { providers },
};
};
export default function LoginPage({
providers,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
const router = useRouter();
return (
<div className="flex w-full justify-center">
<div className="flex min-h-full flex-col justify-center py-12 px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<img
alt="Tech Interview Handbook"
className="mx-auto h-24 w-auto"
src="/logo.svg"
/>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
Tech Interview Handbook Portal
</h2>
<p className="mt-2 text-center text-slate-600">
Get your resumes peer-reviewed, discuss solutions to tech interview
questions, get offer data points.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="space-y-4">
{providers != null &&
Object.values(providers).map((provider) => (
<div key={provider.name}>
<Button
addonPosition="start"
display="block"
icon={GitHubIcon}
label={`Sign in with ${provider.name}`}
type="button"
variant="primary"
onClick={() =>
signIn(
provider.id,
router.query.redirect != null
? {
callbackUrl: String(router.query.redirect),
}
: undefined,
)
}
/>
</div>
))}
</div>
</div>
</div>
</div>
);
}

@ -3,7 +3,8 @@ import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { Button, Spinner } from '@tih/ui'; import { Button, Spinner } from '@tih/ui';
import DashboardOfferCard from '~/components/offers/dashboard/DashboardProfileCard'; import DashboardProfileCard from '~/components/offers/dashboard/DashboardProfileCard';
import Container from '~/components/shared/Container';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
@ -30,19 +31,21 @@ export default function ProfilesDashboard() {
if (status === 'loading' || userProfilesQuery.isLoading) { if (status === 'loading' || userProfilesQuery.isLoading) {
return ( return (
<div className="flex h-screen w-screen"> <div className="flex w-full">
<div className="m-auto mx-auto w-full justify-center"> <div className="m-auto mx-auto w-full justify-center">
<Spinner className="m-10" display="block" size="lg" /> <Spinner className="m-10" display="block" size="lg" />
</div> </div>
</div> </div>
); );
} }
if (status === 'unauthenticated') { if (status === 'unauthenticated') {
signIn(); signIn();
} }
if (userProfiles.length === 0) { if (userProfiles.length === 0) {
return ( return (
<div className="flex h-screen w-screen"> <div className="flex w-full">
<div className="m-auto mx-auto w-full justify-center text-xl"> <div className="m-auto mx-auto w-full justify-center text-xl">
<div className="mb-8 flex w-full flex-row justify-center"> <div className="mb-8 flex w-full flex-row justify-center">
<h2>You have not saved any offer profiles yet.</h2> <h2>You have not saved any offer profiles yet.</h2>
@ -59,37 +62,36 @@ export default function ProfilesDashboard() {
</div> </div>
); );
} }
return ( return (
<> <Container variant="xs">
{userProfilesQuery.isLoading && ( {userProfilesQuery.isLoading && (
<div className="flex h-screen w-screen"> <div className="flex h-screen">
<div className="m-auto mx-auto w-full justify-center"> <div className="m-auto mx-auto w-full justify-center">
<Spinner className="m-10" display="block" size="lg" /> <Spinner className="m-10" display="block" size="lg" />
</div> </div>
</div> </div>
)} )}
{!userProfilesQuery.isLoading && ( {!userProfilesQuery.isLoading && (
<div className="mt-8 overflow-y-auto"> <div className="overflow-y-auto py-8">
<h1 className="mx-auto mb-4 w-3/4 text-start text-4xl font-bold text-slate-900"> <h1 className="mx-auto mb-4 text-start text-4xl font-bold text-slate-900">
Your dashboard Your dashboard
</h1> </h1>
<p className="mx-auto w-3/4 text-start text-xl text-slate-900"> <p className="mt-4 text-xl leading-8 text-slate-500">
Save your offer profiles to dashboard to easily access and edit them Save your offer profiles to your dashboard to easily access and edit
later. them later.
</p> </p>
<div className="justfy-center mt-8 flex w-screen"> <div className="mt-8 flex justify-center">
<ul className="mx-auto w-3/4 space-y-3" role="list"> <ul className="w-full space-y-4" role="list">
{userProfiles?.map((profile) => ( {userProfiles?.map((profile) => (
<li <li key={profile.id}>
key={profile.id} <DashboardProfileCard key={profile.id} profile={profile} />
className="overflow-hidden bg-white px-4 py-4 shadow sm:rounded-md sm:px-6">
<DashboardOfferCard key={profile.id} profile={profile} />
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
</div> </div>
)} )}
</> </Container>
); );
} }

@ -5,14 +5,16 @@ import { Banner } from '@tih/ui';
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import OffersTable from '~/components/offers/table/OffersTable'; import OffersTable from '~/components/offers/table/OffersTable';
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import Container from '~/components/shared/Container';
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
import type { JobTitleType } from '~/components/shared/JobTitles';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead'; import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
export default function OffersHomePage() { export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('software-engineer'); const [jobTitleFilter, setJobTitleFilter] = useState<JobTitleType | ''>('');
const [companyFilter, setCompanyFilter] = useState(''); const [companyFilter, setCompanyFilter] = useState('');
const [cityFilter, setCityFilter] = useState(''); const [countryFilter, setCountryFilter] = useState('');
const { event: gaEvent } = useGoogleAnalytics(); const { event: gaEvent } = useGoogleAnalytics();
return ( return (
@ -24,21 +26,23 @@ export default function OffersHomePage() {
</Link> </Link>
. .
</Banner> </Banner>
<div className="text-primary-600 flex items-center justify-end space-x-1 bg-slate-100 px-4 pt-4"> <div className="text-primary-600 flex items-center justify-end space-x-1 bg-slate-100 px-4 pt-4 sm:text-lg">
<span> <span>
<MapPinIcon className="flex h-7 w-7" /> <MapPinIcon className="flex h-7 w-7" />
</span> </span>
<CitiesTypeahead <CountriesTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="All Cities" placeholder="All Countries"
onSelect={(option) => { onSelect={(option) => {
if (option) { if (option) {
setCityFilter(option.value); setCountryFilter(option.value);
gaEvent({ gaEvent({
action: `offers.table_filter_city_${option.value}`, action: `offers.table_filter_country_${option.value}`,
category: 'engagement', category: 'engagement',
label: 'Filter by city', label: 'Filter by country',
}); });
} else {
setCountryFilter('');
} }
}} }}
/> />
@ -60,15 +64,18 @@ export default function OffersHomePage() {
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<JobTitlesTypeahead <JobTitlesTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="Software Engineer" placeholder="All Job Titles"
textSize="inherit"
onSelect={(option) => { onSelect={(option) => {
if (option) { if (option) {
setjobTitleFilter(option.value); setJobTitleFilter(option.value as JobTitleType);
gaEvent({ gaEvent({
action: `offers.table_filter_job_title_${option.value}`, action: `offers.table_filter_job_title_${option.value}`,
category: 'engagement', category: 'engagement',
label: 'Filter by job title', label: 'Filter by job title',
}); });
} else {
setJobTitleFilter('');
} }
}} }}
/> />
@ -76,6 +83,7 @@ export default function OffersHomePage() {
<CompaniesTypeahead <CompaniesTypeahead
isLabelHidden={true} isLabelHidden={true}
placeholder="All Companies" placeholder="All Companies"
textSize="inherit"
onSelect={(option) => { onSelect={(option) => {
if (option) { if (option) {
setCompanyFilter(option.value); setCompanyFilter(option.value);
@ -84,19 +92,21 @@ export default function OffersHomePage() {
category: 'engagement', category: 'engagement',
label: 'Filter by company', label: 'Filter by company',
}); });
} else {
setCompanyFilter('');
} }
}} }}
/> />
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-center bg-white pb-20 pt-10"> <Container className="pb-20 pt-10">
<OffersTable <OffersTable
cityFilter={cityFilter}
companyFilter={companyFilter} companyFilter={companyFilter}
countryFilter={countryFilter}
jobTitleFilter={jobTitleFilter} jobTitleFilter={jobTitleFilter}
/> />
</div> </Container>
</main> </main>
); );
} }

@ -195,24 +195,26 @@ export default function OfferProfile() {
)} )}
{getProfileQuery.isLoading && ( {getProfileQuery.isLoading && (
<div className="flex h-screen w-screen"> <div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-screen justify-center"> <div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
<div className="text-center">Loading...</div> <div className="text-center">Loading...</div>
</div> </div>
</div> </div>
)} )}
{!getProfileQuery.isLoading && !getProfileQuery.isError && ( {!getProfileQuery.isLoading && !getProfileQuery.isError && (
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x"> <div className="w-full divide-x lg:flex">
<div className="h-full w-2/3 divide-y"> <div className="divide-y lg:w-2/3">
<ProfileHeader <div className="h-fit">
background={background} <ProfileHeader
handleDelete={handleDelete} background={background}
isEditable={isEditable} handleDelete={handleDelete}
isLoading={getProfileQuery.isLoading} isEditable={isEditable}
selectedTab={selectedTab} isLoading={getProfileQuery.isLoading}
setSelectedTab={setSelectedTab} selectedTab={selectedTab}
/> setSelectedTab={setSelectedTab}
<div className="h-4/5 w-full overflow-y-scroll pb-32"> />
</div>
<div className="pb-4">
<ProfileDetails <ProfileDetails
analysis={analysis} analysis={analysis}
background={background} background={background}
@ -224,7 +226,9 @@ export default function OfferProfile() {
/> />
</div> </div>
</div> </div>
<div className="h-full w-1/3 bg-white"> <div
className="bg-white lg:fixed lg:right-0 lg:bottom-0 lg:w-1/3"
style={{ top: 64 }}>
<ProfileComments <ProfileComments
isDisabled={deleteMutation.isLoading} isDisabled={deleteMutation.isLoading}
isEditable={isEditable} isEditable={isEditable}

@ -71,55 +71,57 @@ export default function OffersSubmissionResult() {
<> <>
{getAnalysis.isLoading && ( {getAnalysis.isLoading && (
<div className="flex h-screen w-screen"> <div className="flex h-screen w-screen">
<div className="m-auto mx-auto w-screen justify-center"> <div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
<div className="text-center">Loading...</div> <div className="text-center">Loading...</div>
</div> </div>
</div> </div>
)} )}
{!getAnalysis.isLoading && ( {!getAnalysis.isLoading && (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll"> <div ref={pageRef} className="w-full">
<div className="mb-20 flex justify-center"> <div className="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="block w-full max-w-screen-md overflow-hidden rounded-lg sm:shadow-lg md:my-10">
<div className="mb-4 flex justify-end"> <div className="flex justify-center bg-slate-100 px-4 py-4 sm:px-6 lg:px-8">
<Breadcrumbs <Breadcrumbs
currentStep={step} currentStep={step}
setStep={setStep} setStep={setStep}
steps={breadcrumbSteps} steps={breadcrumbSteps}
/> />
</div> </div>
{steps[step]} <div className="bg-white p-6 sm:p-10">
{step === 0 && ( {steps[step]}
<div className="flex justify-end"> {step === 0 && (
<Button <div className="flex justify-end">
disabled={false} <Button
icon={ArrowRightIcon} disabled={false}
label="Next" icon={ArrowRightIcon}
variant="secondary" label="Next"
onClick={() => setStep(step + 1)} variant="primary"
/> onClick={() => setStep(step + 1)}
</div> />
)} </div>
{step === 1 && ( )}
<div className="flex items-center justify-between"> {step === 1 && (
<Button <div className="flex items-center justify-between">
addonPosition="start" <Button
icon={ArrowLeftIcon} addonPosition="start"
label="Previous" icon={ArrowLeftIcon}
variant="secondary" label="Previous"
onClick={() => setStep(step - 1)} variant="secondary"
/> onClick={() => setStep(step - 1)}
<Button />
href={getProfilePath( <Button
offerProfileId as string, href={getProfilePath(
token as string, offerProfileId as string,
)} token as string,
icon={EyeIcon} )}
label="View your profile" icon={EyeIcon}
variant="primary" label="View your profile"
/> variant="primary"
</div> />
)} </div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

@ -1,412 +0,0 @@
import React, { useState } from 'react';
import { trpc } from '~/utils/trpc';
function Test() {
const [createdData, setCreatedData] = useState('');
const [error, setError] = useState('');
const createMutation = trpc.useMutation(['offers.profile.create'], {
onError(err) {
alert(err);
},
onSuccess(data) {
setCreatedData(JSON.stringify(data));
},
});
const addToUserProfileMutation = trpc.useMutation(
['offers.user.profile.addToUserProfile'],
{
onError(err) {
alert(err);
},
onSuccess(data) {
setCreatedData(JSON.stringify(data));
},
},
);
const deleteCommentMutation = trpc.useMutation(['offers.comments.delete'], {
onError(err) {
alert(err);
},
onSuccess(data) {
setCreatedData(JSON.stringify(data));
},
});
const handleDeleteComment = () => {
deleteCommentMutation.mutate({
id: 'cl97fprun001j7iyg6ev9x983',
profileId: 'cl96stky5002ew32gx2kale2x',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: 'cl97dl51k001e7iygd5v5gt58',
});
};
const updateCommentMutation = trpc.useMutation(['offers.comments.update'], {
onError(err) {
alert(err);
},
onSuccess(data) {
setCreatedData(JSON.stringify(data));
},
});
const handleUpdateComment = () => {
updateCommentMutation.mutate({
id: 'cl97fxb0y001l7iyg14sdobt2',
message: 'hello hello',
profileId: 'cl96stky5002ew32gx2kale2x',
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
});
};
const createCommentMutation = trpc.useMutation(['offers.comments.create'], {
onError(err) {
alert(err);
},
onSuccess(data) {
setCreatedData(JSON.stringify(data));
},
});
const handleCreate = () => {
createCommentMutation.mutate({
message: 'wassup bro',
profileId: 'cl9efyn9p004ww3u42mjgl1vn',
replyingToId: 'cl9el4xj10001w3w21o3p2iny',
userId: 'cl9ehvpng0000w3ec2mpx0bdd',
});
};
const handleLink = () => {
addToUserProfileMutation.mutate({
profileId: 'cl9efyn9p004ww3u42mjgl1vn',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
// UserId: 'cl9ehvpng0000w3ec2mpx0bdd',
});
};
const handleClick = () => {
createMutation.mutate({
background: {
educations: [
{
endDate: new Date('2018-09-30T07:58:54.000Z'),
field: 'Computer Science',
school: 'National University of Singapore',
startDate: new Date('2014-09-30T07:58:54.000Z'),
type: 'Bachelors',
},
],
experiences: [
{
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
durationInMonths: 24,
jobType: 'FULLTIME',
level: 'Junior',
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
value: 104100,
},
},
],
specificYoes: [
{
domain: 'Front End',
yoe: 2,
},
{
domain: 'Full Stack',
yoe: 2,
},
],
totalYoe: 4,
},
offers: [
{
comments: 'I am a Raffles Institution almumni',
// Comments: '',
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Leveraged having multiple offers',
offersFullTime: {
baseSalary: {
currency: 'SGD',
value: 2222,
},
bonus: {
currency: 'SGD',
value: 2222,
},
level: 'Junior',
stocks: {
currency: 'SGD',
value: 0,
},
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
value: 4444,
},
},
},
{
comments: '',
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Leveraged having multiple offers',
offersFullTime: {
baseSalary: {
currency: 'SGD',
value: 84000,
},
bonus: {
currency: 'SGD',
value: 20000,
},
level: 'Junior',
stocks: {
currency: 'SGD',
value: 100,
},
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
value: 104100,
},
},
},
],
});
};
const profileId = 'cl9j50xzk008vutfqg6mta2ey'; // Remember to change this filed after testing deleting
const data = trpc.useQuery(
[
`offers.profile.listOne`,
{
profileId,
token:
'24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
},
],
{
onError(err) {
setError(err.shape?.message || '');
},
},
);
trpc.useQuery(
[
`offers.profile.isValidToken`,
{
profileId: 'cl9scdzuh0000tt727ipone1k',
token:
'aa628d0db3ad7a5f84895537d4cca38edd0a9b8b96d869cddeb967fccf068c08',
},
],
{
onError(err) {
setError(err.shape?.message || '');
},
},
);
const replies = trpc.useQuery(
['offers.comments.getComments', { profileId }],
{
onError(err) {
setError(err.shape?.message || '');
},
},
);
const deleteMutation = trpc.useMutation(['offers.profile.delete']);
const handleDelete = (id: string) => {
deleteMutation.mutate({
profileId: id,
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
});
};
const updateMutation = trpc.useMutation(['offers.profile.update'], {
onError(err) {
alert(err);
},
onSuccess(response) {
setCreatedData(JSON.stringify(response));
},
});
const handleUpdate = () => {
updateMutation.mutate({
background: {
educations: [
{
backgroundId: 'cl9i68fv60001tthj23g9tuv4',
endDate: new Date('2018-09-30T07:58:54.000Z'),
field: 'Computer Science',
id: 'cl9i87y7z004otthjmpsd48wo',
school: 'National University of Singapore',
startDate: new Date('2014-09-30T07:58:54.000Z'),
type: 'Bachelors',
},
],
experiences: [
{
backgroundId: 'cl9i68fv60001tthj23g9tuv4',
company: {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl9j4yawz0003utlp1uaa1t8o',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
durationInMonths: 24,
// Id: 'cl9j4yawz0003utlp1uaa1t8o',
jobType: 'FULLTIME',
level: 'Junior',
monthlySalary: null,
monthlySalaryId: null,
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
id: 'cl9i68fvc0005tthj7r1rhvb1',
value: 100,
},
totalCompensationId: 'cl9i68fvc0005tthj7r1rhvb1',
},
],
id: 'cl9i68fv60001tthj23g9tuv4',
offersProfileId: 'cl9i68fv60000tthj8t3zkox0',
specificYoes: [
{
backgroundId: 'cl9i68fv60001tthj23g9tuv4',
domain: 'Backend',
id: 'cl9i68fvc0008tthjlxslzfo4',
yoe: 5,
},
{
backgroundId: 'cl9i68fv60001tthj23g9tuv4',
domain: 'Backend',
id: 'cl9i68fvc0009tthjwol3285l',
yoe: 4,
},
],
totalYoe: 1,
},
createdAt: '2022-10-13T08:28:13.518Z',
// Discussion: [],
id: 'cl9i68fv60000tthj8t3zkox0',
isEditable: true,
offers: [
{
comments: 'this IS SO IEUHDAEUIGDI',
company: {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl9j4yawz0003utlp1uaa1t8o',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
id: 'cl9i68fve000ntthj5h9yvqnh',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
negotiationStrategy: 'Charmed the guy with my face',
offersFullTime: {
baseSalary: {
currency: 'SGD',
id: 'cl9i68fve000ptthjn55hpoe4',
value: 1999999999,
},
baseSalaryId: 'cl9i68fve000ptthjn55hpoe4',
bonus: {
currency: 'SGD',
id: 'cl9i68fve000rtthjqo2ktljt',
value: 1410065407,
},
bonusId: 'cl9i68fve000rtthjqo2ktljt',
id: 'cl9i68fve000otthjqk0g01k0',
level: 'EXPERT',
stocks: {
currency: 'SGD',
id: 'cl9i68fvf000ttthjt2ode0cc',
value: -558038585,
},
stocksId: 'cl9i68fvf000ttthjt2ode0cc',
title: 'software-engineer',
totalCompensation: {
currency: 'SGD',
id: 'cl9i68fvf000vtthjg90s48nj',
value: 55555555,
},
totalCompensationId: 'cl9i68fvf000vtthjg90s48nj',
},
offersFullTimeId: 'cl9i68fve000otthjqk0g01k0',
offersIntern: null,
offersInternId: null,
profileId: 'cl9i68fv60000tthj8t3zkox0',
},
],
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: null,
});
};
return (
<>
<div>{createdData}</div>
<div>{JSON.stringify(replies.data?.data)}</div>
<button type="button" onClick={handleClick}>
Click Me!
</button>
<button type="button" onClick={handleUpdate}>
UPDATE!
</button>
<button type="button" onClick={handleLink}>
LINKKKK!
</button>
<button type="button" onClick={handleCreate}>
CREATE COMMENT!
</button>
<button type="button" onClick={handleDeleteComment}>
DELETE COMMENT!
</button>
<button type="button" onClick={handleUpdateComment}>
UPDATE COMMENT!
</button>
<button
className="text-danger-600"
type="button"
onClick={() => {
handleDelete(profileId);
}}>
DELETE THIS PROFILE
</button>
<div>{JSON.stringify(data.data)}</div>
<div>{JSON.stringify(error)}</div>
</>
);
}
export default Test;

@ -1,17 +0,0 @@
import React from 'react';
import { trpc } from '~/utils/trpc';
function GenerateAnalysis() {
const analysisMutation = trpc.useMutation(['offers.analysis.generate']);
return (
<div>
{JSON.stringify(
analysisMutation.mutate({ profileId: 'cl9lwe9m902k5utskjs52wc0j' }),
)}
</div>
);
}
export default GenerateAnalysis;

@ -1,14 +0,0 @@
import React from 'react';
import { trpc } from '~/utils/trpc';
function GetAnalysis() {
const analysis = trpc.useQuery([
'offers.analysis.get',
{ profileId: 'cl9lwe9m902k5utskjs52wc0j' },
]);
return <div>{JSON.stringify(analysis.data)}</div>;
}
export default GetAnalysis;

@ -1,53 +0,0 @@
import React from 'react';
import { trpc } from '~/utils/trpc';
function Test() {
const data = trpc.useQuery([
'offers.list',
{
currency: 'SGD',
limit: 100,
location: 'Singapore, Singapore',
offset: 0,
sortBy: '-totalCompensation',
yoeCategory: 1,
},
]);
const deleteMutation = trpc.useMutation(['offers.profile.delete']);
const handleDelete = (id: string) => {
deleteMutation.mutate({ profileId: id, token: ' dadaadad' });
};
return (
<ul>
<li>
<b>{JSON.stringify(data.data?.paging)}</b>
</li>
<li>
<ul>
{data.data?.data.map((offer) => {
return (
<li key={offer.id}>
<button
className="text-danger-600"
type="button"
onClick={() => {
handleDelete(offer.profileId);
}}>
DELETE THIS PROFILE AND ALL ITS OFFERS
</button>
<div>{JSON.stringify(offer)}</div>
<br />
</li>
);
})}
</ul>
</li>
</ul>
);
}
export default Test;

@ -22,6 +22,7 @@ import type { QuestionAge } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants'; import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import { locationOptionToSlug } from '~/utils/questions/locationSlug';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates'; import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import { import {
useSearchParam, useSearchParam,
@ -33,20 +34,11 @@ import type { Location } from '~/types/questions.d';
import { SortType } from '~/types/questions.d'; import { SortType } from '~/types/questions.d';
import { SortOrder } from '~/types/questions.d'; import { SortOrder } from '~/types/questions.d';
function locationToSlug(value: Location & TypeaheadOption): string {
return [
value.countryId,
value.stateId,
value.cityId,
value.id,
value.label,
value.value,
].join('-');
}
export default function QuestionsBrowsePage() { export default function QuestionsBrowsePage() {
const router = useRouter(); const router = useRouter();
const [query, setQuery] = useState('');
const [ const [
selectedCompanySlugs, selectedCompanySlugs,
setSelectedCompanySlugs, setSelectedCompanySlugs,
@ -86,7 +78,7 @@ export default function QuestionsBrowsePage() {
useSearchParam('roles'); useSearchParam('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] = const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchParam<Location & TypeaheadOption>('locations', { useSearchParam<Location & TypeaheadOption>('locations', {
paramToString: locationToSlug, paramToString: locationOptionToSlug,
stringToParam: (param) => { stringToParam: (param) => {
const [countryId, stateId, cityId, id, label, value] = param.split('-'); const [countryId, stateId, cityId, id, label, value] = param.split('-');
return { cityId, countryId, id, label, stateId, value }; return { cityId, countryId, id, label, stateId, value };
@ -170,13 +162,14 @@ export default function QuestionsBrowsePage() {
const questionsInfiniteQuery = trpc.useInfiniteQuery( const questionsInfiniteQuery = trpc.useInfiniteQuery(
[ [
'questions.questions.getQuestionsByFilter', 'questions.questions.getQuestionsByFilterAndContent',
{ {
// TODO: Enable filtering by countryIds and stateIds // TODO: Enable filtering by countryIds and stateIds
cityIds: selectedLocations cityIds: selectedLocations
.map(({ cityId }) => cityId) .map(({ cityId }) => cityId)
.filter((id) => id !== undefined) as Array<string>, .filter((id) => id !== undefined) as Array<string>,
companyIds: selectedCompanySlugs.map((slug) => slug.split('_')[0]), companyIds: selectedCompanySlugs.map((slug) => slug.split('_')[0]),
content: query,
countryIds: [], countryIds: [],
endDate: today, endDate: today,
limit: 10, limit: 10,
@ -263,7 +256,7 @@ export default function QuestionsBrowsePage() {
pathname, pathname,
query: { query: {
companies: selectedCompanySlugs, companies: selectedCompanySlugs,
locations: selectedLocations.map(locationToSlug), locations: selectedLocations.map(locationOptionToSlug),
questionAge: selectedQuestionAge, questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes, questionTypes: selectedQuestionTypes,
roles: selectedRoles, roles: selectedRoles,
@ -351,7 +344,6 @@ export default function QuestionsBrowsePage() {
isLabelHidden={true} isLabelHidden={true}
placeholder="Search companies" placeholder="Search companies"
onSelect={(option) => { onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
onOptionChange({ onOptionChange({
...option, ...option,
checked: true, checked: true,
@ -392,7 +384,6 @@ export default function QuestionsBrowsePage() {
isLabelHidden={true} isLabelHidden={true}
placeholder="Search roles" placeholder="Search roles"
onSelect={(option) => { onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
onOptionChange({ onOptionChange({
...option, ...option,
checked: true, checked: true,
@ -453,7 +444,6 @@ export default function QuestionsBrowsePage() {
isLabelHidden={true} isLabelHidden={true}
placeholder="Search locations" placeholder="Search locations"
onSelect={(option) => { onSelect={(option) => {
// @ts-ignore TODO(offers): fix potentially empty value.
onOptionChange({ onOptionChange({
...option, ...option,
checked: true, checked: true,
@ -485,8 +475,8 @@ export default function QuestionsBrowsePage() {
</Head> </Head>
<main className="flex flex-1 flex-col items-stretch"> <main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1"> <div className="flex h-full flex-1">
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto"> <section className="min-h-0 flex-1 overflow-auto">
<div className="m-4 flex max-w-3xl flex-1 flex-col items-stretch justify-start gap-6"> <div className="my-4 mx-auto flex max-w-3xl flex-col items-stretch justify-start gap-6">
<ContributeQuestionCard <ContributeQuestionCard
onSubmit={(data) => { onSubmit={(data) => {
const { cityId, countryId, stateId } = data.location; const { cityId, countryId, stateId } = data.location;
@ -505,11 +495,15 @@ export default function QuestionsBrowsePage() {
<div className="flex flex-col items-stretch gap-4"> <div className="flex flex-col items-stretch gap-4">
<div className="sticky top-0 border-b border-slate-300 bg-slate-50 py-4"> <div className="sticky top-0 border-b border-slate-300 bg-slate-50 py-4">
<QuestionSearchBar <QuestionSearchBar
query={query}
sortOrderValue={sortOrder} sortOrderValue={sortOrder}
sortTypeValue={sortType} sortTypeValue={sortType}
onFilterOptionsToggle={() => { onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen); setFilterDrawerOpen(!filterDrawerOpen);
}} }}
onQueryChange={(newQuery) => {
setQuery(newQuery);
}}
onSortOrderChange={setSortOrder} onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType} onSortTypeChange={setSortType}
/> />

@ -10,13 +10,13 @@ export default function QuestionsHomePage() {
const router = useRouter(); const router = useRouter();
const handleLandingQuery = async (data: LandingQueryData) => { const handleLandingQuery = async (data: LandingQueryData) => {
const { company, location, questionType } = data; const { companySlug, location, questionType } = data;
// Go to browse page // Go to browse page
router.push({ router.push({
pathname: '/questions/browse', pathname: '/questions/browse',
query: { query: {
companies: [company], companies: [companySlug],
locations: [location], locations: [location],
questionTypes: [questionType], questionTypes: [questionType],
}, },

@ -22,6 +22,7 @@ import ResumeCommentsForm from '~/components/resumes/comments/ResumeCommentsForm
import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList'; import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList';
import ResumePdf from '~/components/resumes/ResumePdf'; import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText'; import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
import loginPageHref from '~/components/shared/loginPageHref';
import type { import type {
ExperienceFilter, ExperienceFilter,
@ -107,7 +108,7 @@ export default function ResumeReviewPage() {
const onStarButtonClick = () => { const onStarButtonClick = () => {
if (session?.user?.id == null) { if (session?.user?.id == null) {
router.push('/api/auth/signin'); router.push(loginPageHref());
return; return;
} }
@ -184,8 +185,8 @@ export default function ResumeReviewPage() {
<Button <Button
className="h-10 shadow-md" className="h-10 shadow-md"
display="block" display="block"
href="/api/auth/signin" href={loginPageHref()}
label="Sign in to join discussion" label="Log in to join discussion"
variant="primary" variant="primary"
/> />
); );

@ -24,6 +24,7 @@ import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill'; import ResumeFilterPill from '~/components/resumes/browse/ResumeFilterPill';
import ResumeListItems from '~/components/resumes/browse/ResumeListItems'; import ResumeListItems from '~/components/resumes/browse/ResumeListItems';
import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton'; import ResumeSignInButton from '~/components/resumes/shared/ResumeSignInButton';
import loginPageHref from '~/components/shared/loginPageHref';
import type { import type {
Filter, Filter,
@ -257,7 +258,7 @@ export default function ResumeHomePage() {
const onSubmitResume = () => { const onSubmitResume = () => {
if (sessionData === null) { if (sessionData === null) {
router.push('/api/auth/signin'); router.push(loginPageHref());
} else { } else {
router.push('/resumes/submit'); router.push('/resumes/submit');
} }

@ -21,6 +21,7 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics'; import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines'; import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import loginPageHref from '~/components/shared/loginPageHref';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys'; import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters'; import { EXPERIENCES, LOCATIONS, ROLES } from '~/utils/resumes/resumeFilters';
@ -129,7 +130,7 @@ export default function SubmitResumeForm({
// Route user to sign in if not logged in // Route user to sign in if not logged in
useEffect(() => { useEffect(() => {
if (status === 'unauthenticated') { if (status === 'unauthenticated') {
router.push('/api/auth/signin'); router.push(loginPageHref());
} }
}, [router, status]); }, [router, status]);

@ -425,8 +425,8 @@ export const offersProfileRouter = createRouter()
level: x.level, level: x.level,
location: { location: {
connect: { connect: {
id: x.cityId id: x.cityId,
} },
}, },
title: x.title, title: x.title,
totalCompensation: totalCompensation:
@ -481,9 +481,9 @@ export const offersProfileRouter = createRouter()
location: { location: {
connect: { connect: {
where: { where: {
id: x.cityId id: x.cityId,
} },
} },
}, },
title: x.title, title: x.title,
totalCompensation: totalCompensation:
@ -539,9 +539,9 @@ export const offersProfileRouter = createRouter()
location: { location: {
connect: { connect: {
where: { where: {
id: x.cityId id: x.cityId,
} },
} },
}, },
monthlySalary: monthlySalary:
x.monthlySalary != null x.monthlySalary != null
@ -595,9 +595,9 @@ export const offersProfileRouter = createRouter()
location: { location: {
connect: { connect: {
where: { where: {
id: x.cityId id: x.cityId,
} },
} },
}, },
monthlySalary: monthlySalary:
x.monthlySalary != null x.monthlySalary != null
@ -680,10 +680,8 @@ export const offersProfileRouter = createRouter()
jobType: x.jobType, jobType: x.jobType,
location: { location: {
connect: { connect: {
where: { id: x.cityId,
id: x.cityId },
}
}
}, },
monthYearReceived: x.monthYearReceived, monthYearReceived: x.monthYearReceived,
negotiationStrategy: x.negotiationStrategy, negotiationStrategy: x.negotiationStrategy,
@ -726,10 +724,8 @@ export const offersProfileRouter = createRouter()
jobType: x.jobType, jobType: x.jobType,
location: { location: {
connect: { connect: {
where: { id: x.cityId,
id: x.cityId },
}
}
}, },
monthYearReceived: x.monthYearReceived, monthYearReceived: x.monthYearReceived,
negotiationStrategy: x.negotiationStrategy, negotiationStrategy: x.negotiationStrategy,
@ -988,26 +984,10 @@ export const offersProfileRouter = createRouter()
}); });
if (exp.monthlySalary) { if (exp.monthlySalary) {
if (exp.monthlySalary.id) { await ctx.prisma.offersExperience.update({
await ctx.prisma.offersCurrency.update({ data: {
data: { monthlySalary: {
baseCurrency: baseCurrencyString, upsert: {
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
where: {
id: exp.monthlySalary.id,
},
});
} else {
await ctx.prisma.offersExperience.update({
data: {
monthlySalary: {
create: { create: {
baseCurrency: baseCurrencyString, baseCurrency: baseCurrencyString,
baseValue: await convert( baseValue: await convert(
@ -1018,36 +998,30 @@ export const offersProfileRouter = createRouter()
currency: exp.monthlySalary.currency, currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value, value: exp.monthlySalary.value,
}, },
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
}, },
}, },
where: { },
id: exp.id, where: {
}, id: exp.id,
}); },
} });
} }
if (exp.totalCompensation) { if (exp.totalCompensation) {
if (exp.totalCompensation.id) { await ctx.prisma.offersExperience.update({
await ctx.prisma.offersCurrency.update({ data: {
data: { totalCompensation: {
baseCurrency: baseCurrencyString, upsert: {
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
where: {
id: exp.totalCompensation.id,
},
});
} else {
await ctx.prisma.offersExperience.update({
data: {
totalCompensation: {
create: { create: {
baseCurrency: baseCurrencyString, baseCurrency: baseCurrencyString,
baseValue: await convert( baseValue: await convert(
@ -1058,13 +1032,23 @@ export const offersProfileRouter = createRouter()
currency: exp.totalCompensation.currency, currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value, value: exp.totalCompensation.value,
}, },
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
}, },
}, },
where: { },
id: exp.id, where: {
}, id: exp.id,
}); },
} });
} }
} else if (!exp.id) { } else if (!exp.id) {
// Create new experience // Create new experience
@ -1090,8 +1074,8 @@ export const offersProfileRouter = createRouter()
level: exp.level, level: exp.level,
location: { location: {
connect: { connect: {
id: exp.cityId id: exp.cityId,
} },
}, },
title: exp.title, title: exp.title,
totalCompensation: exp.totalCompensation totalCompensation: exp.totalCompensation
@ -1161,8 +1145,8 @@ export const offersProfileRouter = createRouter()
level: exp.level, level: exp.level,
location: { location: {
connect: { connect: {
id: exp.cityId id: exp.cityId,
} },
}, },
title: exp.title, title: exp.title,
totalCompensation: { totalCompensation: {
@ -1229,8 +1213,8 @@ export const offersProfileRouter = createRouter()
level: exp.level, level: exp.level,
location: { location: {
connect: { connect: {
id: exp.cityId id: exp.cityId,
} },
}, },
title: exp.title, title: exp.title,
}, },
@ -1272,8 +1256,8 @@ export const offersProfileRouter = createRouter()
level: exp.level, level: exp.level,
location: { location: {
connect: { connect: {
id: exp.cityId id: exp.cityId,
} },
}, },
title: exp.title, title: exp.title,
}, },
@ -1321,8 +1305,8 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType, jobType: exp.jobType,
location: { location: {
connect: { connect: {
id: exp.cityId id: exp.cityId,
} },
}, },
monthlySalary: { monthlySalary: {
create: { create: {
@ -1386,8 +1370,8 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType, jobType: exp.jobType,
location: { location: {
connect: { connect: {
id: exp.cityId id: exp.cityId,
} },
}, },
monthlySalary: { monthlySalary: {
create: { create: {
@ -1453,7 +1437,7 @@ export const offersProfileRouter = createRouter()
location: { location: {
connect: { connect: {
id: exp.cityId, id: exp.cityId,
} },
}, },
title: exp.title, title: exp.title,
}, },
@ -1493,8 +1477,8 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType, jobType: exp.jobType,
location: { location: {
connect: { connect: {
id: exp.cityId id: exp.cityId,
} },
}, },
title: exp.title, title: exp.title,
}, },
@ -1600,8 +1584,8 @@ export const offersProfileRouter = createRouter()
comments: offerToUpdate.comments, comments: offerToUpdate.comments,
company: { company: {
connect: { connect: {
id: offerToUpdate.companyId id: offerToUpdate.companyId,
} },
}, },
jobType: jobType:
offerToUpdate.jobType === JobType.FULLTIME offerToUpdate.jobType === JobType.FULLTIME
@ -1609,8 +1593,8 @@ export const offersProfileRouter = createRouter()
: JobType.INTERN, : JobType.INTERN,
location: { location: {
connect: { connect: {
id: offerToUpdate.cityId id: offerToUpdate.cityId,
} },
}, },
monthYearReceived: offerToUpdate.monthYearReceived, monthYearReceived: offerToUpdate.monthYearReceived,
negotiationStrategy: offerToUpdate.negotiationStrategy, negotiationStrategy: offerToUpdate.negotiationStrategy,
@ -1625,6 +1609,32 @@ export const offersProfileRouter = createRouter()
data: { data: {
internshipCycle: internshipCycle:
offerToUpdate.offersIntern.internshipCycle ?? undefined, offerToUpdate.offersIntern.internshipCycle ?? undefined,
monthlySalary: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersIntern.monthlySalary.value,
offerToUpdate.offersIntern.monthlySalary.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersIntern.monthlySalary.currency,
value: offerToUpdate.offersIntern.monthlySalary.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersIntern.monthlySalary.value,
offerToUpdate.offersIntern.monthlySalary.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersIntern.monthlySalary.currency,
value: offerToUpdate.offersIntern.monthlySalary.value,
},
},
},
startYear: offerToUpdate.offersIntern.startYear ?? undefined, startYear: offerToUpdate.offersIntern.startYear ?? undefined,
title: offerToUpdate.offersIntern.title, title: offerToUpdate.offersIntern.title,
}, },
@ -1632,21 +1642,6 @@ export const offersProfileRouter = createRouter()
id: offerToUpdate.offersIntern.id, id: offerToUpdate.offersIntern.id,
}, },
}); });
await ctx.prisma.offersCurrency.update({
data: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersIntern.monthlySalary.value,
offerToUpdate.offersIntern.monthlySalary.currency,
baseCurrencyString,
),
currency: offerToUpdate.offersIntern.monthlySalary.currency,
value: offerToUpdate.offersIntern.monthlySalary.value,
},
where: {
id: offerToUpdate.offersIntern.monthlySalary.id,
},
});
} }
if (offerToUpdate.offersFullTime?.totalCompensation != null) { if (offerToUpdate.offersFullTime?.totalCompensation != null) {
@ -1660,70 +1655,145 @@ export const offersProfileRouter = createRouter()
}, },
}); });
if (offerToUpdate.offersFullTime.baseSalary != null) { if (offerToUpdate.offersFullTime.baseSalary != null) {
await ctx.prisma.offersCurrency.update({ await ctx.prisma.offersFullTime.update({
data: { data: {
baseCurrency: baseCurrencyString, baseSalary: {
baseValue: await convert( upsert: {
offerToUpdate.offersFullTime.baseSalary.value, create: {
offerToUpdate.offersFullTime.baseSalary.currency, baseCurrency: baseCurrencyString,
baseCurrencyString, baseValue: await convert(
), offerToUpdate.offersFullTime.baseSalary.value,
currency: offerToUpdate.offersFullTime.baseSalary.currency, offerToUpdate.offersFullTime.baseSalary.currency,
value: offerToUpdate.offersFullTime.baseSalary.value, baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.baseSalary.currency,
value: offerToUpdate.offersFullTime.baseSalary.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.baseSalary.value,
offerToUpdate.offersFullTime.baseSalary.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.baseSalary.currency,
value: offerToUpdate.offersFullTime.baseSalary.value,
},
},
},
}, },
where: { where: {
id: offerToUpdate.offersFullTime.baseSalary.id, id: offerToUpdate.offersFullTime.id,
}, },
}); });
} }
if (offerToUpdate.offersFullTime.bonus != null) { if (offerToUpdate.offersFullTime.bonus != null) {
await ctx.prisma.offersCurrency.update({ await ctx.prisma.offersFullTime.update({
data: { data: {
baseCurrency: baseCurrencyString, bonus: {
baseValue: await convert( upsert: {
offerToUpdate.offersFullTime.bonus.value, create: {
offerToUpdate.offersFullTime.bonus.currency, baseCurrency: baseCurrencyString,
baseCurrencyString, baseValue: await convert(
), offerToUpdate.offersFullTime.bonus.value,
currency: offerToUpdate.offersFullTime.bonus.currency, offerToUpdate.offersFullTime.bonus.currency,
value: offerToUpdate.offersFullTime.bonus.value, baseCurrencyString,
),
currency: offerToUpdate.offersFullTime.bonus.currency,
value: offerToUpdate.offersFullTime.bonus.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.bonus.value,
offerToUpdate.offersFullTime.bonus.currency,
baseCurrencyString,
),
currency: offerToUpdate.offersFullTime.bonus.currency,
value: offerToUpdate.offersFullTime.bonus.value,
},
},
},
}, },
where: { where: {
id: offerToUpdate.offersFullTime.bonus.id, id: offerToUpdate.offersFullTime.id,
}, },
}); });
} }
if (offerToUpdate.offersFullTime.stocks != null) { if (offerToUpdate.offersFullTime.stocks != null) {
await ctx.prisma.offersCurrency.update({ await ctx.prisma.offersFullTime.update({
data: { data: {
baseCurrency: baseCurrencyString, stocks: {
baseValue: await convert( upsert: {
offerToUpdate.offersFullTime.stocks.value, create: {
offerToUpdate.offersFullTime.stocks.currency, baseCurrency: baseCurrencyString,
baseCurrencyString, baseValue: await convert(
), offerToUpdate.offersFullTime.stocks.value,
currency: offerToUpdate.offersFullTime.stocks.currency, offerToUpdate.offersFullTime.stocks.currency,
value: offerToUpdate.offersFullTime.stocks.value, baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.stocks.currency,
value: offerToUpdate.offersFullTime.stocks.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.stocks.value,
offerToUpdate.offersFullTime.stocks.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.stocks.currency,
value: offerToUpdate.offersFullTime.stocks.value,
},
},
},
}, },
where: { where: {
id: offerToUpdate.offersFullTime.stocks.id, id: offerToUpdate.offersFullTime.id,
}, },
}); });
} }
await ctx.prisma.offersCurrency.update({ await ctx.prisma.offersFullTime.update({
data: { data: {
baseCurrency: baseCurrencyString, totalCompensation: {
baseValue: await convert( upsert: {
offerToUpdate.offersFullTime.totalCompensation.value, create: {
offerToUpdate.offersFullTime.totalCompensation.currency, baseCurrency: baseCurrencyString,
baseCurrencyString, baseValue: await convert(
), offerToUpdate.offersFullTime.totalCompensation.value,
currency: offerToUpdate.offersFullTime.totalCompensation
offerToUpdate.offersFullTime.totalCompensation.currency, .currency,
value: offerToUpdate.offersFullTime.totalCompensation.value, baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.totalCompensation
.currency,
value:
offerToUpdate.offersFullTime.totalCompensation.value,
},
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.totalCompensation.value,
offerToUpdate.offersFullTime.totalCompensation
.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.totalCompensation
.currency,
value:
offerToUpdate.offersFullTime.totalCompensation.value,
},
},
},
}, },
where: { where: {
id: offerToUpdate.offersFullTime.totalCompensation.id, id: offerToUpdate.offersFullTime.id,
}, },
}); });
} }
@ -1750,8 +1820,8 @@ export const offersProfileRouter = createRouter()
jobType: offerToUpdate.jobType, jobType: offerToUpdate.jobType,
location: { location: {
connect: { connect: {
id: offerToUpdate.cityId id: offerToUpdate.cityId,
} },
}, },
monthYearReceived: offerToUpdate.monthYearReceived, monthYearReceived: offerToUpdate.monthYearReceived,
negotiationStrategy: offerToUpdate.negotiationStrategy, negotiationStrategy: offerToUpdate.negotiationStrategy,
@ -1808,8 +1878,8 @@ export const offersProfileRouter = createRouter()
jobType: offerToUpdate.jobType, jobType: offerToUpdate.jobType,
location: { location: {
connect: { connect: {
id: offerToUpdate.cityId id: offerToUpdate.cityId,
} },
}, },
monthYearReceived: offerToUpdate.monthYearReceived, monthYearReceived: offerToUpdate.monthYearReceived,
negotiationStrategy: offerToUpdate.negotiationStrategy, negotiationStrategy: offerToUpdate.negotiationStrategy,

@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { JobType } from '@prisma/client';
import { TRPCError } from '@trpc/server'; import { TRPCError } from '@trpc/server';
import { import {
@ -12,10 +13,7 @@ import { createValidationRegex } from '~/utils/offers/zodRegex';
import { createRouter } from '../context'; import { createRouter } from '../context';
const getOrder = (prefix: string) => { const getOrder = (prefix: string) => {
if (prefix === '+') { return prefix === '+' ? 'asc' : 'desc';
return 'asc';
}
return 'desc';
}; };
const sortingKeysMap = { const sortingKeysMap = {
@ -31,8 +29,10 @@ const yoeCategoryMap: Record<number, string> = {
3: 'Senior', 3: 'Senior',
}; };
const getYoeRange = (yoeCategory: number) => { const getYoeRange = (yoeCategory: number | null | undefined) => {
return yoeCategoryMap[yoeCategory] === 'Fresh Grad' return yoeCategory == null
? { maxYoe: 100, minYoe: 0 }
: yoeCategoryMap[yoeCategory] === 'Fresh Grad'
? { maxYoe: 2, minYoe: 0 } ? { maxYoe: 2, minYoe: 0 }
: yoeCategoryMap[yoeCategory] === 'Mid' : yoeCategoryMap[yoeCategory] === 'Mid'
? { maxYoe: 5, minYoe: 3 } ? { maxYoe: 5, minYoe: 3 }
@ -43,8 +43,8 @@ const getYoeRange = (yoeCategory: number) => {
export const offersRouter = createRouter().query('list', { export const offersRouter = createRouter().query('list', {
input: z.object({ input: z.object({
cityId: z.string(),
companyId: z.string().nullish(), companyId: z.string().nullish(),
countryId: z.string().nullish(),
currency: z.string().nullish(), currency: z.string().nullish(),
dateEnd: z.date().nullish(), dateEnd: z.date().nullish(),
dateStart: z.date().nullish(), dateStart: z.date().nullish(),
@ -57,14 +57,14 @@ export const offersRouter = createRouter().query('list', {
.regex(createValidationRegex(Object.keys(sortingKeysMap), '[+-]{1}')) .regex(createValidationRegex(Object.keys(sortingKeysMap), '[+-]{1}'))
.nullish(), .nullish(),
title: z.string().nullish(), title: z.string().nullish(),
yoeCategory: z.number().min(0).max(3), yoeCategory: z.number().min(0).max(3).nullish(),
yoeMax: z.number().max(100).nullish(), yoeMax: z.number().max(100).nullish(),
yoeMin: z.number().min(0).nullish(), yoeMin: z.number().min(0).nullish(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const yoeRange = getYoeRange(input.yoeCategory); const yoeRange = getYoeRange(input.yoeCategory);
const yoeMin = input.yoeMin ? input.yoeMin : yoeRange?.minYoe; const yoeMin = input.yoeMin != null ? input.yoeMin : yoeRange?.minYoe;
const yoeMax = input.yoeMax ? input.yoeMax : yoeRange?.maxYoe; const yoeMax = input.yoeMax != null ? input.yoeMax : yoeRange?.maxYoe;
if (!input.sortBy) { if (!input.sortBy) {
input.sortBy = '-' + sortingKeysMap.monthYearReceived; input.sortBy = '-' + sortingKeysMap.monthYearReceived;
@ -132,7 +132,14 @@ export const offersRouter = createRouter().query('list', {
where: { where: {
AND: [ AND: [
{ {
cityId: input.cityId.length === 0 ? undefined : input.cityId, location: {
state: {
countryId:
input.countryId != null && input.countryId.length !== 0
? input.countryId
: undefined,
},
},
}, },
{ {
offersIntern: { offersIntern: {
@ -142,7 +149,7 @@ export const offersRouter = createRouter().query('list', {
{ {
offersIntern: { offersIntern: {
title: title:
input.title && input.title.length !== 0 input.title != null && input.title.length !== 0
? input.title ? input.title
: undefined, : undefined,
}, },
@ -245,7 +252,14 @@ export const offersRouter = createRouter().query('list', {
where: { where: {
AND: [ AND: [
{ {
cityId: input.cityId.length === 0 ? undefined : input.cityId, location: {
state: {
countryId:
input.countryId != null && input.countryId.length !== 0
? input.countryId
: undefined,
},
},
}, },
{ {
offersIntern: { offersIntern: {
@ -260,7 +274,7 @@ export const offersRouter = createRouter().query('list', {
{ {
offersFullTime: { offersFullTime: {
title: title:
input.title && input.title.length !== 0 input.title != null && input.title.length !== 0
? input.title ? input.title
: undefined, : undefined,
}, },
@ -380,6 +394,7 @@ export const offersRouter = createRouter().query('list', {
numOfPages: Math.ceil(data.length / input.limit), numOfPages: Math.ceil(data.length / input.limit),
totalItems: data.length, totalItems: data.length,
}, },
!yoeRange ? JobType.INTERN : JobType.FULLTIME,
); );
}, },
}); });

@ -13,6 +13,7 @@ export const questionsQuestionRouter = createRouter()
input: z.object({ input: z.object({
cityIds: z.string().array(), cityIds: z.string().array(),
companyIds: z.string().array(), companyIds: z.string().array(),
content: z.string().optional(),
countryIds: z.string().array(), countryIds: z.string().array(),
cursor: z.string().nullish(), cursor: z.string().nullish(),
endDate: z.date().default(new Date()), endDate: z.date().default(new Date()),
@ -235,7 +236,8 @@ export const questionsQuestionRouter = createRouter()
SELECT id FROM "QuestionsQuestion" SELECT id FROM "QuestionsQuestion"
WHERE WHERE
to_tsvector("content") @@ to_tsquery('english', ${query}) to_tsvector("content") @@ to_tsquery('english', ${query})
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC; ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC
LIMIT 3;
`; `;
const relatedQuestionsIdArray = relatedQuestionsId.map( const relatedQuestionsIdArray = relatedQuestionsId.map(
@ -280,4 +282,183 @@ export const questionsQuestionRouter = createRouter()
return processedQuestionsData; return processedQuestionsData;
}, },
})
.query('getQuestionsByFilterAndContent', {
input: z.object({
cityIds: z.string().array(),
companyIds: z.string().array(),
content: z.string(),
countryIds: z.string().array(),
cursor: z.string().nullish(),
endDate: z.date().default(new Date()),
limit: z.number().min(1).default(50),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder),
sortType: z.nativeEnum(SortType),
startDate: z.date().optional(),
stateIds: z.string().array(),
}),
async resolve({ ctx, input }) {
const escapeChars = /[()|&:*!]/g;
const query = input.content
.replace(escapeChars, ' ')
.trim()
.split(/\s+/)
.join(' | ');
let relatedQuestionsId: Array<{ id: string }> = [];
if (input.content !== "") {
relatedQuestionsId = await ctx.prisma
.$queryRaw`
SELECT id FROM "QuestionsQuestion"
WHERE
to_tsvector("content") @@ to_tsquery('english', ${query})
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC
LIMIT 3;
`;
}
const relatedQuestionsIdArray = relatedQuestionsId.map(
(current) => current.id,
);
const { cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? [
{
upvotes: input.sortOrder,
},
{
id: input.sortOrder,
},
]
: [
{
lastSeenAt: input.sortOrder,
},
{
id: input.sortOrder,
},
];
const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor: cursor ? { id: cursor } : undefined,
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
city: true,
company: true,
country: true,
role: true,
seenAt: true,
state: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
id: input.content !== "" ? {
in: relatedQuestionsIdArray,
} : undefined,
...(input.questionTypes.length > 0
? {
questionType: {
in: input.questionTypes,
},
}
: {}),
encounters: {
some: {
seenAt: {
gte: input.startDate,
lte: input.endDate,
},
...(input.companyIds.length > 0
? {
company: {
id: {
in: input.companyIds,
},
},
}
: {}),
...(input.cityIds.length > 0
? {
city: {
id: {
in: input.cityIds,
},
},
}
: {}),
...(input.countryIds.length > 0
? {
country: {
id: {
in: input.countryIds,
},
},
}
: {}),
...(input.stateIds.length > 0
? {
state: {
id: {
in: input.stateIds,
},
},
}
: {}),
...(input.roles.length > 0
? {
role: {
in: input.roles,
},
}
: {}),
},
},
},
});
const processedQuestionsData = questionsData.map(
createQuestionWithAggregateData,
);
let nextCursor: typeof cursor | undefined = undefined;
if (questionsData.length > input.limit) {
const nextItem = questionsData.pop()!;
processedQuestionsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
nextCursor = nextIdCursor;
}
return {
data: processedQuestionsData,
nextCursor,
};
},
}); });

@ -65,11 +65,14 @@ export type SpecificYoe = {
}; };
export type DashboardOffer = { export type DashboardOffer = {
baseSalary?: Valuation;
bonus?: Valuation;
company: OffersCompany; company: OffersCompany;
id: string; id: string;
income: Valuation; income: Valuation;
monthYearReceived: Date; monthYearReceived: Date;
profileId: string; profileId: string;
stocks?: Valuation;
title: string; title: string;
totalYoe: number; totalYoe: number;
}; };
@ -123,6 +126,7 @@ export type User = {
export type GetOffersResponse = { export type GetOffersResponse = {
data: Array<DashboardOffer>; data: Array<DashboardOffer>;
jobType: JobType;
paging: Paging; paging: Paging;
}; };

@ -69,122 +69,120 @@ export const generateAnalysis = async (params: {
}) => { }) => {
const { ctx, input } = params; const { ctx, input } = params;
await ctx.prisma.offersAnalysis.deleteMany({ await ctx.prisma.offersAnalysis.deleteMany({
where: { where: {
profileId: input.profileId, profileId: input.profileId,
}, },
}); });
const offers = await ctx.prisma.offersOffer.findMany({ const offers = await ctx.prisma.offersOffer.findMany({
include: {
company: true,
location: {
include: { include: {
company: true, state: {
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
baseSalary: true,
bonus: true,
stocks: true,
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: { include: {
background: true, country: true,
}, },
}, },
}, },
orderBy: [ },
{ offersFullTime: {
offersFullTime: { include: {
totalCompensation: { baseSalary: true,
baseValue: 'desc', bonus: true,
}, stocks: true,
}, totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
orderBy: [
{
offersFullTime: {
totalCompensation: {
baseValue: 'desc',
}, },
{ },
offersIntern: { },
monthlySalary: { {
baseValue: 'desc', offersIntern: {
}, monthlySalary: {
}, baseValue: 'desc',
}, },
],
where: {
profileId: input.profileId,
}, },
}); },
],
where: {
profileId: input.profileId,
},
});
if (!offers || offers.length === 0) { if (!offers || offers.length === 0) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'No offers found on this profile', message: 'No offers found on this profile',
}); });
} }
const overallHighestOffer = offers[0]; const overallHighestOffer = offers[0];
if ( if (
!overallHighestOffer.profile.background || !overallHighestOffer.profile.background ||
overallHighestOffer.profile.background.totalYoe == null overallHighestOffer.profile.background.totalYoe == null
) { ) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
message: 'YOE not found', message: 'YOE not found',
}); });
} }
const yoe = overallHighestOffer.profile.background.totalYoe as number; const yoe = overallHighestOffer.profile.background.totalYoe as number;
const monthYearReceived = new Date(overallHighestOffer.monthYearReceived); const monthYearReceived = new Date(overallHighestOffer.monthYearReceived);
monthYearReceived.setFullYear(monthYearReceived.getFullYear() - 1); monthYearReceived.setFullYear(monthYearReceived.getFullYear() - 1);
let similarOffers = await ctx.prisma.offersOffer.findMany({ let similarOffers = await ctx.prisma.offersOffer.findMany({
include: {
company: true,
location: {
include: { include: {
company: true, state: {
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: { include: {
monthlySalary: true, country: true,
}, },
}, },
profile: { },
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: {
include: { include: {
background: { experiences: {
include: { include: {
experiences: { company: true,
location: {
include: { include: {
company: true, state: {
location: {
include: { include: {
state: { country: true,
include: {
country: true,
},
},
}, },
}, },
}, },
@ -194,264 +192,214 @@ export const generateAnalysis = async (params: {
}, },
}, },
}, },
orderBy: [ },
{ },
offersFullTime: { orderBy: [
totalCompensation: { {
baseValue: 'desc', offersFullTime: {
}, totalCompensation: {
}, baseValue: 'desc',
}, },
{ },
offersIntern: { },
monthlySalary: { {
baseValue: 'desc', offersIntern: {
}, monthlySalary: {
}, baseValue: 'desc',
}, },
], },
where: { },
AND: [ ],
{ where: {
location: overallHighestOffer.location, AND: [
}, {
location: overallHighestOffer.location,
},
{
monthYearReceived: {
gte: monthYearReceived,
},
},
{
OR: [
{ {
monthYearReceived: { offersFullTime: {
gte: monthYearReceived, title: overallHighestOffer.offersFullTime?.title,
},
offersIntern: {
title: overallHighestOffer.offersIntern?.title,
}, },
}, },
{ ],
OR: [ },
{
profile: {
background: {
AND: [
{ {
offersFullTime: { totalYoe: {
title: overallHighestOffer.offersFullTime?.title, gte: Math.max(yoe - 1, 0),
}, lte: yoe + 1,
offersIntern: {
title: overallHighestOffer.offersIntern?.title,
}, },
}, },
], ],
}, },
{ },
profile: {
background: {
AND: [
{
totalYoe: {
gte: Math.max(yoe - 1, 0),
lte: yoe + 1,
},
},
],
},
},
},
],
}, },
}); ],
},
// COMPANY ANALYSIS });
const companyMap = new Map<string, Offer>();
offers.forEach((offer) => {
if (companyMap.get(offer.companyId) == null) {
companyMap.set(offer.companyId, offer);
}
});
const companyAnalysis = Array.from(companyMap.values()).map(
(companyOffer) => {
// TODO: Refactor calculating analysis into a function
let similarCompanyOffers = similarOffers.filter(
(offer) => offer.companyId === companyOffer.companyId,
);
const companyIndex = searchOfferPercentile(
companyOffer,
similarCompanyOffers,
);
const companyPercentile =
similarCompanyOffers.length <= 1
? 100
: 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
// Get top offers (excluding user's offer) // COMPANY ANALYSIS
similarCompanyOffers = similarCompanyOffers.filter( const companyMap = new Map<string, Offer>();
(offer) => offer.id !== companyOffer.id, offers.forEach((offer) => {
); if (companyMap.get(offer.companyId) == null) {
companyMap.set(offer.companyId, offer);
const noOfSimilarCompanyOffers = similarCompanyOffers.length; }
const similarCompanyOffers90PercentileIndex = Math.ceil( });
noOfSimilarCompanyOffers * 0.1,
);
const topPercentileCompanyOffers =
noOfSimilarCompanyOffers > 2
? similarCompanyOffers.slice(
similarCompanyOffers90PercentileIndex,
similarCompanyOffers90PercentileIndex + 2,
)
: similarCompanyOffers;
return { const companyAnalysis = Array.from(companyMap.values()).map(
companyName: companyOffer.company.name, (companyOffer) => {
noOfSimilarOffers: noOfSimilarCompanyOffers, // TODO: Refactor calculating analysis into a function
percentile: companyPercentile, let similarCompanyOffers = similarOffers.filter(
topSimilarOffers: topPercentileCompanyOffers, (offer) => offer.companyId === companyOffer.companyId,
};
},
); );
// OVERALL ANALYSIS const companyIndex = searchOfferPercentile(
const overallIndex = searchOfferPercentile( companyOffer,
overallHighestOffer, similarCompanyOffers,
similarOffers,
); );
const overallPercentile = const companyPercentile =
similarOffers.length <= 1 similarCompanyOffers.length <= 1
? 100 ? 100
: 100 - (100 * overallIndex) / (similarOffers.length - 1); : 100 - (100 * companyIndex) / (similarCompanyOffers.length - 1);
similarOffers = similarOffers.filter( // Get top offers (excluding user's offer)
(offer) => offer.id !== overallHighestOffer.id, similarCompanyOffers = similarCompanyOffers.filter(
(offer) => offer.id !== companyOffer.id,
); );
const noOfSimilarOffers = similarOffers.length; const noOfSimilarCompanyOffers = similarCompanyOffers.length;
const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1); const similarCompanyOffers90PercentileIndex = Math.ceil(
const topPercentileOffers = noOfSimilarCompanyOffers * 0.1,
noOfSimilarOffers > 2 );
? similarOffers.slice( const topPercentileCompanyOffers =
similarOffers90PercentileIndex, noOfSimilarCompanyOffers > 2
similarOffers90PercentileIndex + 2, ? similarCompanyOffers.slice(
similarCompanyOffers90PercentileIndex,
similarCompanyOffers90PercentileIndex + 2,
) )
: similarOffers; : similarCompanyOffers;
const analysis = await ctx.prisma.offersAnalysis.create({ return {
data: { companyName: companyOffer.company.name,
companyAnalysis: { noOfSimilarOffers: noOfSimilarCompanyOffers,
create: companyAnalysis.map((analysisUnit) => { percentile: companyPercentile,
return { topSimilarOffers: topPercentileCompanyOffers,
companyName: analysisUnit.companyName, };
noOfSimilarOffers: analysisUnit.noOfSimilarOffers, },
percentile: analysisUnit.percentile, );
topSimilarOffers: {
connect: analysisUnit.topSimilarOffers.map((offer) => { // OVERALL ANALYSIS
return { id: offer.id }; const overallIndex = searchOfferPercentile(
}), overallHighestOffer,
}, similarOffers,
}; );
}), const overallPercentile =
}, similarOffers.length <= 1
overallAnalysis: { ? 100
create: { : 100 - (100 * overallIndex) / (similarOffers.length - 1);
companyName: overallHighestOffer.company.name,
noOfSimilarOffers, similarOffers = similarOffers.filter(
percentile: overallPercentile, (offer) => offer.id !== overallHighestOffer.id,
topSimilarOffers: { );
connect: topPercentileOffers.map((offer) => {
return { id: offer.id }; const noOfSimilarOffers = similarOffers.length;
}), const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1);
}, const topPercentileOffers =
}, noOfSimilarOffers > 2
}, ? similarOffers.slice(
overallHighestOffer: { similarOffers90PercentileIndex,
connect: { similarOffers90PercentileIndex + 2,
id: overallHighestOffer.id, )
}, : similarOffers;
},
profile: { const analysis = await ctx.prisma.offersAnalysis.create({
connect: { data: {
id: input.profileId, companyAnalysis: {
create: companyAnalysis.map((analysisUnit) => {
return {
companyName: analysisUnit.companyName,
noOfSimilarOffers: analysisUnit.noOfSimilarOffers,
percentile: analysisUnit.percentile,
topSimilarOffers: {
connect: analysisUnit.topSimilarOffers.map((offer) => {
return { id: offer.id };
}),
}, },
};
}),
},
overallAnalysis: {
create: {
companyName: overallHighestOffer.company.name,
noOfSimilarOffers,
percentile: overallPercentile,
topSimilarOffers: {
connect: topPercentileOffers.map((offer) => {
return { id: offer.id };
}),
}, },
}, },
},
overallHighestOffer: {
connect: {
id: overallHighestOffer.id,
},
},
profile: {
connect: {
id: input.profileId,
},
},
},
include: {
companyAnalysis: {
include: { include: {
companyAnalysis: { topSimilarOffers: {
include: { include: {
topSimilarOffers: { company: true,
location: {
include: { include: {
company: true, state: {
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: { include: {
background: { country: true,
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
}, },
}, },
}, },
}, },
}, offersFullTime: {
},
overallAnalysis: {
include: {
topSimilarOffers: {
include: { include: {
company: true, totalCompensation: true,
location: { },
include: { },
state: { offersIntern: {
include: { include: {
country: true, monthlySalary: true,
}, },
}, },
}, profile: {
}, include: {
offersFullTime: { background: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: { include: {
background: { experiences: {
include: { include: {
experiences: { company: true,
location: {
include: { include: {
company: true, state: {
location: {
include: { include: {
state: { country: true,
include: {
country: true,
},
},
}, },
}, },
}, },
@ -464,7 +412,11 @@ export const generateAnalysis = async (params: {
}, },
}, },
}, },
overallHighestOffer: { },
},
overallAnalysis: {
include: {
topSimilarOffers: {
include: { include: {
company: true, company: true,
location: { location: {
@ -488,13 +440,61 @@ export const generateAnalysis = async (params: {
}, },
profile: { profile: {
include: { include: {
background: true, background: {
include: {
experiences: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
},
},
},
},
}, },
}, },
}, },
}, },
}, },
}); },
overallHighestOffer: {
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
totalCompensation: true,
},
},
offersIntern: {
include: {
monthlySalary: true,
},
},
profile: {
include: {
background: true,
},
},
},
},
},
});
return profileAnalysisDtoMapper(analysis); return profileAnalysisDtoMapper(analysis);
}; };

@ -9,19 +9,23 @@ export function timeSinceNow(date: Date | number | string) {
let interval = seconds / 31536000; let interval = seconds / 31536000;
if (interval > 1) { if (interval > 1) {
return `${Math.floor(interval)} years`; const time: number = Math.floor(interval);
return time === 1 ? `${time} year` : `${time} years`;
} }
interval = seconds / 2592000; interval = seconds / 2592000;
if (interval > 1) { if (interval > 1) {
return `${Math.floor(interval)} months`; const time: number = Math.floor(interval);
return time === 1 ? `${time} month` : `${time} months`;
} }
interval = seconds / 86400; interval = seconds / 86400;
if (interval > 1) { if (interval > 1) {
return `${Math.floor(interval)} days`; const time: number = Math.floor(interval);
return time === 1 ? `${time} day` : `${time} days`;
} }
interval = seconds / 3600; interval = seconds / 3600;
if (interval > 1) { if (interval > 1) {
return `${Math.floor(interval)} hours`; const time: number = Math.floor(interval);
return time === 1 ? `${time} hour` : `${time} hours`;
} }
interval = seconds / 60; interval = seconds / 60;
if (interval > 1) { if (interval > 1) {

@ -0,0 +1,18 @@
import type {
FilterChoice,
FilterOption,
} from '~/components/questions/filter/FilterSection';
export function companyOptionToSlug(option: FilterChoice): string {
return `${option.id}_${option.label}`;
}
export function slugToCompanyOption(slug: string): FilterOption {
const [id, label] = slug.split('_');
return {
checked: true,
id,
label,
value: id,
};
}

@ -0,0 +1,16 @@
import type { TypeaheadOption } from '@tih/ui';
import type { Location } from '~/types/questions';
export function locationOptionToSlug(
value: Location & TypeaheadOption,
): string {
return [
value.countryId,
value.stateId,
value.cityId,
value.id,
value.label,
value.value,
].join('-');
}

@ -19,7 +19,8 @@ export default function Banner({ children, size = 'md', onHide }: Props) {
size === 'xs' && 'text-xs', size === 'xs' && 'text-xs',
)}> )}>
<div className="mx-auto max-w-7xl py-2 px-3 sm:px-6 lg:px-8"> <div className="mx-auto max-w-7xl py-2 px-3 sm:px-6 lg:px-8">
<div className="pr-16 sm:px-16 sm:text-center"> <div
className={clsx('text-center sm:px-16', onHide != null && 'pr-16')}>
<p className="font-medium text-white">{children}</p> <p className="font-medium text-white">{children}</p>
</div> </div>
{onHide != null && ( {onHide != null && (

@ -8,7 +8,7 @@ export default function HorizontalDivider({ className }: Props) {
return ( return (
<hr <hr
aria-hidden={true} aria-hidden={true}
className={clsx('my-2 h-0 border-t border-slate-100', className)} className={clsx('my-2 h-0 border-t border-slate-200', className)}
/> />
); );
} }

@ -28,7 +28,7 @@ export default function Tabs<T>({ label, tabs, value, onChange }: Props<T>) {
children: tab.label, children: tab.label,
className: clsx( className: clsx(
isSelected isSelected
? 'bg-indigo-100 text-indigo-700' ? 'bg-primary-100 text-primary-700'
: 'hover:bg-slate-100 text-slate-500 hover:text-slate-700', : 'hover:bg-slate-100 text-slate-500 hover:text-slate-700',
'px-3 py-2 font-medium text-sm rounded-md', 'px-3 py-2 font-medium text-sm rounded-md',
), ),

@ -29,17 +29,25 @@ type Props = Readonly<{
isLabelHidden?: boolean; isLabelHidden?: boolean;
label: string; label: string;
noResultsMessage?: string; noResultsMessage?: string;
nullable?: boolean;
onQueryChange: ( onQueryChange: (
value: string, value: string,
event: React.ChangeEvent<HTMLInputElement>, event: React.ChangeEvent<HTMLInputElement>,
) => void; ) => void;
onSelect: (option: TypeaheadOption | null) => void;
options: ReadonlyArray<TypeaheadOption>; options: ReadonlyArray<TypeaheadOption>;
textSize?: TypeaheadTextSize; textSize?: TypeaheadTextSize;
value?: TypeaheadOption | null; value?: TypeaheadOption | null;
}> & }> &
Readonly<Attributes>; Readonly<Attributes> &
(
| {
nullable: true;
onSelect: (option: TypeaheadOption | null) => void;
}
| {
nullable?: false;
onSelect: (option: TypeaheadOption) => void;
}
);
type State = 'error' | 'normal'; type State = 'error' | 'normal';

Loading…
Cancel
Save