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-query": "^3.39.2",
"superjson": "^1.10.0",
"unique-names-generator": "^4.7.1",
"zod": "^3.18.0"
},
"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())
questionId String
userId String?
upvotes Int @default(0)
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -485,6 +486,7 @@ model QuestionsAnswer {
questionId String
userId String?
content String @db.Text
upvotes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -513,6 +515,7 @@ model QuestionsAnswerComment {
answerId String
userId String?
content String @db.Text
upvotes Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

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

@ -34,7 +34,7 @@ export default function MobileNavigation({
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
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>
<div className="fixed inset-0 z-40 flex">
<Transition.Child

@ -9,12 +9,12 @@ export function Breadcrumbs({ stepLabels, currentStep }: BreadcrumbsProps) {
{stepLabels.map((label, index) => (
<div key={label} className="flex space-x-1">
{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 && (
<p className="text-sm text-gray-400">{'>'}</p>
<p className="text-sm text-slate-400">{'>'}</p>
)}
</div>
))}

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

@ -2,15 +2,15 @@ export default function OffersTitle() {
return (
<>
<div className="flex items-end justify-center">
<h1 className="mt-16 text-center text-4xl font-bold text-indigo-600">
Tech Handbook Offers Repo
<h1 className="text-primary-600 mt-16 text-center text-4xl font-bold">
Offer Profile Repository
</h1>
</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
</div>
<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
</div>
</>

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

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

@ -8,13 +8,17 @@ import {
emptyOption,
FieldError,
locationOptions,
titleOptions,
} from '~/components/offers/constants';
import type { BackgroundPostData } from '~/components/offers/types';
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 FormSelect from '../../forms/FormSelect';
import FormTextInput from '../../forms/FormTextInput';
@ -26,11 +30,11 @@ function YoeSection() {
const backgroundFields = formState.errors.background;
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)
</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">
<FormTextInput
errorMessage={backgroundFields?.totalYoe?.message}
@ -92,13 +96,13 @@ function FullTimeJobFields() {
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Title"
options={titleOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.title`)}
<div>
<JobTitlesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.title`, value)
}
/>
</div>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
@ -112,6 +116,7 @@ function FullTimeJobFields() {
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
@ -177,13 +182,13 @@ function InternshipJobFields() {
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Title"
options={titleOptions}
placeholder={emptyOption}
{...register(`background.experiences.0.title`)}
<div>
<JobTitlesTypeahead
onSelect={({ value }) =>
setValue(`background.experiences.0.title`, value)
}
/>
</div>
<div>
<CompaniesTypeahead
onSelect={({ value }) =>
@ -197,6 +202,7 @@ function InternshipJobFields() {
endAddOn={
<FormSelect
borderStyle="borderless"
defaultValue={Currency.SGD}
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
@ -245,10 +251,10 @@ function CurrentJobSection() {
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
</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">
<FormRadioList
defaultValue={JobType.FULLTIME}
@ -282,10 +288,10 @@ function EducationSection() {
const { register } = useFormContext();
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
</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">
<FormSelect
display="block"
@ -310,6 +316,22 @@ function EducationSection() {
{...register(`background.educations.0.school`)}
/>
</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>
</div>
</>
@ -319,13 +341,9 @@ function EducationSection() {
export default function BackgroundForm() {
return (
<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
</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>
<YoeSection />
<CurrentJobSection />

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

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

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

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

@ -8,9 +8,9 @@ export default function ProfilePhotoHolder({
const sizeMap = { lg: '16', sm: '12' };
return (
<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
className="h-full w-full text-gray-300"
className="h-full w-full text-slate-300"
fill="currentColor"
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" />

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

@ -1,5 +1,8 @@
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 { formatDate } from '~/utils/offers/time';
@ -13,19 +16,21 @@ export default function OfferTableRow({
return (
<tr
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
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">
{company.name}
</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">{convertMoneyToString(income)}</td>
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
<td className="space-x-4 py-4 px-6">
<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}`}>
View Profile
</Link>

@ -109,7 +109,7 @@ export default function OffersTable({
function renderHeader() {
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>
{[
'Company',
@ -145,7 +145,7 @@ export default function OffersTable({
<Spinner display="block" size="lg" />
</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()}
<tbody>
{offers.map((offer) => (

@ -19,13 +19,13 @@ export default function OffersTablePagination({
<nav
aria-label="Table navigation"
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
<span className="font-semibold text-gray-900 dark:text-white">
<span className="font-semibold text-slate-900">
{` ${startNumber} - ${endNumber} `}
</span>
{`of `}
<span className="font-semibold text-gray-900 dark:text-white">
<span className="font-semibold text-slate-900">
{pagination.totalItems}
</span>
</span>

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

@ -30,7 +30,7 @@ export default function ContributeQuestionCard({
return (
<div>
<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"
onClick={handleOpenContribute}>
<TextInput

@ -48,7 +48,7 @@ export default function ContributeQuestionDialog({
leave="ease-in duration-200"
leaveFrom="opacity-100"
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>
<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">
@ -66,7 +66,7 @@ export default function ContributeQuestionDialog({
<div className="mt-3 w-full sm:mt-0 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900">
className="text-lg font-medium leading-6 text-slate-900">
Contribute question
</Dialog.Title>
<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}`}>
{cardContent}
{showDeleteButton && (
<div className="invisible self-center fill-red-700 group-hover:visible">
<div className="fill-danger-700 invisible self-center group-hover:visible">
<Button
icon={TrashIcon}
isLabelHidden={true}

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

@ -186,13 +186,13 @@ export default function ContributeQuestionForm({
</div>
<div className="flex gap-x-2">
<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"
onClick={onDiscard}>
Discard
</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}
label="Contribute"
type="submit"

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

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

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

@ -14,8 +14,8 @@ export default function ResumeFilterPill({
return (
<button
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',
isSelected ? 'bg-indigo-500 text-white' : 'bg-white text-indigo-500',
'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-primary-500 text-white' : 'text-primary-500 bg-white',
)}
type="button"
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="col-span-4">
{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">
<BriefcaseIcon
aria-hidden="true"

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

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

@ -1,7 +1,9 @@
import clsx from 'clsx';
import { useSession } from 'next-auth/react';
import {
BookOpenIcon,
BriefcaseIcon,
ChatBubbleLeftRightIcon,
CodeBracketSquareIcon,
FaceSmileIcon,
IdentificationIcon,
@ -9,24 +11,20 @@ import {
} from '@heroicons/react/24/outline';
import { ResumesSection } from '@prisma/client';
import { Spinner } from '@tih/ui';
import { Button } from '@tih/ui';
import { trpc } from '~/utils/trpc';
import { RESUME_COMMENTS_SECTIONS } from './resumeCommentConstants';
import ResumeCommentListItem from './ResumeCommentListItem';
import ResumeSignInButton from '../shared/ResumeSignInButton';
import type { ResumeComment } from '~/types/resume-comments';
type ResumeCommentsListProps = Readonly<{
resumeId: string;
setShowCommentsForm: (show: boolean) => void;
}>;
export default function ResumeCommentsList({
resumeId,
setShowCommentsForm,
}: ResumeCommentsListProps) {
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 (
<div className="space-y-3">
{renderButton()}
{commentsQuery.isLoading ? (
<div className="col-span-10 pt-4">
<Spinner display="block" size="lg" />
</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 }) => {
const comments = commentsQuery.data
? commentsQuery.data.filter((comment: ResumeComment) => {
@ -85,12 +66,18 @@ export default function ResumeCommentsList({
return (
<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)}
<div className="w-fit text-lg font-medium">{label}</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 ? (
comments.map((comment) => {
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 className="relative flex flex-row pr-6 pt-2">
<div className="flex-grow border-t border-gray-300" />
</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',
commentVotesQuery.data?.userVote?.value === Vote.UPVOTE ||
upvoteAnimation
? 'fill-indigo-500'
: 'fill-gray-400',
? 'fill-primary-500'
: 'fill-slate-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-indigo-500',
'hover:fill-primary-500',
upvoteAnimation && 'animate-[bounce_0.5s_infinite] cursor-default',
)}
/>
</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}
</div>
@ -115,12 +115,12 @@ export default function ResumeCommentVoteButtons({
'h-4 w-4',
commentVotesQuery.data?.userVote?.value === Vote.DOWNVOTE ||
downvoteAnimation
? 'fill-red-500'
: 'fill-gray-400',
? 'fill-danger-500'
: 'fill-slate-400',
userId &&
!downvoteAnimation &&
!upvoteAnimation &&
'hover:fill-red-500',
'hover:fill-danger-500',
downvoteAnimation &&
'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">
<Container className="relative">
<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.
</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
engineers in the field.
</p>
<Link href="/resumes/browse">
<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">
Start browsing now
</button>

@ -7,7 +7,7 @@ export function Hero() {
<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">
Resume review{' '}
<span className="relative whitespace-nowrap text-indigo-500">
<span className="text-primary-500 relative whitespace-nowrap">
<svg
aria-hidden="true"
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">
<Link href="/resumes/browse">
<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">
Start browsing now
</button>
</Link>
<Link href="https://www.youtube.com/watch?v=dQw4w9WgXcQ">
<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">
<svg
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" />
</svg>
<span className="ml-3">Watch video</span>

@ -49,7 +49,7 @@ export function PrimaryFeatures() {
return (
<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">
<Container className="relative">
<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() {
return (
<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">
<Container>
<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>
{descriptionOverflow && (
<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}>
{isExpanded ? 'See Less' : 'See More'}
</p>

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

@ -9,6 +9,7 @@ type Props = Readonly<{
isLabelHidden?: boolean;
onSelect: (option: TypeaheadOption) => void;
placeHolder?: string;
required?: boolean;
}>;
export default function CompaniesTypeahead({
@ -16,6 +17,7 @@ export default function CompaniesTypeahead({
onSelect,
isLabelHidden,
placeHolder,
required,
}: Props) {
const [query, setQuery] = useState('');
const companies = trpc.useQuery([
@ -42,6 +44,7 @@ export default function CompaniesTypeahead({
})) ?? []
}
placeholder={placeHolder}
required={required}
onQueryChange={setQuery}
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() {
return (
<Html className="h-full bg-gray-50">
<Html className="h-full bg-slate-50">
<Head />
<body className="h-full overflow-hidden">
<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 { Select } from '@tih/ui';
import type { SVGProps } from 'react';
import {
BookmarkSquareIcon,
ChartBarSquareIcon,
InformationCircleIcon,
ShareIcon,
TableCellsIcon,
UsersIcon,
} from '@heroicons/react/24/outline';
import { titleOptions } from '~/components/offers/constants';
import OffersTitle from '~/components/offers/OffersTitle';
import OffersTable from '~/components/offers/table/OffersTable';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import LeftTextCard from '~/components/offers/landing/LeftTextCard';
import RightTextCard from '~/components/offers/landing/RightTextCard';
import { HOME_URL } from '~/components/offers/types';
export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('Software Engineer');
const [companyFilter, setCompanyFilter] = useState('');
const features = [
{
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 (
<main className="flex-1 overflow-y-auto">
<div className="grid-rows grid h-1/2 bg-gray-100">
<OffersTitle />
<div className="flex items-start justify-center">
<div className="mt-4 flex items-center">
Viewing offers for
<div className="mx-4">
<Select
isLabelHidden={true}
label="Select a job title"
options={titleOptions}
value={jobTitleFilter}
onChange={setjobTitleFilter}
const footerNavigation = {
social: [
{
href: '#',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
<path
clipRule="evenodd"
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"
fillRule="evenodd"
/>
</svg>
),
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>
in
<div className="ml-4">
<CompaniesTypeahead
isLabelHidden={true}
placeHolder="All companies"
onSelect={({ value }) => setCompanyFilter(value)}
{/* Alternating Feature Sections */}
<div className="relative overflow-hidden pt-16 pb-32">
<div
aria-hidden="true"
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 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 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 className="flex justify-center bg-white pb-20 pt-10">
<OffersTable
companyFilter={companyFilter}
jobTitleFilter={jobTitleFilter}
{/* Gradient Feature Section */}
<div className="to-primary-600 bg-gradient-to-r from-purple-800">
<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>
</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,
OfferDisplayData,
} 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 { getProfilePath } from '~/utils/offers/link';
import { formatDate } from '~/utils/offers/time';
@ -19,6 +23,7 @@ import { trpc } from '~/utils/trpc';
import type { Profile, ProfileAnalysis, ProfileOffer } from '~/types/offers';
export default function OfferProfile() {
const { showToast } = useToast();
const ErrorPage = (
<Error statusCode={404} title="Requested profile does not exist." />
);
@ -42,7 +47,7 @@ export default function OfferProfile() {
enabled: typeof offerProfileId === 'string',
onSuccess: (data: Profile) => {
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 (!data?.isEditable && token !== '') {
@ -60,7 +65,9 @@ export default function OfferProfile() {
companyName: res.company.name,
id: res.offersFullTime.id,
jobLevel: res.offersFullTime.level,
jobTitle: res.offersFullTime.title,
jobTitle: getLabelForJobTitleType(
res.offersFullTime.title as JobTitleType,
),
location: res.location,
negotiationStrategy: res.negotiationStrategy,
otherComment: res.comments,
@ -75,7 +82,9 @@ export default function OfferProfile() {
const filteredOffer: OfferDisplayData = {
companyName: res.company.name,
id: res.offersIntern!.id,
jobTitle: res.offersIntern!.title,
jobTitle: getLabelForJobTitleType(
res.offersIntern!.title as JobTitleType,
),
location: res.location,
monthlySalary: convertMoneyToString(
res.offersIntern!.monthlySalary,
@ -105,7 +114,9 @@ export default function OfferProfile() {
companyName: experience.company?.name,
duration: experience.durationInMonths,
jobLevel: experience.level,
jobTitle: experience.title,
jobTitle: experience.title
? getLabelForJobTitleType(experience.title as JobTitleType)
: null,
monthlySalary: experience.monthlySalary
? convertMoneyToString(experience.monthlySalary)
: null,
@ -131,11 +142,18 @@ export default function OfferProfile() {
const trpcContext = trpc.useContext();
const deleteMutation = trpc.useMutation(['offers.profile.delete'], {
onError: () => {
alert('Error deleting profile'); // TODO: replace with toast
showToast({
title: `Error deleting offers profile.`,
variant: 'failure',
});
},
onSuccess: () => {
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 { NoSymbolIcon } from '@heroicons/react/24/outline';
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 ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOption } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
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 { SORT_TYPES } from '~/utils/questions/constants';
import { SORT_ORDERS } from '~/utils/questions/constants';
import { APP_TITLE } from '~/utils/questions/constants';
import { ROLES } from '~/utils/questions/constants';
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import { QUESTION_AGES, QUESTION_TYPES } from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useSearchParam,
@ -148,12 +146,18 @@ export default function QuestionsBrowsePage() {
: undefined;
}, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery(
const {
data: questionsQueryData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.useInfiniteQuery(
[
'questions.questions.getQuestionsByFilter',
{
companyNames: selectedCompanies,
endDate: today,
limit: 10,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: selectedRoles,
@ -163,10 +167,21 @@ export default function QuestionsBrowsePage() {
},
],
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
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 { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
@ -180,12 +195,17 @@ export default function QuestionsBrowsePage() {
const [loaded, setLoaded] = useState(false);
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
const companyFilterOptions = useMemo(() => {
return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const [selectedCompanyOptions, setSelectedCompanyOptions] = useState<
Array<FilterOption>
>([]);
const [selectedRoleOptions, setSelectedRoleOptions] = useState<
Array<FilterOption>
>([]);
const [selectedLocationOptions, setSelectedLocationOptions] = useState<
Array<FilterOption>
>([]);
const questionTypeFilterOptions = useMemo(() => {
return QUESTION_TYPES.map((questionType) => ({
@ -201,20 +221,6 @@ export default function QuestionsBrowsePage() {
}));
}, [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(() => {
return (
areCompaniesInitialized &&
@ -287,35 +293,89 @@ export default function QuestionsBrowsePage() {
setSelectedQuestionAge('all');
setSelectedRoles([]);
setSelectedLocations([]);
setSelectedCompanyOptions([]);
setSelectedRoleOptions([]);
setSelectedLocationOptions([]);
}}
/>
<FilterSection
label="Company"
options={companyFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
label="Companies"
options={selectedCompanyOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
<CompanyTypeahead
{...field}
clearOnSelect={true}
filterOption={(option) => {
return !selectedCompanyOptions.some((selectedOption) => {
return selectedOption.value === option.value;
});
}}
isLabelHidden={true}
label="Companies"
options={options}
placeholder="Search companies"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
onSelect={(option) => {
onOptionChange({
...option,
checked: true,
});
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies([...selectedCompanies, optionValue]);
onOptionChange={(option) => {
if (option.checked) {
setSelectedCompanies([...selectedCompanies, option.label]);
setSelectedCompanyOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
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"
options={questionTypeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, optionValue]);
onOptionChange={(option) => {
if (option.checked) {
setSelectedQuestionTypes([...selectedQuestionTypes, option.value]);
} else {
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
(questionType) => questionType !== option.value,
),
);
}
@ -341,68 +401,47 @@ export default function QuestionsBrowsePage() {
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
onOptionChange={({ value }) => {
setSelectedQuestionAge(value);
}}
/>
<FilterSection
label="Roles"
options={roleFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
label="Locations"
options={selectedLocationOptions}
renderInput={({ onOptionChange, field: { ref: _, ...field } }) => (
<LocationTypeahead
{...field}
isLabelHidden={true}
label="Roles"
options={options}
placeholder="Search roles"
// 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),
);
}
clearOnSelect={true}
filterOption={(option) => {
return !selectedLocationOptions.some((selectedOption) => {
return selectedOption.value === option.value;
});
}}
/>
<FilterSection
label="Location"
options={locationFilterOptions}
renderInput={({
onOptionChange,
options,
field: { ref: _, ...field },
}) => (
<Typeahead
{...field}
isLabelHidden={true}
label="Locations"
options={options}
placeholder="Search locations"
// eslint-disable-next-line @typescript-eslint/no-empty-function
onQueryChange={() => {}}
onSelect={({ value }) => {
onOptionChange(value, true);
onSelect={(option) => {
onOptionChange({
...option,
checked: true,
});
}}
/>
)}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations([...selectedLocations, optionValue]);
onOptionChange={(option) => {
if (option.checked) {
setSelectedLocations([...selectedLocations, option.value]);
setSelectedLocationOptions((prevOptions) => [
...prevOptions,
{ ...option, checked: true },
]);
} else {
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}
onSortTypeChange={setSortType}
/>
<div className="flex flex-col gap-4 pb-4">
{(questions ?? []).map((question) => (
<div className="flex flex-col gap-2 pb-4">
{(questionsQueryData?.pages ?? []).flatMap(
({ data: questions }) =>
questions.map((question) => (
<QuestionOverviewCard
key={question.id}
answerCount={question.numAnswers}
companies={{ [question.company]: 1 }}
companies={
question.aggregatedQuestionEncounters.companyCounts
}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
locations={{ [question.location]: 1 }}
locations={
question.aggregatedQuestionEncounters.locationCounts
}
questionId={question.id}
receivedCount={question.receivedCount}
roles={{ [question.role]: 1 }}
timestamp={question.seenAt.toLocaleDateString(undefined, {
roles={
question.aggregatedQuestionEncounters.roleCounts
}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
})}
},
)}
type={question.type}
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">
<NoSymbolIcon className="h-6 w-6" />
<p>Nothing found.</p>

@ -50,7 +50,7 @@ export default function ListPage() {
{lists.map((list) => (
<li
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' : ''
}`}>
<button
@ -83,7 +83,7 @@ export default function ListPage() {
className={`${
active
? '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`}
type="button">
Delete

@ -3,7 +3,7 @@ import formatDistanceToNow from 'date-fns/formatDistanceToNow';
import Error from 'next/error';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
import {
AcademicCapIcon,
@ -14,9 +14,10 @@ import {
PencilSquareIcon,
StarIcon,
} 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 ResumeExpandableText from '~/components/resumes/shared/ResumeExpandableText';
@ -59,6 +60,7 @@ export default function ResumeReviewPage() {
session?.user?.id != null && session.user.id === detailsQuery.data?.userId;
const [isEditMode, setIsEditMode] = useState(false);
const [showCommentsForm, setShowCommentsForm] = useState(false);
const onStarButtonClick = () => {
if (session?.user?.id == null) {
@ -81,6 +83,32 @@ export default function ResumeReviewPage() {
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) {
return (
<SubmitResumeForm
@ -120,12 +148,21 @@ export default function ResumeReviewPage() {
</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">
<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}
</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
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}
type="button"
onClick={onStarButtonClick}>
@ -141,7 +178,7 @@ export default function ResumeReviewPage() {
className={clsx(
detailsQuery.data?.stars.length
? 'text-orange-400'
: 'text-gray-400',
: 'text-slate-400',
)}
/>
)}
@ -152,42 +189,36 @@ export default function ResumeReviewPage() {
{detailsQuery.data?._count.stars}
</span>
</button>
{userIsOwner && (
<button
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 className="hidden xl:block">{renderReviewButton()}</div>
</div>
</div>
<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
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}
</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
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}
</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
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}
</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
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, {
addSuffix: true,
@ -195,10 +226,10 @@ export default function ResumeReviewPage() {
</div>
</div>
{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
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
key={detailsQuery.data.additionalInfo}
@ -206,12 +237,35 @@ export default function ResumeReviewPage() {
/>
</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} />
</div>
<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>
</main>

@ -113,7 +113,7 @@ export default function ResumeHomePage() {
useEffect(() => {
setCurrentPage(1);
}, [userFilters, sortOrder]);
}, [userFilters, sortOrder, searchValue]);
const allResumesQuery = trpc.useQuery(
[
@ -126,6 +126,7 @@ export default function ResumeHomePage() {
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
@ -144,6 +145,7 @@ export default function ResumeHomePage() {
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
@ -163,6 +165,7 @@ export default function ResumeHomePage() {
searchValue: useDebounceValue(searchValue, DEBOUNCE_DELAY),
skip,
sortOrder,
take: PAGE_LIMIT,
},
],
{
@ -279,11 +282,11 @@ export default function ResumeHomePage() {
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">
<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
</h2>
<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"
onClick={() => setMobileFiltersOpen(false)}>
<span className="sr-only">Close menu</span>
@ -291,9 +294,9 @@ export default function ResumeHomePage() {
</button>
</div>
<form className="mt-4 border-t border-gray-200">
<form className="mt-4 border-t border-slate-200">
<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">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
@ -310,12 +313,12 @@ export default function ResumeHomePage() {
<Disclosure
key={filter.id}
as="div"
className="border-t border-gray-200 px-4 py-6">
className="border-t border-slate-200 px-4 py-6">
{({ open }) => (
<>
<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">
<span className="font-medium text-gray-900">
<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-slate-900">
{filter.label}
</span>
<span className="ml-6 flex items-center">
@ -338,7 +341,7 @@ export default function ResumeHomePage() {
{filter.options.map((option) => (
<div
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
label={option.label}
value={userFilters[filter.id].includes(
@ -368,16 +371,16 @@ export default function ResumeHomePage() {
</Transition.Root>
</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="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">
Shortcuts
</h3>
<div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form>
<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">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
@ -389,19 +392,19 @@ export default function ResumeHomePage() {
</li>
))}
</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
</h3>
{filters.map((filter) => (
<Disclosure
key={filter.id}
as="div"
className="border-b border-gray-200 py-6">
className="border-b border-slate-200 py-6">
{({ open }) => (
<>
<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">
<span className="font-medium text-gray-900">
<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-slate-900">
{filter.label}
</span>
<span className="ml-6 flex items-center">
@ -428,7 +431,7 @@ export default function ResumeHomePage() {
{filter.options.map((option) => (
<div
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
label={option.label}
value={userFilters[filter.id].includes(
@ -453,8 +456,8 @@ export default function ResumeHomePage() {
</form>
</div>
</div>
<div className="w-full">
<div className="lg:border-grey-200 flex flex-wrap items-center justify-between pb-2 lg:border-b">
<div className="relative lg:left-64 lg:w-[calc(100%-16rem)]">
<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>
<Tabs
@ -477,10 +480,9 @@ export default function ResumeHomePage() {
onChange={onTabChange}
/>
</div>
<div>
<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"
onClick={onSubmitResume}>
Submit Resume
@ -489,9 +491,9 @@ export default function ResumeHomePage() {
</div>
<div className="flex flex-wrap items-center justify-start gap-8">
<div className="w-64">
<form>
<TextInput
label=""
isLabelHidden={true}
label="search"
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
@ -499,7 +501,6 @@ export default function ResumeHomePage() {
value={searchValue}
onChange={setSearchValue}
/>
</form>
</div>
<div>
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
@ -513,16 +514,15 @@ export default function ResumeHomePage() {
</DropdownMenu>
</div>
<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"
onClick={() => setMobileFiltersOpen(true)}>
<span className="sr-only">Filters</span>
<FunnelIcon aria-hidden="true" className="h-6 w-6" />
</button>
<div>
<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"
onClick={onSubmitResume}>
Submit Resume
@ -550,10 +550,15 @@ export default function ResumeHomePage() {
{getEmptyDataText(tabsValue, searchValue, userFilters)}
</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()} />
</div>
</div>
<div className="flex h-[15%] items-center justify-center">
{getTabTotalPages() > 1 && (
<div className="mt-4 flex justify-center">
<div>
<Pagination
current={currentPage}
end={getTabTotalPages()}
@ -563,7 +568,8 @@ export default function ResumeHomePage() {
/>
</div>
)}
</>
</div>
</div>
)}
</div>
</div>

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

@ -5,12 +5,15 @@ import { useToast } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
import type { Month, MonthYear } from '~/components/shared/MonthYearPicker';
import MonthYearPicker from '~/components/shared/MonthYearPicker';
export default function HomePage() {
const [selectedCompany, setSelectedCompany] =
useState<TypeaheadOption | null>(null);
const [selectedJobTitle, setSelectedJobTitle] =
useState<TypeaheadOption | null>(null);
const [monthYear, setMonthYear] = useState<MonthYear>({
month: (new Date().getMonth() + 1) as Month,
year: new Date().getFullYear(),
@ -30,6 +33,11 @@ export default function HomePage() {
/>
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider />
<JobTitlesTypeahead
onSelect={(option) => setSelectedJobTitle(option)}
/>
<pre>{JSON.stringify(selectedJobTitle, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} />
<HorizontalDivider />
<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="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h1 className="text-xl font-semibold text-gray-900">Todos</h1>
<p className="mt-2 text-sm text-gray-700">
<h1 className="text-xl font-semibold text-slate-900">Todos</h1>
<p className="mt-2 text-sm text-slate-700">
A list of all Todos added by everyone.
</p>
</div>
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<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">
Add Todo
</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="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">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr className="divide-x divide-gray-200">
<table className="min-w-full divide-y divide-slate-300">
<thead className="bg-slate-50">
<tr className="divide-x divide-slate-200">
<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">
Description
</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">
Creator
</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">
Last Updated
</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">
Status
</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">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
<tbody className="divide-y divide-slate-200 bg-white">
{todosQuery.data?.map((todo) => (
<tr key={todo.id} className="divide-x divide-gray-200">
<td className="whitespace-nowrap py-4 pl-4 pr-4 text-sm text-gray-500 sm:pl-6">
<tr key={todo.id} className="divide-x divide-slate-200">
<td className="whitespace-nowrap py-4 pl-4 pr-4 text-sm text-slate-500 sm:pl-6">
{todo.id === currentlyEditingTodo ? (
<form
ref={formRef}
@ -120,7 +120,7 @@ export default function TodoList() {
}}>
<input
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}
name="text"
type="text"
@ -130,19 +130,19 @@ export default function TodoList() {
todo.text
)}
</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}
</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', {
dateStyle: 'long',
timeStyle: 'medium',
})}
</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
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"
onChange={() => {
todoUpdateMutation.mutate({
@ -155,12 +155,12 @@ export default function TodoList() {
}}
/>
</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 && (
<>
{currentlyEditingTodo === todo.id ? (
<a
className="text-indigo-600 hover:text-indigo-900"
className="text-primary-600 hover:text-primary-900"
href="#"
onClick={() => {
setCurrentlyEditingTodo(null);
@ -169,7 +169,7 @@ export default function TodoList() {
</a>
) : (
<a
className="text-indigo-600 hover:text-indigo-900"
className="text-primary-600 hover:text-primary-900"
href="#"
onClick={async () => {
setCurrentlyEditingTodo(todo.id);
@ -178,7 +178,7 @@ export default function TodoList() {
</a>
)}
<a
className="text-indigo-600 hover:text-indigo-900"
className="text-primary-600 hover:text-primary-900"
href="#"
onClick={async () => {
const confirmDelete = window.confirm(

@ -27,7 +27,7 @@ export default function TodosCreate() {
</h1>
<form
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) => {
event.preventDefault();
if (!formRef.current) {
@ -52,14 +52,14 @@ export default function TodosCreate() {
}}>
<div className="mt-6">
<label
className="block text-sm font-medium text-gray-700"
className="block text-sm font-medium text-slate-700"
htmlFor="text">
Text
</label>
<div className="mt-1 flex rounded-md shadow-sm">
<input
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"
name="text"
type="text"
@ -71,12 +71,12 @@ export default function TodosCreate() {
<div className="pt-5">
<div className="flex justify-end">
<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">
Cancel
</Link>
<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">
Save
</button>

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

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

@ -27,7 +27,7 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
createdAt: 'desc',
},
where: {
answerId : input.answerId,
answerId: input.answerId,
},
});
return questionAnswerCommentsData.map((data) => {
@ -166,13 +166,29 @@ export const questionsAnswerCommentRouter = createProtectedRouter()
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: {
answerCommentId,
userId,
vote,
},
});
}),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: answerCommentId,
},
}),
]);
return answerCommentVote;
},
})
.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: {
vote,
},
where: {
id,
},
});
}),
ctx.prisma.questionsAnswerComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.answerCommentId,
},
}),
]);
return answerCommentVote;
},
})
.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: {
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;
return await ctx.prisma.questionsAnswerVote.create({
const incrementValue = vote === Vote.UPVOTE ? 1 : -1;
const [answerVote] = await ctx.prisma.$transaction([
ctx.prisma.questionsAnswerVote.create({
data: {
answerId,
userId,
vote,
},
});
}),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: answerId,
},
}),
]);
return answerVote;
},
})
.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: {
vote,
},
where: {
id,
},
});
}),
ctx.prisma.questionsAnswer.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.answerId,
},
}),
]);
return questionsAnswerVote;
},
})
.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: {
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 { 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: {
questionCommentId,
userId,
vote,
},
});
}),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: questionCommentId,
},
}),
]);
return questionCommentVote;
},
})
.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: {
vote,
},
where: {
id,
},
});
}),
ctx.prisma.questionsQuestionComment.update({
data: {
upvotes: {
increment: incrementValue,
},
},
where: {
id: voteToUpdate.questionCommentId,
},
}),
]);
return questionCommentVote;
},
})
.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: {
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 roleCounts: Record<string, number> = {};
let latestSeenAt = questionEncountersData[0].seenAt;
for (let i = 0; i < questionEncountersData.length; i++) {
const encounter = questionEncountersData[i];
latestSeenAt = latestSeenAt < encounter.seenAt ? encounter.seenAt : latestSeenAt;
if (!(encounter.company!.name in companyCounts)) {
companyCounts[encounter.company!.name] = 1;
}
@ -46,6 +50,7 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
const questionEncounter: AggregatedQuestionEncounter = {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
};
@ -72,7 +77,6 @@ export const questionsQuestionEncounterRouter = createProtectedRouter()
},
})
.mutation('update', {
//
input: z.object({
companyId: z.string().optional(),
id: z.string(),

@ -11,9 +11,16 @@ export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', {
input: z.object({
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()),
limit: z.number().min(1).default(50),
locations: z.string().array(),
pageSize: z.number().default(50),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
sortOrder: z.nativeEnum(SortOrder),
@ -21,16 +28,34 @@ export const questionsQuestionRouter = createProtectedRouter()
startDate: z.date().optional(),
}),
async resolve({ ctx, input }) {
const { cursor } = input;
const sortCondition =
input.sortType === SortType.TOP
? {
? [
{
upvotes: input.sortOrder,
}
: {
},
{
id: input.sortOrder,
},
]
: [
{
lastSeenAt: input.sortOrder,
};
},
{
id: input.sortOrder,
},
];
const questionsData = await ctx.prisma.questionsQuestion.findMany({
cursor:
cursor !== undefined
? {
id: cursor ? cursor!.idCursor : undefined,
}
: undefined,
include: {
_count: {
select: {
@ -53,9 +78,8 @@ export const questionsQuestionRouter = createProtectedRouter()
},
votes: true,
},
orderBy: {
...sortCondition,
},
orderBy: sortCondition,
take: input.limit + 1,
where: {
...(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(
(previousValue: number, currentValue) => {
let result: number = previousValue;
@ -116,23 +140,78 @@ export const questionsQuestionRouter = createProtectedRouter()
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 = {
company: data.encounters[0].company!.name ?? 'Unknown company',
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: data.content,
id: data.id,
location: data.encounters[0].location ?? 'Unknown location',
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
receivedCount: data.encounters.length,
role: data.encounters[0].role ?? 'Unknown role',
seenAt: data.encounters[0].seenAt,
seenAt: latestSeenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
};
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', {
@ -190,16 +269,45 @@ export const questionsQuestionRouter = createProtectedRouter()
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 = {
company: questionData.encounters[0].company!.name ?? 'Unknown company',
aggregatedQuestionEncounters: {
companyCounts,
latestSeenAt,
locationCounts,
roleCounts,
},
content: questionData.content,
id: questionData.id,
location: questionData.encounters[0].location ?? 'Unknown location',
numAnswers: questionData._count.answers,
numComments: questionData._count.comments,
numVotes: votes,
receivedCount: questionData.encounters.length,
role: questionData.encounters[0].role ?? 'Unknown role',
seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType,
updatedAt: questionData.updatedAt,

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

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

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

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

@ -3,7 +3,6 @@ export function getProfileLink(profileId: string, token?: string) {
}
export function copyProfileLink(profileId: string, token?: string) {
// TODO: Add notification
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;
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) {

@ -7,6 +7,7 @@ type Props = Readonly<{
defaultValue?: boolean;
description?: string;
disabled?: boolean;
errorMessage?: string;
label: string;
name?: string;
onChange?: (
@ -21,6 +22,7 @@ function CheckboxInput(
defaultValue,
description,
disabled = false,
errorMessage,
label,
name,
value,
@ -30,8 +32,10 @@ function CheckboxInput(
) {
const id = useId();
const descriptionId = useId();
const errorId = useId();
return (
<div>
<div
className={clsx(
'relative flex',
@ -54,13 +58,13 @@ function CheckboxInput(
id={id}
name={name}
type="checkbox"
onChange={
onChange != null
? (event) => {
onChange?.(event.target.checked, event);
}
: undefined
onChange={(event) => {
if (!onChange) {
return;
}
onChange(event.target.checked, event);
}}
/>
</div>
<div className="ml-3 text-sm">
@ -84,6 +88,12 @@ function CheckboxInput(
)}
</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);
elements.push(
<PaginationPage
key={page}
isCurrent={current === page}
label={page}
onClick={(event) => {
@ -83,7 +84,7 @@ export default function Pagination({
addPage(i);
}
if (lastAddedPage < current - pagePadding) {
if (lastAddedPage < current - pagePadding - 1) {
elements.push(<PaginationEllipsis />);
}
@ -91,7 +92,7 @@ export default function Pagination({
addPage(i);
}
if (lastAddedPage < end - pagePadding) {
if (lastAddedPage < end - pagePadding - 1) {
elements.push(<PaginationEllipsis />);
}

@ -88,7 +88,7 @@ function Select<T>(
aria-label={isLabelHidden ? label : undefined}
className={clsx(
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],
borderClasses[borderStyle],
disabled && 'bg-slate-100',

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

@ -142,7 +142,7 @@ function TextInput(
</label>
<div
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',
containerClass,
)}>
@ -178,7 +178,7 @@ function TextInput(
aria-describedby={hasError ? errorId : undefined}
aria-invalid={hasError ? true : undefined}
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,
disabled && 'bg-transparent',
)}

@ -88,7 +88,7 @@ export default function Typeahead({
)}
</Combobox.Label>
<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
className={clsx(
'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"
leaveFrom="opacity-100"
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 !== '' ? (
<div className="relative cursor-default select-none py-2 px-4 text-slate-700">
{noResultsMessage}

@ -14405,6 +14405,11 @@ unique-filename@^1.1.1:
dependencies:
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:
version "2.0.2"
resolved "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz"

Loading…
Cancel
Save