Merge branch 'main' into hongpo/add-question-text-match

pull/412/head
hpkoh 3 years ago
commit 818ad26563

@ -38,6 +38,7 @@
"react-popper-tooltip": "^4.4.2", "react-popper-tooltip": "^4.4.2",
"react-query": "^3.39.2", "react-query": "^3.39.2",
"superjson": "^1.10.0", "superjson": "^1.10.0",
"unique-names-generator": "^4.7.1",
"zod": "^3.18.0" "zod": "^3.18.0"
}, },
"devDependencies": { "devDependencies": {

@ -0,0 +1,14 @@
/*
Warnings:
- Added the required column `upvotes` to the `QuestionsAnswerComment` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "QuestionsAnswer" ADD COLUMN "upvotes" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "QuestionsAnswerComment" ADD COLUMN "upvotes" INTEGER NOT NULL;
-- AlterTable
ALTER TABLE "QuestionsQuestionComment" ADD COLUMN "upvotes" INTEGER NOT NULL DEFAULT 0;

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "QuestionsAnswerComment" ALTER COLUMN "upvotes" SET DEFAULT 0;

@ -457,6 +457,7 @@ model QuestionsQuestionComment {
id String @id @default(cuid()) id String @id @default(cuid())
questionId String questionId String
userId String? userId String?
upvotes Int @default(0)
content String @db.Text content String @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -485,6 +486,7 @@ model QuestionsAnswer {
questionId String questionId String
userId String? userId String?
content String @db.Text content String @db.Text
upvotes Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -513,6 +515,7 @@ model QuestionsAnswerComment {
answerId String answerId String
userId String? userId String?
content String @db.Text content String @db.Text
upvotes Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

@ -85,8 +85,8 @@ function ProfileJewel() {
{({ active }) => ( {({ active }) => (
<Link <Link
className={clsx( className={clsx(
active ? 'bg-gray-100' : '', active ? 'bg-slate-100' : '',
'block px-4 py-2 text-sm text-gray-700', 'block px-4 py-2 text-sm text-slate-700',
)} )}
href={item.href} href={item.href}
onClick={item.onClick}> onClick={item.onClick}>
@ -178,9 +178,9 @@ export default function AppShell({ children }: Props) {
{/* Content area */} {/* Content area */}
<div className="flex h-screen flex-1 flex-col overflow-hidden"> <div className="flex h-screen flex-1 flex-col overflow-hidden">
<header className="w-full"> <header className="w-full">
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white shadow-sm"> <div className="relative z-10 flex h-16 flex-shrink-0 border-b border-slate-200 bg-white shadow-sm">
<button <button
className="focus:ring-primary-500 border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset md:hidden" className="focus:ring-primary-500 border-r border-slate-200 px-4 text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset md:hidden"
type="button" type="button"
onClick={() => setMobileMenuOpen(true)}> onClick={() => setMobileMenuOpen(true)}>
<span className="sr-only">Open sidebar</span> <span className="sr-only">Open sidebar</span>

@ -34,7 +34,7 @@ export default function MobileNavigation({
leave="transition-opacity ease-linear duration-300" leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" /> <div className="fixed inset-0 bg-slate-600 bg-opacity-75" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-40 flex"> <div className="fixed inset-0 z-40 flex">
<Transition.Child <Transition.Child

@ -9,12 +9,12 @@ export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) {
{stepLabels.map((label, index) => ( {stepLabels.map((label, index) => (
<div key={label} className="flex space-x-1"> <div key={label} className="flex space-x-1">
{index === currentStep ? ( {index === currentStep ? (
<p className="text-sm text-purple-700">{label}</p> <p className="text-primary-700 text-sm">{label}</p>
) : ( ) : (
<p className="text-sm text-gray-400">{label}</p> <p className="text-sm text-slate-400">{label}</p>
)} )}
{index !== stepLabels.length - 1 && ( {index !== stepLabels.length - 1 && (
<p className="text-sm text-gray-400">{'>'}</p> <p className="text-sm text-slate-400">{'>'}</p>
)} )}
</div> </div>
))} ))}

@ -1,13 +1,14 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation'; import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [ const navigation: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Benchmark your offer' }, { href: '/offers/browse', name: 'Browse' },
{ href: '/offers/submit', name: 'Analyse your offers' },
]; ];
const config = { const config = {
navigation, navigation,
showGlobalNav: false, showGlobalNav: false,
title: 'Tech Offers Repo', title: 'Offer Profile Repository',
titleHref: '/offers', titleHref: '/offers',
}; };

@ -2,15 +2,15 @@ export default function OffersTitle() {
return ( return (
<> <>
<div className="flex items-end justify-center"> <div className="flex items-end justify-center">
<h1 className="mt-16 text-center text-4xl font-bold text-indigo-600"> <h1 className="text-primary-600 mt-16 text-center text-4xl font-bold">
Tech Handbook Offers Repo Offer Profile Repository
</h1> </h1>
</div> </div>
<div className="mt-2 text-center text-2xl font-normal text-indigo-500"> <div className="text-primary-500 mt-2 text-center text-2xl font-normal">
Reveal profile stories behind offers Reveal profile stories behind offers
</div> </div>
<div className="items-top flex justify-center text-xl font-normal"> <div className="items-top flex justify-center text-xl font-normal">
Benchmark your offers and profiles, learn from other's offer profile, Click into offers to view profiles, benchmark your offers and profiles,
and discuss with the community and discuss with the community
</div> </div>
</> </>

@ -2,26 +2,6 @@ import { EducationBackgroundType } from './types';
export const emptyOption = '----'; export const emptyOption = '----';
// TODO: use enums
export const titleOptions = [
{
label: 'Software Engineer',
value: 'Software Engineer',
},
{
label: 'Frontend Engineer',
value: 'Frontend Engineer',
},
{
label: 'Backend Engineer',
value: 'Backend Engineer',
},
{
label: 'Full-stack Engineer',
value: 'Full-stack Engineer',
},
];
export const locationOptions = [ export const locationOptions = [
{ {
label: 'Singapore, Singapore', label: 'Singapore, Singapore',

@ -0,0 +1,55 @@
import type { ReactNode } from 'react';
import { HOME_URL } from '~/components/offers/types';
type LeftTextCardProps = Readonly<{
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: string;
title: string;
}>;
export default function LeftTextCard({
description,
icon,
imageAlt,
imageSrc,
title,
}: LeftTextCardProps) {
return (
<div className="lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
<div className="mx-auto max-w-xl px-4 sm:px-6 lg:mx-0 lg:max-w-none lg:py-16 lg:px-0">
<div>
<div>
<span className="to-primary-500 flex h-12 w-12 items-center justify-center rounded-md bg-gradient-to-r from-purple-600">
{icon}
</span>
</div>
<div className="mt-6">
<h2 className="text-3xl font-bold tracking-tight text-gray-900">
{title}
</h2>
<p className="mt-4 text-lg text-gray-500">{description}</p>
<div className="mt-6">
<a
className="to-primary-500 inline-flex rounded-md border border-transparent bg-gradient-to-r from-purple-600 bg-origin-border px-4 py-2 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={HOME_URL}>
Get started
</a>
</div>
</div>
</div>
</div>
<div className="mt-12 sm:mt-16 lg:mt-0">
<div className="-mr-48 pl-4 sm:pl-6 md:-mr-16 lg:relative lg:m-0 lg:h-full lg:px-0">
<img
alt={imageAlt}
className="w-full rounded-xl shadow-xl ring-1 ring-black ring-opacity-5 lg:absolute lg:left-0 lg:h-full lg:w-auto lg:max-w-none"
src={imageSrc}
/>
</div>
</div>
</div>
);
}

@ -0,0 +1,55 @@
import type { ReactNode } from 'react';
import { HOME_URL } from '~/components/offers/types';
type RightTextCarddProps = Readonly<{
description: string;
icon: ReactNode;
imageAlt: string;
imageSrc: string;
title: string;
}>;
export default function RightTextCard({
description,
icon,
imageAlt,
imageSrc,
title,
}: RightTextCarddProps) {
return (
<div className="lg:mx-auto lg:grid lg:max-w-7xl lg:grid-flow-col-dense lg:grid-cols-2 lg:gap-24 lg:px-8">
<div className="mx-auto max-w-xl px-4 sm:px-6 lg:col-start-2 lg:mx-0 lg:max-w-none lg:py-32 lg:px-0">
<div>
<div>
<span className="to-primary-500 flex h-12 w-12 items-center justify-center rounded-md bg-gradient-to-r from-purple-600">
{icon}
</span>
</div>
<div className="mt-6">
<h2 className="text-3xl font-bold tracking-tight text-gray-900">
{title}
</h2>
<p className="mt-4 text-lg text-gray-500">{description}</p>
<div className="mt-6">
<a
className="to-primary-500 inline-flex rounded-md border border-transparent bg-gradient-to-r from-purple-600 bg-origin-border px-4 py-2 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={HOME_URL}>
Get started
</a>
</div>
</div>
</div>
</div>
<div className="mt-12 sm:mt-16 lg:col-start-1 lg:mt-0">
<div className="-ml-48 pr-4 sm:pr-6 md:-ml-16 lg:relative lg:m-0 lg:h-full lg:px-0">
<img
alt={imageAlt}
className="w-full rounded-xl shadow-xl ring-1 ring-black ring-opacity-5 lg:absolute lg:right-0 lg:h-full lg:w-auto lg:max-w-none"
src={imageSrc}
/>
</div>
</div>
</div>
);
}

@ -1,9 +1,9 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useState } from 'react'; // Import { useState } from 'react';
import { setTimeout } from 'timers'; // import { setTimeout } from 'timers';
import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid'; import { DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline'; import { EyeIcon } from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui'; import { Button, TextInput, useToast } from '@tih/ui';
import { import {
copyProfileLink, copyProfileLink,
@ -20,31 +20,31 @@ export default function OffersProfileSave({
profileId, profileId,
token, token,
}: OfferProfileSaveProps) { }: OfferProfileSaveProps) {
const [linkCopied, setLinkCopied] = useState(false); const { showToast } = useToast();
const [isSaving, setSaving] = useState(false); // Const [isSaving, setSaving] = useState(false);
const [isSaved, setSaved] = useState(false); // const [isSaved, setSaved] = useState(false);
const router = useRouter(); const router = useRouter();
const saveProfile = () => { // Const saveProfile = () => {
setSaving(true); // setSaving(true);
setTimeout(() => { // setTimeout(() => {
setSaving(false); // setSaving(false);
setSaved(true); // setSaved(true);
}, 5); // }, 5);
}; // };
return ( return (
<div className="flex w-full justify-center"> <div className="flex w-full justify-center">
<div className="max-w-2xl text-center"> <div className="max-w-2xl text-center">
<h5 className="mb-6 text-4xl font-bold text-gray-900"> <h5 className="mb-6 text-4xl font-bold text-slate-900">
Save for future edits Save for future edits
</h5> </h5>
<p className="mb-2 text-gray-900">We value your privacy.</p> <p className="mb-2 text-slate-900">We value your privacy.</p>
<p className="mb-5 text-gray-900"> <p className="mb-5 text-slate-900">
To keep you offer profile strictly anonymous, only people who have the To keep you offer profile strictly anonymous, only people who have the
link below can edit it. link below can edit it.
</p> </p>
<div className="mb-5 grid grid-cols-12 gap-4"> <div className="mb-20 grid grid-cols-12 gap-4">
<div className="col-span-11"> <div className="col-span-11">
<TextInput <TextInput
disabled={true} disabled={true}
@ -59,17 +59,15 @@ export default function OffersProfileSave({
label="Copy" label="Copy"
variant="primary" variant="primary"
onClick={() => { onClick={() => {
copyProfileLink(profileId, token), setLinkCopied(true); copyProfileLink(profileId, token);
showToast({
title: `Profile edit link copied to clipboard!`,
variant: 'success',
});
}} }}
/> />
</div> </div>
<div className="mb-20"> {/* <p className="mb-5 text-slate-900">
{linkCopied && (
<p className="text-purple-700">Link copied to clipboard!</p>
)}
</div>
<p className="mb-5 text-gray-900">
If you do not want to keep the edit link, you can opt to save this If you do not want to keep the edit link, you can opt to save this
profile under your user account. It will still only be editable by profile under your user account. It will still only be editable by
you. you.
@ -83,7 +81,7 @@ export default function OffersProfileSave({
variant="primary" variant="primary"
onClick={saveProfile} onClick={saveProfile}
/> />
</div> </div> */}
<div> <div>
<Button <Button
icon={EyeIcon} icon={EyeIcon}

@ -115,7 +115,7 @@ export default function OffersSubmissionForm({
), ),
hasNext: true, hasNext: true,
hasPrevious: false, hasPrevious: false,
label: 'Offer details', label: 'Offers',
}, },
{ {
component: <BackgroundForm key={1} />, component: <BackgroundForm key={1} />,
@ -125,28 +125,33 @@ export default function OffersSubmissionForm({
}, },
{ {
component: ( component: (
<OfferAnalysis <OffersProfileSave
key={2} key={2}
allAnalysis={analysis} profileId={createProfileResponse.id || ''}
isError={generateAnalysisMutation.isError} token={createProfileResponse.token}
isLoading={generateAnalysisMutation.isLoading}
/> />
), ),
hasNext: true, hasNext: true,
hasPrevious: false, hasPrevious: false,
label: 'Analysis', label: 'Save profile',
}, },
{ {
component: ( component: (
<OffersProfileSave <div>
<h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Result
</h5>
<OfferAnalysis
key={3} key={3}
profileId={createProfileResponse.id || ''} allAnalysis={analysis}
token={createProfileResponse.token} isError={generateAnalysisMutation.isError}
isLoading={generateAnalysisMutation.isLoading}
/> />
</div>
), ),
hasNext: false, hasNext: false,
hasPrevious: false, hasPrevious: true,
label: 'Save', label: 'Analysis',
}, },
]; ];
@ -231,7 +236,7 @@ export default function OffersSubmissionForm({
<FormProvider {...formMethods}> <FormProvider {...formMethods}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
{formSteps[formStep].component} {formSteps[formStep].component}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */} <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre>
{formSteps[formStep].hasNext && ( {formSteps[formStep].hasNext && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button

@ -8,13 +8,17 @@ import {
emptyOption, emptyOption,
FieldError, FieldError,
locationOptions, locationOptions,
titleOptions,
} from '~/components/offers/constants'; } from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types'; import type { BackgroundPostData } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import { CURRENCY_OPTIONS } from '~/utils/offers/currency/CurrencyEnum'; import {
Currency,
CURRENCY_OPTIONS,
} from '~/utils/offers/currency/CurrencyEnum';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
import FormRadioList from '../../forms/FormRadioList'; import FormRadioList from '../../forms/FormRadioList';
import FormSelect from '../../forms/FormSelect'; import FormSelect from '../../forms/FormSelect';
import FormTextInput from '../../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
@ -26,11 +30,11 @@ function YoeSection() {
const backgroundFields = formState.errors.background; const backgroundFields = formState.errors.background;
return ( return (
<> <>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400"> <h6 className="mb-2 text-left text-xl font-medium text-slate-400">
Years of Experience (YOE) Years of Experience (YOE)
</h6> </h6>
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5"> <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"> <div className="mb-2 grid grid-cols-3 space-x-3">
<FormTextInput <FormTextInput
errorMessage={backgroundFields?.totalYoe?.message} errorMessage={backgroundFields?.totalYoe?.message}
@ -92,13 +96,13 @@ function FullTimeJobFields() {
return ( return (
<> <>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <JobTitlesTypeahead
label="Title" onSelect={({ value }) =>
options={titleOptions} setValue(`background.experiences.0.title`, value)
placeholder={emptyOption} }
{...register(`background.experiences.0.title`)}
/> />
</div>
<div> <div>
<CompaniesTypeahead <CompaniesTypeahead
onSelect={({ value }) => onSelect={({ value }) =>
@ -112,6 +116,7 @@ function FullTimeJobFields() {
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
@ -177,13 +182,13 @@ function InternshipJobFields() {
return ( return (
<> <>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <JobTitlesTypeahead
label="Title" onSelect={({ value }) =>
options={titleOptions} setValue(`background.experiences.0.title`, value)
placeholder={emptyOption} }
{...register(`background.experiences.0.title`)}
/> />
</div>
<div> <div>
<CompaniesTypeahead <CompaniesTypeahead
onSelect={({ value }) => onSelect={({ value }) =>
@ -197,6 +202,7 @@ function InternshipJobFields() {
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
@ -245,10 +251,10 @@ function CurrentJobSection() {
return ( return (
<> <>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400"> <h6 className="mb-2 text-left text-xl font-medium text-slate-400">
Current / Previous Job Current / Previous Job
</h6> </h6>
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5"> <div className="mb-5 rounded-lg border border-slate-200 px-10 py-5">
<div className="mb-5"> <div className="mb-5">
<FormRadioList <FormRadioList
defaultValue={JobType.FULLTIME} defaultValue={JobType.FULLTIME}
@ -282,10 +288,10 @@ function EducationSection() {
const { register } = useFormContext(); const { register } = useFormContext();
return ( return (
<> <>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400"> <h6 className="mb-2 text-left text-xl font-medium text-slate-400">
Education Education
</h6> </h6>
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5"> <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"> <div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <FormSelect
display="block" display="block"
@ -310,6 +316,22 @@ function EducationSection() {
{...register(`background.educations.0.school`)} {...register(`background.educations.0.school`)}
/> />
</div> </div>
<div className="grid grid-cols-2 space-x-3">
<FormMonthYearPicker
monthLabel="Candidature Start"
yearLabel=""
{...register(`background.educations.0.startDate`, {
required: FieldError.REQUIRED,
})}
/>
<FormMonthYearPicker
monthLabel="Candidature End"
yearLabel=""
{...register(`background.educations.0.endDate`, {
required: FieldError.REQUIRED,
})}
/>
</div>
</Collapsible> </Collapsible>
</div> </div>
</> </>
@ -319,13 +341,9 @@ function EducationSection() {
export default function BackgroundForm() { export default function BackgroundForm() {
return ( return (
<div> <div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900"> <h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Help us better gauge your offers Help us better gauge your offers
</h5> </h5>
<h6 className="text-md mx-10 mb-8 text-center font-light text-gray-600">
This section is mostly optional, but your background information helps
us benchmark your offers.
</h6>
<div> <div>
<YoeSection /> <YoeSection />
<CurrentJobSection /> <CurrentJobSection />

@ -13,6 +13,7 @@ import { JobType } from '@prisma/client';
import { Button, Dialog } from '@tih/ui'; import { Button, Dialog } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import { import {
defaultFullTimeOfferValues, defaultFullTimeOfferValues,
@ -23,7 +24,6 @@ import {
FieldError, FieldError,
internshipCycleOptions, internshipCycleOptions,
locationOptions, locationOptions,
titleOptions,
yearOptions, yearOptions,
} from '../../constants'; } from '../../constants';
import FormMonthYearPicker from '../../forms/FormMonthYearPicker'; import FormMonthYearPicker from '../../forms/FormMonthYearPicker';
@ -32,7 +32,10 @@ import FormTextArea from '../../forms/FormTextArea';
import FormTextInput from '../../forms/FormTextInput'; import FormTextInput from '../../forms/FormTextInput';
import type { OfferFormData } from '../../types'; import type { OfferFormData } from '../../types';
import { JobTypeLabel } from '../../types'; import { JobTypeLabel } from '../../types';
import { CURRENCY_OPTIONS } from '../../../../utils/offers/currency/CurrencyEnum'; import {
Currency,
CURRENCY_OPTIONS,
} from '../../../../utils/offers/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{ type FullTimeOfferDetailsFormProps = Readonly<{
index: number; index: number;
@ -62,25 +65,22 @@ function FullTimeOfferDetailsForm({
}, [watchCurrency, index, setValue]); }, [watchCurrency, index, setValue]);
return ( return (
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5"> <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 className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <JobTitlesTypeahead
errorMessage={offerFields?.offersFullTime?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true} required={true}
{...register(`offers.${index}.offersFullTime.title`, { onSelect={({ value }) =>
required: FieldError.REQUIRED, setValue(`offers.${index}.offersFullTime.title`, value)
})} }
/> />
</div>
<FormTextInput <FormTextInput
errorMessage={offerFields?.offersFullTime?.specialization?.message} errorMessage={offerFields?.offersFullTime?.level?.message}
label="Focus / Specialization" label="Level"
placeholder="e.g. Front End" placeholder="e.g. L4, Junior"
required={true} required={true}
{...register(`offers.${index}.offersFullTime.specialization`, { {...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.REQUIRED, required: FieldError.REQUIRED,
})} })}
/> />
@ -88,22 +88,12 @@ function FullTimeOfferDetailsForm({
<div className="mb-5 flex grid grid-cols-2 space-x-3"> <div className="mb-5 flex grid grid-cols-2 space-x-3">
<div> <div>
<CompaniesTypeahead <CompaniesTypeahead
required={true}
onSelect={({ value }) => onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value) setValue(`offers.${index}.companyId`, value)
} }
/> />
</div> </div>
<FormTextInput
errorMessage={offerFields?.offersFullTime?.level?.message}
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.offersFullTime.level`, {
required: FieldError.REQUIRED,
})}
/>
</div>
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3">
<FormSelect <FormSelect
display="block" display="block"
errorMessage={offerFields?.location?.message} errorMessage={offerFields?.location?.message}
@ -115,6 +105,8 @@ function FullTimeOfferDetailsForm({
required: FieldError.REQUIRED, required: FieldError.REQUIRED,
})} })}
/> />
</div>
<div className="mb-5 flex grid grid-cols-2 items-start space-x-3">
<FormMonthYearPicker <FormMonthYearPicker
monthLabel="Date Received" monthLabel="Date Received"
monthRequired={true} monthRequired={true}
@ -129,6 +121,7 @@ function FullTimeOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
@ -165,14 +158,12 @@ function FullTimeOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register( {...register(
`offers.${index}.offersFullTime.baseSalary.currency`, `offers.${index}.offersFullTime.baseSalary.currency`,
{
required: FieldError.REQUIRED,
},
)} )}
/> />
} }
@ -180,13 +171,11 @@ function FullTimeOfferDetailsForm({
errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message} errorMessage={offerFields?.offersFullTime?.baseSalary?.value?.message}
label="Base Salary (Annual)" label="Base Salary (Annual)"
placeholder="0" placeholder="0"
required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.baseSalary.value`, { {...register(`offers.${index}.offersFullTime.baseSalary.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -194,25 +183,22 @@ function FullTimeOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.bonus.currency`, { {...register(`offers.${index}.offersFullTime.bonus.currency`)}
required: FieldError.REQUIRED,
})}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.offersFullTime?.bonus?.value?.message} errorMessage={offerFields?.offersFullTime?.bonus?.value?.message}
label="Bonus (Annual)" label="Bonus (Annual)"
placeholder="0" placeholder="0"
required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.bonus.value`, { {...register(`offers.${index}.offersFullTime.bonus.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -222,25 +208,22 @@ function FullTimeOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
{...register(`offers.${index}.offersFullTime.stocks.currency`, { {...register(`offers.${index}.offersFullTime.stocks.currency`)}
required: FieldError.REQUIRED,
})}
/> />
} }
endAddOnType="element" endAddOnType="element"
errorMessage={offerFields?.offersFullTime?.stocks?.value?.message} errorMessage={offerFields?.offersFullTime?.stocks?.value?.message}
label="Stocks (Annual)" label="Stocks (Annual)"
placeholder="0" placeholder="0"
required={true}
startAddOn="$" startAddOn="$"
startAddOnType="label" startAddOnType="label"
type="number" type="number"
{...register(`offers.${index}.offersFullTime.stocks.value`, { {...register(`offers.${index}.offersFullTime.stocks.value`, {
min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 }, min: { message: FieldError.NON_NEGATIVE_NUMBER, value: 0 },
required: FieldError.REQUIRED,
valueAsNumber: true, valueAsNumber: true,
})} })}
/> />
@ -289,34 +272,21 @@ function InternshipOfferDetailsForm({
const offerFields = formState.errors.offers?.[index]; const offerFields = formState.errors.offers?.[index];
return ( return (
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5"> <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 className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect <div>
display="block" <JobTitlesTypeahead
errorMessage={offerFields?.offersIntern?.title?.message}
label="Title"
options={titleOptions}
placeholder={emptyOption}
required={true}
{...register(`offers.${index}.offersIntern.title`, {
minLength: 1,
required: FieldError.REQUIRED,
})}
/>
<FormTextInput
errorMessage={offerFields?.offersIntern?.specialization?.message}
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true} required={true}
{...register(`offers.${index}.offersIntern.specialization`, { onSelect={({ value }) =>
minLength: 1, setValue(`offers.${index}.offersIntern.title`, value)
required: FieldError.REQUIRED, }
})}
/> />
</div> </div>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3"> <div className="mb-5 grid grid-cols-2 space-x-3">
<div> <div>
<CompaniesTypeahead <CompaniesTypeahead
required={true}
onSelect={({ value }) => onSelect={({ value }) =>
setValue(`offers.${index}.companyId`, value) setValue(`offers.${index}.companyId`, value)
} }
@ -374,6 +344,7 @@ function InternshipOfferDetailsForm({
endAddOn={ endAddOn={
<FormSelect <FormSelect
borderStyle="borderless" borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true} isLabelHidden={true}
label="Currency" label="Currency"
options={CURRENCY_OPTIONS} options={CURRENCY_OPTIONS}
@ -503,7 +474,7 @@ export default function OfferDetailsForm({
return ( return (
<div className="mb-5"> <div className="mb-5">
<h5 className="mb-8 text-center text-4xl font-bold text-gray-900"> <h5 className="mb-8 text-center text-4xl font-bold text-slate-900">
Fill in your offer details Fill in your offer details
</h5> </h5>
<div className="flex w-full justify-center"> <div className="flex w-full justify-center">

@ -30,7 +30,7 @@ export default function EducationCard({
)} )}
</div> </div>
{(startDate || endDate) && ( {(startDate || endDate) && (
<div className="font-light text-gray-400"> <div className="font-light text-slate-400">
<p>{`${startDate || 'N/A'} - ${endDate || 'N/A'}`}</p> <p>{`${startDate || 'N/A'} - ${endDate || 'N/A'}`}</p>
</div> </div>
)} )}

@ -44,12 +44,12 @@ export default function OfferCard({
</div> </div>
</div> </div>
{!duration && receivedMonth && ( {!duration && receivedMonth && (
<div className="font-light text-gray-400"> <div className="font-light text-slate-400">
<p>{receivedMonth}</p> <p>{receivedMonth}</p>
</div> </div>
)} )}
{duration && ( {duration && (
<div className="font-light text-gray-400"> <div className="font-light text-slate-400">
<p>{`${duration} months`}</p> <p>{`${duration} months`}</p>
</div> </div>
)} )}
@ -83,7 +83,7 @@ export default function OfferCard({
</div> </div>
))} ))}
{totalCompensation && ( {totalCompensation && (
<div className="ml-6 flex flex-row font-light text-gray-400"> <div className="ml-6 flex flex-row font-light text-slate-400">
<p> <p>
Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '} Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '}
{bonus} {bonus}

@ -1,7 +1,13 @@
import { signIn, useSession } from 'next-auth/react'; import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline'; import { ClipboardDocumentIcon, ShareIcon } from '@heroicons/react/24/outline';
import { Button, HorizontalDivider, Spinner, TextArea } from '@tih/ui'; import {
Button,
HorizontalDivider,
Spinner,
TextArea,
useToast,
} from '@tih/ui';
import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard'; import ExpandableCommentCard from '~/components/offers/profile/comments/ExpandableCommentCard';
@ -30,6 +36,7 @@ export default function ProfileComments({
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [currentReply, setCurrentReply] = useState<string>(''); const [currentReply, setCurrentReply] = useState<string>('');
const [replies, setReplies] = useState<Array<Reply>>(); const [replies, setReplies] = useState<Array<Reply>>();
const { showToast } = useToast();
const commentsQuery = trpc.useQuery( const commentsQuery = trpc.useQuery(
['offers.comments.getComments', { profileId }], ['offers.comments.getComments', { profileId }],
@ -51,6 +58,10 @@ export default function ProfileComments({
}); });
function handleComment(message: string) { function handleComment(message: string) {
if (!currentReply.length) {
return;
}
if (isEditable) { if (isEditable) {
// If it is with edit permission, send comment to API with username = null // If it is with edit permission, send comment to API with username = null
createCommentMutation.mutate( createCommentMutation.mutate(
@ -104,7 +115,13 @@ export default function ProfileComments({
label="Copy profile edit link" label="Copy profile edit link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => copyProfileLink(profileId, token)} onClick={() => {
copyProfileLink(profileId, token);
showToast({
title: `Profile edit link copied to clipboard!`,
variant: 'success',
});
}}
/> />
)} )}
<Button <Button
@ -115,10 +132,17 @@ export default function ProfileComments({
label="Copy public link" label="Copy public link"
size="sm" size="sm"
variant="secondary" variant="secondary"
onClick={() => copyProfileLink(profileId)} onClick={() => {
copyProfileLink(profileId);
showToast({
title: `Public profile link copied to clipboard!`,
variant: 'success',
});
}}
/> />
</div> </div>
<h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2> <h2 className="mt-2 mb-6 text-2xl font-bold">Discussions</h2>
{isEditable || session?.user?.name ? (
<div> <div>
<TextArea <TextArea
label={`Comment as ${ label={`Comment as ${
@ -131,7 +155,11 @@ export default function ProfileComments({
<div className="mt-2 flex w-full justify-end"> <div className="mt-2 flex w-full justify-end">
<div className="w-fit"> <div className="w-fit">
<Button <Button
disabled={commentsQuery.isLoading} disabled={
commentsQuery.isLoading ||
!currentReply.length ||
createCommentMutation.isLoading
}
display="block" display="block"
isLabelHidden={false} isLabelHidden={false}
isLoading={createCommentMutation.isLoading} isLoading={createCommentMutation.isLoading}
@ -144,7 +172,10 @@ export default function ProfileComments({
</div> </div>
<HorizontalDivider /> <HorizontalDivider />
</div> </div>
<div className="h-full overflow-y-scroll"> ) : (
<div>Please log in before commenting on this profile.</div>
)}
<div className="h-full overflow-y-auto">
<div className="h-content mb-96 w-full"> <div className="h-content mb-96 w-full">
{replies?.map((reply: Reply) => ( {replies?.map((reply: Reply) => (
<ExpandableCommentCard <ExpandableCommentCard

@ -8,9 +8,9 @@ export default function ProfilePhotoHolder({
const sizeMap = { lg: '16', sm: '12' }; const sizeMap = { lg: '16', sm: '12' };
return ( return (
<span <span
className={`inline-block h-${sizeMap[size]} w-${sizeMap[size]} overflow-hidden rounded-full bg-gray-100`}> className={`inline-block h-${sizeMap[size]} w-${sizeMap[size]} overflow-hidden rounded-full bg-slate-100`}>
<svg <svg
className="h-full w-full text-gray-300" className="h-full w-full text-slate-300"
fill="currentColor" fill="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" /> <path d="M24 20.993V24H0v-2.996A14.977 14.977 0 0112.004 15c4.904 0 9.26 2.354 11.996 5.993zM16.002 8.999a4 4 0 11-8 0 4 4 0 018 0z" />

@ -43,6 +43,10 @@ export default function CommentCard({
}); });
function handleReply() { function handleReply() {
if (!currentReply.length) {
return;
}
if (token && token.length > 0) { if (token && token.length > 0) {
// If it is with edit permission, send comment to API with username = null // If it is with edit permission, send comment to API with username = null
createCommentMutation.mutate( createCommentMutation.mutate(
@ -96,12 +100,12 @@ export default function CommentCard({
</div> </div>
<div className="mt-2 mb-2 flex flex-row ">{message}</div> <div className="mt-2 mb-2 flex flex-row ">{message}</div>
<div className="flex flex-row items-center justify-start space-x-4 "> <div className="flex flex-row items-center justify-start space-x-4 ">
<div className="flex flex-col text-sm font-light text-gray-400">{`${timeSinceNow( <div className="flex flex-col text-sm font-light text-slate-400">{`${timeSinceNow(
createdAt, createdAt,
)} ago`}</div> )} ago`}</div>
{replyLength > 0 && ( {replyLength > 0 && (
<div <div
className="flex cursor-pointer flex-col text-sm text-purple-600 hover:underline" className="text-primary-600 flex cursor-pointer flex-col text-sm hover:underline"
onClick={handleExpanded}> onClick={handleExpanded}>
{isExpanded ? `Hide replies` : `View replies (${replyLength})`} {isExpanded ? `Hide replies` : `View replies (${replyLength})`}
</div> </div>
@ -124,7 +128,7 @@ export default function CommentCard({
<TextArea <TextArea
isLabelHidden={true} isLabelHidden={true}
label="Comment" label="Comment"
placeholder="Type your comment here" placeholder="Type your reply here"
resize="none" resize="none"
value={currentReply} value={currentReply}
onChange={(value) => setCurrentReply(value)} onChange={(value) => setCurrentReply(value)}
@ -132,6 +136,9 @@ export default function CommentCard({
<div className="mt-2 flex w-full justify-end"> <div className="mt-2 flex w-full justify-end">
<div className="w-fit"> <div className="w-fit">
<Button <Button
disabled={
!currentReply.length || createCommentMutation.isLoading
}
display="block" display="block"
isLabelHidden={false} isLabelHidden={false}
isLoading={createCommentMutation.isLoading} isLoading={createCommentMutation.isLoading}

@ -1,5 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { convertMoneyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
@ -13,19 +16,21 @@ export default function OfferTableRow({
return ( return (
<tr <tr
key={id} key={id}
className="border-b bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600"> className="border-b bg-white hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:hover:bg-slate-600">
<th <th
className="whitespace-nowrap py-4 px-6 font-medium text-gray-900 dark:text-white" className="whitespace-nowrap py-4 px-6 font-medium text-slate-900 dark:text-white"
scope="row"> scope="row">
{company.name} {company.name}
</th> </th>
<td className="py-4 px-6">{title}</td> <td className="py-4 px-6">
{getLabelForJobTitleType(title as JobTitleType)}
</td>
<td className="py-4 px-6">{totalYoe}</td> <td className="py-4 px-6">{totalYoe}</td>
<td className="py-4 px-6">{convertMoneyToString(income)}</td> <td className="py-4 px-6">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td> <td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="space-x-4 py-4 px-6"> <td className="space-x-4 py-4 px-6">
<Link <Link
className="font-medium text-indigo-600 hover:underline dark:text-indigo-500" className="text-primary-600 dark:text-primary-500 font-medium hover:underline"
href={`/offers/profile/${profileId}`}> href={`/offers/profile/${profileId}`}>
View Profile View Profile
</Link> </Link>

@ -109,7 +109,7 @@ export default function OffersTable({
function renderHeader() { function renderHeader() {
return ( return (
<thead className="bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400"> <thead className="bg-slate-50 text-xs uppercase text-slate-700">
<tr> <tr>
{[ {[
'Company', 'Company',
@ -145,7 +145,7 @@ export default function OffersTable({
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
</div> </div>
) : ( ) : (
<table className="w-full text-left text-sm text-gray-500 dark:text-gray-400"> <table className="w-full text-left text-sm text-slate-500">
{renderHeader()} {renderHeader()}
<tbody> <tbody>
{offers.map((offer) => ( {offers.map((offer) => (

@ -19,13 +19,13 @@ export default function OffersTablePagination({
<nav <nav
aria-label="Table navigation" aria-label="Table navigation"
className="flex items-center justify-between p-4"> className="flex items-center justify-between p-4">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400"> <span className="text-sm font-normal text-slate-500">
Showing Showing
<span className="font-semibold text-gray-900 dark:text-white"> <span className="font-semibold text-slate-900">
{` ${startNumber} - ${endNumber} `} {` ${startNumber} - ${endNumber} `}
</span> </span>
{`of `} {`of `}
<span className="font-semibold text-gray-900 dark:text-white"> <span className="font-semibold text-slate-900">
{pagination.totalItems} {pagination.totalItems}
</span> </span>
</span> </span>

@ -2,6 +2,8 @@ import type { JobType } from '@prisma/client';
import type { MonthYear } from '~/components/shared/MonthYearPicker'; import type { MonthYear } from '~/components/shared/MonthYearPicker';
export const HOME_URL = '/offers/browse';
/* /*
* Offer Profile * Offer Profile
*/ */

@ -30,7 +30,7 @@ export default function ContributeQuestionCard({
return ( return (
<div> <div>
<button <button
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-gray-100" className="flex 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" type="button"
onClick={handleOpenContribute}> onClick={handleOpenContribute}>
<TextInput <TextInput

@ -48,7 +48,7 @@ export default function ContributeQuestionDialog({
leave="ease-in duration-200" leave="ease-in duration-200"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" /> <div className="fixed inset-0 bg-slate-500 bg-opacity-75 transition-opacity" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto"> <div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
@ -66,7 +66,7 @@ export default function ContributeQuestionDialog({
<div className="mt-3 w-full sm:mt-0 sm:text-left"> <div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title <Dialog.Title
as="h3" as="h3"
className="text-lg font-medium leading-6 text-gray-900"> className="text-lg font-medium leading-6 text-slate-900">
Contribute question Contribute question
</Dialog.Title> </Dialog.Title>
<div className="w-full"> <div className="w-full">

@ -212,7 +212,7 @@ export default function BaseQuestionCard({
className={`group flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}> className={`group flex gap-4 rounded-md border border-slate-300 bg-white p-4 ${hoverClass}`}>
{cardContent} {cardContent}
{showDeleteButton && ( {showDeleteButton && (
<div className="invisible self-center fill-red-700 group-hover:visible"> <div className="fill-danger-700 invisible self-center group-hover:visible">
<Button <Button
icon={TrashIcon} icon={TrashIcon}
isLabelHidden={true} isLabelHidden={true}

@ -17,29 +17,26 @@ export type FilterChoices<V extends string = string> = ReadonlyArray<
FilterChoice<V> FilterChoice<V>
>; >;
type FilterSectionType<FilterOptions extends Array<FilterOption>> = type FilterSectionType<V extends string> =
| { | {
isSingleSelect: true; isSingleSelect: true;
onOptionChange: (optionValue: FilterOptions[number]['value']) => void; onOptionChange: (option: FilterOption<V>) => void;
} }
| { | {
isSingleSelect?: false; isSingleSelect?: false;
onOptionChange: ( onOptionChange: (option: FilterOption<V>) => void;
optionValue: FilterOptions[number]['value'],
checked: boolean,
) => void;
}; };
export type FilterSectionProps<FilterOptions extends Array<FilterOption>> = export type FilterSectionProps<V extends string = string> =
FilterSectionType<FilterOptions> & { FilterSectionType<V> & {
label: string; label: string;
options: FilterOptions; options: Array<FilterOption<V>>;
} & ( } & (
| { | {
renderInput: (props: { renderInput: (props: {
field: UseFormRegisterReturn<'search'>; field: UseFormRegisterReturn<'search'>;
onOptionChange: FilterSectionType<FilterOptions>['onOptionChange']; onOptionChange: FilterSectionType<V>['onOptionChange'];
options: FilterOptions; options: Array<FilterOption<V>>;
}) => React.ReactNode; }) => React.ReactNode;
showAll?: never; showAll?: never;
} }
@ -53,16 +50,14 @@ export type FilterSectionFormData = {
search: string; search: string;
}; };
export default function FilterSection< export default function FilterSection<V extends string>({
FilterOptions extends Array<FilterOption>,
>({
label, label,
options, options,
showAll, showAll,
onOptionChange, onOptionChange,
isSingleSelect, isSingleSelect,
renderInput, renderInput,
}: FilterSectionProps<FilterOptions>) { }: FilterSectionProps<V>) {
const { register, reset } = useForm<FilterSectionFormData>(); const { register, reset } = useForm<FilterSectionFormData>();
const registerSearch = register('search'); const registerSearch = register('search');
@ -76,7 +71,9 @@ export default function FilterSection<
}; };
const autocompleteOptions = useMemo(() => { const autocompleteOptions = useMemo(() => {
return options.filter((option) => !option.checked) as FilterOptions; return options.filter((option) => !option.checked) as Array<
FilterOption<V>
>;
}, [options]); }, [options]);
const selectedCount = useMemo(() => { const selectedCount = useMemo(() => {
@ -102,11 +99,12 @@ export default function FilterSection<
<div className="z-10"> <div className="z-10">
{renderInput({ {renderInput({
field, field,
onOptionChange: async ( onOptionChange: async (option: FilterOption<V>) => {
optionValue: FilterOptions[number]['value'],
) => {
reset(); reset();
return onOptionChange(optionValue, true); return onOptionChange({
...option,
checked: true,
});
}, },
options: autocompleteOptions, options: autocompleteOptions,
})} })}
@ -119,7 +117,13 @@ export default function FilterSection<
label={label} label={label}
value={options.find((option) => option.checked)?.value} value={options.find((option) => option.checked)?.value}
onChange={(value) => { onChange={(value) => {
onOptionChange(value); const changedOption = options.find(
(option) => option.value === value,
)!;
onOptionChange({
...changedOption,
checked: !changedOption.checked,
});
}}> }}>
{options.map((option) => ( {options.map((option) => (
<RadioList.Item <RadioList.Item
@ -140,7 +144,10 @@ export default function FilterSection<
label={option.label} label={option.label}
value={option.checked} value={option.checked}
onChange={(checked) => { onChange={(checked) => {
onOptionChange(option.value, checked); onOptionChange({
...option,
checked,
});
}} }}
/> />
))} ))}

@ -186,13 +186,13 @@ export default function ContributeQuestionForm({
</div> </div>
<div className="flex gap-x-2"> <div className="flex gap-x-2">
<button <button
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" className="focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-base font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button" type="button"
onClick={onDiscard}> onClick={onDiscard}>
Discard Discard
</button> </button>
<Button <Button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-gray-400 sm:ml-3 sm:w-auto sm:text-sm" className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!canSubmit} disabled={!canSubmit}
label="Contribute" label="Contribute"
type="submit" type="submit"

@ -1,4 +1,6 @@
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { useState } from 'react';
import { useMemo } from 'react';
import { Button, Typeahead } from '@tih/ui'; import { Button, Typeahead } from '@tih/ui';
import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone'; import type { RequireAllOrNone } from '~/utils/questions/RequireAllOrNone';
@ -7,6 +9,8 @@ type TypeaheadProps = ComponentProps<typeof Typeahead>;
type TypeaheadOption = TypeaheadProps['options'][number]; type TypeaheadOption = TypeaheadProps['options'][number];
export type ExpandedTypeaheadProps = RequireAllOrNone<{ export type ExpandedTypeaheadProps = RequireAllOrNone<{
clearOnSelect?: boolean;
filterOption: (option: TypeaheadOption) => boolean;
onSuggestionClick: (option: TypeaheadOption) => void; onSuggestionClick: (option: TypeaheadOption) => void;
suggestedCount: number; suggestedCount: number;
}> & }> &
@ -15,9 +19,20 @@ export type ExpandedTypeaheadProps = RequireAllOrNone<{
export default function ExpandedTypeahead({ export default function ExpandedTypeahead({
suggestedCount = 0, suggestedCount = 0,
onSuggestionClick, onSuggestionClick,
filterOption = () => true,
clearOnSelect = false,
options,
onSelect,
...typeaheadProps ...typeaheadProps
}: ExpandedTypeaheadProps) { }: ExpandedTypeaheadProps) {
const suggestions = typeaheadProps.options.slice(0, suggestedCount); const [key, setKey] = useState(0);
const filteredOptions = useMemo(() => {
return options.filter(filterOption);
}, [options, filterOption]);
const suggestions = useMemo(
() => filteredOptions.slice(0, suggestedCount),
[filteredOptions, suggestedCount],
);
return ( return (
<div className="flex flex-wrap gap-x-2"> <div className="flex flex-wrap gap-x-2">
@ -32,7 +47,17 @@ export default function ExpandedTypeahead({
/> />
))} ))}
<div className="flex-1"> <div className="flex-1">
<Typeahead {...typeaheadProps} /> <Typeahead
key={key}
options={filteredOptions}
{...typeaheadProps}
onSelect={(option) => {
if (clearOnSelect) {
setKey((key + 1) % 2);
}
onSelect(option);
}}
/>
</div> </div>
</div> </div>
); );

@ -14,11 +14,12 @@ export default function ResumeUserBadge({
return ( return (
<div className="group relative flex items-center justify-center"> <div className="group relative flex items-center justify-center">
<div <div
className="absolute -top-3 hidden w-48 -translate-y-full flex-col className="absolute left-6 z-10 hidden w-48 flex-col
justify-center gap-1 rounded-lg bg-white px-2 py-2 text-center drop-shadow-xl justify-center gap-1 rounded-xl bg-white px-2 py-2 text-center drop-shadow-lg
after:absolute after:left-1/2 after:top-[100%] after:-translate-x-1/2
after:border-8 after:border-x-transparent after:border-b-transparent before:absolute before:top-12 before:-translate-x-6
after:border-t-white after:drop-shadow-lg after:content-[''] before:border-8 before:border-y-transparent before:border-l-transparent
before:border-r-white before:drop-shadow-lg before:content-['']
group-hover:flex"> group-hover:flex">
<Icon className="h-12 w-12 self-center" /> <Icon className="h-12 w-12 self-center" />
<p className="font-medium">{title}</p> <p className="font-medium">{title}</p>

@ -33,7 +33,7 @@ const TIER_ONE = 5;
export const RESUME_USER_BADGES: Array<BadgeInfo> = [ export const RESUME_USER_BADGES: Array<BadgeInfo> = [
{ {
description: `Reviewed over ${TIER_THREE} resumes`, description: `Reviewed ${TIER_THREE} resumes`,
icon: ResumeBadgeSuperheroIcon, icon: ResumeBadgeSuperheroIcon,
id: 'Superhero', id: 'Superhero',
isValid: (payload: BadgePayload) => isValid: (payload: BadgePayload) =>
@ -41,7 +41,7 @@ export const RESUME_USER_BADGES: Array<BadgeInfo> = [
title: 'True saviour of the people', title: 'True saviour of the people',
}, },
{ {
description: `Reviewed over ${TIER_TWO} resumes`, description: `Reviewed ${TIER_TWO} resumes`,
icon: ResumeBadgeDetectiveIcon, icon: ResumeBadgeDetectiveIcon,
id: 'Detective', id: 'Detective',
isValid: (payload: BadgePayload) => isValid: (payload: BadgePayload) =>
@ -50,7 +50,7 @@ export const RESUME_USER_BADGES: Array<BadgeInfo> = [
title: 'Keen eye for details like a private eye', title: 'Keen eye for details like a private eye',
}, },
{ {
description: `Reviewed over ${TIER_ONE} resumes`, description: `Reviewed ${TIER_ONE} resumes`,
icon: ResumeBadgeEagleIcon, icon: ResumeBadgeEagleIcon,
id: 'Eagle', id: 'Eagle',
isValid: (payload: BadgePayload) => isValid: (payload: BadgePayload) =>

@ -14,8 +14,8 @@ export default function ResumeFilterPill({
return ( return (
<button <button
className={clsx( className={clsx(
'rounded-xl border border-indigo-500 border-transparent px-2 py-1 text-xs font-medium focus:bg-indigo-500 focus:text-white', 'border-primary-500 focus:bg-primary-500 rounded-xl border border-transparent px-2 py-1 text-xs font-medium focus:text-white',
isSelected ? 'bg-indigo-500 text-white' : 'bg-white text-indigo-500', isSelected ? 'bg-primary-500 text-white' : 'text-primary-500 bg-white',
)} )}
type="button" type="button"
onClick={onClick}> onClick={onClick}>

@ -22,7 +22,7 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
<div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100"> <div className="grid grid-cols-8 gap-4 border-b border-slate-200 p-4 hover:bg-slate-100">
<div className="col-span-4"> <div className="col-span-4">
{resumeInfo.title} {resumeInfo.title}
<div className="mt-2 flex items-center justify-start text-xs text-indigo-500"> <div className="text-primary-500 mt-2 flex items-center justify-start text-xs">
<div className="flex"> <div className="flex">
<BriefcaseIcon <BriefcaseIcon
aria-hidden="true" aria-hidden="true"

@ -1,4 +1,5 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react'; import { useState } from 'react';
import { ChevronUpIcon } from '@heroicons/react/20/solid'; import { ChevronUpIcon } from '@heroicons/react/20/solid';
import { FaceSmileIcon } from '@heroicons/react/24/outline'; import { FaceSmileIcon } from '@heroicons/react/24/outline';
@ -26,12 +27,7 @@ export default function ResumeCommentListItem({
const [showReplies, setShowReplies] = useState(true); const [showReplies, setShowReplies] = useState(true);
return ( return (
<div <div className="min-w-fit">
className={clsx(
'min-w-fit rounded-md bg-white ',
!comment.parentId &&
'w-11/12 border-2 border-indigo-300 p-2 drop-shadow-md',
)}>
<div className="flex flex-row space-x-2 p-1 align-top"> <div className="flex flex-row space-x-2 p-1 align-top">
{/* Image Icon */} {/* Image Icon */}
{comment.user.image ? ( {comment.user.image ? (
@ -58,23 +54,22 @@ export default function ResumeCommentListItem({
<div className="flex flex-row items-center space-x-1"> <div className="flex flex-row items-center space-x-1">
<p <p
className={clsx( className={clsx(
'font-medium text-black', 'font-medium text-gray-800',
!!comment.parentId && 'text-sm', !!comment.parentId && 'text-sm',
)}> )}>
{comment.user.name ?? 'Reviewer ABC'} {comment.user.name ?? 'Reviewer ABC'}
</p> </p>
<p className="text-xs font-medium text-indigo-800"> <p className="text-primary-800 text-xs font-medium">
{isCommentOwner ? '(Me)' : ''} {isCommentOwner ? '(Me)' : ''}
</p> </p>
<ResumeUserBadges userId={comment.user.userId} /> <ResumeUserBadges userId={comment.user.userId} />
</div> </div>
<div className="px-2 text-xs text-gray-600"> <div className="px-2 text-xs text-slate-600">
{comment.createdAt.toLocaleString('en-US', { {formatDistanceToNow(comment.createdAt, {
dateStyle: 'medium', addSuffix: true,
timeStyle: 'short',
})} })}
</div> </div>
</div> </div>
@ -86,10 +81,12 @@ export default function ResumeCommentListItem({
setIsEditingComment={setIsEditingComment} setIsEditingComment={setIsEditingComment}
/> />
) : ( ) : (
<div className="text-gray-800">
<ResumeExpandableText <ResumeExpandableText
key={comment.description} key={comment.description}
text={comment.description} text={comment.description}
/> />
</div>
)} )}
{/* Upvote and edit */} {/* Upvote and edit */}
@ -101,7 +98,7 @@ export default function ResumeCommentListItem({
<> <>
{isCommentOwner && ( {isCommentOwner && (
<button <button
className="px-1 text-xs text-indigo-800 hover:text-indigo-400" className="text-primary-800 hover:text-primary-400 px-1 text-xs"
type="button" type="button"
onClick={() => setIsEditingComment(true)}> onClick={() => setIsEditingComment(true)}>
Edit Edit
@ -110,7 +107,7 @@ export default function ResumeCommentListItem({
{!comment.parentId && ( {!comment.parentId && (
<button <button
className="px-1 text-xs text-indigo-800 hover:text-indigo-400" className="text-primary-800 hover:text-primary-400 px-1 text-xs"
type="button" type="button"
onClick={() => setIsReplyingComment(true)}> onClick={() => setIsReplyingComment(true)}>
Reply Reply
@ -134,7 +131,7 @@ export default function ResumeCommentListItem({
{comment.children.length > 0 && ( {comment.children.length > 0 && (
<div className="min-w-fit space-y-1 pt-2"> <div className="min-w-fit space-y-1 pt-2">
<button <button
className="flex items-center space-x-1 rounded-md text-xs font-medium text-indigo-800 hover:text-indigo-300" className="text-primary-800 hover:text-primary-300 flex items-center space-x-1 rounded-md text-xs font-medium"
type="button" type="button"
onClick={() => setShowReplies(!showReplies)}> onClick={() => setShowReplies(!showReplies)}>
<ChevronUpIcon <ChevronUpIcon
@ -143,16 +140,24 @@ export default function ResumeCommentListItem({
!showReplies && 'rotate-180 transform', !showReplies && 'rotate-180 transform',
)} )}
/> />
<span>{showReplies ? 'Hide replies' : 'Show replies'}</span> <span>
{showReplies
? `Hide ${
comment.children.length === 1 ? 'reply' : 'replies'
}`
: `Show ${comment.children.length} ${
comment.children.length === 1 ? 'reply' : 'replies'
}`}
</span>
</button> </button>
{showReplies && ( {showReplies && (
<div className="flex flex-row"> <div className="flex flex-row">
<div className="relative flex flex-col px-2 py-2"> <div className="relative flex flex-col px-2 py-2">
<div className="flex-grow border-r border-gray-300" /> <div className="flex-grow border-r border-slate-300" />
</div> </div>
<div className="flex flex-col space-y-1"> <div className="flex flex-1 flex-col space-y-1">
{comment.children.map((child) => { {comment.children.map((child) => {
return ( return (
<ResumeCommentListItem <ResumeCommentListItem

@ -83,14 +83,14 @@ export default function ResumeCommentsForm({
}; };
return ( return (
<div className="h-[calc(100vh-13rem)] overflow-y-auto"> <div className="h-[calc(100vh-13rem)] overflow-y-auto pb-4">
<h2 className="text-xl font-semibold text-gray-800">Add your review</h2> <h2 className="text-xl font-semibold text-slate-800">Add your review</h2>
<p className="text-gray-800"> <p className="text-slate-800">
Please fill in at least one section to submit your review Please fill in at least one section to submit your review
</p> </p>
<form <form
className="w-full space-y-8 divide-y divide-gray-200" className="w-full space-y-8 divide-y divide-slate-200"
onSubmit={handleSubmit(onSubmit)}> onSubmit={handleSubmit(onSubmit)}>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<TextArea <TextArea

@ -1,7 +1,9 @@
import clsx from 'clsx';
import { useSession } from 'next-auth/react'; import { useSession } from 'next-auth/react';
import { import {
BookOpenIcon, BookOpenIcon,
BriefcaseIcon, BriefcaseIcon,
ChatBubbleLeftRightIcon,
CodeBracketSquareIcon, CodeBracketSquareIcon,
FaceSmileIcon, FaceSmileIcon,
IdentificationIcon, IdentificationIcon,
@ -9,24 +11,20 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { ResumesSection } from '@prisma/client'; import { ResumesSection } from '@prisma/client';
import { Spinner } from '@tih/ui'; import { Spinner } from '@tih/ui';
import { Button } from '@tih/ui';
import { trpc } from '~/utils/trpc'; import { trpc } from '~/utils/trpc';
import { RESUME_COMMENTS_SECTIONS } from './resumeCommentConstants'; import { RESUME_COMMENTS_SECTIONS } from './resumeCommentConstants';
import ResumeCommentListItem from './ResumeCommentListItem'; import ResumeCommentListItem from './ResumeCommentListItem';
import ResumeSignInButton from '../shared/ResumeSignInButton';
import type { ResumeComment } from '~/types/resume-comments'; import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentsListProps = Readonly<{ type ResumeCommentsListProps = Readonly<{
resumeId: string; resumeId: string;
setShowCommentsForm: (show: boolean) => void;
}>; }>;
export default function ResumeCommentsList({ export default function ResumeCommentsList({
resumeId, resumeId,
setShowCommentsForm,
}: ResumeCommentsListProps) { }: ResumeCommentsListProps) {
const { data: sessionData } = useSession(); const { data: sessionData } = useSession();
@ -50,31 +48,14 @@ export default function ResumeCommentsList({
} }
}; };
const renderButton = () => {
if (sessionData === null) {
return <ResumeSignInButton text="to join discussion" />;
}
return (
<Button
className="-mb-2"
display="block"
label="Add your review"
variant="tertiary"
onClick={() => setShowCommentsForm(true)}
/>
);
};
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{renderButton()}
{commentsQuery.isLoading ? ( {commentsQuery.isLoading ? (
<div className="col-span-10 pt-4"> <div className="col-span-10 pt-4">
<Spinner display="block" size="lg" /> <Spinner display="block" size="lg" />
</div> </div>
) : ( ) : (
<div className="m-2 flow-root h-[calc(100vh-17rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden pt-14 pb-6"> <div className="mb-8 flow-root h-[calc(100vh-13rem)] w-full flex-col space-y-4 overflow-y-auto overflow-x-hidden">
{RESUME_COMMENTS_SECTIONS.map(({ label, value }) => { {RESUME_COMMENTS_SECTIONS.map(({ label, value }) => {
const comments = commentsQuery.data const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => { ? commentsQuery.data.filter((comment: ResumeComment) => {
@ -85,12 +66,18 @@ export default function ResumeCommentsList({
return ( return (
<div key={value} className="mb-4 space-y-4"> <div key={value} className="mb-4 space-y-4">
<div className="flex flex-row items-center space-x-2 text-indigo-800"> <div className="text-primary-800 flex flex-row items-center space-x-2">
{renderIcon(value)} {renderIcon(value)}
<div className="w-fit text-lg font-medium">{label}</div> <div className="w-fit text-lg font-medium">{label}</div>
</div> </div>
<div className="w-full space-y-4 pr-4">
<div
className={clsx(
'space-y-2 rounded-md border-2 bg-white px-4 py-3 drop-shadow-md',
commentCount ? 'border-slate-300' : 'border-slate-300',
)}>
{commentCount > 0 ? ( {commentCount > 0 ? (
comments.map((comment) => { comments.map((comment) => {
return ( return (
@ -102,9 +89,21 @@ export default function ResumeCommentsList({
); );
}) })
) : ( ) : (
<div>There are no comments for this section yet!</div> <div className="flex flex-row items-center text-sm">
<ChatBubbleLeftRightIcon className="mr-2 h-6 w-6 text-slate-500" />
<div className="text-slate-500">
There are no comments for this section yet!
</div>
</div>
)} )}
</div> </div>
</div>
<div className="relative flex flex-row pr-6 pt-2">
<div className="flex-grow border-t border-gray-300" />
</div>
</div>
); );
})} })}
</div> </div>

@ -1,40 +0,0 @@
import { useState } from 'react';
import ResumeCommentsForm from './ResumeCommentsForm';
import ResumeCommentsList from './ResumeCommentsList';
type CommentsSectionProps = {
resumeId: string;
};
export default function ResumeCommentsSection({
resumeId,
}: CommentsSectionProps) {
const [showCommentsForm, setShowCommentsForm] = useState(false);
return (
<>
<div className="relative p-2 lg:hidden">
<div aria-hidden="true" className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-50 px-3 text-lg font-medium text-gray-900">
Reviews
</span>
</div>
</div>
{showCommentsForm ? (
<ResumeCommentsForm
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
) : (
<ResumeCommentsList
resumeId={resumeId}
setShowCommentsForm={setShowCommentsForm}
/>
)}
</>
);
}

@ -86,18 +86,18 @@ export default function ResumeCommentVoteButtons({
'h-4 w-4', 'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE || commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
upvoteAnimation upvoteAnimation
? 'fill-indigo-500' ? 'fill-primary-500'
: 'fill-gray-400', : 'fill-slate-400',
userId && userId &&
!downvoteAnimation && !downvoteAnimation &&
!upvoteAnimation && !upvoteAnimation &&
'hover:fill-indigo-500', 'hover:fill-primary-500',
upvoteAnimation && 'animate-[bounce_0.5s_infinite] cursor-default', upvoteAnimation && 'animate-[bounce_0.5s_infinite] cursor-default',
)} )}
/> />
</button> </button>
<div className="flex min-w-[1rem] justify-center text-xs"> <div className="flex min-w-[1rem] justify-center text-xs font-semibold text-gray-700">
{commentVotesQuery.data?.numVotes ?? 0} {commentVotesQuery.data?.numVotes ?? 0}
</div> </div>
@ -115,12 +115,12 @@ export default function ResumeCommentVoteButtons({
'h-4 w-4', 'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE || commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
downvoteAnimation downvoteAnimation
? 'fill-red-500' ? 'fill-danger-500'
: 'fill-gray-400', : 'fill-slate-400',
userId && userId &&
!downvoteAnimation && !downvoteAnimation &&
!upvoteAnimation && !upvoteAnimation &&
'hover:fill-red-500', 'hover:fill-danger-500',
downvoteAnimation && downvoteAnimation &&
'animate-[bounce_0.5s_infinite] cursor-default', 'animate-[bounce_0.5s_infinite] cursor-default',
)} )}

@ -7,16 +7,16 @@ export function CallToAction() {
<section className="relative overflow-hidden py-32" id="get-started-today"> <section className="relative overflow-hidden py-32" id="get-started-today">
<Container className="relative"> <Container className="relative">
<div className="mx-auto max-w-lg text-center"> <div className="mx-auto max-w-lg text-center">
<h2 className="font-display text-3xl tracking-tight text-gray-900 sm:text-4xl"> <h2 className="font-display text-3xl tracking-tight text-slate-900 sm:text-4xl">
Resume review can start right now. Resume review can start right now.
</h2> </h2>
<p className="mt-4 text-lg tracking-tight text-gray-600"> <p className="mt-4 text-lg tracking-tight text-slate-600">
It's free! Take charge of your resume game by learning from the top It's free! Take charge of your resume game by learning from the top
engineers in the field. engineers in the field.
</p> </p>
<Link href="/resumes/browse"> <Link href="/resumes/browse">
<button <button
className="mt-4 rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white" className="bg-primary-500 mt-4 rounded-md py-2 px-3 text-sm font-medium text-white"
type="button"> type="button">
Start browsing now Start browsing now
</button> </button>

@ -7,7 +7,7 @@ export function Hero() {
<Container className="pb-36 pt-20 text-center lg:pt-32"> <Container className="pb-36 pt-20 text-center lg:pt-32">
<h1 className="font-display mx-auto max-w-4xl text-5xl font-medium tracking-tight text-slate-900 sm:text-7xl"> <h1 className="font-display mx-auto max-w-4xl text-5xl font-medium tracking-tight text-slate-900 sm:text-7xl">
Resume review{' '} Resume review{' '}
<span className="relative whitespace-nowrap text-indigo-500"> <span className="text-primary-500 relative whitespace-nowrap">
<svg <svg
aria-hidden="true" aria-hidden="true"
className="absolute top-2/3 left-0 h-[0.58em] w-full fill-blue-300/70" className="absolute top-2/3 left-0 h-[0.58em] w-full fill-blue-300/70"
@ -26,18 +26,18 @@ export function Hero() {
<div className="mt-10 flex justify-center gap-x-4"> <div className="mt-10 flex justify-center gap-x-4">
<Link href="/resumes/browse"> <Link href="/resumes/browse">
<button <button
className="rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white" className="bg-primary-500 rounded-md py-2 px-3 text-sm font-medium text-white"
type="button"> type="button">
Start browsing now Start browsing now
</button> </button>
</Link> </Link>
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ"> <Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
<button <button
className="group inline-flex items-center justify-center rounded-md py-2 px-4 text-sm ring-1 ring-slate-200 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:outline-indigo-600 focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600" className="focus-visible:outline-primary-600 group inline-flex items-center justify-center rounded-md py-2 px-4 text-sm ring-1 ring-slate-200 hover:text-slate-900 hover:ring-slate-300 focus:outline-none focus-visible:ring-slate-300 active:bg-slate-100 active:text-slate-600"
type="button"> type="button">
<svg <svg
aria-hidden="true" aria-hidden="true"
className="h-3 w-3 flex-none fill-indigo-600 group-active:fill-current"> className="fill-primary-600 h-3 w-3 flex-none group-active:fill-current">
<path d="m9.997 6.91-7.583 3.447A1 1 0 0 1 1 9.447V2.553a1 1 0 0 1 1.414-.91L9.997 5.09c.782.355.782 1.465 0 1.82Z" /> <path d="m9.997 6.91-7.583 3.447A1 1 0 0 1 1 9.447V2.553a1 1 0 0 1 1.414-.91L9.997 5.09c.782.355.782 1.465 0 1.82Z" />
</svg> </svg>
<span className="ml-3">Watch video</span> <span className="ml-3">Watch video</span>

@ -49,7 +49,7 @@ export function PrimaryFeatures() {
return ( return (
<section <section
className="relative overflow-hidden bg-gradient-to-r from-indigo-400 to-indigo-700 pt-20 pb-28 sm:py-32" className="from-primary-400 to-primary-700 relative overflow-hidden bg-gradient-to-r pt-20 pb-28 sm:py-32"
id="features"> id="features">
<Container className="relative"> <Container className="relative">
<div className="max-w-2xl md:mx-auto md:text-center xl:max-w-none"> <div className="max-w-2xl md:mx-auto md:text-center xl:max-w-none">

@ -94,7 +94,7 @@ function QuoteIcon(props: QuoteProps) {
export function Testimonials() { export function Testimonials() {
return ( return (
<section <section
className="bg-gradient-to-r from-indigo-700 to-indigo-400 py-20 sm:py-32" className="from-primary-700 to-primary-400 bg-gradient-to-r py-20 sm:py-32"
id="testimonials"> id="testimonials">
<Container> <Container>
<div className="mx-auto max-w-2xl md:text-center"> <div className="mx-auto max-w-2xl md:text-center">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1006 KiB

After

Width:  |  Height:  |  Size: 909 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 437 KiB

After

Width:  |  Height:  |  Size: 396 KiB

@ -36,7 +36,7 @@ export default function ResumeExpandableText({
</span> </span>
{descriptionOverflow && ( {descriptionOverflow && (
<p <p
className="mt-1 cursor-pointer text-xs text-indigo-500 hover:text-indigo-300" className="text-primary-500 hover:text-primary-300 mt-1 cursor-pointer text-xs"
onClick={onSeeActionClicked}> onClick={onSeeActionClicked}>
{isExpanded ? 'See Less' : 'See More'} {isExpanded ? 'See Less' : 'See More'}
</p> </p>

@ -8,10 +8,10 @@ type Props = Readonly<{
export default function ResumeSignInButton({ text, className }: Props) { export default function ResumeSignInButton({ text, className }: Props) {
return ( return (
<div className={clsx('flex justify-center pt-4', className)}> <div className={clsx('flex justify-center', className)}>
<p> <p>
<a <a
className="text-primary-800 hover:text-primary-500" className="text-indigo-500 hover:text-indigo-600"
href="/api/auth/signin" href="/api/auth/signin"
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();

@ -9,6 +9,7 @@ type Props = Readonly<{
isLabelHidden?: boolean; isLabelHidden?: boolean;
onSelect: (option: TypeaheadOption) => void; onSelect: (option: TypeaheadOption) => void;
placeHolder?: string; placeHolder?: string;
required?: boolean;
}>; }>;
export default function CompaniesTypeahead({ export default function CompaniesTypeahead({
@ -16,6 +17,7 @@ export default function CompaniesTypeahead({
onSelect, onSelect,
isLabelHidden, isLabelHidden,
placeHolder, placeHolder,
required,
}: Props) { }: Props) {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const companies = trpc.useQuery([ const companies = trpc.useQuery([
@ -42,6 +44,7 @@ export default function CompaniesTypeahead({
})) ?? [] })) ?? []
} }
placeholder={placeHolder} placeholder={placeHolder}
required={required}
onQueryChange={setQuery} onQueryChange={setQuery}
onSelect={onSelect} onSelect={onSelect}
/> />

@ -0,0 +1,31 @@
export const JobTitleLabels = {
'ai-ml-engineer': 'AI/ML Engineer',
'algorithms-engineer': 'Algorithms Engineer',
'android-engineer': 'Android Software Engineer',
'applications-engineer': 'Applications Engineer',
'back-end-engineer': 'Back End Engineer',
'business-engineer': 'Business Engineer',
'data-engineer': 'Data Engineer',
'devops-engineer': 'DevOps Engineer',
'enterprise-engineer': 'Enterprise Engineer',
'front-end-engineer': 'Front End Engineer',
'hardware-engineer': 'Hardware Engineer',
'ios-engineer': 'iOS Software Engineer',
'mobile-engineer': 'Mobile Software Engineer (iOS + Android)',
'networks-engineer': 'Networks Engineer',
'partner-engineer': 'Partner Engineer',
'production-engineer': 'Production Engineer',
'research-engineer': 'Research Engineer',
'sales-engineer': 'Sales Engineer',
'security-engineer': 'Security Engineer',
'site-reliability-engineer': 'Site Reliability Engineer (SRE)',
'software-engineer': 'Software Engineer',
'systems-engineer': 'Systems Engineer',
'test-engineer': 'QA/Test Engineer (SDET)',
};
export type JobTitleType = keyof typeof JobTitleLabels;
export function getLabelForJobTitleType(jobTitle: JobTitleType): string {
return JobTitleLabels[jobTitle];
}

@ -0,0 +1,48 @@
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;
onSelect: (option: TypeaheadOption) => void;
placeHolder?: string;
required?: boolean;
}>;
export default function JobTitlesTypeahead({
disabled,
onSelect,
isLabelHidden,
placeHolder,
required,
}: Props) {
const [query, setQuery] = useState('');
const options = Object.entries(JobTitleLabels)
.map(([slug, label]) => ({
id: slug,
label,
value: slug,
}))
.filter(
({ label }) =>
label.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) > -1,
);
return (
<Typeahead
disabled={disabled}
isLabelHidden={isLabelHidden}
label="Job Title"
noResultsMessage="No available job titles."
nullable={true}
options={options}
placeholder={placeHolder}
required={required}
onQueryChange={setQuery}
onSelect={onSelect}
/>
);
}

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

@ -0,0 +1,45 @@
import { useState } from 'react';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('software-engineer');
const [companyFilter, setCompanyFilter] = useState('');
return (
<main className="flex-1 overflow-y-auto">
<div className="grid-rows grid h-1/2 bg-slate-100">
<OffersTitle />
<div className="flex items-start justify-center">
<div className="mt-4 flex items-center">
Viewing offers for
<div className="mx-4">
<JobTitlesTypeahead
isLabelHidden={true}
placeHolder="Software Engineer"
onSelect={({ value }) => setjobTitleFilter(value)}
/>
</div>
in
<div className="ml-4">
<CompaniesTypeahead
isLabelHidden={true}
placeHolder="All Companies"
onSelect={({ value }) => setCompanyFilter(value)}
/>
</div>
</div>
</div>
</div>
<div className="flex justify-center bg-white pb-20 pt-10">
<OffersTable
companyFilter={companyFilter}
jobTitleFilter={jobTitleFilter}
/>
</div>
</main>
);
}

@ -1,48 +1,244 @@
import { useState } from 'react'; import type { SVGProps } from 'react';
import { Select } from '@tih/ui'; import {
BookmarkSquareIcon,
ChartBarSquareIcon,
InformationCircleIcon,
ShareIcon,
TableCellsIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
import { titleOptions } from '~/components/offers/constants'; import LeftTextCard from '~/components/offers/landing/LeftTextCard';
import OffersTitle from '~/components/offers/OffersTitle'; import RightTextCard from '~/components/offers/landing/RightTextCard';
import OffersTable from '~/components/offers/table/OffersTable'; import { HOME_URL } from '~/components/offers/types';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
export default function OffersHomePage() { const features = [
const [jobTitleFilter, setjobTitleFilter] = useState('Software Engineer'); {
const [companyFilter, setCompanyFilter] = useState(''); description:
'Profile names are randomly generated to keep your offers strictly anonymous.',
icon: UsersIcon,
name: 'Anonymized Profile Name',
},
{
description:
'Only users with the edit link can edit that profile. Share profiles to others using a public link without giving edit permission.',
icon: ShareIcon,
name: 'Edit Link v.s. Public Link',
},
{
description:
"Offer profiles will not be automatically saved under creators' account in our database unless explicit permission is given.",
icon: BookmarkSquareIcon,
name: 'Save with Permission',
},
];
return ( const footerNavigation = {
<main className="flex-1 overflow-y-auto"> social: [
<div className="grid-rows grid h-1/2 bg-gray-100"> {
<OffersTitle /> href: '#',
<div className="flex items-start justify-center"> icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<div className="mt-4 flex items-center"> <svg fill="currentColor" viewBox="0 0 24 24" {...props}>
Viewing offers for <path
<div className="mx-4"> clipRule="evenodd"
<Select d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z"
isLabelHidden={true} fillRule="evenodd"
label="Select a job title" />
options={titleOptions} </svg>
value={jobTitleFilter} ),
onChange={setjobTitleFilter} name: 'Facebook',
},
{
href: '#',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z"
fillRule="evenodd"
/> />
</svg>
),
name: 'Instagram',
},
{
href: 'https://github.com/yangshun/tech-interview-handbook',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
fillRule="evenodd"
/>
</svg>
),
name: 'GitHub',
},
],
};
export default function LandingPage() {
return (
<div className="mx-auto w-full overflow-y-auto bg-white">
<main>
{/* Hero section */}
<div className="relative h-full">
<div className="relative px-4 py-16 sm:px-6 sm:py-24 lg:py-32 lg:px-8">
<h1 className="text-center text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl">
<span>Choosing offers </span>
<span className="from-primary-600 -mb-1 mr-2 bg-gradient-to-r to-purple-500 bg-clip-text pb-1 pr-4 italic text-transparent">
made easier
</span>
</h1>
<p className="mx-auto mt-6 max-w-lg text-center text-xl sm:max-w-3xl">
Analyze your offers using profiles from fellow software engineers.
</p>
<div className="mx-auto mt-10 max-w-sm sm:flex sm:max-w-none sm:justify-center">
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-2 sm:gap-5 sm:space-y-0">
<a
className="border-grey-600 flex items-center justify-center rounded-md border bg-white bg-white px-4 py-3 text-base font-medium text-indigo-700 shadow-sm hover:bg-indigo-50 sm:px-8"
href={HOME_URL}>
Get started
</a>
<a
className="bg-primary-500 flex items-center justify-center rounded-md border border-transparent px-4 py-3 text-base font-medium text-white shadow-sm hover:bg-opacity-70 sm:px-8"
href="#">
Live demo
</a>
</div>
</div>
</div>
</div> </div>
in
<div className="ml-4"> {/* Alternating Feature Sections */}
<CompaniesTypeahead <div className="relative overflow-hidden pt-16 pb-32">
isLabelHidden={true} <div
placeHolder="All companies" aria-hidden="true"
onSelect={({ value }) => setCompanyFilter(value)} className="absolute inset-x-0 top-0 h-48 bg-gradient-to-b from-gray-100"
/>
<div className="relative">
<LeftTextCard
description="An offer profile includes not only offers that a person received in their application cycle, but also background information such as education and work experience. Use offer profiles to help you better contextualize offers."
icon={
<InformationCircleIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-1.jpg"
title="Choosing an offer needs context"
/> />
</div> </div>
<div className="mt-36">
<RightTextCard
description="With our offer engine analysis, you can compare your offers with other offers on the market and make an informed decision."
icon={
<ChartBarSquareIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Customer profile user interface"
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-2.jpg"
title="Better understand your offers"
/>
</div> </div>
<div className="mt-36">
<LeftTextCard
description="Filter relevant offers by job title, company, submission date, salary and more."
icon={
<TableCellsIcon
aria-hidden="true"
className="h-6 w-6 text-white"
/>
}
imageAlt="Offer table page"
imageSrc="https://tailwindui.com/img/component-images/inbox-app-screenshot-1.jpg"
title="Stay informed of recent offers"
/>
</div> </div>
</div> </div>
<div className="flex justify-center bg-white pb-20 pt-10">
<OffersTable {/* Gradient Feature Section */}
companyFilter={companyFilter} <div className="to-primary-600 bg-gradient-to-r from-purple-800">
jobTitleFilter={jobTitleFilter} <div className="mx-auto max-w-4xl px-4 py-16 sm:px-6 sm:pt-20 sm:pb-24 lg:max-w-7xl lg:px-8 lg:pt-24">
<h2 className="flex justify-center text-4xl font-bold tracking-tight text-white">
Your privacy is our priority.
</h2>
<p className="text-primary-100 mt-4 flex flex-row justify-center text-lg">
All offer profiles are anonymized and we do not store information
about your personal identity.
</p>
<div className="mt-12 grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 lg:mt-16 lg:grid-cols-3 lg:gap-x-8 lg:gap-y-16">
{features.map((feature) => (
<div key={feature.name}>
<div>
<span className="flex h-12 w-12 items-center justify-center rounded-md bg-white bg-opacity-10">
<feature.icon
aria-hidden="true"
className="h-6 w-6 text-white"
/> />
</span>
</div>
<div className="mt-6">
<h3 className="text-lg font-medium text-white">
{feature.name}
</h3>
<p className="text-primary-100 mt-2 text-base">
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* CTA Section */}
<div className="bg-white">
<div className="mx-auto max-w-4xl py-16 px-4 sm:px-6 sm:py-24 lg:flex lg:max-w-7xl lg:items-center lg:justify-between lg:px-8">
<h2 className="text-4xl font-bold tracking-tight text-gray-900 sm:text-4xl">
<span className="block">Ready to get started?</span>
<span className="to-primary-600 -mb-1 block bg-gradient-to-r from-purple-600 bg-clip-text pb-1 text-transparent">
Create your own offer profile today.
</span>
</h2>
<div className="mt-6 space-y-4 sm:flex sm:space-y-0 sm:space-x-5">
<a
className="to-primary-500 flex items-center justify-center rounded-md border border-transparent bg-gradient-to-r from-purple-600 bg-origin-border px-4 py-3 text-base font-medium text-white shadow-sm hover:from-purple-700 hover:to-indigo-700"
href={HOME_URL}>
Get Started
</a>
</div>
</div>
</div> </div>
</main> </main>
<footer aria-labelledby="footer-heading" className="bg-gray-50">
<h2 className="sr-only" id="footer-heading">
Footer
</h2>
<div className="mx-auto max-w-7xl px-4 pt-0 pb-8 sm:px-6 lg:px-8">
<div className="mt-12 border-t border-gray-200 pt-8 md:flex md:items-center md:justify-between lg:mt-16">
<div className="flex space-x-6 md:order-2">
{footerNavigation.social.map((item) => (
<a
key={item.name}
className="text-gray-400 hover:text-gray-500"
href={item.href}>
<span className="sr-only">{item.name}</span>
<item.icon aria-hidden="true" className="h-6 w-6" />
</a>
))}
</div>
<p className="mt-8 text-base text-gray-400 md:order-1 md:mt-0">
&copy; 2022 Tech Interview Handbook Offer Profile Repository. All
rights reserved.
</p>
</div>
</div>
</footer>
</div>
); );
} }

@ -10,7 +10,11 @@ import type {
BackgroundDisplayData, BackgroundDisplayData,
OfferDisplayData, OfferDisplayData,
} from '~/components/offers/types'; } from '~/components/offers/types';
import { HOME_URL } from '~/components/offers/types';
import type { JobTitleType } from '~/components/shared/JobTitles';
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
import { useToast } from '~/../../../packages/ui/dist';
import { convertMoneyToString } from '~/utils/offers/currency'; import { convertMoneyToString } from '~/utils/offers/currency';
import { getProfilePath } from '~/utils/offers/link'; import { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time'; import { formatDate } from '~/utils/offers/time';
@ -19,6 +23,7 @@ import { trpc } from '~/utils/trpc';
import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers'; import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers';
export default function OfferProfile() { export default function OfferProfile() {
const { showToast } = useToast();
const ErrorPage = ( const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." /> <Error statusCode={404} title="Requested profile does not exist." />
); );
@ -42,7 +47,7 @@ export default function OfferProfile() {
enabled: typeof offerProfileId === 'string', enabled: typeof offerProfileId === 'string',
onSuccess: (data: Profile) => { onSuccess: (data: Profile) => {
if (!data) { if (!data) {
router.push('/offers'); router.push(HOME_URL);
} }
// If the profile is not editable with a wrong token, redirect to the profile page // If the profile is not editable with a wrong token, redirect to the profile page
if (!data?.isEditable && token !== '') { if (!data?.isEditable && token !== '') {
@ -60,7 +65,9 @@ export default function OfferProfile() {
companyName: res.company.name, companyName: res.company.name,
id: res.offersFullTime.id, id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level, jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title, jobTitle: getLabelForJobTitleType(
res.offersFullTime.title as JobTitleType,
),
location: res.location, location: res.location,
negotiationStrategy: res.negotiationStrategy, negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments, otherComment: res.comments,
@ -75,7 +82,9 @@ export default function OfferProfile() {
const filteredOffer: OfferDisplayData = { const filteredOffer: OfferDisplayData = {
companyName: res.company.name, companyName: res.company.name,
id: res.offersIntern!.id, id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title, jobTitle: getLabelForJobTitleType(
res.offersIntern!.title as JobTitleType,
),
location: res.location, location: res.location,
monthlySalary: convertMoneyToString( monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary, res.offersIntern!.monthlySalary,
@ -105,7 +114,9 @@ export default function OfferProfile() {
companyName: experience.company?.name, companyName: experience.company?.name,
duration: experience.durationInMonths, duration: experience.durationInMonths,
jobLevel: experience.level, jobLevel: experience.level,
jobTitle: experience.title, jobTitle: experience.title
? getLabelForJobTitleType(experience.title as JobTitleType)
: null,
monthlySalary: experience.monthlySalary monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary) ? convertMoneyToString(experience.monthlySalary)
: null, : null,
@ -131,11 +142,18 @@ export default function OfferProfile() {
const trpcContext = trpc.useContext(); const trpcContext = trpc.useContext();
const deleteMutation = trpc.useMutation(['offers.profile.delete'], { const deleteMutation = trpc.useMutation(['offers.profile.delete'], {
onError: () => { onError: () => {
alert('Error deleting profile'); // TODO: replace with toast showToast({
title: `Error deleting offers profile.`,
variant: 'failure',
});
}, },
onSuccess: () => { onSuccess: () => {
trpcContext.invalidateQueries(['offers.profile.listOne']); trpcContext.invalidateQueries(['offers.profile.listOne']);
router.push('/offers'); router.push(HOME_URL);
showToast({
title: `Offers profile successfully deleted!`,
variant: 'success',
});
}, },
}); });

@ -5,24 +5,22 @@ import { useEffect, useMemo, useState } from 'react';
import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid'; import { Bars3BottomLeftIcon } from '@heroicons/react/20/solid';
import { NoSymbolIcon } from '@heroicons/react/24/outline'; import { NoSymbolIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
import { Button, SlideOut, Typeahead } from '@tih/ui'; import { Button, SlideOut } from '@tih/ui';
import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard'; import QuestionOverviewCard from '~/components/questions/card/question/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard'; import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOption } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection'; import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar'; import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
import CompanyTypeahead from '~/components/questions/typeahead/CompanyTypeahead';
import LocationTypeahead from '~/components/questions/typeahead/LocationTypeahead';
import RoleTypeahead from '~/components/questions/typeahead/RoleTypeahead';
import type { QuestionAge } from '~/utils/questions/constants'; import type { QuestionAge } from '~/utils/questions/constants';
import { SORT_TYPES } from '~/utils/questions/constants'; import { SORT_TYPES } from '~/utils/questions/constants';
import { SORT_ORDERS } from '~/utils/questions/constants'; import { SORT_ORDERS } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants'; import { APP_TITLE } from '~/utils/questions/constants';
import { ROLES } from '~/utils/questions/constants'; import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug'; import createSlug from '~/utils/questions/createSlug';
import { import {
useSearchParam, useSearchParam,
@ -148,12 +146,18 @@ export default function QuestionsBrowsePage() {
: undefined; : undefined;
}, [selectedQuestionAge]); }, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery( const {
data: questionsQueryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.useInfiniteQuery(
[ [
'questions.questions.getQuestionsByFilter', 'questions.questions.getQuestionsByFilter',
{ {
companyNames: selectedCompanies, companyNames: selectedCompanies,
endDate: today, endDate: today,
limit: 10,
locations: selectedLocations, locations: selectedLocations,
questionTypes: selectedQuestionTypes, questionTypes: selectedQuestionTypes,
roles: selectedRoles, roles: selectedRoles,
@ -163,10 +167,21 @@ export default function QuestionsBrowsePage() {
}, },
], ],
{ {
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true, keepPreviousData: true,
}, },
); );
const questionCount = useMemo(() => {
if (!questionsQueryData) {
return undefined;
}
return questionsQueryData.pages.reduce(
(acc, page) => acc + page.data.length,
0,
);
}, [questionsQueryData]);
const utils = trpc.useContext(); const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation( const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create', 'questions.questions.create',
@ -180,12 +195,17 @@ export default function QuestionsBrowsePage() {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const companyFilterOptions = useMemo(() => { const [selectedCompanyOptions, setSelectedCompanyOptions] = useState<
return COMPANIES.map((company) => ({ Array<FilterOption>
...company, >([]);
checked: selectedCompanies.includes(company.value),
})); const [selectedRoleOptions, setSelectedRoleOptions] = useState<
}, [selectedCompanies]); Array<FilterOption>
>([]);
const [selectedLocationOptions, setSelectedLocationOptions] = useState<
Array<FilterOption>
>([]);
const questionTypeFilterOptions = useMemo(() => { const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({ return QUESTION_TYPES.map((questionType) => ({
@ -201,20 +221,6 @@ export default function QuestionsBrowsePage() {
})); }));
}, [selectedQuestionAge]); }, [selectedQuestionAge]);
const roleFilterOptions = useMemo(() => {
return ROLES.map((role) => ({
...role,
checked: selectedRoles.includes(role.value),
}));
}, [selectedRoles]);
const locationFilterOptions = useMemo(() => {
return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
const areSearchOptionsInitialized = useMemo(() => { const areSearchOptionsInitialized = useMemo(() => {
return ( return (
areCompaniesInitialized && areCompaniesInitialized &&
@ -287,35 +293,89 @@ export default function QuestionsBrowsePage() {
setSelectedQuestionAge('all'); setSelectedQuestionAge('all');
setSelectedRoles([]); setSelectedRoles([]);
setSelectedLocations([]); setSelectedLocations([]);
setSelectedCompanyOptions([]);
setSelectedRoleOptions([]);
setSelectedLocationOptions([]);
}} }}
/> />
<FilterSection <FilterSection
label="Company" label="Companies"
options={companyFilterOptions} options={selectedCompanyOptions}
renderInput={({ renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
onOptionChange, <CompanyTypeahead
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field} {...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedCompanyOptions.some((selectedOption) => {
return selectedOption.value === option.value;
});
}}
isLabelHidden={true} isLabelHidden={true}
label="Companies"
options={options}
placeholder="Search companies" placeholder="Search companies"
// eslint-disable-next-line @typescript-eslint/no-empty-function onSelect={(option) => {
onQueryChange={() => {}} onOptionChange({
onSelect={({ value }) => { ...option,
onOptionChange(value, true); checked: true,
});
}} }}
/> />
)} )}
onOptionChange={(optionValue, checked) => { onOptionChange={(option) => {
if (checked) { if (option.checked) {
setSelectedCompanies([...selectedCompanies, optionValue]); setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanyOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else { } else {
setSelectedCompanies( setSelectedCompanies(
selectedCompanies.filter((company) => company !== optionValue), selectedCompanies.filter((company) => company !== option.label),
);
setSelectedCompanyOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.label !== option.label,
),
);
}
}}
/>
<FilterSection
label="Roles"
options={selectedRoleOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
<RoleTypeahead
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedRoleOptions.some((selectedOption) => {
return selectedOption.value === option.value;
});
}}
isLabelHidden={true}
placeholder="Search roles"
onSelect={(option) => {
onOptionChange({
...option,
checked: true,
});
}}
/>
)}
onOptionChange={(option) => {
if (option.checked) {
setSelectedRoles([...selectedRoles, option.value]);
setSelectedRoleOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
setSelectedRoles(
selectedCompanies.filter((role) => role !== option.value),
);
setSelectedRoleOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
); );
} }
}} }}
@ -324,13 +384,13 @@ export default function QuestionsBrowsePage() {
label="Question types" label="Question types"
options={questionTypeFilterOptions} options={questionTypeFilterOptions}
showAll={true} showAll={true}
onOptionChange={(optionValue, checked) => { onOptionChange={(option) => {
if (checked) { if (option.checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]); setSelectedQuestionTypes([...selectedQuestionTypes, option.value]);
} else { } else {
setSelectedQuestionTypes( setSelectedQuestionTypes(
selectedQuestionTypes.filter( selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue, (questionType) => questionType !== option.value,
), ),
); );
} }
@ -341,68 +401,47 @@ export default function QuestionsBrowsePage() {
label="Question age" label="Question age"
options={questionAgeFilterOptions} options={questionAgeFilterOptions}
showAll={true} showAll={true}
onOptionChange={(optionValue) => { onOptionChange={({ value }) => {
setSelectedQuestionAge(optionValue); setSelectedQuestionAge(value);
}} }}
/> />
<FilterSection <FilterSection
label="Roles" label="Locations"
options={roleFilterOptions} options={selectedLocationOptions}
renderInput={({ renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
onOptionChange, <LocationTypeahead
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field} {...field}
isLabelHidden={true} clearOnSelect={true}
label="Roles" filterOption={(option) => {
options={options} return !selectedLocationOptions.some((selectedOption) => {
placeholder="Search roles" return selectedOption.value === option.value;
// eslint-disable-next-line @typescript-eslint/no-empty-function });
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedRoles([...selectedRoles, optionValue]);
} else {
setSelectedRoles(
selectedRoles.filter((role) => role !== optionValue),
);
}
}} }}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
isLabelHidden={true} isLabelHidden={true}
label="Locations"
options={options}
placeholder="Search locations" placeholder="Search locations"
// eslint-disable-next-line @typescript-eslint/no-empty-function onSelect={(option) => {
onQueryChange={() => {}} onOptionChange({
onSelect={({ value }) => { ...option,
onOptionChange(value, true); checked: true,
});
}} }}
/> />
)} )}
onOptionChange={(optionValue, checked) => { onOptionChange={(option) => {
if (checked) { if (option.checked) {
setSelectedLocations([...selectedLocations, optionValue]); setSelectedLocations([...selectedLocations, option.value]);
setSelectedLocationOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else { } else {
setSelectedLocations( setSelectedLocations(
selectedLocations.filter((location) => location !== optionValue), selectedLocations.filter((role) => role !== option.value),
);
setSelectedLocationOptions((prevOptions) =>
prevOptions.filter(
(prevOption) => prevOption.value !== option.value,
),
); );
} }
}} }}
@ -443,29 +482,50 @@ export default function QuestionsBrowsePage() {
onSortOrderChange={setSortOrder} onSortOrderChange={setSortOrder}
onSortTypeChange={setSortType} onSortTypeChange={setSortType}
/> />
<div className="flex flex-col gap-4 pb-4"> <div className="flex flex-col gap-2 pb-4">
{(questions ?? []).map((question) => ( {(questionsQueryData?.pages ?? []).flatMap(
({ data: questions }) =>
questions.map((question) => (
<QuestionOverviewCard <QuestionOverviewCard
key={question.id} key={question.id}
answerCount={question.numAnswers} answerCount={question.numAnswers}
companies={{ [question.company]: 1 }} companies={
question.aggregatedQuestionEncounters.companyCounts
}
content={question.content} content={question.content}
href={`/questions/${question.id}/${createSlug( href={`/questions/${question.id}/${createSlug(
question.content, question.content,
)}`} )}`}
locations={{ [question.location]: 1 }} locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id} questionId={question.id}
receivedCount={question.receivedCount} receivedCount={question.receivedCount}
roles={{ [question.role]: 1 }} roles={
timestamp={question.seenAt.toLocaleDateString(undefined, { question.aggregatedQuestionEncounters.roleCounts
}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short', month: 'short',
year: 'numeric', year: 'numeric',
})} },
)}
type={question.type} type={question.type}
upvoteCount={question.numVotes} upvoteCount={question.numVotes}
/> />
))} )),
{questions?.length === 0 && ( )}
<Button
disabled={!hasNextPage || isFetchingNextPage}
isLoading={isFetchingNextPage}
label={hasNextPage ? 'Load more' : 'Nothing more to load'}
variant="tertiary"
onClick={() => {
fetchNextPage();
}}
/>
{questionCount === 0 && (
<div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600"> <div className="flex w-full items-center justify-center gap-2 rounded-md border border-slate-300 bg-slate-200 p-4 text-slate-600">
<NoSymbolIcon className="h-6 w-6" /> <NoSymbolIcon className="h-6 w-6" />
<p>Nothing found.</p> <p>Nothing found.</p>

@ -50,7 +50,7 @@ export default function ListPage() {
{lists.map((list) => ( {lists.map((list) => (
<li <li
key={list.id} key={list.id}
className={`flex items-center hover:bg-gray-50 ${ className={`flex items-center hover:bg-slate-50 ${
selectedList === list.id ? 'bg-primary-100' : '' selectedList === list.id ? 'bg-primary-100' : ''
}`}> }`}>
<button <button
@ -83,7 +83,7 @@ export default function ListPage() {
className={`${ className={`${
active active
? 'bg-violet-500 text-white' ? 'bg-violet-500 text-white'
: 'text-gray-900' : 'text-slate-900'
} group flex w-full items-center rounded-md px-2 py-2 text-sm`} } group flex w-full items-center rounded-md px-2 py-2 text-sm`}
type="button"> type="button">
Delete Delete

@ -3,7 +3,7 @@ import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Error from 'next/error'; import Error from 'next/error';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react'; import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react'; import { useState } from 'react';
import { import {
AcademicCapIcon, AcademicCapIcon,
@ -14,9 +14,10 @@ import {
PencilSquareIcon, PencilSquareIcon,
StarIcon, StarIcon,
} from '@heroicons/react/20/solid'; } from '@heroicons/react/20/solid';
import { Spinner } from '@tih/ui'; import { Button, Spinner } from '@tih/ui';
import ResumeCommentsSection from '~/components/resumes/comments/ResumeCommentsSection'; import ResumeCommentsForm from '~/components/resumes/comments/ResumeCommentsForm';
import ResumeCommentsList from '~/components/resumes/comments/ResumeCommentsList';
import ResumePdf from '~/components/resumes/ResumePdf'; import ResumePdf from '~/components/resumes/ResumePdf';
import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText'; import ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
@ -59,6 +60,7 @@ export default function ResumeReviewPage() {
session?.user?.id != null && session.user.id === detailsQuery.data?.userId; session?.user?.id != null && session.user.id === detailsQuery.data?.userId;
const [isEditMode, setIsEditMode] = useState(false); const [isEditMode, setIsEditMode] = useState(false);
const [showCommentsForm, setShowCommentsForm] = useState(false);
const onStarButtonClick = () => { const onStarButtonClick = () => {
if (session?.user?.id == null) { if (session?.user?.id == null) {
@ -81,6 +83,32 @@ export default function ResumeReviewPage() {
setIsEditMode(true); setIsEditMode(true);
}; };
const renderReviewButton = () => {
if (session === null) {
return (
<div className=" flex h-10 justify-center rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-[400] hover:cursor-pointer hover:bg-slate-50">
<a
href="/api/auth/signin"
onClick={(event) => {
event.preventDefault();
signIn();
}}>
Sign in to join discussion
</a>
</div>
);
}
return (
<Button
className="h-10 py-2"
display="block"
label="Add your review"
variant="tertiary"
onClick={() => setShowCommentsForm(true)}
/>
);
};
if (isEditMode && detailsQuery.data != null) { if (isEditMode && detailsQuery.data != null) {
return ( return (
<SubmitResumeForm <SubmitResumeForm
@ -120,12 +148,21 @@ export default function ResumeReviewPage() {
</Head> </Head>
<main className="h-[calc(100vh-2rem)] flex-1 space-y-2 overflow-y-auto py-4 px-8 xl:px-12 2xl:pr-16"> <main className="h-[calc(100vh-2rem)] flex-1 space-y-2 overflow-y-auto py-4 px-8 xl:px-12 2xl:pr-16">
<div className="flex justify-between"> <div className="flex justify-between">
<h1 className="text-2xl font-semibold leading-7 text-gray-900 sm:truncate sm:text-3xl sm:tracking-tight"> <h1 className="pr-2 text-2xl font-semibold leading-7 text-slate-900 sm:truncate sm:text-3xl sm:tracking-tight">
{detailsQuery.data.title} {detailsQuery.data.title}
</h1> </h1>
<div className="flex gap-4"> <div className="flex gap-4 xl:pr-4">
{userIsOwner && (
<button
className="p h-10 rounded-md border border-slate-300 bg-white py-1 px-2 text-center"
type="button"
onClick={onEditButtonClick}>
<PencilSquareIcon className="text-primary-600 hover:text-primary-300 h-6 w-6" />
</button>
)}
<button <button
className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:hover:bg-white" className="isolate inline-flex h-10 items-center space-x-4 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:hover:bg-white"
disabled={starMutation.isLoading || unstarMutation.isLoading} disabled={starMutation.isLoading || unstarMutation.isLoading}
type="button" type="button"
onClick={onStarButtonClick}> onClick={onStarButtonClick}>
@ -141,7 +178,7 @@ export default function ResumeReviewPage() {
className={clsx( className={clsx(
detailsQuery.data?.stars.length detailsQuery.data?.stars.length
? 'text-orange-400' ? 'text-orange-400'
: 'text-gray-400', : 'text-slate-400',
)} )}
/> />
)} )}
@ -152,42 +189,36 @@ export default function ResumeReviewPage() {
{detailsQuery.data?._count.stars} {detailsQuery.data?._count.stars}
</span> </span>
</button> </button>
{userIsOwner && (
<button <div className="hidden xl:block">{renderReviewButton()}</div>
className="p h-10 rounded-md border border-gray-300 bg-white py-1 px-2 text-center"
type="button"
onClick={onEditButtonClick}>
<PencilSquareIcon className="h-6 w-6 text-indigo-600 hover:text-indigo-300" />
</button>
)}
</div> </div>
</div> </div>
<div className="flex flex-col lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8"> <div className="flex flex-col lg:mt-0 lg:flex-row lg:flex-wrap lg:space-x-8">
<div className="mt-2 flex items-center text-sm text-gray-500"> <div className="mt-2 flex items-center text-sm text-slate-600 xl:mt-1">
<BriefcaseIcon <BriefcaseIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/> />
{detailsQuery.data.role} {detailsQuery.data.role}
</div> </div>
<div className="flex items-center pt-2 text-sm text-gray-500"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<MapPinIcon <MapPinIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/> />
{detailsQuery.data.location} {detailsQuery.data.location}
</div> </div>
<div className="flex items-center pt-2 text-sm text-gray-500"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<AcademicCapIcon <AcademicCapIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/> />
{detailsQuery.data.experience} {detailsQuery.data.experience}
</div> </div>
<div className="flex items-center pt-2 text-sm text-gray-500"> <div className="flex items-center pt-2 text-sm text-slate-600 xl:pt-1">
<CalendarIcon <CalendarIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/> />
{`Uploaded ${formatDistanceToNow(detailsQuery.data.createdAt, { {`Uploaded ${formatDistanceToNow(detailsQuery.data.createdAt, {
addSuffix: true, addSuffix: true,
@ -195,10 +226,10 @@ export default function ResumeReviewPage() {
</div> </div>
</div> </div>
{detailsQuery.data.additionalInfo && ( {detailsQuery.data.additionalInfo && (
<div className="flex items-start whitespace-pre-wrap pt-2 text-sm text-gray-500"> <div className="flex items-start whitespace-pre-wrap pt-2 text-sm text-slate-600 xl:pt-1">
<InformationCircleIcon <InformationCircleIcon
aria-hidden="true" aria-hidden="true"
className="mr-1.5 h-5 w-5 flex-shrink-0 text-gray-400" className="mr-1.5 h-5 w-5 flex-shrink-0 text-indigo-400"
/> />
<ResumeExpandableText <ResumeExpandableText
key={detailsQuery.data.additionalInfo} key={detailsQuery.data.additionalInfo}
@ -206,12 +237,35 @@ export default function ResumeReviewPage() {
/> />
</div> </div>
)} )}
<div className="flex w-full flex-col gap-6 py-4 lg:flex-row">
<div className="w-full lg:w-[780px]"> <div className="flex w-full flex-col gap-6 py-4 xl:flex-row xl:py-0">
<div className="w-full xl:w-1/2">
<ResumePdf url={detailsQuery.data.url} /> <ResumePdf url={detailsQuery.data.url} />
</div> </div>
<div className="grow"> <div className="grow">
<ResumeCommentsSection resumeId={resumeId as string} /> <div className="relative p-2 xl:hidden">
<div
aria-hidden="true"
className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-slate-50 px-3 text-lg font-medium text-slate-900">
Reviews
</span>
</div>
</div>
<div className="mb-4 xl:hidden">{renderReviewButton()}</div>
{showCommentsForm ? (
<ResumeCommentsForm
resumeId={resumeId as string}
setShowCommentsForm={setShowCommentsForm}
/>
) : (
<ResumeCommentsList resumeId={resumeId as string} />
)}
</div> </div>
</div> </div>
</main> </main>

@ -113,7 +113,7 @@ export default function ResumeHomePage() {
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [userFilters, sortOrder]); }, [userFilters, sortOrder, searchValue]);
const allResumesQuery = trpc.useQuery( const allResumesQuery = trpc.useQuery(
[ [
@ -126,6 +126,7 @@ export default function ResumeHomePage() {
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
take: PAGE_LIMIT,
}, },
], ],
{ {
@ -144,6 +145,7 @@ export default function ResumeHomePage() {
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
take: PAGE_LIMIT,
}, },
], ],
{ {
@ -163,6 +165,7 @@ export default function ResumeHomePage() {
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY), searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip, skip,
sortOrder, sortOrder,
take: PAGE_LIMIT,
}, },
], ],
{ {
@ -279,11 +282,11 @@ export default function ResumeHomePage() {
leaveTo="translate-x-full"> leaveTo="translate-x-full">
<Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-scroll bg-white py-4 pb-12 shadow-xl"> <Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-scroll bg-white py-4 pb-12 shadow-xl">
<div className="flex items-center justify-between px-4"> <div className="flex items-center justify-between px-4">
<h2 className="text-lg font-medium text-gray-900"> <h2 className="text-lg font-medium text-slate-900">
Shortcuts Shortcuts
</h2> </h2>
<button <button
className="-mr-2 flex h-10 w-10 items-center justify-center rounded-md bg-white p-2 text-gray-400" className="-mr-2 flex h-10 w-10 items-center justify-center rounded-md bg-white p-2 text-slate-400"
type="button" type="button"
onClick={() => setMobileFiltersOpen(false)}> onClick={() => setMobileFiltersOpen(false)}>
<span className="sr-only">Close menu</span> <span className="sr-only">Close menu</span>
@ -291,9 +294,9 @@ export default function ResumeHomePage() {
</button> </button>
</div> </div>
<form className="mt-4 border-t border-gray-200"> <form className="mt-4 border-t border-slate-200">
<ul <ul
className="flex flex-wrap justify-start gap-4 px-4 py-3 font-medium text-gray-900" className="flex flex-wrap justify-start gap-4 px-4 py-3 font-medium text-slate-900"
role="list"> role="list">
{SHORTCUTS.map((shortcut) => ( {SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}> <li key={shortcut.name}>
@ -310,12 +313,12 @@ export default function ResumeHomePage() {
<Disclosure <Disclosure
key={filter.id} key={filter.id}
as="div" as="div"
className="border-t border-gray-200 px-4 py-6"> className="border-t border-slate-200 px-4 py-6">
{({ open }) => ( {({ open }) => (
<> <>
<h3 className="-mx-2 -my-3 flow-root"> <h3 className="-mx-2 -my-3 flow-root">
<Disclosure.Button className="flex w-full items-center justify-between bg-white px-2 py-3 text-gray-400 hover:text-gray-500"> <Disclosure.Button className="flex w-full items-center justify-between bg-white px-2 py-3 text-slate-400 hover:text-slate-500">
<span className="font-medium text-gray-900"> <span className="font-medium text-slate-900">
{filter.label} {filter.label}
</span> </span>
<span className="ml-6 flex items-center"> <span className="ml-6 flex items-center">
@ -338,7 +341,7 @@ export default function ResumeHomePage() {
{filter.options.map((option) => ( {filter.options.map((option) => (
<div <div
key={option.value} key={option.value}
className="[&>div>div:nth-child(2)>label]:font-normal [&>div>div:nth-child(1)>input]:text-indigo-600 [&>div>div:nth-child(1)>input]:ring-indigo-500"> className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput <CheckboxInput
label={option.label} label={option.label}
value={userFilters[filter.id].includes( value={userFilters[filter.id].includes(
@ -368,16 +371,16 @@ export default function ResumeHomePage() {
</Transition.Root> </Transition.Root>
</div> </div>
<main className="h-[calc(100vh-4rem)] flex-auto overflow-y-scroll px-8 pt-6 pb-4"> <main className="h-[calc(100vh-4rem)] flex-auto px-8 pb-4">
<div className="flex justify-start"> <div className="flex justify-start">
<div className="hidden w-1/6 pt-2 lg:block"> <div className="fixed top-0 bottom-0 mt-24 hidden w-64 overflow-auto lg:block">
<h3 className="text-md font-medium tracking-tight text-gray-900"> <h3 className="text-md font-medium tracking-tight text-gray-900">
Shortcuts Shortcuts
</h3> </h3>
<div className="w-100 pt-4 sm:pr-0 md:pr-4"> <div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form> <form>
<ul <ul
className="flex w-11/12 flex-wrap justify-start gap-2 pb-6 text-sm font-medium text-gray-900" className="flex w-11/12 flex-wrap justify-start gap-2 pb-6 text-sm font-medium text-slate-900"
role="list"> role="list">
{SHORTCUTS.map((shortcut) => ( {SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}> <li key={shortcut.name}>
@ -389,19 +392,19 @@ export default function ResumeHomePage() {
</li> </li>
))} ))}
</ul> </ul>
<h3 className="text-md font-medium tracking-tight text-gray-900"> <h3 className="text-md font-medium tracking-tight text-slate-900">
Explore these filters Explore these filters
</h3> </h3>
{filters.map((filter) => ( {filters.map((filter) => (
<Disclosure <Disclosure
key={filter.id} key={filter.id}
as="div" as="div"
className="border-b border-gray-200 py-6"> className="border-b border-slate-200 py-6">
{({ open }) => ( {({ open }) => (
<> <>
<h3 className="-my-3 flow-root"> <h3 className="-my-3 flow-root">
<Disclosure.Button className="flex w-full items-center justify-between py-3 text-sm text-gray-400 hover:text-gray-500"> <Disclosure.Button className="flex w-full items-center justify-between py-3 text-sm text-slate-400 hover:text-slate-500">
<span className="font-medium text-gray-900"> <span className="font-medium text-slate-900">
{filter.label} {filter.label}
</span> </span>
<span className="ml-6 flex items-center"> <span className="ml-6 flex items-center">
@ -428,7 +431,7 @@ export default function ResumeHomePage() {
{filter.options.map((option) => ( {filter.options.map((option) => (
<div <div
key={option.value} key={option.value}
className="[&>div>div:nth-child(2)>label]:font-normal [&>div>div:nth-child(1)>input]:text-indigo-600 [&>div>div:nth-child(1)>input]:ring-indigo-500"> className="[&>div>div:nth-child(1)>input]:text-primary-600 [&>div>div:nth-child(1)>input]:ring-primary-500 [&>div>div:nth-child(2)>label]:font-normal">
<CheckboxInput <CheckboxInput
label={option.label} label={option.label}
value={userFilters[filter.id].includes( value={userFilters[filter.id].includes(
@ -453,8 +456,8 @@ export default function ResumeHomePage() {
</form> </form>
</div> </div>
</div> </div>
<div className="w-full"> <div className="relative lg:left-64 lg:w-[calc(100%-16rem)]">
<div className="lg:border-grey-200 flex flex-wrap items-center justify-between pb-2 lg:border-b"> <div className="lg:border-grey-200 sticky top-0 z-10 flex flex-wrap items-center justify-between bg-gray-50 pt-6 pb-2 lg:border-b">
<div className="border-grey-200 mb-4 flex w-full justify-between border-b pb-2 lg:mb-0 lg:w-auto lg:border-none lg:pb-0"> <div className="border-grey-200 mb-4 flex w-full justify-between border-b pb-2 lg:mb-0 lg:w-auto lg:border-none lg:pb-0">
<div> <div>
<Tabs <Tabs
@ -477,10 +480,9 @@ export default function ResumeHomePage() {
onChange={onTabChange} onChange={onTabChange}
/> />
</div> </div>
<div> <div>
<button <button
className="ml-4 rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white lg:hidden" className="bg-primary-500 ml-4 rounded-md py-2 px-3 text-sm font-medium text-white lg:hidden"
type="button" type="button"
onClick={onSubmitResume}> onClick={onSubmitResume}>
Submit Resume Submit Resume
@ -489,9 +491,9 @@ export default function ResumeHomePage() {
</div> </div>
<div className="flex flex-wrap items-center justify-start gap-8"> <div className="flex flex-wrap items-center justify-start gap-8">
<div className="w-64"> <div className="w-64">
<form>
<TextInput <TextInput
label="" isLabelHidden={true}
label="search"
placeholder="Search Resumes" placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon} startAddOn={MagnifyingGlassIcon}
startAddOnType="icon" startAddOnType="icon"
@ -499,7 +501,6 @@ export default function ResumeHomePage() {
value={searchValue} value={searchValue}
onChange={setSearchValue} onChange={setSearchValue}
/> />
</form>
</div> </div>
<div> <div>
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}> <DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
@ -513,16 +514,15 @@ export default function ResumeHomePage() {
</DropdownMenu> </DropdownMenu>
</div> </div>
<button <button
className="-m-2 text-gray-400 hover:text-gray-500 lg:hidden" className="-m-2 text-slate-400 hover:text-slate-500 lg:hidden"
type="button" type="button"
onClick={() => setMobileFiltersOpen(true)}> onClick={() => setMobileFiltersOpen(true)}>
<span className="sr-only">Filters</span> <span className="sr-only">Filters</span>
<FunnelIcon aria-hidden="true" className="h-6 w-6" /> <FunnelIcon aria-hidden="true" className="h-6 w-6" />
</button> </button>
<div> <div>
<button <button
className="hidden w-36 rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white lg:block" className="bg-primary-500 hidden w-36 rounded-md py-2 px-3 text-sm font-medium text-white lg:block"
type="button" type="button"
onClick={onSubmitResume}> onClick={onSubmitResume}>
Submit Resume Submit Resume
@ -550,10 +550,15 @@ export default function ResumeHomePage() {
{getEmptyDataText(tabsValue, searchValue, userFilters)} {getEmptyDataText(tabsValue, searchValue, userFilters)}
</div> </div>
) : ( ) : (
<> <div className="h-[calc(100vh-9rem)] pb-10 lg:h-[calc(100vh-6rem)]">
<div className="h-[85%] overflow-y-auto">
<div>
<ResumeListItems resumes={getTabResumes()} /> <ResumeListItems resumes={getTabResumes()} />
</div>
</div>
<div className="flex h-[15%] items-center justify-center">
{getTabTotalPages() > 1 && ( {getTabTotalPages() > 1 && (
<div className="mt-4 flex justify-center"> <div>
<Pagination <Pagination
current={currentPage} current={currentPage}
end={getTabTotalPages()} end={getTabTotalPages()}
@ -563,7 +568,8 @@ export default function ResumeHomePage() {
/> />
</div> </div>
)} )}
</> </div>
</div>
)} )}
</div> </div>
</div> </div>

@ -14,6 +14,7 @@ import {
CheckboxInput, CheckboxInput,
Dialog, Dialog,
Select, Select,
Spinner,
TextArea, TextArea,
TextInput, TextInput,
} from '@tih/ui'; } from '@tih/ui';
@ -73,7 +74,7 @@ export default function SubmitResumeForm({
>(null); >(null);
const [isDialogShown, setIsDialogShown] = useState(false); const [isDialogShown, setIsDialogShown] = useState(false);
const { data: session, status } = useSession(); const { status } = useSession();
const router = useRouter(); const router = useRouter();
const trpcContext = trpc.useContext(); const trpcContext = trpc.useContext();
const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert'); const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert');
@ -85,10 +86,16 @@ export default function SubmitResumeForm({
setValue, setValue,
reset, reset,
watch, watch,
clearErrors,
formState: { errors, isDirty, dirtyFields }, formState: { errors, isDirty, dirtyFields },
} = useForm<IFormInput>({ } = useForm<IFormInput>({
defaultValues: { defaultValues: {
additionalInfo: '',
experience: '',
isChecked: false, isChecked: false,
location: '',
role: '',
title: '',
...initFormDetails, ...initFormDetails,
}, },
}); });
@ -122,12 +129,10 @@ export default function SubmitResumeForm({
// Route user to sign in if not logged in // Route user to sign in if not logged in
useEffect(() => { useEffect(() => {
if (status !== 'loading') { if (status === 'unauthenticated') {
if (session?.user?.id == null) {
router.push('/api/auth/signin'); router.push('/api/auth/signin');
} }
} }, [router, status]);
}, [router, session, status]);
const onSubmit: SubmitHandler<IFormInput> = async (data) => { const onSubmit: SubmitHandler<IFormInput> = async (data) => {
setIsLoading(true); setIsLoading(true);
@ -221,7 +226,7 @@ export default function SubmitResumeForm({
}, [errors?.file, invalidFileUploadError]); }, [errors?.file, invalidFileUploadError]);
const onValueChange = (section: InputKeys, value: string) => { const onValueChange = (section: InputKeys, value: string) => {
setValue(section, value.trim(), { shouldTouch: false }); setValue(section, value.trim(), { shouldDirty: true });
}; };
return ( return (
@ -229,6 +234,13 @@ export default function SubmitResumeForm({
<Head> <Head>
<title>Upload a Resume</title> <title>Upload a Resume</title>
</Head> </Head>
{status === 'loading' && (
<div className="w-full pt-4">
{' '}
<Spinner display="block" size="lg" />{' '}
</div>
)}
{status === 'authenticated' && (
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-auto"> <main className="h-[calc(100vh-4rem)] flex-1 overflow-y-auto">
<section <section
aria-labelledby="primary-heading" aria-labelledby="primary-heading"
@ -268,12 +280,18 @@ export default function SubmitResumeForm({
</h1> </h1>
{/* Title Section */} {/* Title Section */}
<TextInput <TextInput
{...register('title', { required: true })} {...(register('title', { required: true }), {})}
defaultValue={initFormDetails?.title}
disabled={isLoading} disabled={isLoading}
errorMessage={
errors.title?.message != null
? 'Title cannot be empty'
: undefined
}
label="Title" label="Title"
placeholder={TITLE_PLACEHOLDER} placeholder={TITLE_PLACEHOLDER}
required={true} required={true}
onChange={(val) => setValue('title', val)} onChange={(val) => onValueChange('title', val)}
/> />
<div className="flex gap-8"> <div className="flex gap-8">
<Select <Select
@ -284,7 +302,7 @@ export default function SubmitResumeForm({
options={ROLES} options={ROLES}
placeholder=" " placeholder=" "
required={true} required={true}
onChange={(val) => setValue('role', val)} onChange={(val) => onValueChange('role', val)}
/> />
<Select <Select
{...register('experience', { required: true })} {...register('experience', { required: true })}
@ -293,7 +311,7 @@ export default function SubmitResumeForm({
options={EXPERIENCES} options={EXPERIENCES}
placeholder=" " placeholder=" "
required={true} required={true}
onChange={(val) => setValue('experience', val)} onChange={(val) => onValueChange('experience', val)}
/> />
</div> </div>
<Select <Select
@ -303,7 +321,7 @@ export default function SubmitResumeForm({
options={LOCATIONS} options={LOCATIONS}
placeholder=" " placeholder=" "
required={true} required={true}
onChange={(val) => setValue('location', val)} onChange={(val) => onValueChange('location', val)}
/> />
{/* Upload resume form */} {/* Upload resume form */}
{isNewForm && ( {isNewForm && (
@ -318,41 +336,41 @@ export default function SubmitResumeForm({
<div <div
{...getRootProps()} {...getRootProps()}
className={clsx( className={clsx(
fileUploadError ? 'border-danger-600' : 'border-gray-300', fileUploadError
'flex cursor-pointer justify-center rounded-md border-2 border-dashed bg-gray-100 py-4', ? 'border-danger-600'
: 'border-slate-300',
'flex cursor-pointer justify-center rounded-md border-2 border-dashed bg-slate-100 py-4',
)}> )}>
<input
{...register('file', { required: true })}
{...getInputProps()}
accept="application/pdf"
className="sr-only"
disabled={isLoading}
id="file-upload"
name="file-upload"
type="file"
/>
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
{resumeFile == null ? ( {resumeFile == null ? (
<ArrowUpCircleIcon className="m-auto h-10 w-10 text-indigo-500" /> <ArrowUpCircleIcon className="text-primary-500 m-auto h-10 w-10" />
) : ( ) : (
<p <p
className="cursor-pointer underline underline-offset-1 hover:text-indigo-600" className="hover:text-primary-600 cursor-pointer underline underline-offset-1"
onClick={onClickDownload}> onClick={onClickDownload}>
{resumeFile.name} {resumeFile.name}
</p> </p>
)} )}
<div className="flex items-center text-sm">
<label <label
className="rounded-md focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2" className="focus-within:ring-primary-500 flex items-center rounded-md text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2"
htmlFor="file-upload"> htmlFor="file-upload">
<span className="font-medium">Drop file here</span> <span className="font-medium">Drop file here</span>
<span className="mr-1 ml-1 font-light">or</span> <span className="mr-1 ml-1 font-light">or</span>
<span className="cursor-pointer font-medium text-indigo-600 hover:text-indigo-400"> <span className="text-primary-600 hover:text-primary-400 cursor-pointer font-medium">
{resumeFile == null ? 'Select file' : 'Replace file'} {resumeFile == null ? 'Select file' : 'Replace file'}
</span> </span>
<input
{...register('file', { required: true })}
{...getInputProps()}
accept="application/pdf"
className="sr-only"
disabled={isLoading}
id="file-upload"
name="file-upload"
type="file"
/>
</label> </label>
</div> <p className="text-xs text-slate-500">
<p className="text-xs text-gray-500">
PDF up to {FILE_SIZE_LIMIT_MB}MB PDF up to {FILE_SIZE_LIMIT_MB}MB
</p> </p>
</div> </div>
@ -364,7 +382,8 @@ export default function SubmitResumeForm({
)} )}
{/* Additional Info Section */} {/* Additional Info Section */}
<TextArea <TextArea
{...(register('additionalInfo'), {})} {...(register('additionalInfo'),
{ defaultValue: initFormDetails?.additionalInfo })}
disabled={isLoading} disabled={isLoading}
label="Additional Information" label="Additional Information"
placeholder={ADDITIONAL_INFO_PLACEHOLDER} placeholder={ADDITIONAL_INFO_PLACEHOLDER}
@ -377,8 +396,18 @@ export default function SubmitResumeForm({
<CheckboxInput <CheckboxInput
{...register('isChecked', { required: true })} {...register('isChecked', { required: true })}
disabled={isLoading} disabled={isLoading}
errorMessage={
!errors.file && errors.isChecked
? 'Please tick the checkbox after reading through the guidelines.'
: undefined
}
label="I have read and will follow the guidelines stated." label="I have read and will follow the guidelines stated."
onChange={(val) => setValue('isChecked', val)} onChange={(val) => {
if (val) {
clearErrors('isChecked');
}
setValue('isChecked', val);
}}
/> />
</> </>
)} )}
@ -403,6 +432,7 @@ export default function SubmitResumeForm({
</form> </form>
</section> </section>
</main> </main>
)}
</> </>
); );
} }

@ -5,12 +5,15 @@ import { useToast } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui'; import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead'; import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import type { Month, MonthYear } from '~/components/shared/MonthYearPicker'; import type { Month, MonthYear } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker'; import MonthYearPicker from '~/components/shared/MonthYearPicker';
export default function HomePage() { export default function HomePage() {
const [selectedCompany, setSelectedCompany] = const [selectedCompany, setSelectedCompany] =
useState<TypeaheadOption | null>(null); useState<TypeaheadOption | null>(null);
const [selectedJobTitle, setSelectedJobTitle] =
useState<TypeaheadOption | null>(null);
const [monthYear, setMonthYear] = useState<MonthYear>({ const [monthYear, setMonthYear] = useState<MonthYear>({
month: (new Date().getMonth() + 1) as Month, month: (new Date().getMonth() + 1) as Month,
year: new Date().getFullYear(), year: new Date().getFullYear(),
@ -30,6 +33,11 @@ export default function HomePage() {
/> />
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre> <pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider /> <HorizontalDivider />
<JobTitlesTypeahead
onSelect={(option) => setSelectedJobTitle(option)}
/>
<pre>{JSON.stringify(selectedJobTitle, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} /> <MonthYearPicker value={monthYear} onChange={setMonthYear} />
<HorizontalDivider /> <HorizontalDivider />
<Button <Button

@ -34,14 +34,14 @@ export default function TodoList() {
<div className="mx-auto px-4 py-8 sm:px-6 lg:px-8"> <div className="mx-auto px-4 py-8 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center"> <div className="sm:flex sm:items-center">
<div className="sm:flex-auto"> <div className="sm:flex-auto">
<h1 className="text-xl font-semibold text-gray-900">Todos</h1> <h1 className="text-xl font-semibold text-slate-900">Todos</h1>
<p className="mt-2 text-sm text-gray-700"> <p className="mt-2 text-sm text-slate-700">
A list of all Todos added by everyone. A list of all Todos added by everyone.
</p> </p>
</div> </div>
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none"> <div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<Link <Link
className="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto" className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex items-center justify-center rounded-md border border-transparent px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 sm:w-auto"
href="/todos/new"> href="/todos/new">
Add Todo Add Todo
</Link> </Link>
@ -54,40 +54,40 @@ export default function TodoList() {
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8"> <div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg"> <div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300"> <table className="min-w-full divide-y divide-slate-300">
<thead className="bg-gray-50"> <thead className="bg-slate-50">
<tr className="divide-x divide-gray-200"> <tr className="divide-x divide-slate-200">
<th <th
className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-gray-900 sm:pl-6" className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-slate-900 sm:pl-6"
scope="col"> scope="col">
Description Description
</th> </th>
<th <th
className="px-4 py-3.5 text-left text-sm font-semibold text-gray-900" className="px-4 py-3.5 text-left text-sm font-semibold text-slate-900"
scope="col"> scope="col">
Creator Creator
</th> </th>
<th <th
className="px-4 py-3.5 text-left text-sm font-semibold text-gray-900" className="px-4 py-3.5 text-left text-sm font-semibold text-slate-900"
scope="col"> scope="col">
Last Updated Last Updated
</th> </th>
<th <th
className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-gray-900 sm:pr-6" className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-slate-900 sm:pr-6"
scope="col"> scope="col">
Status Status
</th> </th>
<th <th
className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-gray-900 sm:pr-6" className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-slate-900 sm:pr-6"
scope="col"> scope="col">
Actions Actions
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody className="divide-y divide-slate-200 bg-white">
{todosQuery.data?.map((todo) => ( {todosQuery.data?.map((todo) => (
<tr key={todo.id} className="divide-x divide-gray-200"> <tr key={todo.id} className="divide-x divide-slate-200">
<td className="whitespace-nowrap py-4 pl-4 pr-4 text-sm text-gray-500 sm:pl-6"> <td className="whitespace-nowrap py-4 pl-4 pr-4 text-sm text-slate-500 sm:pl-6">
{todo.id === currentlyEditingTodo ? ( {todo.id === currentlyEditingTodo ? (
<form <form
ref={formRef} ref={formRef}
@ -120,7 +120,7 @@ export default function TodoList() {
}}> }}>
<input <input
autoFocus={true} autoFocus={true}
className="block w-full min-w-0 flex-1 rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" className="focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-1 rounded-md border-slate-300 sm:text-sm"
defaultValue={todo.text} defaultValue={todo.text}
name="text" name="text"
type="text" type="text"
@ -130,19 +130,19 @@ export default function TodoList() {
todo.text todo.text
)} )}
</td> </td>
<td className="whitespace-nowrap p-4 text-sm text-gray-500"> <td className="whitespace-nowrap p-4 text-sm text-slate-500">
{todo.user.name} {todo.user.name}
</td> </td>
<td className="whitespace-nowrap p-4 text-sm text-gray-500"> <td className="whitespace-nowrap p-4 text-sm text-slate-500">
{todo.updatedAt.toLocaleString('en-US', { {todo.updatedAt.toLocaleString('en-US', {
dateStyle: 'long', dateStyle: 'long',
timeStyle: 'medium', timeStyle: 'medium',
})} })}
</td> </td>
<td className="whitespace-nowrap py-4 pl-4 pr-4 text-sm text-gray-500 sm:pr-6"> <td className="whitespace-nowrap py-4 pl-4 pr-4 text-sm text-slate-500 sm:pr-6">
<input <input
checked={todo.status === 'COMPLETE'} checked={todo.status === 'COMPLETE'}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-slate-300"
type="checkbox" type="checkbox"
onChange={() => { onChange={() => {
todoUpdateMutation.mutate({ todoUpdateMutation.mutate({
@ -155,12 +155,12 @@ export default function TodoList() {
}} }}
/> />
</td> </td>
<td className="space-x-4 whitespace-nowrap py-4 pl-4 pr-4 text-sm text-gray-500 sm:pr-6"> <td className="space-x-4 whitespace-nowrap py-4 pl-4 pr-4 text-sm text-slate-500 sm:pr-6">
{data?.user?.id === todo.userId && ( {data?.user?.id === todo.userId && (
<> <>
{currentlyEditingTodo === todo.id ? ( {currentlyEditingTodo === todo.id ? (
<a <a
className="text-indigo-600 hover:text-indigo-900" className="text-primary-600 hover:text-primary-900"
href="#" href="#"
onClick={() => { onClick={() => {
setCurrentlyEditingTodo(null); setCurrentlyEditingTodo(null);
@ -169,7 +169,7 @@ export default function TodoList() {
</a> </a>
) : ( ) : (
<a <a
className="text-indigo-600 hover:text-indigo-900" className="text-primary-600 hover:text-primary-900"
href="#" href="#"
onClick={async () => { onClick={async () => {
setCurrentlyEditingTodo(todo.id); setCurrentlyEditingTodo(todo.id);
@ -178,7 +178,7 @@ export default function TodoList() {
</a> </a>
)} )}
<a <a
className="text-indigo-600 hover:text-indigo-900" className="text-primary-600 hover:text-primary-900"
href="#" href="#"
onClick={async () => { onClick={async () => {
const confirmDelete = window.confirm( const confirmDelete = window.confirm(

@ -27,7 +27,7 @@ export default function TodosCreate() {
</h1> </h1>
<form <form
ref={formRef} ref={formRef}
className="w-full space-y-8 divide-y divide-gray-200" className="w-full space-y-8 divide-y divide-slate-200"
onSubmit={async (event) => { onSubmit={async (event) => {
event.preventDefault(); event.preventDefault();
if (!formRef.current) { if (!formRef.current) {
@ -52,14 +52,14 @@ export default function TodosCreate() {
}}> }}>
<div className="mt-6"> <div className="mt-6">
<label <label
className="block text-sm font-medium text-gray-700" className="block text-sm font-medium text-slate-700"
htmlFor="text"> htmlFor="text">
Text Text
</label> </label>
<div className="mt-1 flex rounded-md shadow-sm"> <div className="mt-1 flex rounded-md shadow-sm">
<input <input
autoFocus={true} autoFocus={true}
className="block w-full min-w-0 flex-1 rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm" className="focus:border-primary-500 focus:ring-primary-500 block w-full min-w-0 flex-1 rounded-md border-slate-300 sm:text-sm"
id="text" id="text"
name="text" name="text"
type="text" type="text"
@ -71,12 +71,12 @@ export default function TodosCreate() {
<div className="pt-5"> <div className="pt-5">
<div className="flex justify-end"> <div className="flex justify-end">
<Link <Link
className="rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" className="focus:ring-primary-500 rounded-md border border-slate-300 bg-white py-2 px-4 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-2"
href="/todos"> href="/todos">
Cancel Cancel
</Link> </Link>
<button <button
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 ml-3 inline-flex justify-center rounded-md border border-transparent py-2 px-4 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2"
type="submit"> type="submit">
Save Save
</button> </button>

@ -1,4 +1,4 @@
import crypto, { randomUUID } from 'crypto'; import crypto from 'crypto';
import { z } from 'zod'; import { z } from 'zod';
import { JobType } from '@prisma/client'; import { JobType } from '@prisma/client';
import * as trpc from '@trpc/server'; import * as trpc from '@trpc/server';
@ -10,6 +10,7 @@ import {
} from '~/mappers/offers-mappers'; } from '~/mappers/offers-mappers';
import { baseCurrencyString } from '~/utils/offers/currency'; import { baseCurrencyString } from '~/utils/offers/currency';
import { convert } from '~/utils/offers/currency/currencyExchange'; import { convert } from '~/utils/offers/currency/currencyExchange';
import { generateRandomName, generateRandomStringForToken } from '~/utils/offers/randomGenerator';
import { createValidationRegex } from '~/utils/offers/zodRegex'; import { createValidationRegex } from '~/utils/offers/zodRegex';
import { createRouter } from '../context'; import { createRouter } from '../context';
@ -263,9 +264,12 @@ export const offersProfileRouter = createRouter()
// TODO: add more // TODO: add more
const token = crypto const token = crypto
.createHash('sha256') .createHash('sha256')
.update(Date.now().toString()) .update(Date.now().toString() + generateRandomStringForToken())
.digest('hex'); .digest('hex');
// Generate random name until unique
const uniqueName: string = await generateRandomName();
const profile = await ctx.prisma.offersProfile.create({ const profile = await ctx.prisma.offersProfile.create({
data: { data: {
background: { background: {
@ -538,7 +542,7 @@ export const offersProfileRouter = createRouter()
}), }),
), ),
}, },
profileName: randomUUID().substring(0, 10), profileName: uniqueName,
}, },
}); });
@ -707,7 +711,7 @@ export const offersProfileRouter = createRouter()
// Update existing experience // Update existing experience
await ctx.prisma.offersExperience.update({ await ctx.prisma.offersExperience.update({
data: { data: {
companyId: exp.companyId, companyId: exp.companyId, // TODO: check if can change with connect or whether there is a difference
durationInMonths: exp.durationInMonths, durationInMonths: exp.durationInMonths,
level: exp.level, level: exp.level,
specialization: exp.specialization, specialization: exp.specialization,
@ -718,6 +722,7 @@ export const offersProfileRouter = createRouter()
}); });
if (exp.monthlySalary) { if (exp.monthlySalary) {
if (exp.monthlySalary.id) {
await ctx.prisma.offersCurrency.update({ await ctx.prisma.offersCurrency.update({
data: { data: {
baseCurrency: baseCurrencyString, baseCurrency: baseCurrencyString,
@ -733,9 +738,31 @@ export const offersProfileRouter = createRouter()
id: exp.monthlySalary.id, id: exp.monthlySalary.id,
}, },
}); });
} else {
await ctx.prisma.offersExperience.update({
data: {
monthlySalary: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.monthlySalary.value,
exp.monthlySalary.currency,
baseCurrencyString,
),
currency: exp.monthlySalary.currency,
value: exp.monthlySalary.value,
},
},
},
where: {
id: exp.id,
},
});
}
} }
if (exp.totalCompensation) { if (exp.totalCompensation) {
if (exp.totalCompensation.id) {
await ctx.prisma.offersCurrency.update({ await ctx.prisma.offersCurrency.update({
data: { data: {
baseCurrency: baseCurrencyString, baseCurrency: baseCurrencyString,
@ -751,12 +778,35 @@ export const offersProfileRouter = createRouter()
id: exp.totalCompensation.id, id: exp.totalCompensation.id,
}, },
}); });
} else {
await ctx.prisma.offersExperience.update({
data: {
totalCompensation: {
create: {
baseCurrency: baseCurrencyString,
baseValue: await convert(
exp.totalCompensation.value,
exp.totalCompensation.currency,
baseCurrencyString,
),
currency: exp.totalCompensation.currency,
value: exp.totalCompensation.value,
},
},
},
where: {
id: exp.id,
},
});
}
} }
} else if (!exp.id) { } else if (!exp.id) {
// Create new experience // Create new experience
if (exp.jobType === JobType.FULLTIME) { if (exp.jobType === JobType.FULLTIME) {
if (exp.totalCompensation?.currency != null && if (
exp.totalCompensation?.value != null) { exp.totalCompensation?.currency != null &&
exp.totalCompensation?.value != null
) {
if (exp.companyId) { if (exp.companyId) {
await ctx.prisma.offersBackground.update({ await ctx.prisma.offersBackground.update({
data: { data: {
@ -866,8 +916,10 @@ export const offersProfileRouter = createRouter()
}); });
} }
} else if (exp.jobType === JobType.INTERN) { } else if (exp.jobType === JobType.INTERN) {
if (exp.monthlySalary?.currency != null && if (
exp.monthlySalary?.value != null) { exp.monthlySalary?.currency != null &&
exp.monthlySalary?.value != null
) {
if (exp.companyId) { if (exp.companyId) {
await ctx.prisma.offersBackground.update({ await ctx.prisma.offersBackground.update({
data: { data: {

@ -5,8 +5,8 @@ import {
dashboardOfferDtoMapper, dashboardOfferDtoMapper,
getOffersResponseMapper, getOffersResponseMapper,
} from '~/mappers/offers-mappers'; } from '~/mappers/offers-mappers';
import { convertWithDate } from '~/utils/offers/currency/currencyExchange';
import { Currency } from '~/utils/offers/currency/CurrencyEnum'; import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import { convertWithDate } from '~/utils/offers/currency/currencyExchange';
import { createValidationRegex } from '~/utils/offers/zodRegex'; import { createValidationRegex } from '~/utils/offers/zodRegex';
import { createRouter } from '../context'; import { createRouter } from '../context';
@ -103,22 +103,32 @@ export const offersRouter = createRouter().query('list', {
monthYearReceived: order, monthYearReceived: order,
} }
: sortingKey === sortingKeysMap.totalCompensation : sortingKey === sortingKeysMap.totalCompensation
? { ? [
{
offersIntern: { offersIntern: {
monthlySalary: { monthlySalary: {
baseValue: order, baseValue: order,
}, },
}, },
} },
{
monthYearReceived: 'desc',
},
]
: sortingKey === sortingKeysMap.totalYoe : sortingKey === sortingKeysMap.totalYoe
? { ? [
{
profile: { profile: {
background: { background: {
totalYoe: order, totalYoe: order,
}, },
}, },
} },
: undefined, {
monthYearReceived: 'desc',
},
]
: { monthYearReceived: 'desc' },
where: { where: {
AND: [ AND: [
{ {
@ -207,22 +217,32 @@ export const offersRouter = createRouter().query('list', {
monthYearReceived: order, monthYearReceived: order,
} }
: sortingKey === sortingKeysMap.totalCompensation : sortingKey === sortingKeysMap.totalCompensation
? { ? [
offersFullTime: { {
totalCompensation: { offersIntern: {
monthlySalary: {
baseValue: order, baseValue: order,
}, },
}, },
} },
{
monthYearReceived: 'desc',
},
]
: sortingKey === sortingKeysMap.totalYoe : sortingKey === sortingKeysMap.totalYoe
? { ? [
{
profile: { profile: {
background: { background: {
totalYoe: order, totalYoe: order,
}, },
}, },
} },
: undefined, {
monthYearReceived: 'desc',
},
]
: { monthYearReceived: 'desc' },
where: { where: {
AND: [ AND: [
{ {

@ -27,7 +27,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
createdAt: 'desc', createdAt: 'desc',
}, },
where: { where: {
answerId : input.answerId, answerId: input.answerId,
}, },
}); });
return questionAnswerCommentsData.map((data) => { return questionAnswerCommentsData.map((data) => {
@ -166,13 +166,29 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
const { answerCommentId, vote } = input; const { answerCommentId, vote } = input;
return await ctx.prisma.questionsAnswerCommentVote.create({ const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.create({
data: { data: {
answerCommentId, answerCommentId,
userId, userId,
vote, vote,
}, },
}); }),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: answerCommentId,
},
}),
]);
return answerCommentVote;
}, },
}) })
.mutation('updateVote', { .mutation('updateVote', {
@ -198,14 +214,30 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsAnswerCommentVote.update({ const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.update({
data: { data: {
vote, vote,
}, },
where: { where: {
id, id,
}, },
}); }),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.answerCommentId,
},
}),
]);
return answerCommentVote;
}, },
}) })
.mutation('deleteVote', { .mutation('deleteVote', {
@ -229,10 +261,26 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsAnswerCommentVote.delete({ const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [answerCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerCommentVote.delete({
where: { where: {
id: input.id, id: input.id,
}, },
}); }),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.answerCommentId,
},
}),
]);
return answerCommentVote;
}, },
}); });

@ -229,13 +229,28 @@ export const questionsAnswerRouter = createProtectedRouter()
const { answerId, vote } = input; const { answerId, vote } = input;
return await ctx.prisma.questionsAnswerVote.create({ const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [answerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.create({
data: { data: {
answerId, answerId,
userId, userId,
vote, vote,
}, },
}); }),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: answerId,
},
}),
]);
return answerVote;
}, },
}) })
.mutation('updateVote', { .mutation('updateVote', {
@ -260,14 +275,30 @@ export const questionsAnswerRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsAnswerVote.update({ const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.update({
data: { data: {
vote, vote,
}, },
where: { where: {
id, id,
}, },
}); }),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.answerId,
},
}),
]);
return questionsAnswerVote;
}, },
}) })
.mutation('deleteVote', { .mutation('deleteVote', {
@ -290,10 +321,26 @@ export const questionsAnswerRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsAnswerVote.delete({ const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [questionsAnswerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.delete({
where: { where: {
id: input.id, id: input.id,
}, },
}); }),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.answerId,
},
}),
]);
return questionsAnswerVote;
}, },
}); });

@ -166,13 +166,28 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const { questionCommentId, vote } = input; const { questionCommentId, vote } = input;
return await ctx.prisma.questionsQuestionCommentVote.create({ const incrementValue: number = vote === Vote.UPVOTE ? 1 : -1;
const [ questionCommentVote ] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.create({
data: { data: {
questionCommentId, questionCommentId,
userId, userId,
vote, vote,
}, },
}); }),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: questionCommentId,
},
}),
]);
return questionCommentVote;
}, },
}) })
.mutation('updateVote', { .mutation('updateVote', {
@ -198,14 +213,30 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsQuestionCommentVote.update({ const incrementValue = vote === Vote.UPVOTE ? 2 : -2;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.update({
data: { data: {
vote, vote,
}, },
where: { where: {
id, id,
}, },
}); }),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.questionCommentId,
},
}),
]);
return questionCommentVote;
}, },
}) })
.mutation('deleteVote', { .mutation('deleteVote', {
@ -229,10 +260,25 @@ export const questionsQuestionCommentRouter = createProtectedRouter()
}); });
} }
return await ctx.prisma.questionsQuestionCommentVote.delete({ const incrementValue = voteToDelete.vote === Vote.UPVOTE ? -1 : 1;
const [questionCommentVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsQuestionCommentVote.delete({
where: { where: {
id: input.id, id: input.id,
}, },
}); }),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToDelete.questionCommentId,
},
}),
]);
return questionCommentVote;
}, },
}); });

@ -25,9 +25,13 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
const locationCounts: Record<string, number> = {}; const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {}; const roleCounts: Record<string, number> = {};
let latestSeenAt = questionEncountersData[0].seenAt;
for (let i = 0; i < questionEncountersData.length; i++) { for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i]; const encounter = questionEncountersData[i];
latestSeenAt = latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) { if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1; companyCounts[encounter.company!.name] = 1;
} }
@ -46,6 +50,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
const questionEncounter: AggregatedQuestionEncounter = { const questionEncounter: AggregatedQuestionEncounter = {
companyCounts, companyCounts,
latestSeenAt,
locationCounts, locationCounts,
roleCounts, roleCounts,
}; };
@ -72,7 +77,6 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
}, },
}) })
.mutation('update', { .mutation('update', {
//
input: z.object({ input: z.object({
companyId: z.string().optional(), companyId: z.string().optional(),
id: z.string(), id: z.string(),

@ -11,9 +11,16 @@ export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', { .query('getQuestionsByFilter', {
input: z.object({ input: z.object({
companyNames: z.string().array(), companyNames: z.string().array(),
cursor: z
.object({
idCursor: z.string().optional(),
lastSeenCursor: z.date().nullish().optional(),
upvoteCursor: z.number().optional(),
})
.nullish(),
endDate: z.date().default(new Date()), endDate: z.date().default(new Date()),
limit: z.number().min(1).default(50),
locations: z.string().array(), locations: z.string().array(),
pageSize: z.number().default(50),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(), questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(), roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder), sortOrder: z.nativeEnum(SortOrder),
@ -21,16 +28,34 @@ export const questionsQuestionRouter = createProtectedRouter()
startDate: z.date().optional(), startDate: z.date().optional(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const { cursor } = input;
const sortCondition = const sortCondition =
input.sortType === SortType.TOP input.sortType === SortType.TOP
? { ? [
{
upvotes: input.sortOrder, upvotes: input.sortOrder,
} },
: { {
id: input.sortOrder,
},
]
: [
{
lastSeenAt: input.sortOrder, lastSeenAt: input.sortOrder,
}; },
{
id: input.sortOrder,
},
];
const questionsData = await ctx.prisma.questionsQuestion.findMany({ const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor:
cursor !== undefined
? {
id: cursor ? cursor!.idCursor : undefined,
}
: undefined,
include: { include: {
_count: { _count: {
select: { select: {
@ -53,9 +78,8 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
votes: true, votes: true,
}, },
orderBy: { orderBy: sortCondition,
...sortCondition, take: input.limit + 1,
},
where: { where: {
...(input.questionTypes.length > 0 ...(input.questionTypes.length > 0
? { ? {
@ -98,7 +122,7 @@ export const questionsQuestionRouter = createProtectedRouter()
}, },
}); });
return questionsData.map((data) => { const processedQuestionsData = questionsData.map((data) => {
const votes: number = data.votes.reduce( const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => { (previousValue: number, currentValue) => {
let result: number = previousValue; let result: number = previousValue;
@ -116,23 +140,78 @@ export const questionsQuestionRouter = createProtectedRouter()
0, 0,
); );
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = data.encounters[0].seenAt;
for (let i = 0; i < data.encounters.length; i++) {
const encounter = data.encounters[i];
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = { const question: Question = {
company: data.encounters[0].company!.name ?? 'Unknown company', aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: data.content, content: data.content,
id: data.id, id: data.id,
location: data.encounters[0].location ?? 'Unknown location',
numAnswers: data._count.answers, numAnswers: data._count.answers,
numComments: data._count.comments, numComments: data._count.comments,
numVotes: votes, numVotes: votes,
receivedCount: data.encounters.length, receivedCount: data.encounters.length,
role: data.encounters[0].role ?? 'Unknown role', seenAt: latestSeenAt,
seenAt: data.encounters[0].seenAt,
type: data.questionType, type: data.questionType,
updatedAt: data.updatedAt, updatedAt: data.updatedAt,
user: data.user?.name ?? '', user: data.user?.name ?? '',
}; };
return question; return question;
}); });
let nextCursor: typeof cursor | undefined = undefined;
if (questionsData.length > input.limit) {
const nextItem = questionsData.pop()!;
processedQuestionsData.pop();
const nextIdCursor: string | undefined = nextItem.id;
const nextLastSeenCursor =
input.sortType === SortType.NEW ? nextItem.lastSeenAt : undefined;
const nextUpvoteCursor =
input.sortType === SortType.TOP ? nextItem.upvotes : undefined;
nextCursor = {
idCursor: nextIdCursor,
lastSeenCursor: nextLastSeenCursor,
upvoteCursor: nextUpvoteCursor,
};
}
return {
data: processedQuestionsData,
nextCursor,
};
}, },
}) })
.query('getQuestionById', { .query('getQuestionById', {
@ -190,16 +269,45 @@ export const questionsQuestionRouter = createProtectedRouter()
0, 0,
); );
const companyCounts: Record<string, number> = {};
const locationCounts: Record<string, number> = {};
const roleCounts: Record<string, number> = {};
let latestSeenAt = questionData.encounters[0].seenAt;
for (const encounter of questionData.encounters) {
latestSeenAt =
latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
companyCounts[encounter.company!.name] += 1;
if (!(encounter.location in locationCounts)) {
locationCounts[encounter.location] = 1;
}
locationCounts[encounter.location] += 1;
if (!(encounter.role in roleCounts)) {
roleCounts[encounter.role] = 1;
}
roleCounts[encounter.role] += 1;
}
const question: Question = { const question: Question = {
company: questionData.encounters[0].company!.name ?? 'Unknown company', aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: questionData.content, content: questionData.content,
id: questionData.id, id: questionData.id,
location: questionData.encounters[0].location ?? 'Unknown location',
numAnswers: questionData._count.answers, numAnswers: questionData._count.answers,
numComments: questionData._count.comments, numComments: questionData._count.comments,
numVotes: votes, numVotes: votes,
receivedCount: questionData.encounters.length, receivedCount: questionData.encounters.length,
role: questionData.encounters[0].role ?? 'Unknown role',
seenAt: questionData.encounters[0].seenAt, seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType, type: questionData.questionType,
updatedAt: questionData.updatedAt, updatedAt: questionData.updatedAt,

@ -15,6 +15,7 @@ export const resumesRouter = createRouter()
searchValue: z.string(), searchValue: z.string(),
skip: z.number(), skip: z.number(),
sortOrder: z.string(), sortOrder: z.string(),
take: z.number(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const { const {
@ -25,6 +26,7 @@ export const resumesRouter = createRouter()
numComments, numComments,
skip, skip,
searchValue, searchValue,
take,
} = input; } = input;
const userId = ctx.session?.user?.id; const userId = ctx.session?.user?.id;
const totalRecords = await ctx.prisma.resumesResume.count({ const totalRecords = await ctx.prisma.resumesResume.count({
@ -37,6 +39,7 @@ export const resumesRouter = createRouter()
experience: { in: experienceFilters }, experience: { in: experienceFilters },
location: { in: locationFilters }, location: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
}, },
}); });
const resumesData = await ctx.prisma.resumesResume.findMany({ const resumesData = await ctx.prisma.resumesResume.findMany({
@ -74,7 +77,7 @@ export const resumesRouter = createRouter()
} }
: { comments: { _count: 'desc' } }, : { comments: { _count: 'desc' } },
skip, skip,
take: 10, take,
where: { where: {
...(numComments === 0 && { ...(numComments === 0 && {
comments: { comments: {

@ -53,6 +53,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
searchValue: z.string(), searchValue: z.string(),
skip: z.number(), skip: z.number(),
sortOrder: z.string(), sortOrder: z.string(),
take: z.number(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
@ -64,6 +65,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
sortOrder, sortOrder,
numComments, numComments,
skip, skip,
take,
} = input; } = input;
const totalRecords = await ctx.prisma.resumesStar.count({ const totalRecords = await ctx.prisma.resumesStar.count({
where: { where: {
@ -76,6 +78,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
experience: { in: experienceFilters }, experience: { in: experienceFilters },
location: { in: locationFilters }, location: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
}, },
userId, userId,
}, },
@ -121,7 +124,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
}, },
}, },
skip, skip,
take: 10, take,
where: { where: {
resume: { resume: {
...(numComments === 0 && { ...(numComments === 0 && {
@ -167,6 +170,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
searchValue: z.string(), searchValue: z.string(),
skip: z.number(), skip: z.number(),
sortOrder: z.string(), sortOrder: z.string(),
take: z.number(),
}), }),
async resolve({ ctx, input }) { async resolve({ ctx, input }) {
const userId = ctx.session.user.id; const userId = ctx.session.user.id;
@ -177,6 +181,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
sortOrder, sortOrder,
searchValue, searchValue,
numComments, numComments,
take,
skip, skip,
} = input; } = input;
const totalRecords = await ctx.prisma.resumesResume.count({ const totalRecords = await ctx.prisma.resumesResume.count({
@ -189,6 +194,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
experience: { in: experienceFilters }, experience: { in: experienceFilters },
location: { in: locationFilters }, location: { in: locationFilters },
role: { in: roleFilters }, role: { in: roleFilters },
title: { contains: searchValue, mode: 'insensitive' },
userId, userId,
}, },
}); });
@ -224,7 +230,7 @@ export const resumesResumeUserRouter = createProtectedRouter()
} }
: { comments: { _count: 'desc' } }, : { comments: { _count: 'desc' } },
skip, skip,
take: 10, take,
where: { where: {
...(numComments === 0 && { ...(numComments === 0 && {
comments: { comments: {

@ -1,16 +1,13 @@
import type { QuestionsQuestionType } from '@prisma/client'; import type { QuestionsQuestionType } from '@prisma/client';
export type Question = { export type Question = {
// TODO: company, location, role maps aggregatedQuestionEncounters: AggregatedQuestionEncounter;
company: string;
content: string; content: string;
id: string; id: string;
location: string;
numAnswers: number; numAnswers: number;
numComments: number; numComments: number;
numVotes: number; numVotes: number;
receivedCount: number; receivedCount: number;
role: string;
seenAt: Date; seenAt: Date;
type: QuestionsQuestionType; type: QuestionsQuestionType;
updatedAt: Date; updatedAt: Date;
@ -19,6 +16,7 @@ export type Question = {
export type AggregatedQuestionEncounter = { export type AggregatedQuestionEncounter = {
companyCounts: Record<string, number>; companyCounts: Record<string, number>;
latestSeenAt: Date;
locationCounts: Record<string, number>; locationCounts: Record<string, number>;
roleCounts: Record<string, number>; roleCounts: Record<string, number>;
}; };

@ -1,170 +1,169 @@
// eslint-disable-next-line no-shadow // eslint-disable-next-line no-shadow
export enum Currency { export enum Currency {
AED = "AED", // 'UNITED ARAB EMIRATES DIRHAM' AED = 'AED', // 'UNITED ARAB EMIRATES DIRHAM'
AFN = "AFN", // 'AFGHAN AFGHANI' AFN = 'AFN', // 'AFGHAN AFGHANI'
ALL = "ALL", // 'ALBANIAN LEK' ALL = 'ALL', // 'ALBANIAN LEK'
AMD = "AMD", // 'ARMENIAN DRAM' AMD = 'AMD', // 'ARMENIAN DRAM'
ANG = "ANG", // 'NETHERLANDS ANTILLEAN GUILDER' ANG = 'ANG', // 'NETHERLANDS ANTILLEAN GUILDER'
AOA = "AOA", // 'ANGOLAN KWANZA' AOA = 'AOA', // 'ANGOLAN KWANZA'
ARS = "ARS", // 'ARGENTINE PESO' ARS = 'ARS', // 'ARGENTINE PESO'
AUD = "AUD", // 'AUSTRALIAN DOLLAR' AUD = 'AUD', // 'AUSTRALIAN DOLLAR'
AWG = "AWG", // 'ARUBAN FLORIN' AWG = 'AWG', // 'ARUBAN FLORIN'
AZN = "AZN", // 'AZERBAIJANI MANAT' AZN = 'AZN', // 'AZERBAIJANI MANAT'
BAM = "BAM", // 'BOSNIA-HERZEGOVINA CONVERTIBLE MARK' BAM = 'BAM', // 'BOSNIA-HERZEGOVINA CONVERTIBLE MARK'
BBD = "BBD", // 'BAJAN DOLLAR' BBD = 'BBD', // 'BAJAN DOLLAR'
BDT = "BDT", // 'BANGLADESHI TAKA' BDT = 'BDT', // 'BANGLADESHI TAKA'
BGN = "BGN", // 'BULGARIAN LEV' BGN = 'BGN', // 'BULGARIAN LEV'
BHD = "BHD", // 'BAHRAINI DINAR' BHD = 'BHD', // 'BAHRAINI DINAR'
BIF = "BIF", // 'BURUNDIAN FRANC' BIF = 'BIF', // 'BURUNDIAN FRANC'
BMD = "BMD", // 'BERMUDAN DOLLAR' BMD = 'BMD', // 'BERMUDAN DOLLAR'
BND = "BND", // 'BRUNEI DOLLAR' BND = 'BND', // 'BRUNEI DOLLAR'
BOB = "BOB", // 'BOLIVIAN BOLIVIANO' BOB = 'BOB', // 'BOLIVIAN BOLIVIANO'
BRL = "BRL", // 'BRAZILIAN REAL' BRL = 'BRL', // 'BRAZILIAN REAL'
BSD = "BSD", // 'BAHAMIAN DOLLAR' BSD = 'BSD', // 'BAHAMIAN DOLLAR'
BTN = "BTN", // 'BHUTAN CURRENCY' BTN = 'BTN', // 'BHUTAN CURRENCY'
BWP = "BWP", // 'BOTSWANAN PULA' BWP = 'BWP', // 'BOTSWANAN PULA'
BYN = "BYN", // 'NEW BELARUSIAN RUBLE' BYN = 'BYN', // 'NEW BELARUSIAN RUBLE'
BYR = "BYR", // 'BELARUSIAN RUBLE' BYR = 'BYR', // 'BELARUSIAN RUBLE'
BZD = "BZD", // 'BELIZE DOLLAR' BZD = 'BZD', // 'BELIZE DOLLAR'
CAD = "CAD", // 'CANADIAN DOLLAR' CAD = 'CAD', // 'CANADIAN DOLLAR'
CDF = "CDF", // 'CONGOLESE FRANC' CDF = 'CDF', // 'CONGOLESE FRANC'
CHF = "CHF", // 'SWISS FRANC' CHF = 'CHF', // 'SWISS FRANC'
CLF = "CLF", // 'CHILEAN UNIT OF ACCOUNT (UF)' CLF = 'CLF', // 'CHILEAN UNIT OF ACCOUNT (UF)'
CLP = "CLP", // 'CHILEAN PESO' CLP = 'CLP', // 'CHILEAN PESO'
CNY = "CNY", // 'CHINESE YUAN' CNY = 'CNY', // 'CHINESE YUAN'
COP = "COP", // 'COLOMBIAN PESO' COP = 'COP', // 'COLOMBIAN PESO'
CRC = "CRC", // 'COSTA RICAN COLÓN' CRC = 'CRC', // 'COSTA RICAN COLÓN'
CUC = "CUC", // 'CUBAN CONVERTIBLE PESO' CUC = 'CUC', // 'CUBAN CONVERTIBLE PESO'
CUP = "CUP", // 'CUBAN PESO' CUP = 'CUP', // 'CUBAN PESO'
CVE = "CVE", // 'CAPE VERDEAN ESCUDO' CVE = 'CVE', // 'CAPE VERDEAN ESCUDO'
CVX = "CVX", // 'CONVEX FINANCE' CVX = 'CVX', // 'CONVEX FINANCE'
CZK = "CZK", // 'CZECH KORUNA' CZK = 'CZK', // 'CZECH KORUNA'
DJF = "DJF", // 'DJIBOUTIAN FRANC' DJF = 'DJF', // 'DJIBOUTIAN FRANC'
DKK = "DKK", // 'DANISH KRONE' DKK = 'DKK', // 'DANISH KRONE'
DOP = "DOP", // 'DOMINICAN PESO' DOP = 'DOP', // 'DOMINICAN PESO'
DZD = "DZD", // 'ALGERIAN DINAR' DZD = 'DZD', // 'ALGERIAN DINAR'
EGP = "EGP", // 'EGYPTIAN POUND' EGP = 'EGP', // 'EGYPTIAN POUND'
ERN = "ERN", // 'ERITREAN NAKFA' ERN = 'ERN', // 'ERITREAN NAKFA'
ETB = "ETB", // 'ETHIOPIAN BIRR' ETB = 'ETB', // 'ETHIOPIAN BIRR'
ETC = "ETC", // 'ETHEREUM CLASSIC' ETC = 'ETC', // 'ETHEREUM CLASSIC'
EUR = "EUR", // 'EURO' EUR = 'EUR', // 'EURO'
FEI = "FEI", // 'FEI USD' FEI = 'FEI', // 'FEI USD'
FJD = "FJD", // 'FIJIAN DOLLAR' FJD = 'FJD', // 'FIJIAN DOLLAR'
FKP = "FKP", // 'FALKLAND ISLANDS POUND' FKP = 'FKP', // 'FALKLAND ISLANDS POUND'
GBP = "GBP", // 'POUND STERLING' GBP = 'GBP', // 'POUND STERLING'
GEL = "GEL", // 'GEORGIAN LARI' GEL = 'GEL', // 'GEORGIAN LARI'
GHS = "GHS", // 'GHANAIAN CEDI' GHS = 'GHS', // 'GHANAIAN CEDI'
GIP = "GIP", // 'GIBRALTAR POUND' GIP = 'GIP', // 'GIBRALTAR POUND'
GMD = "GMD", // 'GAMBIAN DALASI' GMD = 'GMD', // 'GAMBIAN DALASI'
GNF = "GNF", // 'GUINEAN FRANC' GNF = 'GNF', // 'GUINEAN FRANC'
GTQ = "GTQ", // 'GUATEMALAN QUETZAL' GTQ = 'GTQ', // 'GUATEMALAN QUETZAL'
GYD = "GYD", // 'GUYANAESE DOLLAR' GYD = 'GYD', // 'GUYANAESE DOLLAR'
HKD = "HKD", // 'HONG KONG DOLLAR' HKD = 'HKD', // 'HONG KONG DOLLAR'
HNL = "HNL", // 'HONDURAN LEMPIRA' HNL = 'HNL', // 'HONDURAN LEMPIRA'
HRK = "HRK", // 'CROATIAN KUNA' HRK = 'HRK', // 'CROATIAN KUNA'
HTG = "HTG", // 'HAITIAN GOURDE' HTG = 'HTG', // 'HAITIAN GOURDE'
HUF = "HUF", // 'HUNGARIAN FORINT' HUF = 'HUF', // 'HUNGARIAN FORINT'
ICP = "ICP", // 'INTERNET COMPUTER' ICP = 'ICP', // 'INTERNET COMPUTER'
IDR = "IDR", // 'INDONESIAN RUPIAH' IDR = 'IDR', // 'INDONESIAN RUPIAH'
ILS = "ILS", // 'ISRAELI NEW SHEKEL' ILS = 'ILS', // 'ISRAELI NEW SHEKEL'
INR = "INR", // 'INDIAN RUPEE' INR = 'INR', // 'INDIAN RUPEE'
IQD = "IQD", // 'IRAQI DINAR' IQD = 'IQD', // 'IRAQI DINAR'
IRR = "IRR", // 'IRANIAN RIAL' IRR = 'IRR', // 'IRANIAN RIAL'
ISK = "ISK", // 'ICELANDIC KRÓNA' ISK = 'ISK', // 'ICELANDIC KRÓNA'
JEP = "JEP", // 'JERSEY POUND' JEP = 'JEP', // 'JERSEY POUND'
JMD = "JMD", // 'JAMAICAN DOLLAR' JMD = 'JMD', // 'JAMAICAN DOLLAR'
JOD = "JOD", // 'JORDANIAN DINAR' JOD = 'JOD', // 'JORDANIAN DINAR'
JPY = "JPY", // 'JAPANESE YEN' JPY = 'JPY', // 'JAPANESE YEN'
KES = "KES", // 'KENYAN SHILLING' KES = 'KES', // 'KENYAN SHILLING'
KGS = "KGS", // 'KYRGYSTANI SOM' KGS = 'KGS', // 'KYRGYSTANI SOM'
KHR = "KHR", // 'CAMBODIAN RIEL' KHR = 'KHR', // 'CAMBODIAN RIEL'
KMF = "KMF", // 'COMORIAN FRANC' KMF = 'KMF', // 'COMORIAN FRANC'
KPW = "KPW", // 'NORTH KOREAN WON' KPW = 'KPW', // 'NORTH KOREAN WON'
KRW = "KRW", // 'SOUTH KOREAN WON' KRW = 'KRW', // 'SOUTH KOREAN WON'
KWD = "KWD", // 'KUWAITI DINAR' KWD = 'KWD', // 'KUWAITI DINAR'
KYD = "KYD", // 'CAYMAN ISLANDS DOLLAR' KYD = 'KYD', // 'CAYMAN ISLANDS DOLLAR'
KZT = "KZT", // 'KAZAKHSTANI TENGE' KZT = 'KZT', // 'KAZAKHSTANI TENGE'
LAK = "LAK", // 'LAOTIAN KIP' LAK = 'LAK', // 'LAOTIAN KIP'
LBP = "LPB", // 'LEBANESE POUND' LBP = 'LPB', // 'LEBANESE POUND'
LKR = "LKR", // 'SRI LANKAN RUPEE' LKR = 'LKR', // 'SRI LANKAN RUPEE'
LRD = "LRD", // 'LIBERIAN DOLLAR' LRD = 'LRD', // 'LIBERIAN DOLLAR'
LSL = "LSL", // 'LESOTHO LOTI' LSL = 'LSL', // 'LESOTHO LOTI'
LTL = "LTL", // 'LITHUANIAN LITAS' LTL = 'LTL', // 'LITHUANIAN LITAS'
LVL = "LVL", // 'LATVIAN LATS' LVL = 'LVL', // 'LATVIAN LATS'
LYD = "LYD", // 'LIBYAN DINAR' LYD = 'LYD', // 'LIBYAN DINAR'
MAD = "MAD", // 'MOROCCAN DIRHAM' MAD = 'MAD', // 'MOROCCAN DIRHAM'
MDL = "MDL", // 'MOLDOVAN LEU' MDL = 'MDL', // 'MOLDOVAN LEU'
MGA = "MGA", // 'MALAGASY ARIARY' MGA = 'MGA', // 'MALAGASY ARIARY'
MKD = "MKD", // 'MACEDONIAN DENAR' MKD = 'MKD', // 'MACEDONIAN DENAR'
MMK = "MMK", // 'MYANMAR KYAT' MMK = 'MMK', // 'MYANMAR KYAT'
MNT = "MNT", // 'MONGOLIAN TUGRIK' MNT = 'MNT', // 'MONGOLIAN TUGRIK'
MOP = "MOP", // 'MACANESE PATACA' MOP = 'MOP', // 'MACANESE PATACA'
MRO = "MRO", // 'MAURITANIAN OUGUIYA' MRO = 'MRO', // 'MAURITANIAN OUGUIYA'
MUR = "MUR", // 'MAURITIAN RUPEE' MUR = 'MUR', // 'MAURITIAN RUPEE'
MVR = "MVR", // 'MALDIVIAN RUFIYAA' MVR = 'MVR', // 'MALDIVIAN RUFIYAA'
MWK = "MWK", // 'MALAWIAN KWACHA' MWK = 'MWK', // 'MALAWIAN KWACHA'
MXN = "MXN", // 'MEXICAN PESO' MXN = 'MXN', // 'MEXICAN PESO'
MYR = "MYR", // 'MALAYSIAN RINGGIT' MYR = 'MYR', // 'MALAYSIAN RINGGIT'
MZN = "MZN", // 'MOZAMBICAN METICAL' MZN = 'MZN', // 'MOZAMBICAN METICAL'
NAD = "NAD", // 'NAMIBIAN DOLLAR' NAD = 'NAD', // 'NAMIBIAN DOLLAR'
NGN = "NGN", // 'NIGERIAN NAIRA' NGN = 'NGN', // 'NIGERIAN NAIRA'
NIO = "NIO", // 'NICARAGUAN CÓRDOBA' NIO = 'NIO', // 'NICARAGUAN CÓRDOBA'
NOK = "NOK", // 'NORWEGIAN KRONE' NOK = 'NOK', // 'NORWEGIAN KRONE'
NPR = "NPR", // 'NEPALESE RUPEE' NPR = 'NPR', // 'NEPALESE RUPEE'
NZD = "NZD", // 'NEW ZEALAND DOLLAR' NZD = 'NZD', // 'NEW ZEALAND DOLLAR'
OMR = "OMR", // 'OMANI RIAL' OMR = 'OMR', // 'OMANI RIAL'
ONE = "ONE", // 'MENLO ONE' ONE = 'ONE', // 'MENLO ONE'
PAB = "PAB", // 'PANAMANIAN BALBOA' PAB = 'PAB', // 'PANAMANIAN BALBOA'
PGK = "PGK", // 'PAPUA NEW GUINEAN KINA' PGK = 'PGK', // 'PAPUA NEW GUINEAN KINA'
PHP = "PHP", // 'PHILIPPINE PESO' PHP = 'PHP', // 'PHILIPPINE PESO'
PKR = "PKR", // 'PAKISTANI RUPEE' PKR = 'PKR', // 'PAKISTANI RUPEE'
PLN = "PLN", // 'POLAND ZŁOTY' PLN = 'PLN', // 'POLAND ZŁOTY'
PYG = "PYG", // 'PARAGUAYAN GUARANI' PYG = 'PYG', // 'PARAGUAYAN GUARANI'
QAR = "QAR", // 'QATARI RIAL' QAR = 'QAR', // 'QATARI RIAL'
RON = "RON", // 'ROMANIAN LEU' RON = 'RON', // 'ROMANIAN LEU'
RSD = "RSD", // 'SERBIAN DINAR' RSD = 'RSD', // 'SERBIAN DINAR'
RUB = "RUB", // 'RUSSIAN RUBLE' RUB = 'RUB', // 'RUSSIAN RUBLE'
RWF = "RWF", // 'RWANDAN FRANC' RWF = 'RWF', // 'RWANDAN FRANC'
SAR = "SAR", // 'SAUDI RIYAL' SAR = 'SAR', // 'SAUDI RIYAL'
SBD = "SBD", // 'SOLOMON ISLANDS DOLLAR' SBD = 'SBD', // 'SOLOMON ISLANDS DOLLAR'
SCR = "SCR", // 'SEYCHELLOIS RUPEE' SCR = 'SCR', // 'SEYCHELLOIS RUPEE'
SDG = "SDG", // 'SUDANESE POUND' SDG = 'SDG', // 'SUDANESE POUND'
SEK = "SEK", // 'SWEDISH KRONA' SEK = 'SEK', // 'SWEDISH KRONA'
SGD = "SGD", // 'SINGAPORE DOLLAR' SGD = 'SGD', // 'SINGAPORE DOLLAR'
SHIB = "SHIB", // 'SHIBA INU' SHP = 'SHP', // 'SAINT HELENA POUND'
SHP = "SHP", // 'SAINT HELENA POUND' SLL = 'SLL', // 'SIERRA LEONEAN LEONE'
SLL = "SLL", // 'SIERRA LEONEAN LEONE' SOS = 'SOS', // 'SOMALI SHILLING'
SOS = "SOS", // 'SOMALI SHILLING' SRD = 'SRD', // 'SURINAMESE DOLLAR'
SRD = "SRD", // 'SURINAMESE DOLLAR' STD = 'STD', // 'SÃO TOMÉ AND PRÍNCIPE DOBRA (PRE-2018)'
STD = "STD", // 'SÃO TOMÉ AND PRÍNCIPE DOBRA (PRE-2018)' SVC = 'SVC', // 'SALVADORAN COLÓN'
SVC = "SVC", // 'SALVADORAN COLÓN' SYP = 'SYP', // 'SYRIAN POUND'
SYP = "SYP", // 'SYRIAN POUND' SZL = 'SZL', // 'SWAZI LILANGENI'
SZL = "SZL", // 'SWAZI LILANGENI' THB = 'THB', // 'THAI BAHT'
THB = "THB", // 'THAI BAHT' TJS = 'TJS', // 'TAJIKISTANI SOMONI'
TJS = "TJS", // 'TAJIKISTANI SOMONI' TMT = 'TMT', // 'TURKMENISTANI MANAT'
TMT = "TMT", // 'TURKMENISTANI MANAT' TND = 'TND', // 'TUNISIAN DINAR'
TND = "TND", // 'TUNISIAN DINAR' TOP = 'TOP', // "TONGAN PA'ANGA"
TOP = "TOP", // "TONGAN PA'ANGA" TRY = 'TRY', // 'TURKISH LIRA'
TRY = "TRY", // 'TURKISH LIRA' TTD = 'TTD', // 'TRINIDAD & TOBAGO DOLLAR'
TTD = "TTD", // 'TRINIDAD & TOBAGO DOLLAR' TWD = 'TWD', // 'NEW TAIWAN DOLLAR'
TWD = "TWD", // 'NEW TAIWAN DOLLAR' TZS = 'TZS', // 'TANZANIAN SHILLING'
TZS = "TZS", // 'TANZANIAN SHILLING' UAH = 'UAH', // 'UKRAINIAN HRYVNIA'
UAH = "UAH", // 'UKRAINIAN HRYVNIA' UGX = 'UGX', // 'UGANDAN SHILLING'
UGX = "UGX", // 'UGANDAN SHILLING' USD = 'USD', // 'UNITED STATES DOLLAR'
USD = "USD", // 'UNITED STATES DOLLAR' UYU = 'UYU', // 'URUGUAYAN PESO'
UYU = "UYU", // 'URUGUAYAN PESO' UZS = 'UZS', // 'UZBEKISTANI SOM'
UZS = "UZS", // 'UZBEKISTANI SOM' VND = 'VND', // 'VIETNAMESE DONG'
VND = "VND", // 'VIETNAMESE DONG' VUV = 'VUV', // 'VANUATU VATU'
VUV = "VUV", // 'VANUATU VATU' WST = 'WST', // 'SAMOAN TALA'
WST = "WST", // 'SAMOAN TALA' XAF = 'XAF', // 'CENTRAL AFRICAN CFA FRANC'
XAF = "XAF", // 'CENTRAL AFRICAN CFA FRANC' XCD = 'XCD', // 'EAST CARIBBEAN DOLLAR'
XCD = "XCD", // 'EAST CARIBBEAN DOLLAR' XOF = 'XOF', // 'WEST AFRICAN CFA FRANC'
XOF = "XOF", // 'WEST AFRICAN CFA FRANC' XPF = 'XPF', // 'CFP FRANC'
XPF = "XPF", // 'CFP FRANC' YER = 'YER', // 'YEMENI RIAL'
YER = "YER", // 'YEMENI RIAL' ZAR = 'ZAR', // 'SOUTH AFRICAN RAND'
ZAR = "ZAR", // 'SOUTH AFRICAN RAND' ZMW = 'ZMW', // 'ZAMBIAN KWACHA'
ZMW = "ZMW", // 'ZAMBIAN KWACHA' ZWL = 'ZWL', // 'ZIMBABWEAN DOLLAR'
ZWL = "ZWL", // 'ZIMBABWEAN DOLLAR'
} }
export const CURRENCY_OPTIONS = Object.entries(Currency).map( export const CURRENCY_OPTIONS = Object.entries(Currency).map(

@ -3,7 +3,6 @@ export function getProfileLink(profileId: string, token?: string) {
} }
export function copyProfileLink(profileId: string, token?: string) { export function copyProfileLink(profileId: string, token?: string) {
// TODO: Add notification
navigator.clipboard.writeText(getProfileLink(profileId, token)); navigator.clipboard.writeText(getProfileLink(profileId, token));
} }

@ -0,0 +1,44 @@
import type { Config} from 'unique-names-generator';
import { countries, names } from 'unique-names-generator';
import { adjectives, animals,colors, uniqueNamesGenerator } from 'unique-names-generator';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient()
const customConfig: Config = {
dictionaries: [adjectives, colors, animals],
length: 3,
separator: '-',
};
export async function generateRandomName(): Promise<string> {
let uniqueName: string = uniqueNamesGenerator(customConfig);
let sameNameProfiles = await prisma.offersProfile.findMany({
where: {
profileName: uniqueName
}
})
while (sameNameProfiles.length !== 0) {
uniqueName = uniqueNamesGenerator(customConfig);
sameNameProfiles = await prisma.offersProfile.findMany({
where: {
profileName: uniqueName
}
})
}
return uniqueName
}
const tokenConfig: Config = {
dictionaries: [adjectives, colors, animals, countries, names]
.sort((_a, _b) => 0.5 - Math.random()),
length: 5,
separator: '-',
};
export function generateRandomStringForToken(): string {
return uniqueNamesGenerator(tokenConfig)
}

@ -25,9 +25,11 @@ export function timeSinceNow(date: Date | number | string) {
} }
interval = seconds / 60; interval = seconds / 60;
if (interval > 1) { if (interval > 1) {
return `${Math.floor(interval)} minutes`; const time: number = Math.floor(interval);
return time === 1 ? `${time} minute` : `${time} minutes`;
} }
return `${Math.floor(interval)} seconds`; const time: number = Math.floor(interval);
return time === 1 ? `${time} second` : `${time} seconds`;
} }
export function formatDate(value: Date | number | string) { export function formatDate(value: Date | number | string) {

@ -7,6 +7,7 @@ type Props = Readonly<{
defaultValue?: boolean; defaultValue?: boolean;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
errorMessage?: string;
label: string; label: string;
name?: string; name?: string;
onChange?: ( onChange?: (
@ -21,6 +22,7 @@ function CheckboxInput(
defaultValue, defaultValue,
description, description,
disabled = false, disabled = false,
errorMessage,
label, label,
name, name,
value, value,
@ -30,8 +32,10 @@ function CheckboxInput(
) { ) {
const id = useId(); const id = useId();
const descriptionId = useId(); const descriptionId = useId();
const errorId = useId();
return ( return (
<div>
<div <div
className={clsx( className={clsx(
'relative flex', 'relative flex',
@ -54,13 +58,13 @@ function CheckboxInput(
id={id} id={id}
name={name} name={name}
type="checkbox" type="checkbox"
onChange={ onChange={(event) => {
onChange != null if (!onChange) {
? (event) => { return;
onChange?.(event.target.checked, event);
}
: undefined
} }
onChange(event.target.checked, event);
}}
/> />
</div> </div>
<div className="ml-3 text-sm"> <div className="ml-3 text-sm">
@ -84,6 +88,12 @@ function CheckboxInput(
)} )}
</div> </div>
</div> </div>
{errorMessage && (
<p className="text-danger-600 mt-2 text-sm" id={errorId}>
{errorMessage}
</p>
)}
</div>
); );
} }

@ -69,6 +69,7 @@ export default function Pagination({
pageNumberSet.add(page); pageNumberSet.add(page);
elements.push( elements.push(
<PaginationPage <PaginationPage
key={page}
isCurrent={current === page} isCurrent={current === page}
label={page} label={page}
onClick={(event) => { onClick={(event) => {
@ -83,7 +84,7 @@ export default function Pagination({
addPage(i); addPage(i);
} }
if (lastAddedPage < current - pagePadding) { if (lastAddedPage < current - pagePadding - 1) {
elements.push(<PaginationEllipsis />); elements.push(<PaginationEllipsis />);
} }
@ -91,7 +92,7 @@ export default function Pagination({
addPage(i); addPage(i);
} }
if (lastAddedPage < end - pagePadding) { if (lastAddedPage < end - pagePadding - 1) {
elements.push(<PaginationEllipsis />); elements.push(<PaginationEllipsis />);
} }

@ -88,7 +88,7 @@ function Select<T>(
aria-label={isLabelHidden ? label : undefined} aria-label={isLabelHidden ? label : undefined}
className={clsx( className={clsx(
display === 'block' && 'block w-full', display === 'block' && 'block w-full',
'rounded-md py-2 pl-3 pr-8 text-base focus:outline-none sm:text-sm', 'rounded-md py-2 pl-3 pr-8 text-sm focus:outline-none',
stateClasses[state], stateClasses[state],
borderClasses[borderStyle], borderClasses[borderStyle],
disabled && 'bg-slate-100', disabled && 'bg-slate-100',

@ -108,7 +108,7 @@ function TextArea(
aria-describedby={hasError ? errorId : undefined} aria-describedby={hasError ? errorId : undefined}
aria-invalid={hasError ? true : undefined} aria-invalid={hasError ? true : undefined}
className={clsx( className={clsx(
'block w-full rounded-md sm:text-sm', 'block w-full rounded-md text-sm',
stateClasses[state].textArea, stateClasses[state].textArea,
disabled && 'bg-slate-100', disabled && 'bg-slate-100',
resizeClasses[resize], resizeClasses[resize],

@ -142,7 +142,7 @@ function TextInput(
</label> </label>
<div <div
className={clsx( className={clsx(
'flex w-full overflow-hidden rounded-md border focus-within:ring-1 sm:text-sm', 'flex w-full overflow-hidden rounded-md border text-sm focus-within:ring-1',
disabled && 'pointer-events-none select-none bg-slate-100', disabled && 'pointer-events-none select-none bg-slate-100',
containerClass, containerClass,
)}> )}>
@ -178,7 +178,7 @@ function TextInput(
aria-describedby={hasError ? errorId : undefined} aria-describedby={hasError ? errorId : undefined}
aria-invalid={hasError ? true : undefined} aria-invalid={hasError ? true : undefined}
className={clsx( className={clsx(
'flex-1 border-none focus:outline-none focus:ring-0 sm:text-sm', 'w-0 flex-1 border-none text-sm focus:outline-none focus:ring-0',
inputClass, inputClass,
disabled && 'bg-transparent', disabled && 'bg-transparent',
)} )}

@ -88,7 +88,7 @@ export default function Typeahead({
)} )}
</Combobox.Label> </Combobox.Label>
<div className="relative"> <div className="relative">
<div className="focus-visible:ring-offset-primary-300 relative w-full cursor-default overflow-hidden rounded-lg border border-slate-300 bg-white text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 sm:text-sm"> <div className="focus-visible:ring-offset-primary-300 relative w-full cursor-default overflow-hidden rounded-lg border border-slate-300 bg-white text-left text-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2">
<Combobox.Input <Combobox.Input
className={clsx( className={clsx(
'w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-slate-900 focus:ring-0', 'w-full border-none py-2 pl-3 pr-10 text-sm leading-5 text-slate-900 focus:ring-0',
@ -117,7 +117,7 @@ export default function Typeahead({
leave="transition ease-in duration-100" leave="transition ease-in duration-100"
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0"> leaveTo="opacity-0">
<Combobox.Options className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"> <Combobox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{options.length === 0 && query !== '' ? ( {options.length === 0 && query !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700"> <div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage} {noResultsMessage}

@ -14405,6 +14405,11 @@ unique-filename@^1.1.1:
dependencies: dependencies:
unique-slug "^2.0.0" unique-slug "^2.0.0"
unique-names-generator@^4.7.1:
version "4.7.1"
resolved "https://registry.yarnpkg.com/unique-names-generator/-/unique-names-generator-4.7.1.tgz#966407b12ba97f618928f77322cfac8c80df5597"
integrity sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==
unique-slug@^2.0.0: unique-slug@^2.0.0:
version "2.0.2" version "2.0.2"
resolved "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz" resolved "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz"

Loading…
Cancel
Save