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

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

@ -35,6 +35,7 @@ export default function ProductNavigation({
<Link
className="hover:text-primary-700 flex items-center gap-2 text-base font-medium"
href={titleHref}>
<div>
{titleHref !== '/' &&
(logo ?? (
<img
@ -43,6 +44,7 @@ export default function ProductNavigation({
src="/logo.svg"
/>
))}
</div>
{title}
</Link>
<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;
step?: number;
};
}>;
type BreadcrumbsProps = Readonly<{
currentStep: number;
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) {
return (
<div className="flex space-x-1">
<nav aria-label="Submit offer stages" className="inline-flex">
<ol className="mx-auto flex w-full space-x-2 sm:space-x-4" role="list">
{steps.map(({ label, step }, index) => (
<div key={label} className="flex space-x-1">
{step === currentStep
? getPrimaryText(label)
: step !== undefined
? getTextWithLink(label, () => setStep(step))
: getSlateText(label)}
{index !== steps.length - 1 && getSlateText('>')}
</div>
<li key={step} className="flex items-center">
{index > 0 && (
<ChevronRightIcon
aria-hidden="true"
className="h-5 w-5 flex-shrink-0 text-slate-400"
/>
)}
<button
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>
))}
</div>
</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 { HorizontalDivider } from '@tih/ui';
import type { JobTitleType } 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';
type Props = Readonly<{
disableTopDivider?: boolean;
offer: UserProfileOffer;
}>;
export default function DashboardProfileCard({
disableTopDivider,
offer: {
company,
income,
@ -27,29 +29,53 @@ export default function DashboardProfileCard({
},
}: Props) {
return (
<>
{!disableTopDivider && <HorizontalDivider />}
<div className="px-4 py-4 sm:px-6">
<div className="flex items-end justify-between">
<div className="col-span-1 row-span-3">
<p className="font-bold">
<h4 className="font-medium">
{getLabelForJobTitleType(title as JobTitleType)}
</p>
<p>
{location
? `Company: ${company.name}, ${location.cityName}`
: `Company: ${company.name}`}
</p>
{level && <p>Level: {level}</p>}
</h4>
<div className="mt-1 flex flex-col sm:mt-0 sm:flex-row sm:flex-wrap sm:space-x-4">
{company?.name && (
<div className="mt-2 flex items-center text-sm text-gray-500">
<BuildingOfficeIcon
aria-hidden="true"
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 className="col-span-1 row-span-3">
<p className="text-end">{formatDate(monthYearReceived)}</p>
<p className="text-end text-xl">
<p className="text-end text-lg font-medium leading-6 text-slate-900">
{jobType === JobType.FULLTIME
? `${convertMoneyToString(income)} / year`
: `${convertMoneyToString(income)} / month`}
</p>
<p className="text-end text-sm text-slate-500">
{formatDate(monthYearReceived)}
</p>
</div>
</div>
</div>
</>
);
}

@ -1,5 +1,6 @@
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 { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -43,30 +44,34 @@ export default function DashboardProfileCard({
);
function handleRemoveProfile() {
// TODO(offers): Confirm before removal.
removeSavedProfileMutation.mutate({ profileId: id });
}
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 */}
<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="flex items-center gap-x-5">
<div>
<div className="border-b border-slate-200 bg-white px-4 py-5 sm:px-6">
<div className="-ml-4 -mt-4 flex flex-wrap items-center justify-between sm:flex-nowrap">
<div className="ml-4 mt-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<ProfilePhotoHolder size="sm" />
</div>
<div className="col-span-10">
<p className="text-xl font-bold">{profileName}</p>
<div className="flex flex-row">
<div className="ml-4">
<h2 className="text-lg font-medium leading-6 text-slate-900">
{profileName}
</h2>
<p className="text-sm text-slate-500">
<span>Created at {formatDate(createdAt)}</span>
</p>
</div>
</div>
</div>
<div className="flex self-start">
<div className="ml-4 mt-4 flex flex-shrink-0">
<Button
disabled={removeSavedProfileMutation.isLoading}
icon={XMarkIcon}
icon={BookmarkSlashIcon}
isLabelHidden={true}
label="Remove Profile"
size="md"
@ -75,22 +80,16 @@ export default function DashboardProfileCard({
/>
</div>
</div>
{/* Offers */}
<div>
{offers.map((offer: UserProfileOffer, index) =>
index === 0 ? (
<DashboardOfferCard
key={offer.id}
disableTopDivider={true}
offer={offer}
/>
) : (
<DashboardOfferCard key={offer.id} offer={offer} />
),
)}
</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
disabled={removeSavedProfileMutation.isLoading}
icon={ArrowRightIcon}

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

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

@ -1,7 +1,8 @@
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
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 { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
@ -73,18 +74,24 @@ export default function OffersProfileSave({
};
return (
<div className="flex w-full justify-center">
<div className="flex w-full justify-center pb-10">
<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
</h5>
<p className="mb-2 text-slate-900">We value your privacy.</p>
<p className="mb-5 text-slate-900">
To keep you offer profile strictly anonymous, only people who have the
link below can edit it.
</h2>
<p className="mt-4 text-xl leading-8 text-slate-500">
We value your privacy
</p>
<div className="mb-20 grid grid-cols-12 gap-4">
<div className="col-span-11">
<div className="mt-6 max-w-md text-slate-500">
<div className="bg-info-50 rounded-lg p-6">
<p className="sm:tex-base text-sm">
To keep your offer profile strictly anonymous, it is not linked to
your user account. Only people who have the link below can edit
it. If you want to edit the profile in future, store the link
somewhere.
</p>
<div className="mt-4 flex gap-4">
<div className="grow">
<TextInput
disabled={true}
isLabelHidden={true}
@ -96,7 +103,7 @@ export default function OffersProfileSave({
icon={DocumentDuplicateIcon}
isLabelHidden={true}
label="Copy"
variant="primary"
variant="info"
onClick={() => {
copyProfileLink(profileId, token);
showToast({
@ -111,22 +118,25 @@ export default function OffersProfileSave({
}}
/>
</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.
</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="mb-20">
<div className="mt-6">
<Button
disabled={isSavedQuery.isLoading || isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon}
icon={isSaved ? BookmarkSolidIcon : BookmarkOutlineIcon}
isLoading={saveMutation.isLoading || isSavedQuery.isLoading}
label={isSaved ? 'Saved to user profile' : 'Save to user profile'}
variant="primary"
label={isSaved ? 'Added to account' : 'Add to your account'}
size="sm"
variant="secondary"
onClick={handleSave}
/>
</div>
</div>
</div>
</div>
);
}

@ -15,10 +15,12 @@ export default function OffersSubmissionAnalysis({
return (
<div className="mb-8">
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result
Offer Analysis
</h5>
{!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 && (
<OfferAnalysis

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

@ -21,6 +21,7 @@ import {
} from '~/utils/offers/currency/CurrencyEnum';
import FormRadioList from '../../forms/FormRadioList';
import FormSection from '../../forms/FormSection';
import FormSelect from '../../forms/FormSelect';
import FormTextInput from '../../forms/FormTextInput';
@ -29,14 +30,10 @@ function YoeSection() {
background: BackgroundPostData;
}>();
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">
<div className="mb-2 grid grid-cols-3 space-x-3">
return (
<FormSection title="Years of Experience (YOE)">
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<FormTextInput
errorMessage={backgroundFields?.totalYoe?.message}
label="Total YOE"
@ -51,7 +48,8 @@ function YoeSection() {
/>
</div>
<Collapsible label="Add specific YOEs by domain">
<div className="mb-5 grid grid-cols-2 space-x-3">
<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
errorMessage={backgroundFields?.specificYoes?.[0]?.yoe?.message}
label="Specific YOE 1"
@ -63,11 +61,11 @@ function YoeSection() {
/>
<FormTextInput
label="Specific Domain 1"
placeholder="e.g. Frontend"
placeholder="e.g. Front End"
{...register(`background.specificYoes.0.domain`)}
/>
</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
errorMessage={backgroundFields?.specificYoes?.[1]?.yoe?.message}
label="Specific YOE 2"
@ -79,13 +77,13 @@ function YoeSection() {
/>
<FormTextInput
label="Specific Domain 2"
placeholder="e.g. Backend"
placeholder="e.g. Back End"
{...register(`background.specificYoes.1.domain`)}
/>
</div>
</Collapsible>
</div>
</>
</Collapsible>
</FormSection>
);
}
@ -113,8 +111,7 @@ function FullTimeJobFields() {
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead
value={{
id: watchJobTitle,
@ -127,8 +124,6 @@ function FullTimeJobFields() {
}
}}
/>
</div>
<div>
<CompaniesTypeahead
value={{
id: watchCompanyId,
@ -143,8 +138,7 @@ function FullTimeJobFields() {
}}
/>
</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
endAddOn={
<FormSelect
@ -172,7 +166,7 @@ function FullTimeJobFields() {
/>
</div>
<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
label="Level"
placeholder="e.g. L4, Junior"
@ -195,8 +189,6 @@ function FullTimeJobFields() {
}
}}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
errorMessage={experiencesField?.durationInMonths?.message}
label="Duration (months)"
@ -236,8 +228,7 @@ function InternshipJobFields() {
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead
value={{
id: watchJobTitle,
@ -250,8 +241,6 @@ function InternshipJobFields() {
}
}}
/>
</div>
<div>
<CompaniesTypeahead
value={{
id: watchCompanyId,
@ -266,8 +255,6 @@ function InternshipJobFields() {
}}
/>
</div>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
<FormTextInput
endAddOn={
<FormSelect
@ -291,9 +278,7 @@ function InternshipJobFields() {
valueAsNumber: true,
})}
/>
</div>
<Collapsible label="Add more details">
<div className="mb-5 grid grid-cols-2 space-x-3">
<CitiesTypeahead
label="Location"
value={{
@ -311,7 +296,6 @@ function InternshipJobFields() {
}
}}
/>
</div>
</Collapsible>
</>
);
@ -324,12 +308,7 @@ function CurrentJobSection() {
});
return (
<>
<h6 className="mb-2 text-left text-xl font-medium text-slate-400">
Current / Previous Job
</h6>
<div className="mb-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5">
<FormSection title="Current / Previous Job">
<FormRadioList
defaultValue={watchJobType}
isLabelHidden={true}
@ -347,26 +326,20 @@ function CurrentJobSection() {
value={JobType.INTERN}
/>
</FormRadioList>
</div>
{watchJobType === JobType.FULLTIME ? (
<FullTimeJobFields />
) : (
<InternshipJobFields />
)}
</div>
</>
</FormSection>
);
}
function EducationSection() {
const { register } = useFormContext();
return (
<>
<h6 className="mb-2 text-left text-xl font-medium text-slate-400">
Education
</h6>
<div className="mb-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSection title="Education">
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<FormSelect
display="block"
label="Education Level"
@ -383,26 +356,23 @@ function EducationSection() {
/>
</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>
</>
</FormSection>
);
}
export default function BackgroundForm() {
return (
<div>
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
<div className="space-y-6">
<h2 className="mb-8 text-2xl font-bold text-slate-900 sm:text-center sm:text-4xl">
Help us better gauge your offers
</h5>
<div>
</h2>
<div className="space-y-8 rounded-lg border border-slate-200 p-6 sm:space-y-16 sm:p-8">
<YoeSection />
<CurrentJobSection />
<EducationSection />

@ -10,7 +10,7 @@ import { useFieldArray } from 'react-hook-form';
import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline';
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 CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
@ -29,9 +29,11 @@ import {
yearOptions,
} from '../../constants';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormSection from '../../forms/FormSection';
import FormSelect from '../../forms/FormSelect';
import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../../forms/FormTextInput';
import JobTypeTabs from '../../JobTypeTabs';
import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../../types';
import {
@ -82,9 +84,9 @@ function FullTimeOfferDetailsForm({
}, [watchCurrency, index, setValue]);
return (
<div className="my-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<div className="space-y-8 rounded-lg border border-slate-200 p-6 sm:space-y-16 sm:p-8">
<FormSection title="Company & Title Information">
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<JobTitlesTypeahead
required={true}
value={{
@ -98,7 +100,6 @@ function FullTimeOfferDetailsForm({
}
}}
/>
</div>
<FormTextInput
errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level"
@ -109,7 +110,7 @@ function FullTimeOfferDetailsForm({
})}
/>
</div>
<div className="mb-5 flex grid grid-cols-2 space-x-3">
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<CompaniesTypeahead
required={true}
value={{
@ -143,7 +144,9 @@ function FullTimeOfferDetailsForm({
}}
/>
</div>
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3">
</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}
@ -152,8 +155,6 @@ function FullTimeOfferDetailsForm({
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5">
<FormTextInput
endAddOn={
<FormSelect
@ -190,7 +191,7 @@ function FullTimeOfferDetailsForm({
)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
<FormTextInput
endAddOn={
<FormSelect
@ -205,7 +206,9 @@ function FullTimeOfferDetailsForm({
/>
}
endAddOnType="element"
errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message}
errorMessage={
offerFields?.offersFullTime?.baseSalary?.value?.message
}
label="Base Salary (Annual)"
placeholder="0"
startAddOn="$"
@ -239,8 +242,6 @@ function FullTimeOfferDetailsForm({
valueAsNumber: true,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
endAddOn={
<FormSelect
@ -265,31 +266,35 @@ function FullTimeOfferDetailsForm({
})}
/>
</div>
<div className="mb-5">
</FormSection>
<FormSection title="Additional Information">
<FormTextArea
label="Negotiation Strategy / Interview Performance"
placeholder="e.g. Did well in the behavioral interview / Used competing offers to negotiate for a higher salary"
{...register(`offers.${index}.negotiationStrategy`)}
/>
</div>
<div className="mb-5">
<FormTextArea
label="Comments"
placeholder="e.g. Benefits offered by the company"
{...register(`offers.${index}.comments`)}
/>
</div>
<div className="flex justify-end">
{index > 0 && (
<div className="space-y-4 sm:space-y-6">
<HorizontalDivider />
<div className="flex justify-end">
<Button
icon={TrashIcon}
label="Delete"
variant="secondary"
onClick={() => remove(index)}
variant="tertiary"
onClick={() => {
remove(index);
}}
/>
)}
</div>
</div>
)}
</FormSection>
</div>
);
}
@ -324,9 +329,8 @@ function InternshipOfferDetailsForm({
});
return (
<div className="my-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<div className="space-y-8 rounded-lg border border-slate-200 p-6 sm:space-y-16 sm:p-8">
<FormSection title="Company & Title Information">
<JobTitlesTypeahead
required={true}
value={{
@ -340,10 +344,8 @@ function InternshipOfferDetailsForm({
}
}}
/>
</div>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<div>
<div className="grid grid-cols-1 gap-y-4 sm:grid-cols-2 sm:gap-6">
<CompaniesTypeahead
required={true}
value={{
@ -358,7 +360,6 @@ function InternshipOfferDetailsForm({
}
}}
/>
</div>
<CitiesTypeahead
label="Location"
required={true}
@ -378,7 +379,7 @@ function InternshipOfferDetailsForm({
}}
/>
</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">
<FormSelect
display="block"
errorMessage={offerFields?.offersIntern?.internshipCycle?.message}
@ -403,7 +404,9 @@ function InternshipOfferDetailsForm({
})}
/>
</div>
<div className="mb-5">
</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}
@ -412,8 +415,6 @@ function InternshipOfferDetailsForm({
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5">
<FormTextInput
endAddOn={
<FormSelect
@ -447,33 +448,35 @@ function InternshipOfferDetailsForm({
})}
/>
</div>
<div className="mb-5">
</FormSection>
<FormSection title="Additional Information">
<FormTextArea
label="Negotiation Strategy / Interview Performance"
placeholder="e.g. Did well in the behavioral interview. Used competing offers to negotiate for a higher salary."
{...register(`offers.${index}.negotiationStrategy`)}
/>
</div>
<div className="mb-5">
<FormTextArea
label="Comments"
placeholder="e.g. Encountered similar questions using the Technical Interview Handbook."
{...register(`offers.${index}.comments`)}
/>
</div>
<div className="flex justify-end">
</FormSection>
{index > 0 && (
<div className="space-y-4 sm:space-y-6">
<HorizontalDivider />
<div className="flex justify-end">
<Button
icon={TrashIcon}
label="Delete"
variant="secondary"
variant="tertiary"
onClick={() => {
remove(index);
}}
/>
)}
</div>
</div>
)}
</div>
);
}
@ -489,7 +492,7 @@ function OfferDetailsFormArray({
const { append, remove, fields } = fieldArrayValues;
return (
<div>
<div className="space-y-8">
{fields.map((item, index) => {
return (
<div key={item.id}>
@ -506,7 +509,7 @@ function OfferDetailsFormArray({
icon={PlusIcon}
label="Add another offer"
size="lg"
variant="tertiary"
variant="secondary"
onClick={() =>
append(
jobType === JobType.FULLTIME
@ -547,40 +550,20 @@ export default function OfferDetailsForm({
jobType === JobType.FULLTIME ? JobTypeLabel.INTERN : JobTypeLabel.FULLTIME;
return (
<div className="mb-5">
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
<div className="space-y-6">
<h2 className="mb-8 text-2xl font-bold text-slate-900 sm:text-center sm:text-4xl">
Fill in your offer details
</h5>
<div className="flex w-full justify-center">
<div className="mx-5 w-1/3">
<Button
display="block"
label={JobTypeLabel.FULLTIME}
size="md"
variant={jobType === JobType.FULLTIME ? 'secondary' : 'tertiary'}
onClick={() => {
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) {
</h2>
<JobTypeTabs
value={jobType}
onChange={(newJobType) => {
if (newJobType === jobType) {
return;
}
setDialogOpen(true);
}}
/>
</div>
</div>
<OfferDetailsFormArray
fieldArrayValues={fieldArrayValues}
jobType={jobType}

@ -128,7 +128,7 @@ export default function OfferCard({
);
}
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 />
<BottomSection />
</div>

@ -12,6 +12,7 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
import Tooltip from '~/components/offers/util/Tooltip';
import loginPageHref from '~/components/shared/loginPageHref';
import { copyProfileLink } from '~/utils/offers/link';
import { trpc } from '~/utils/trpc';
@ -109,8 +110,11 @@ export default function ProfileComments({
);
}
return (
<div className="m-4 h-full">
<div className="flex-end flex justify-end space-x-4">
<div className="bh-white h-fit px-4 lg:h-[calc(100vh-4.5rem)] lg:overflow-y-auto">
<div className="bg-white pt-4 lg:sticky lg:top-0">
<div className="flex justify-end">
<div className="grid w-fit space-y-2 lg:grid-cols-1 lg:grid-cols-2 lg:space-y-0 lg:space-x-4">
<div className="col-span-1 flex justify-end">
{isEditable && (
<Tooltip tooltipContent="Copy this link to edit your profile later">
<Button
@ -118,7 +122,7 @@ export default function ProfileComments({
disabled={isDisabled}
icon={ClipboardDocumentIcon}
isLabelHidden={false}
label="Copy profile edit link"
label="Copy edit link"
size="sm"
variant="secondary"
onClick={() => {
@ -136,6 +140,8 @@ export default function ProfileComments({
/>
</Tooltip>
)}
</div>
<div className="col-span-1 flex justify-end">
<Tooltip tooltipContent="Share this profile with your friends">
<Button
addonPosition="start"
@ -160,6 +166,9 @@ export default function ProfileComments({
/>
</Tooltip>
</div>
</div>
</div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
{isEditable || session?.user?.name ? (
<div>
@ -195,13 +204,13 @@ export default function ProfileComments({
<Button
className="mb-5"
display="block"
href="/api/auth/signin"
href={loginPageHref()}
label="Sign in to join discussion"
variant="tertiary"
/>
)}
<div className="h-full overflow-y-auto">
<div className="h-content mb-96 w-full">
</div>
<div className="w-full">
{replies?.map((reply: Reply) => (
<ExpandableCommentCard
key={reply.id}
@ -212,6 +221,5 @@ export default function ProfileComments({
))}
</div>
</div>
</div>
);
}

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

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

@ -1,5 +1,6 @@
import clsx from 'clsx';
import Link from 'next/link';
import { JobType } from '@prisma/client';
import type { JobTitleType } 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';
export type OfferTableRowProps = Readonly<{ row: DashboardOffer }>;
export type OfferTableRowProps = Readonly<{
jobType: JobType;
row: DashboardOffer;
}>;
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) {
return (
<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}
</th>
<td className="py-4 px-6">
<td className="py-4 px-4">
{getLabelForJobTitleType(title as JobTitleType)}
</td>
<td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="py-4 px-4">{totalYoe}</td>
<td className="py-4 px-4">{convertMoneyToString(income)}</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
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
className="text-primary-600 dark:text-primary-500 font-medium hover:underline"

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

@ -1,12 +1,20 @@
// eslint-disable-next-line no-shadow
export enum YOE_CATEGORY {
INTERN = 0,
ENTRY = 1,
MID = 2,
SENIOR = 3,
ENTRY = 'entry',
INTERN = 'intern',
MID = 'mid',
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)',
value: YOE_CATEGORY.ENTRY,

@ -30,6 +30,7 @@ export default function ContributeQuestionCard({
});
return (
<div className="w-full">
<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"
type="button"
@ -73,11 +74,12 @@ export default function ContributeQuestionCard({
Contribute
</h1>
</div>
</button>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
onSubmit={onSubmit}
/>
</button>
</div>
);
}

@ -35,7 +35,12 @@ export default function ContributeQuestionDialog({
return (
<div>
<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
as={Fragment}
enter="ease-out duration-300"

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

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

@ -216,7 +216,11 @@ export default function BaseQuestionCard({
/>
)}
</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}
</p>
{!showReceivedForm &&

@ -128,7 +128,6 @@ export default function ContributeQuestionForm({
{...field}
required={true}
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option);
}}
/>
@ -141,6 +140,7 @@ export default function ContributeQuestionForm({
name="date"
render={({ field }) => (
<MonthYearPicker
className="space-x-2"
monthRequired={true}
value={{
month: ((field.value.getMonth() as number) + 1) as Month,
@ -164,7 +164,6 @@ export default function ContributeQuestionForm({
<CompanyTypeahead
{...field}
required={true}
// @ts-ignore TODO(questions): handle potentially null value.
onSelect={({ id }) => {
field.onChange(id);
}}
@ -181,7 +180,6 @@ export default function ContributeQuestionForm({
{...field}
required={true}
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
field.onChange(option);
}}
/>
@ -278,6 +276,7 @@ export default function ContributeQuestionForm({
</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"
disabled={!checkedSimilar}
label="Contribute"
type="submit"
variant="primary"></Button>

@ -42,14 +42,16 @@ export default function CreateQuestionEncounterForm({
return (
<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 && (
<div>
<CompanyTypeahead
isLabelHidden={true}
placeholder="Other company"
suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value.
placeholder="Company"
// TODO: Fix suggestions and set count back to 3
suggestedCount={0}
onSelect={({ value: company }) => {
setSelectedCompany(company);
}}
@ -64,9 +66,8 @@ export default function CreateQuestionEncounterForm({
<div>
<LocationTypeahead
isLabelHidden={true}
placeholder="Other location"
suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value.
placeholder="Location"
suggestedCount={0}
onSelect={(location) => {
setSelectedLocation(location);
}}
@ -81,9 +82,8 @@ export default function CreateQuestionEncounterForm({
<div>
<RoleTypeahead
isLabelHidden={true}
placeholder="Other role"
suggestedCount={3}
// @ts-ignore TODO(questions): handle potentially null value.
placeholder="Role"
suggestedCount={0}
onSelect={({ value: role }) => {
setSelectedRole(role);
}}
@ -96,6 +96,7 @@ export default function CreateQuestionEncounterForm({
)}
{step === 3 && (
<MonthYearPicker
className="space-x-2"
// TODO: Add label and hide label on Select instead.
monthLabel=""
value={{

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

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

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

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

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

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

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

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

@ -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(
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) {
dashboardOfferDto.income = valuationDtoMapper(
offer.offersIntern.monthlySalary,
@ -712,10 +731,12 @@ export const dashboardOfferDtoMapper = (
export const getOffersResponseMapper = (
data: Array<DashboardOffer>,
paging: Paging,
jobType: JobType
) => {
const getOffersResponse: GetOffersResponse = {
data,
paging,
jobType,
paging
};
return getOffersResponse;
};

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

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

@ -195,15 +195,16 @@ export default function OfferProfile() {
)}
{getProfileQuery.isLoading && (
<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" />
<div className="text-center">Loading...</div>
</div>
</div>
)}
{!getProfileQuery.isLoading && !getProfileQuery.isError && (
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
<div className="h-full w-2/3 divide-y">
<div className="w-full divide-x lg:flex">
<div className="divide-y lg:w-2/3">
<div className="h-fit">
<ProfileHeader
background={background}
handleDelete={handleDelete}
@ -212,7 +213,8 @@ export default function OfferProfile() {
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>
<div className="h-4/5 w-full overflow-y-scroll pb-32">
</div>
<div className="pb-4">
<ProfileDetails
analysis={analysis}
background={background}
@ -224,7 +226,9 @@ export default function OfferProfile() {
/>
</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
isDisabled={deleteMutation.isLoading}
isEditable={isEditable}

@ -71,23 +71,24 @@ export default function OffersSubmissionResult() {
<>
{getAnalysis.isLoading && (
<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" />
<div className="text-center">Loading...</div>
</div>
</div>
)}
{!getAnalysis.isLoading && (
<div ref={pageRef} className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<div className="mb-4 flex justify-end">
<div ref={pageRef} className="w-full">
<div className="flex justify-center">
<div className="block w-full max-w-screen-md overflow-hidden rounded-lg sm:shadow-lg md:my-10">
<div className="flex justify-center bg-slate-100 px-4 py-4 sm:px-6 lg:px-8">
<Breadcrumbs
currentStep={step}
setStep={setStep}
steps={breadcrumbSteps}
/>
</div>
<div className="bg-white p-6 sm:p-10">
{steps[step]}
{step === 0 && (
<div className="flex justify-end">
@ -95,7 +96,7 @@ export default function OffersSubmissionResult() {
disabled={false}
icon={ArrowRightIcon}
label="Next"
variant="secondary"
variant="primary"
onClick={() => setStep(step + 1)}
/>
</div>
@ -123,6 +124,7 @@ export default function OffersSubmissionResult() {
</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 { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import { locationOptionToSlug } from '~/utils/questions/locationSlug';
import relabelQuestionAggregates from '~/utils/questions/relabelQuestionAggregates';
import {
useSearchParam,
@ -33,20 +34,11 @@ import type { Location } from '~/types/questions.d';
import { SortType } 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() {
const router = useRouter();
const [query, setQuery] = useState('');
const [
selectedCompanySlugs,
setSelectedCompanySlugs,
@ -86,7 +78,7 @@ export default function QuestionsBrowsePage() {
useSearchParam('roles');
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchParam<Location & TypeaheadOption>('locations', {
paramToString: locationToSlug,
paramToString: locationOptionToSlug,
stringToParam: (param) => {
const [countryId, stateId, cityId, id, label, value] = param.split('-');
return { cityId, countryId, id, label, stateId, value };
@ -170,13 +162,14 @@ export default function QuestionsBrowsePage() {
const questionsInfiniteQuery = trpc.useInfiniteQuery(
[
'questions.questions.getQuestionsByFilter',
'questions.questions.getQuestionsByFilterAndContent',
{
// TODO: Enable filtering by countryIds and stateIds
cityIds: selectedLocations
.map(({ cityId }) => cityId)
.filter((id) => id !== undefined) as Array<string>,
companyIds: selectedCompanySlugs.map((slug) => slug.split('_')[0]),
content: query,
countryIds: [],
endDate: today,
limit: 10,
@ -263,7 +256,7 @@ export default function QuestionsBrowsePage() {
pathname,
query: {
companies: selectedCompanySlugs,
locations: selectedLocations.map(locationToSlug),
locations: selectedLocations.map(locationOptionToSlug),
questionAge: selectedQuestionAge,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
@ -351,7 +344,6 @@ export default function QuestionsBrowsePage() {
isLabelHidden={true}
placeholder="Search companies"
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
onOptionChange({
...option,
checked: true,
@ -392,7 +384,6 @@ export default function QuestionsBrowsePage() {
isLabelHidden={true}
placeholder="Search roles"
onSelect={(option) => {
// @ts-ignore TODO(questions): handle potentially null value.
onOptionChange({
...option,
checked: true,
@ -453,7 +444,6 @@ export default function QuestionsBrowsePage() {
isLabelHidden={true}
placeholder="Search locations"
onSelect={(option) => {
// @ts-ignore TODO(offers): fix potentially empty value.
onOptionChange({
...option,
checked: true,
@ -485,8 +475,8 @@ export default function QuestionsBrowsePage() {
</Head>
<main className="flex flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto">
<div className="m-4 flex max-w-3xl flex-1 flex-col items-stretch justify-start gap-6">
<section className="min-h-0 flex-1 overflow-auto">
<div className="my-4 mx-auto flex max-w-3xl flex-col items-stretch justify-start gap-6">
<ContributeQuestionCard
onSubmit={(data) => {
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="sticky top-0 border-b border-slate-300 bg-slate-50 py-4">
<QuestionSearchBar
query={query}
sortOrderValue={sortOrder}
sortTypeValue={sortType}
onFilterOptionsToggle={() => {
setFilterDrawerOpen(!filterDrawerOpen);
}}
onQueryChange={(newQuery) => {
setQuery(newQuery);
}}
onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType}
/>

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

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

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

@ -21,6 +21,7 @@ import {
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
import SubmissionGuidelines from '~/components/resumes/submit-form/SubmissionGuidelines';
import loginPageHref from '~/components/shared/loginPageHref';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
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
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/api/auth/signin');
router.push(loginPageHref());
}
}, [router, status]);

@ -425,8 +425,8 @@ export const offersProfileRouter = createRouter()
level: x.level,
location: {
connect: {
id: x.cityId
}
id: x.cityId,
},
},
title: x.title,
totalCompensation:
@ -481,9 +481,9 @@ export const offersProfileRouter = createRouter()
location: {
connect: {
where: {
id: x.cityId
}
}
id: x.cityId,
},
},
},
title: x.title,
totalCompensation:
@ -539,9 +539,9 @@ export const offersProfileRouter = createRouter()
location: {
connect: {
where: {
id: x.cityId
}
}
id: x.cityId,
},
},
},
monthlySalary:
x.monthlySalary != null
@ -595,9 +595,9 @@ export const offersProfileRouter = createRouter()
location: {
connect: {
where: {
id: x.cityId
}
}
id: x.cityId,
},
},
},
monthlySalary:
x.monthlySalary != null
@ -680,10 +680,8 @@ export const offersProfileRouter = createRouter()
jobType: x.jobType,
location: {
connect: {
where: {
id: x.cityId
}
}
id: x.cityId,
},
},
monthYearReceived: x.monthYearReceived,
negotiationStrategy: x.negotiationStrategy,
@ -726,10 +724,8 @@ export const offersProfileRouter = createRouter()
jobType: x.jobType,
location: {
connect: {
where: {
id: x.cityId
}
}
id: x.cityId,
},
},
monthYearReceived: x.monthYearReceived,
negotiationStrategy: x.negotiationStrategy,
@ -988,9 +984,11 @@ export const offersProfileRouter = createRouter()
});
if (exp.monthlySalary) {
if (exp.monthlySalary.id) {
await ctx.prisma.offersCurrency.update({
await ctx.prisma.offersExperience.update({
data: {
monthlySalary: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
@ -1000,15 +998,7 @@ export const offersProfileRouter = createRouter()
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
where: {
id: exp.monthlySalary.id,
},
});
} else {
await ctx.prisma.offersExperience.update({
data: {
monthlySalary: {
create: {
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
@ -1020,17 +1010,19 @@ export const offersProfileRouter = createRouter()
},
},
},
},
where: {
id: exp.id,
},
});
}
}
if (exp.totalCompensation) {
if (exp.totalCompensation.id) {
await ctx.prisma.offersCurrency.update({
await ctx.prisma.offersExperience.update({
data: {
totalCompensation: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
@ -1040,15 +1032,7 @@ export const offersProfileRouter = createRouter()
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
where: {
id: exp.totalCompensation.id,
},
});
} else {
await ctx.prisma.offersExperience.update({
data: {
totalCompensation: {
create: {
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
@ -1060,12 +1044,12 @@ export const offersProfileRouter = createRouter()
},
},
},
},
where: {
id: exp.id,
},
});
}
}
} else if (!exp.id) {
// Create new experience
if (exp.jobType === JobType.FULLTIME) {
@ -1090,8 +1074,8 @@ export const offersProfileRouter = createRouter()
level: exp.level,
location: {
connect: {
id: exp.cityId
}
id: exp.cityId,
},
},
title: exp.title,
totalCompensation: exp.totalCompensation
@ -1161,8 +1145,8 @@ export const offersProfileRouter = createRouter()
level: exp.level,
location: {
connect: {
id: exp.cityId
}
id: exp.cityId,
},
},
title: exp.title,
totalCompensation: {
@ -1229,8 +1213,8 @@ export const offersProfileRouter = createRouter()
level: exp.level,
location: {
connect: {
id: exp.cityId
}
id: exp.cityId,
},
},
title: exp.title,
},
@ -1272,8 +1256,8 @@ export const offersProfileRouter = createRouter()
level: exp.level,
location: {
connect: {
id: exp.cityId
}
id: exp.cityId,
},
},
title: exp.title,
},
@ -1321,8 +1305,8 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType,
location: {
connect: {
id: exp.cityId
}
id: exp.cityId,
},
},
monthlySalary: {
create: {
@ -1386,8 +1370,8 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType,
location: {
connect: {
id: exp.cityId
}
id: exp.cityId,
},
},
monthlySalary: {
create: {
@ -1453,7 +1437,7 @@ export const offersProfileRouter = createRouter()
location: {
connect: {
id: exp.cityId,
}
},
},
title: exp.title,
},
@ -1493,8 +1477,8 @@ export const offersProfileRouter = createRouter()
jobType: exp.jobType,
location: {
connect: {
id: exp.cityId
}
id: exp.cityId,
},
},
title: exp.title,
},
@ -1600,8 +1584,8 @@ export const offersProfileRouter = createRouter()
comments: offerToUpdate.comments,
company: {
connect: {
id: offerToUpdate.companyId
}
id: offerToUpdate.companyId,
},
},
jobType:
offerToUpdate.jobType === JobType.FULLTIME
@ -1609,8 +1593,8 @@ export const offersProfileRouter = createRouter()
: JobType.INTERN,
location: {
connect: {
id: offerToUpdate.cityId
}
id: offerToUpdate.cityId,
},
},
monthYearReceived: offerToUpdate.monthYearReceived,
negotiationStrategy: offerToUpdate.negotiationStrategy,
@ -1625,26 +1609,37 @@ export const offersProfileRouter = createRouter()
data: {
internshipCycle:
offerToUpdate.offersIntern.internshipCycle ?? undefined,
startYear: offerToUpdate.offersIntern.startYear ?? undefined,
title: offerToUpdate.offersIntern.title,
},
where: {
id: offerToUpdate.offersIntern.id,
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,
},
});
await ctx.prisma.offersCurrency.update({
data: {
update: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersIntern.monthlySalary.value,
offerToUpdate.offersIntern.monthlySalary.currency,
baseCurrencyString,
),
currency: offerToUpdate.offersIntern.monthlySalary.currency,
currency:
offerToUpdate.offersIntern.monthlySalary.currency,
value: offerToUpdate.offersIntern.monthlySalary.value,
},
},
},
startYear: offerToUpdate.offersIntern.startYear ?? undefined,
title: offerToUpdate.offersIntern.title,
},
where: {
id: offerToUpdate.offersIntern.monthlySalary.id,
id: offerToUpdate.offersIntern.id,
},
});
}
@ -1660,25 +1655,46 @@ export const offersProfileRouter = createRouter()
},
});
if (offerToUpdate.offersFullTime.baseSalary != null) {
await ctx.prisma.offersCurrency.update({
await ctx.prisma.offersFullTime.update({
data: {
baseSalary: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.baseSalary.value,
offerToUpdate.offersFullTime.baseSalary.currency,
baseCurrencyString,
),
currency: offerToUpdate.offersFullTime.baseSalary.currency,
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: {
id: offerToUpdate.offersFullTime.baseSalary.id,
id: offerToUpdate.offersFullTime.id,
},
});
}
if (offerToUpdate.offersFullTime.bonus != null) {
await ctx.prisma.offersCurrency.update({
await ctx.prisma.offersFullTime.update({
data: {
bonus: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.bonus.value,
@ -1688,42 +1704,96 @@ export const offersProfileRouter = createRouter()
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: {
id: offerToUpdate.offersFullTime.bonus.id,
id: offerToUpdate.offersFullTime.id,
},
});
}
if (offerToUpdate.offersFullTime.stocks != null) {
await ctx.prisma.offersCurrency.update({
await ctx.prisma.offersFullTime.update({
data: {
stocks: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.stocks.value,
offerToUpdate.offersFullTime.stocks.currency,
baseCurrencyString,
),
currency: offerToUpdate.offersFullTime.stocks.currency,
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: {
id: offerToUpdate.offersFullTime.stocks.id,
id: offerToUpdate.offersFullTime.id,
},
});
}
await ctx.prisma.offersCurrency.update({
await ctx.prisma.offersFullTime.update({
data: {
totalCompensation: {
upsert: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
offerToUpdate.offersFullTime.totalCompensation.value,
offerToUpdate.offersFullTime.totalCompensation.currency,
offerToUpdate.offersFullTime.totalCompensation
.currency,
baseCurrencyString,
),
currency:
offerToUpdate.offersFullTime.totalCompensation.currency,
value: offerToUpdate.offersFullTime.totalCompensation.value,
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: {
id: offerToUpdate.offersFullTime.totalCompensation.id,
id: offerToUpdate.offersFullTime.id,
},
});
}
@ -1750,8 +1820,8 @@ export const offersProfileRouter = createRouter()
jobType: offerToUpdate.jobType,
location: {
connect: {
id: offerToUpdate.cityId
}
id: offerToUpdate.cityId,
},
},
monthYearReceived: offerToUpdate.monthYearReceived,
negotiationStrategy: offerToUpdate.negotiationStrategy,
@ -1808,8 +1878,8 @@ export const offersProfileRouter = createRouter()
jobType: offerToUpdate.jobType,
location: {
connect: {
id: offerToUpdate.cityId
}
id: offerToUpdate.cityId,
},
},
monthYearReceived: offerToUpdate.monthYearReceived,
negotiationStrategy: offerToUpdate.negotiationStrategy,

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

@ -13,6 +13,7 @@ export const questionsQuestionRouter = createRouter()
input: z.object({
cityIds: z.string().array(),
companyIds: z.string().array(),
content: z.string().optional(),
countryIds: z.string().array(),
cursor: z.string().nullish(),
endDate: z.date().default(new Date()),
@ -235,7 +236,8 @@ export const questionsQuestionRouter = createRouter()
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;
ORDER BY ts_rank_cd(to_tsvector("content"), to_tsquery('english', ${query}), 4) DESC
LIMIT 3;
`;
const relatedQuestionsIdArray = relatedQuestionsId.map(
@ -280,4 +282,183 @@ export const questionsQuestionRouter = createRouter()
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 = {
baseSalary?: Valuation;
bonus?: Valuation;
company: OffersCompany;
id: string;
income: Valuation;
monthYearReceived: Date;
profileId: string;
stocks?: Valuation;
title: string;
totalYoe: number;
};
@ -123,6 +126,7 @@ export type User = {
export type GetOffersResponse = {
data: Array<DashboardOffer>;
jobType: JobType;
paging: Paging;
};

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

@ -8,7 +8,7 @@ export default function HorizontalDivider({ className }: Props) {
return (
<hr
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,
className: clsx(
isSelected
? 'bg-indigo-100 text-indigo-700'
? 'bg-primary-100 text-primary-700'
: 'hover:bg-slate-100 text-slate-500 hover:text-slate-700',
'px-3 py-2 font-medium text-sm rounded-md',
),

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

Loading…
Cancel
Save