Merge branch 'main' into hongpo/update-question-filter

pull/409/head
hpkoh 3 years ago
commit 4bc794596b

@ -0,0 +1,12 @@
/*
Warnings:
- Added the required column `baseValue` to the `OffersCurrency` table without a default value. This is not possible if the table is not empty.
- Added the required column `updatedAt` to the `OffersCurrency` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "OffersCurrency" ADD COLUMN "baseCurrency" TEXT NOT NULL DEFAULT 'USD',
ADD COLUMN "baseValue" INTEGER NOT NULL,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL;

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "OffersCurrency" ALTER COLUMN "value" SET DATA TYPE DOUBLE PRECISION,
ALTER COLUMN "baseValue" SET DATA TYPE DOUBLE PRECISION;

@ -206,9 +206,9 @@ model OffersBackground {
totalYoe Int
specificYoes OffersSpecificYoe[]
experiences OffersExperience[] // For extensibility in the future
experiences OffersExperience[]
educations OffersEducation[] // For extensibility in the future
educations OffersEducation[]
profile OffersProfile @relation(fields: [offersProfileId], references: [id], onDelete: Cascade)
offersProfileId String @unique
@ -252,10 +252,16 @@ model OffersExperience {
}
model OffersCurrency {
id String @id @default(cuid())
value Int
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
value Float
currency String
baseValue Float
baseCurrency String @default("USD")
// Experience
OffersExperienceTotalCompensation OffersExperience? @relation("ExperienceTotalCompensation")
OffersExperienceMonthlySalary OffersExperience? @relation("ExperienceMonthlySalary")

@ -9,6 +9,7 @@ import {
YOE_CATEGORY,
} from '~/components/offers/table/types';
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import CurrencySelector from '~/utils/offers/currency/CurrencySelector';
import { trpc } from '~/utils/trpc';
@ -25,7 +26,7 @@ export default function OffersTable({
companyFilter,
jobTitleFilter,
}: OffersTableProps) {
const [currency, setCurrency] = useState('SGD'); // TODO: Detect location
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
const [selectedTab, setSelectedTab] = useState(YOE_CATEGORY.ENTRY);
const [pagination, setPagination] = useState<Paging>({
currentPage: 0,
@ -44,12 +45,13 @@ export default function OffersTable({
numOfPages: 0,
totalItems: 0,
});
}, [selectedTab]);
}, [selectedTab, currency]);
const offersQuery = trpc.useQuery(
[
'offers.list',
{
companyId: companyFilter,
currency,
limit: NUMBER_OF_OFFERS_IN_PAGE,
location: 'Singapore, Singapore', // TODO: Geolocation
offset: pagination.currentPage,

@ -42,7 +42,7 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
<div className="flex gap-2 pr-4">
<ChatBubbleLeftIcon className="w-4" />
{`${resumeInfo.numComments} comment${
resumeInfo.numComments > 0 ? 's' : ''
resumeInfo.numComments === 1 ? '' : 's'
}`}
</div>
<div className="flex gap-2">
@ -51,7 +51,9 @@ export default function ResumeListItem({ href, resumeInfo }: Props) {
) : (
<StarIcon className="w-4" />
)}
{resumeInfo.numStars} stars
{`${resumeInfo.numStars} star${
resumeInfo.numStars === 1 ? '' : 's'
}`}
</div>
</div>
</div>

@ -96,15 +96,17 @@ export default function ResumeCommentListItem({
<div className="flex flex-row space-x-1 pt-1 align-middle">
<ResumeCommentVoteButtons commentId={comment.id} userId={userId} />
{/* Action buttons; only present when not editing/replying */}
{isCommentOwner && !isEditingComment && !isReplyingComment && (
{/* Action buttons; only present for authenticated user when not editing/replying */}
{userId && !isEditingComment && !isReplyingComment && (
<>
<button
className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
type="button"
onClick={() => setIsEditingComment(true)}>
Edit
</button>
{isCommentOwner && (
<button
className="px-1 text-xs text-indigo-800 hover:text-indigo-400"
type="button"
onClick={() => setIsEditingComment(true)}>
Edit
</button>
)}
{!comment.parentId && (
<button

@ -9,7 +9,7 @@ type ContainerProps = {
export const Container: FC<ContainerProps> = ({ className, ...props }) => {
return (
<div
className={clsx('mx-auto max-w-7xl px-4 sm:px-6 lg:px-8', className)}
className={clsx('mx-auto max-w-7xl px-4 lg:px-2', className)}
{...props}
/>
);

@ -4,27 +4,27 @@ import { useEffect, useState } from 'react';
import { Tab } from '@headlessui/react';
import { Container } from './Container';
import screenshotExpenses from './images/screenshots/expenses.png';
import screenshotPayroll from './images/screenshots/payroll.png';
import screenshotVatReturns from './images/screenshots/vat-returns.png';
import resumeBrowse from './images/screenshots/resumes-browse.png';
import resumeReview from './images/screenshots/resumes-review.png';
import resumeSubmit from './images/screenshots/resumes-submit.png';
const features = [
{
description:
'Browse the most popular reviewed resumes out there and see what you can learn',
image: screenshotPayroll,
image: resumeBrowse,
title: 'Browse',
},
{
description:
'Upload your own resume easily to get feedback from people in industry.',
image: screenshotExpenses,
image: resumeSubmit,
title: 'Submit',
},
{
description:
'Pass the baton forward by reviewing resumes and bounce off ideas with other engineers out there.',
image: screenshotVatReturns,
image: resumeReview,
title: 'Review',
},
];
@ -49,7 +49,6 @@ export function PrimaryFeatures() {
return (
<section
aria-label="Features for running your books"
className="relative overflow-hidden bg-gradient-to-r from-indigo-400 to-indigo-700 pt-20 pb-28 sm:py-32"
id="features">
<Container className="relative">
@ -64,7 +63,7 @@ export function PrimaryFeatures() {
vertical={tabOrientation === 'vertical'}>
{({ selectedIndex }) => (
<>
<div className="-mx-4 flex overflow-x-auto pb-4 sm:mx-0 sm:overflow-visible sm:pb-0 lg:col-span-5">
<div className="-mx-4 flex overflow-x-auto pb-4 sm:mx-0 sm:overflow-visible sm:pb-0 lg:col-span-4">
<Tab.List className="relative z-10 flex gap-x-4 whitespace-nowrap px-4 sm:mx-auto sm:px-0 lg:mx-0 lg:block lg:gap-x-0 lg:gap-y-1 lg:whitespace-normal">
{features.map((feature, featureIndex) => (
<div
@ -100,7 +99,7 @@ export function PrimaryFeatures() {
))}
</Tab.List>
</div>
<Tab.Panels className="lg:col-span-7">
<Tab.Panels className="lg:col-span-8">
{features.map((feature) => (
<Tab.Panel key={feature.title} unmount={false}>
<div className="relative sm:px-6 lg:hidden">

@ -94,7 +94,6 @@ function QuoteIcon(props: QuoteProps) {
export function Testimonials() {
return (
<section
aria-label="What our customers are saying"
className="bg-gradient-to-r from-indigo-700 to-indigo-400 py-20 sm:py-32"
id="testimonials">
<Container>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

@ -43,20 +43,29 @@ const analysisOfferDtoMapper = (
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<OffersExperience & { company: Company | null }>;
})
| null;
};
},
) => {
const { background, profileName } = offer.profile;
const analysisOfferDto: AnalysisOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
income: { currency: '', value: -1 },
income: { baseCurrency: '', baseValue: -1, currency: '', value: -1 },
jobType: offer.jobType,
level: offer.offersFullTime?.level ?? '',
location: offer.location,
monthYearReceived: offer.monthYearReceived,
negotiationStrategy: offer.negotiationStrategy,
previousCompanies: [],
previousCompanies:
background?.experiences
?.filter((exp) => exp.company != null)
.map((exp) => exp.company?.name ?? '') ?? [],
profileName,
specialization:
offer.jobType === JobType.FULLTIME
@ -74,10 +83,18 @@ const analysisOfferDtoMapper = (
offer.offersFullTime.totalCompensation.value;
analysisOfferDto.income.currency =
offer.offersFullTime.totalCompensation.currency;
analysisOfferDto.income.baseValue =
offer.offersFullTime.totalCompensation.baseValue;
analysisOfferDto.income.baseCurrency =
offer.offersFullTime.totalCompensation.baseCurrency;
} else if (offer.offersIntern?.monthlySalary) {
analysisOfferDto.income.value = offer.offersIntern.monthlySalary.value;
analysisOfferDto.income.currency =
offer.offersIntern.monthlySalary.currency;
analysisOfferDto.income.baseValue =
offer.offersIntern.monthlySalary.baseValue;
analysisOfferDto.income.baseCurrency =
offer.offersIntern.monthlySalary.baseCurrency;
} else {
throw new TRPCError({
code: 'NOT_FOUND',
@ -95,10 +112,26 @@ const analysisDtoMapper = (
OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| (OffersFullTime & {
totalCompensation: OffersCurrency;
})
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
offersIntern:
| (OffersIntern & {
monthlySalary: OffersCurrency;
})
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & {
company: Company | null;
}
>;
})
| null;
};
}
>,
) => {
@ -219,11 +252,15 @@ export const profileAnalysisDtoMapper = (
};
export const valuationDtoMapper = (currency: {
baseCurrency: string;
baseValue: number;
currency: string;
id?: string;
value: number;
}) => {
const valuationDto: Valuation = {
baseCurrency: currency.baseCurrency,
baseValue: currency.baseValue,
currency: currency.currency,
value: currency.value,
};
@ -554,7 +591,12 @@ export const dashboardOfferDtoMapper = (
const dashboardOfferDto: DashboardOffer = {
company: offersCompanyDtoMapper(offer.company),
id: offer.id,
income: valuationDtoMapper({ currency: '', value: -1 }),
income: valuationDtoMapper({
baseCurrency: '',
baseValue: -1,
currency: '',
value: -1,
}),
monthYearReceived: offer.monthYearReceived,
profileId: offer.profileId,
title: offer.offersFullTime?.title ?? '',

@ -3,6 +3,7 @@ import type { Session } from 'next-auth';
import { SessionProvider } from 'next-auth/react';
import React from 'react';
import superjson from 'superjson';
import { ToastsProvider } from '@tih/ui';
import { httpBatchLink } from '@trpc/client/links/httpBatchLink';
import { loggerLink } from '@trpc/client/links/loggerLink';
import { withTRPC } from '@trpc/next';
@ -19,9 +20,11 @@ const MyApp: AppType<{ session: Session | null }> = ({
}) => {
return (
<SessionProvider session={session}>
<AppShell>
<Component {...pageProps} />
</AppShell>
<ToastsProvider>
<AppShell>
<Component {...pageProps} />
</AppShell>
</ToastsProvider>
</SessionProvider>
);
};

@ -1,32 +1,11 @@
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
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 [monthYear, setMonthYear] = useState<MonthYear>({
month: (new Date().getMonth() + 1) as Month,
year: new Date().getFullYear(),
});
return (
<main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center">
<div className="space-y-4">
<h1 className="text-primary-600 text-center text-4xl font-bold">
Homepage
Tech Interview Handbook Portal
</h1>
<CompaniesTypeahead
onSelect={(option) => setSelectedCompany(option)}
/>
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} />
</div>
</div>
</main>

@ -40,7 +40,7 @@ function Test() {
deleteCommentMutation.mutate({
id: 'cl97fprun001j7iyg6ev9x983',
profileId: 'cl96stky5002ew32gx2kale2x',
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: 'cl97dl51k001e7iygd5v5gt58',
});
};
@ -84,7 +84,7 @@ function Test() {
const handleLink = () => {
addToUserProfileMutation.mutate({
profileId: 'cl9efyn9p004ww3u42mjgl1vn',
token: 'afca11e436d21bde24543718fa957c6c625335439dc504f24ee35eae7b5ef1ba',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: 'cl9ehvpng0000w3ec2mpx0bdd',
});
};
@ -103,11 +103,10 @@ function Test() {
],
experiences: [
{
companyId: 'cl9h0bqu50000txxwkhmshhxz',
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
durationInMonths: 24,
jobType: 'FULLTIME',
level: 'Junior',
// "monthlySalary": undefined,
specialization: 'Front End',
title: 'Software Engineer',
totalCompensation: {
@ -132,7 +131,7 @@ function Test() {
{
comments: 'I am a Raffles Institution almumni',
// Comments: '',
companyId: 'cl9h0bqu50000txxwkhmshhxz',
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
@ -140,28 +139,28 @@ function Test() {
offersFullTime: {
baseSalary: {
currency: 'SGD',
value: 84000,
value: 2222,
},
bonus: {
currency: 'SGD',
value: 20000,
value: 2222,
},
level: 'Junior',
specialization: 'Front End',
stocks: {
currency: 'SGD',
value: 100,
value: 0,
},
title: 'Software Engineer',
totalCompensation: {
currency: 'SGD',
value: 104100,
value: 4444,
},
},
},
{
comments: '',
companyId: 'cl9h0bqu50000txxwkhmshhxz',
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
monthYearReceived: new Date('2022-09-30T07:58:54.000Z'),
@ -192,14 +191,14 @@ function Test() {
});
};
const profileId = 'cl9i68fv60000tthj8t3zkox0'; // Remember to change this filed after testing deleting
const profileId = 'cl9j50xzk008vutfqg6mta2ey'; // Remember to change this filed after testing deleting
const data = trpc.useQuery(
[
`offers.profile.listOne`,
{
profileId,
token:
'd14666ff76e267c9e99445844b41410e83874936d0c07e664db73ff0ea76919e',
'24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
},
],
{
@ -223,7 +222,7 @@ function Test() {
const handleDelete = (id: string) => {
deleteMutation.mutate({
profileId: id,
token: 'e7effd2a40adba2deb1ddea4fb9f1e6c3c98ab0a85a88ed1567fc2a107fdb445',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
});
};
@ -257,15 +256,15 @@ function Test() {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl9h0bqug0003txxwgkac0x40',
id: 'cl9j4yawz0003utlp1uaa1t8o',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9h0bqug0003txxwgkac0x40',
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
durationInMonths: 24,
// Id: 'cl9h0bqug0003txxwgkac0x40',
// Id: 'cl9j4yawz0003utlp1uaa1t8o',
jobType: 'FULLTIME',
level: 'Junior',
monthlySalary: null,
@ -309,13 +308,13 @@ function Test() {
createdAt: new Date('2022-10-12T16:19:05.196Z'),
description:
'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
id: 'cl9h0bqug0003txxwgkac0x40',
id: 'cl9j4yawz0003utlp1uaa1t8o',
logoUrl: 'https://logo.clearbit.com/meta.com',
name: 'Meta',
slug: 'meta',
updatedAt: new Date('2022-10-12T16:19:05.196Z'),
},
companyId: 'cl9h0bqug0003txxwgkac0x40',
companyId: 'cl9j4yawz0003utlp1uaa1t8o',
id: 'cl9i68fve000ntthj5h9yvqnh',
jobType: 'FULLTIME',
location: 'Singapore, Singapore',
@ -362,13 +361,13 @@ function Test() {
// createdAt: new Date('2022-10-12T16:19:05.196Z'),
// description:
// 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
// id: 'cl9h0bqug0003txxwgkac0x40',
// id: 'cl9j4yawz0003utlp1uaa1t8o',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9h0bqug0003txxwgkac0x40',
// companyId: 'cl9j4yawz0003utlp1uaa1t8o',
// id: 'cl9i68fvf000ytthj0ltsqt1d',
// jobType: 'FULLTIME',
// location: 'Singapore, Singapore',
@ -415,13 +414,13 @@ function Test() {
// createdAt: new Date('2022-10-12T16:19:05.196Z'),
// description:
// 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
// id: 'cl9h0bqug0003txxwgkac0x40',
// id: 'cl9j4yawz0003utlp1uaa1t8o',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9h0bqug0003txxwgkac0x40',
// companyId: 'cl9j4yawz0003utlp1uaa1t8o',
// id: 'cl96stky9003bw32gc3l955vr',
// jobType: 'FULLTIME',
// location: 'Singapore, Singapore',
@ -468,13 +467,13 @@ function Test() {
// createdAt: new Date('2022-10-12T16:19:05.196Z'),
// description:
// 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
// id: 'cl9h0bqug0003txxwgkac0x40',
// id: 'cl9j4yawz0003utlp1uaa1t8o',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9h0bqug0003txxwgkac0x40',
// companyId: 'cl9j4yawz0003utlp1uaa1t8o',
// id: 'cl976wf28000t7iyga4noyz7s',
// jobType: 'FULLTIME',
// location: 'Singapore, Singapore',
@ -521,13 +520,13 @@ function Test() {
// createdAt: new Date('2022-10-12T16:19:05.196Z'),
// description:
// 'Meta Platforms, Inc., doing business as Meta and formerly named Facebook, Inc., and TheFacebook, Inc., is an American multinational technology conglomerate based in Menlo Park, California. The company owns Facebook, Instagram, and WhatsApp, among other products and services.',
// id: 'cl9h0bqug0003txxwgkac0x40',
// id: 'cl9j4yawz0003utlp1uaa1t8o',
// logoUrl: 'https://logo.clearbit.com/meta.com',
// name: 'Meta',
// slug: 'meta',
// updatedAt: new Date('2022-10-12T16:19:05.196Z'),
// },
// companyId: 'cl9h0bqug0003txxwgkac0x40',
// companyId: 'cl9j4yawz0003utlp1uaa1t8o',
// id: 'cl96tbb3o0051w32gjrpaiiit',
// jobType: 'FULLTIME',
// location: 'Singapore, Singapore',
@ -570,7 +569,7 @@ function Test() {
// },
],
// ProfileName: 'ailing bryann stuart ziqing',
token: 'd3509cb890f0bae0a785afdd6c1c074a140706ab1d155ed338ec22dcca5c92f1',
token: '24bafa6fef803f447d7f2e229b14cb8ee43f0c22dffbe41ee1c1e5e6e870f117',
userId: null,
});
};

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

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

@ -6,11 +6,12 @@ function Test() {
const data = trpc.useQuery([
'offers.list',
{
currency: 'SGD',
limit: 100,
location: 'Singapore, Singapore',
offset: 0,
sortBy: '+totalCompensation',
yoeCategory: 1,
sortBy: '-totalCompensation',
yoeCategory: 2,
},
]);

@ -131,7 +131,9 @@ export default function ResumeReviewPage() {
onClick={onStarButtonClick}>
<span className="relative inline-flex">
<div className="-ml-1 mr-2 h-5 w-5">
{starMutation.isLoading || unstarMutation.isLoading ? (
{starMutation.isLoading ||
unstarMutation.isLoading ||
detailsQuery.isLoading ? (
<Spinner className="mt-0.5" size="xs" />
) : (
<StarIcon

@ -1,9 +1,10 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useEffect, useState } from 'react';
import { Disclosure } from '@headlessui/react';
import { MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import { Fragment, useEffect, useState } from 'react';
import { Dialog, Disclosure, Transition } from '@headlessui/react';
import { FunnelIcon, MinusIcon, PlusIcon } from '@heroicons/react/20/solid';
import { XMarkIcon } from '@heroicons/react/24/outline';
import {
MagnifyingGlassIcon,
NewspaperIcon,
@ -13,6 +14,7 @@ import {
CheckboxList,
DropdownMenu,
Pagination,
Spinner,
Tabs,
TextInput,
} from '@tih/ui';
@ -104,6 +106,7 @@ export default function ResumeHomePage() {
const [userFilters, setUserFilters] = useState(INITIAL_FILTER_STATE);
const [shortcutSelected, setShortcutSelected] = useState('All');
const [currentPage, setCurrentPage] = useState(1);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const skip = (currentPage - 1) * PAGE_LIMIT;
@ -240,88 +243,51 @@ export default function ResumeHomePage() {
<Head>
<title>Resume Review Portal</title>
</Head>
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll">
<div className="ml-4 py-4">
<ResumeReviewsTitle />
</div>
<div className="mt-4 flex items-start">
<div className="w-screen sm:px-4 md:px-8">
<div className="grid grid-cols-12">
<div className="col-span-2 self-end">
<h3 className="text-md mb-4 font-medium tracking-tight text-gray-900">
Shortcuts:
</h3>
</div>
<div className="col-span-10">
<div className="border-grey-200 grid grid-cols-12 items-center gap-4 border-b pb-2">
<div className="col-span-5">
<Tabs
label="Resume Browse Tabs"
tabs={[
{
label: 'All Resumes',
value: BROWSE_TABS_VALUES.ALL,
},
{
label: 'Starred Resumes',
value: BROWSE_TABS_VALUES.STARRED,
},
{
label: 'My Resumes',
value: BROWSE_TABS_VALUES.MY,
},
]}
value={tabsValue}
onChange={onTabChange}
/>
</div>
<div className="col-span-7 flex items-center justify-evenly">
<div className="w-64">
<form>
<TextInput
label=""
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
type="text"
value={searchValue}
onChange={setSearchValue}
/>
</form>
</div>
<div>
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
{Object.entries(SORT_OPTIONS).map(([key, value]) => (
<DropdownMenu.Item
key={key}
isSelected={sortOrder === key}
label={value}
onClick={() =>
setSortOrder(key)
}></DropdownMenu.Item>
))}
</DropdownMenu>
</div>
<div>
<button
className="rounded-md bg-indigo-500 py-2 px-3 text-sm font-medium text-white"
type="button"
onClick={onSubmitResume}>
Submit Resume
</button>
</div>
{/* Mobile Filters */}
<div>
<Transition.Root as={Fragment} show={mobileFiltersOpen}>
<Dialog
as="div"
className="relative z-40 lg:hidden"
onClose={setMobileFiltersOpen}>
<Transition.Child
as={Fragment}
enter="transition-opacity ease-linear duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-linear duration-300"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-black bg-opacity-25" />
</Transition.Child>
<div className="fixed inset-0 z-40 flex">
<Transition.Child
as={Fragment}
enter="transition ease-in-out duration-300 transform"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leaveFrom="translate-x-0"
leaveTo="translate-x-full">
<Dialog.Panel className="relative ml-auto flex h-full w-full max-w-xs flex-col overflow-y-auto 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">
Shortcuts
</h2>
<button
className="-mr-2 flex h-10 w-10 items-center justify-center rounded-md bg-white p-2 text-gray-400"
type="button"
onClick={() => setMobileFiltersOpen(false)}>
<span className="sr-only">Close menu</span>
<XMarkIcon aria-hidden="true" className="h-6 w-6" />
</button>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-12">
<div className="col-span-2">
<div className="w-100 pt-4 sm:pr-0 md:pr-4">
<form>
<h3 className="sr-only">Shortcuts</h3>
<form className="mt-4 border-t border-gray-200">
<ul
className="flex flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900"
className="flex flex-wrap justify-start gap-4 px-4 py-3 font-medium text-gray-900"
role="list">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
@ -333,18 +299,16 @@ export default function ResumeHomePage() {
</li>
))}
</ul>
<h3 className="text-md my-4 font-medium tracking-tight text-gray-900">
Explore these filters:
</h3>
{filters.map((filter) => (
<Disclosure
key={filter.id}
as="div"
className="border-b border-gray-200 py-6">
className="border-t border-gray-200 px-4 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">
<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">
{filter.label}
</span>
@ -363,12 +327,8 @@ export default function ResumeHomePage() {
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="pt-4">
<CheckboxList
description=""
isLabelHidden={true}
label=""
orientation="vertical">
<Disclosure.Panel className="pt-6">
<div className="space-y-6">
{filter.options.map((option) => (
<div
key={option.value}
@ -388,53 +348,230 @@ export default function ResumeHomePage() {
/>
</div>
))}
</CheckboxList>
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
))}
</form>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
</div>
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-auto">
<div className="ml-4 py-4">
<ResumeReviewsTitle />
</div>
<div className="mx-8 mt-4 flex justify-start">
<div className="hidden w-1/6 pt-2 lg:block">
<h3 className="text-md mb-4 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 flex-wrap justify-start gap-4 pb-6 text-sm font-medium text-gray-900"
role="list">
{SHORTCUTS.map((shortcut) => (
<li key={shortcut.name}>
<ResumeFilterPill
isSelected={shortcutSelected === shortcut.name}
title={shortcut.name}
onClick={() => onShortcutChange(shortcut)}
/>
</li>
))}
</ul>
<h3 className="text-md my-4 font-medium tracking-tight text-gray-900">
Explore these filters:
</h3>
{filters.map((filter) => (
<Disclosure
key={filter.id}
as="div"
className="border-b border-gray-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">
{filter.label}
</span>
<span className="ml-6 flex items-center">
{open ? (
<MinusIcon
aria-hidden="true"
className="h-5 w-5"
/>
) : (
<PlusIcon
aria-hidden="true"
className="h-5 w-5"
/>
)}
</span>
</Disclosure.Button>
</h3>
<Disclosure.Panel className="pt-4">
<CheckboxList
description=""
isLabelHidden={true}
label=""
orientation="vertical">
{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">
<CheckboxInput
label={option.label}
value={userFilters[filter.id].includes(
option.value,
)}
onChange={(isChecked) =>
onFilterCheckboxChange(
isChecked,
filter.id,
option.value,
)
}
/>
</div>
))}
</CheckboxList>
</Disclosure.Panel>
</>
)}
</Disclosure>
))}
</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="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
label="Resume Browse Tabs"
tabs={[
{
label: 'All Resumes',
value: BROWSE_TABS_VALUES.ALL,
},
{
label: 'Starred Resumes',
value: BROWSE_TABS_VALUES.STARRED,
},
{
label: 'My Resumes',
value: BROWSE_TABS_VALUES.MY,
},
]}
value={tabsValue}
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"
type="button"
onClick={onSubmitResume}>
Submit Resume
</button>
</div>
</div>
<div className="flex flex-wrap items-center justify-start gap-8">
<div className="w-64">
<form>
<TextInput
label=""
placeholder="Search Resumes"
startAddOn={MagnifyingGlassIcon}
startAddOnType="icon"
type="text"
value={searchValue}
onChange={setSearchValue}
/>
</form>
</div>
<div>
<DropdownMenu align="end" label={SORT_OPTIONS[sortOrder]}>
{Object.entries(SORT_OPTIONS).map(([key, value]) => (
<DropdownMenu.Item
key={key}
isSelected={sortOrder === key}
label={value}
onClick={() => setSortOrder(key)}></DropdownMenu.Item>
))}
</DropdownMenu>
</div>
<button
className="-m-2 text-gray-400 hover:text-gray-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"
type="button"
onClick={onSubmitResume}>
Submit Resume
</button>
</div>
</div>
<div className="col-span-10 mb-6">
{sessionData === null &&
</div>
<div className="mb-6">
{allResumesQuery.isLoading ||
starredResumesQuery.isLoading ||
myResumesQuery.isLoading ? (
<div className="w-full pt-4">
{' '}
<Spinner display="block" size="lg" />{' '}
</div>
) : sessionData === null &&
tabsValue !== BROWSE_TABS_VALUES.ALL ? (
<ResumeSignInButton
className="mt-8"
text={getLoggedOutText(tabsValue)}
<ResumeSignInButton
className="mt-8"
text={getLoggedOutText(tabsValue)}
/>
) : getTabResumes().length === 0 ? (
<div className="mt-24 flex flex-wrap justify-center">
<NewspaperIcon
className="mb-12 basis-full"
height={196}
width={196}
/>
) : getTabResumes().length === 0 ? (
<div className="mt-24 flex flex-wrap justify-center">
<NewspaperIcon
className="mb-12 basis-full"
height={196}
width={196}
{getEmptyDataText(tabsValue, searchValue, userFilters)}
</div>
) : (
<>
<ResumeListItems
isLoading={
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching
}
resumes={getTabResumes()}
/>
<div className="my-4 flex justify-center">
<Pagination
current={currentPage}
end={getTabTotalPages()}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
{getEmptyDataText(tabsValue, searchValue, userFilters)}
</div>
) : (
<>
<ResumeListItems
isLoading={
allResumesQuery.isFetching ||
starredResumesQuery.isFetching ||
myResumesQuery.isFetching
}
resumes={getTabResumes()}
/>
<div className="my-4 flex justify-center">
<Pagination
current={currentPage}
end={getTabTotalPages()}
label="pagination"
start={1}
onSelect={(page) => setCurrentPage(page)}
/>
</div>
</>
)}
</div>
</>
)}
</div>
</div>
</div>

@ -3,7 +3,6 @@ import Head from 'next/head';
import { CallToAction } from '~/components/resumes/landing/CallToAction';
import { Hero } from '~/components/resumes/landing/Hero';
import { PrimaryFeatures } from '~/components/resumes/landing/PrimaryFeatures';
import { Testimonials } from '~/components/resumes/landing/Testimonials';
export default function Home() {
return (
@ -16,7 +15,6 @@ export default function Home() {
<Hero />
<PrimaryFeatures />
<CallToAction />
<Testimonials />
</main>
</>
);

@ -80,6 +80,7 @@ export default function SubmitResumeForm({
const { data: session, status } = useSession();
const router = useRouter();
const trpcContext = trpc.useContext();
const resumeUpsertMutation = trpc.useMutation('resumes.resume.user.upsert');
const isNewForm = initFormDetails == null;
@ -170,6 +171,7 @@ export default function SubmitResumeForm({
},
onSuccess() {
if (isNewForm) {
trpcContext.invalidateQueries('resumes.resume.findAll');
router.push('/resumes/browse');
} else {
onClose();
@ -228,7 +230,7 @@ export default function SubmitResumeForm({
<Head>
<title>Upload a Resume</title>
</Head>
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-scroll">
<main className="h-[calc(100vh-4rem)] flex-1 overflow-y-auto">
<section
aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col lg:order-last">

@ -0,0 +1,51 @@
import { useState } from 'react';
import type { TypeaheadOption } from '@tih/ui';
import { Button } from '@tih/ui';
import { useToast } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
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 [monthYear, setMonthYear] = useState<MonthYear>({
month: (new Date().getMonth() + 1) as Month,
year: new Date().getFullYear(),
});
const { showToast } = useToast();
return (
<main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center">
<div className="space-y-4">
<h1 className="text-primary-600 text-center text-4xl font-bold">
Test Page
</h1>
<CompaniesTypeahead
onSelect={(option) => setSelectedCompany(option)}
/>
<pre>{JSON.stringify(selectedCompany, null, 2)}</pre>
<HorizontalDivider />
<MonthYearPicker value={monthYear} onChange={setMonthYear} />
<HorizontalDivider />
<Button
label="Add toast"
variant="primary"
onClick={() => {
showToast({
// Duration: 10000 (optional)
subtitle: `Some optional subtitle ${Date.now()}`,
title: `Hello World ${Date.now()}`,
variant: 'success',
});
}}
/>
</div>
</div>
</main>
);
}

@ -187,14 +187,14 @@ export const offersAnalysisRouter = createRouter()
{
offersFullTime: {
totalCompensation: {
value: 'desc',
baseValue: 'desc',
},
},
},
{
offersIntern: {
monthlySalary: {
value: 'desc',
baseValue: 'desc',
},
},
},
@ -216,15 +216,17 @@ export const offersAnalysisRouter = createRouter()
// TODO: Shift yoe out of background to make it mandatory
if (
!overallHighestOffer.profile.background ||
overallHighestOffer.profile.background.totalYoe === undefined
overallHighestOffer.profile.background.totalYoe == null
) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Cannot analyse without YOE',
code: 'NOT_FOUND',
message: 'YOE not found',
});
}
const yoe = overallHighestOffer.profile.background.totalYoe as number;
const monthYearReceived = new Date(overallHighestOffer.monthYearReceived);
monthYearReceived.setFullYear(monthYearReceived.getFullYear() - 1);
let similarOffers = await ctx.prisma.offersOffer.findMany({
include: {
@ -257,14 +259,14 @@ export const offersAnalysisRouter = createRouter()
{
offersFullTime: {
totalCompensation: {
value: 'desc',
baseValue: 'desc',
},
},
},
{
offersIntern: {
monthlySalary: {
value: 'desc',
baseValue: 'desc',
},
},
},
@ -274,17 +276,20 @@ export const offersAnalysisRouter = createRouter()
{
location: overallHighestOffer.location,
},
{
monthYearReceived: {
gte: monthYearReceived,
},
},
{
OR: [
{
offersFullTime: {
level: overallHighestOffer.offersFullTime?.level,
specialization:
overallHighestOffer.offersFullTime?.specialization,
title: overallHighestOffer.offersFullTime?.title,
},
offersIntern: {
specialization:
overallHighestOffer.offersIntern?.specialization,
title: overallHighestOffer.offersIntern?.title,
},
},
],
@ -317,7 +322,9 @@ export const offersAnalysisRouter = createRouter()
similarOffers,
);
const overallPercentile =
similarOffers.length === 0 ? 0 : overallIndex / similarOffers.length;
similarOffers.length === 0
? 100
: (100 * overallIndex) / similarOffers.length;
const companyIndex = searchOfferPercentile(
overallHighestOffer,
@ -325,10 +332,11 @@ export const offersAnalysisRouter = createRouter()
);
const companyPercentile =
similarCompanyOffers.length === 0
? 0
: companyIndex / similarCompanyOffers.length;
? 100
: (100 * companyIndex) / similarCompanyOffers.length;
// FIND TOP >=90 PERCENTILE OFFERS
// FIND TOP >=90 PERCENTILE OFFERS, DOESN'T GIVE 100th PERCENTILE
// e.g. If there only 4 offers, it gives the 2nd and 3rd offer
similarOffers = similarOffers.filter(
(offer) => offer.id !== overallHighestOffer.id,
);
@ -337,10 +345,9 @@ export const offersAnalysisRouter = createRouter()
);
const noOfSimilarOffers = similarOffers.length;
const similarOffers90PercentileIndex =
Math.floor(noOfSimilarOffers * 0.9) - 1;
const similarOffers90PercentileIndex = Math.ceil(noOfSimilarOffers * 0.1);
const topPercentileOffers =
noOfSimilarOffers > 1
noOfSimilarOffers > 2
? similarOffers.slice(
similarOffers90PercentileIndex,
similarOffers90PercentileIndex + 2,
@ -348,10 +355,11 @@ export const offersAnalysisRouter = createRouter()
: similarOffers;
const noOfSimilarCompanyOffers = similarCompanyOffers.length;
const similarCompanyOffers90PercentileIndex =
Math.floor(noOfSimilarCompanyOffers * 0.9) - 1;
const similarCompanyOffers90PercentileIndex = Math.ceil(
noOfSimilarCompanyOffers * 0.1,
);
const topPercentileCompanyOffers =
noOfSimilarCompanyOffers > 1
noOfSimilarCompanyOffers > 2
? similarCompanyOffers.slice(
similarCompanyOffers90PercentileIndex,
similarCompanyOffers90PercentileIndex + 2,

@ -26,26 +26,27 @@ export const offersCommentsRouter = createRouter()
user: true,
},
orderBy: {
createdAt: 'desc'
}
createdAt: 'desc',
},
},
replyingTo: true,
user: true,
},
orderBy: {
createdAt: 'desc'
}
createdAt: 'desc',
},
},
},
where: {
id: input.profileId,
}
},
});
const discussions: OffersDiscussion = {
data: result?.discussion
data:
result?.discussion
.filter((x) => {
return x.replyingToId === null
return x.replyingToId === null;
})
.map((x) => {
if (x.user == null) {
@ -81,18 +82,18 @@ export const offersCommentsRouter = createRouter()
message: reply.message,
replies: [],
replyingToId: reply.replyingToId,
user: reply.user
}
user: reply.user,
};
}),
replyingToId: x.replyingToId,
user: x.user
}
user: x.user,
};
return replyType
}) ?? []
}
return replyType;
}) ?? [],
};
return discussions
return discussions;
},
})
.mutation('create', {
@ -101,7 +102,7 @@ export const offersCommentsRouter = createRouter()
profileId: z.string(),
replyingToId: z.string().optional(),
token: z.string().optional(),
userId: z.string().optional()
userId: z.string().optional(),
}),
async resolve({ ctx, input }) {
const profile = await ctx.prisma.offersProfile.findFirst({
@ -156,7 +157,7 @@ export const offersCommentsRouter = createRouter()
const created = await ctx.prisma.offersReply.findFirst({
include: {
user: true
user: true,
},
where: {
id: createdReply.id,
@ -175,10 +176,10 @@ export const offersCommentsRouter = createRouter()
id: '',
image: '',
name: profile?.profileName ?? '<missing name>',
}
}
},
};
return result
return result;
}
throw new trpc.TRPCError({
@ -223,10 +224,10 @@ export const offersCommentsRouter = createRouter()
include: {
replies: {
include: {
user: true
}
user: true,
},
},
user: true
user: true,
},
where: {
id: input.id,
@ -250,8 +251,8 @@ export const offersCommentsRouter = createRouter()
id: '',
image: '',
name: profile?.profileName ?? '<missing name>',
}
}
},
};
}),
replyingToId: updated!.replyingToId,
user: updated!.user ?? {
@ -260,10 +261,10 @@ export const offersCommentsRouter = createRouter()
id: '',
image: '',
name: profile?.profileName ?? '<missing name>',
}
}
},
};
return result
return result;
}
throw new trpc.TRPCError({

@ -5,9 +5,25 @@ import {
dashboardOfferDtoMapper,
getOffersResponseMapper,
} from '~/mappers/offers-mappers';
import { convertWithDate } from '~/utils/offers/currency/currencyExchange';
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
import { createValidationRegex } from '~/utils/offers/zodRegex';
import { createRouter } from '../context';
const getOrder = (prefix: string) => {
if (prefix === '+') {
return 'asc';
}
return 'desc';
};
const sortingKeysMap = {
monthYearReceived: 'monthYearReceived',
totalCompensation: 'totalCompensation',
totalYoe: 'totalYoe',
};
const yoeCategoryMap: Record<number, string> = {
0: 'Internship',
1: 'Fresh Grad',
@ -25,19 +41,10 @@ const getYoeRange = (yoeCategory: number) => {
: null; // Internship
};
const ascOrder = '+';
const descOrder = '-';
const sortingKeys = ['monthYearReceived', 'totalCompensation', 'totalYoe'];
const createSortByValidationRegex = () => {
const startsWithPlusOrMinusOnly = '^[+-]{1}';
const sortingKeysRegex = sortingKeys.join('|');
return new RegExp(startsWithPlusOrMinusOnly + '(' + sortingKeysRegex + ')');
};
export const offersRouter = createRouter().query('list', {
input: z.object({
companyId: z.string().nullish(),
currency: z.string().nullish(),
dateEnd: z.date().nullish(),
dateStart: z.date().nullish(),
limit: z.number().positive(),
@ -45,7 +52,10 @@ export const offersRouter = createRouter().query('list', {
offset: z.number().nonnegative(),
salaryMax: z.number().nonnegative().nullish(),
salaryMin: z.number().nonnegative().nullish(),
sortBy: z.string().regex(createSortByValidationRegex()).nullish(),
sortBy: z
.string()
.regex(createValidationRegex(Object.keys(sortingKeysMap), '[+-]{1}'))
.nullish(),
title: z.string().nullish(),
yoeCategory: z.number().min(0).max(3),
yoeMax: z.number().max(100).nullish(),
@ -56,6 +66,13 @@ export const offersRouter = createRouter().query('list', {
const yoeMin = input.yoeMin ? input.yoeMin : yoeRange?.minYoe;
const yoeMax = input.yoeMax ? input.yoeMax : yoeRange?.maxYoe;
if (!input.sortBy) {
input.sortBy = '-' + sortingKeysMap.monthYearReceived;
}
const order = getOrder(input.sortBy.charAt(0));
const sortingKey = input.sortBy.substring(1);
let data = !yoeRange
? await ctx.prisma.offersOffer.findMany({
// Internship
@ -80,21 +97,84 @@ export const offersRouter = createRouter().query('list', {
},
},
},
orderBy:
sortingKey === sortingKeysMap.monthYearReceived
? {
monthYearReceived: order,
}
: sortingKey === sortingKeysMap.totalCompensation
? {
offersIntern: {
monthlySalary: {
baseValue: order,
},
},
}
: sortingKey === sortingKeysMap.totalYoe
? {
profile: {
background: {
totalYoe: order,
},
},
}
: undefined,
where: {
AND: [
{
location: input.location,
location:
input.location.length === 0 ? undefined : input.location,
},
{
offersIntern: {
isNot: null,
},
},
{
offersIntern: {
title:
input.title && input.title.length !== 0
? input.title
: undefined,
},
},
{
offersIntern: {
monthlySalary: {
baseValue: {
gte: input.salaryMin ?? undefined,
lte: input.salaryMax ?? undefined,
},
},
},
},
{
offersFullTime: {
is: null,
},
},
{
companyId:
input.companyId && input.companyId.length !== 0
? input.companyId
: undefined,
},
{
profile: {
background: {
totalYoe: {
gte: yoeMin,
lte: yoeMax,
},
},
},
},
{
monthYearReceived: {
gte: input.dateStart ?? undefined,
lte: input.dateEnd ?? undefined,
},
},
],
},
})
@ -121,10 +201,33 @@ export const offersRouter = createRouter().query('list', {
},
},
},
orderBy:
sortingKey === sortingKeysMap.monthYearReceived
? {
monthYearReceived: order,
}
: sortingKey === sortingKeysMap.totalCompensation
? {
offersFullTime: {
totalCompensation: {
baseValue: order,
},
},
}
: sortingKey === sortingKeysMap.totalYoe
? {
profile: {
background: {
totalYoe: order,
},
},
}
: undefined,
where: {
AND: [
{
location: input.location,
location:
input.location.length === 0 ? undefined : input.location,
},
{
offersIntern: {
@ -136,6 +239,30 @@ export const offersRouter = createRouter().query('list', {
isNot: null,
},
},
{
offersFullTime: {
title:
input.title && input.title.length !== 0
? input.title
: undefined,
},
},
{
offersFullTime: {
totalCompensation: {
baseValue: {
gte: input.salaryMin ?? undefined,
lte: input.salaryMax ?? undefined,
},
},
},
},
{
companyId:
input.companyId && input.companyId.length !== 0
? input.companyId
: undefined,
},
{
profile: {
background: {
@ -146,165 +273,70 @@ export const offersRouter = createRouter().query('list', {
},
},
},
{
monthYearReceived: {
gte: input.dateStart ?? undefined,
lte: input.dateEnd ?? undefined,
},
},
],
},
});
// FILTERING
data = data.filter((offer) => {
let validRecord = true;
if (input.companyId && input.companyId.length !== 0) {
validRecord = validRecord && offer.company.id === input.companyId;
}
if (input.title && input.title.length !== 0) {
validRecord =
validRecord &&
(offer.offersFullTime?.title === input.title ||
offer.offersIntern?.title === input.title);
}
if (
input.dateStart &&
input.dateEnd &&
input.dateStart.getTime() <= input.dateEnd.getTime()
) {
validRecord =
validRecord &&
offer.monthYearReceived.getTime() >= input.dateStart.getTime() &&
offer.monthYearReceived.getTime() <= input.dateEnd.getTime();
}
if (input.salaryMin != null || input.salaryMax != null) {
const salary = offer.offersFullTime?.totalCompensation.value
? offer.offersFullTime?.totalCompensation.value
: offer.offersIntern?.monthlySalary.value;
if (salary == null) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found',
});
}
if (input.salaryMin != null) {
validRecord = validRecord && salary >= input.salaryMin;
}
if (input.salaryMax != null) {
validRecord = validRecord && salary <= input.salaryMax;
}
}
return validRecord;
});
// SORTING
data = data.sort((offer1, offer2) => {
const defaultReturn =
offer2.monthYearReceived.getTime() - offer1.monthYearReceived.getTime();
if (!input.sortBy) {
return defaultReturn;
}
const order = input.sortBy.charAt(0);
const sortingKey = input.sortBy.substring(1);
if (order === ascOrder) {
return (() => {
if (sortingKey === 'monthYearReceived') {
return (
offer1.monthYearReceived.getTime() -
offer2.monthYearReceived.getTime()
// CONVERTING
const currency = input.currency?.toUpperCase();
if (currency != null && currency in Currency) {
data = await Promise.all(
data.map(async (offer) => {
if (offer.offersFullTime?.totalCompensation != null) {
offer.offersFullTime.totalCompensation.value =
await convertWithDate(
offer.offersFullTime.totalCompensation.value,
offer.offersFullTime.totalCompensation.currency,
currency,
offer.offersFullTime.totalCompensation.updatedAt,
);
offer.offersFullTime.totalCompensation.currency = currency;
offer.offersFullTime.baseSalary.value = await convertWithDate(
offer.offersFullTime.baseSalary.value,
offer.offersFullTime.baseSalary.currency,
currency,
offer.offersFullTime.baseSalary.updatedAt,
);
}
if (sortingKey === 'totalCompensation') {
const salary1 = offer1.offersFullTime?.totalCompensation.value
? offer1.offersFullTime?.totalCompensation.value
: offer1.offersIntern?.monthlySalary.value;
const salary2 = offer2.offersFullTime?.totalCompensation.value
? offer2.offersFullTime?.totalCompensation.value
: offer2.offersIntern?.monthlySalary.value;
if (salary1 == null || salary2 == null) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found',
});
}
return salary1 - salary2;
}
if (sortingKey === 'totalYoe') {
const yoe1 = offer1.profile.background?.totalYoe;
const yoe2 = offer2.profile.background?.totalYoe;
if (yoe1 == null || yoe2 == null) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total years of experience not found',
});
}
return yoe1 - yoe2;
}
return defaultReturn;
})();
}
if (order === descOrder) {
return (() => {
if (sortingKey === 'monthYearReceived') {
return (
offer2.monthYearReceived.getTime() -
offer1.monthYearReceived.getTime()
offer.offersFullTime.baseSalary.currency = currency;
offer.offersFullTime.stocks.value = await convertWithDate(
offer.offersFullTime.stocks.value,
offer.offersFullTime.stocks.currency,
currency,
offer.offersFullTime.stocks.updatedAt,
);
offer.offersFullTime.stocks.currency = currency;
offer.offersFullTime.bonus.value = await convertWithDate(
offer.offersFullTime.bonus.value,
offer.offersFullTime.bonus.currency,
currency,
offer.offersFullTime.bonus.updatedAt,
);
offer.offersFullTime.bonus.currency = currency;
} else if (offer.offersIntern?.monthlySalary != null) {
offer.offersIntern.monthlySalary.value = await convertWithDate(
offer.offersIntern.monthlySalary.value,
offer.offersIntern.monthlySalary.currency,
currency,
offer.offersIntern.monthlySalary.updatedAt,
);
offer.offersIntern.monthlySalary.currency = currency;
} else {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found',
});
}
if (sortingKey === 'totalCompensation') {
const salary1 = offer1.offersFullTime?.totalCompensation.value
? offer1.offersFullTime?.totalCompensation.value
: offer1.offersIntern?.monthlySalary.value;
const salary2 = offer2.offersFullTime?.totalCompensation.value
? offer2.offersFullTime?.totalCompensation.value
: offer2.offersIntern?.monthlySalary.value;
if (salary1 == null || salary2 == null) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total Compensation or Salary not found',
});
}
return salary2 - salary1;
}
if (sortingKey === 'totalYoe') {
const yoe1 = offer1.profile.background?.totalYoe;
const yoe2 = offer2.profile.background?.totalYoe;
if (yoe1 == null || yoe2 == null) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Total years of experience not found',
});
}
return yoe2 - yoe1;
}
return defaultReturn;
})();
}
return defaultReturn;
});
return offer;
}),
);
}
const startRecordIndex: number = input.limit * input.offset;
const endRecordIndex: number =

@ -42,6 +42,8 @@ export type OffersCompany = {
};
export type Valuation = {
baseCurrency: string;
baseValue: number;
currency: string;
value: number;
};

@ -1,167 +1,170 @@
// eslint-disable-next-line no-shadow
export enum Currency {
AED = 'AED', // United Arab Emirates Dirham
AFN = 'AFN', // Afghanistan Afghani
ALL = 'ALL', // Albania Lek
AMD = 'AMD', // Armenia Dram
ANG = 'ANG', // Netherlands Antilles Guilder
AOA = 'AOA', // Angola Kwanza
ARS = 'ARS', // Argentina Peso
AUD = 'AUD', // Australia Dollar
AWG = 'AWG', // Aruba Guilder
AZN = 'AZN', // Azerbaijan New Manat
BAM = 'BAM', // Bosnia and Herzegovina Convertible Marka
BBD = 'BBD', // Barbados Dollar
BDT = 'BDT', // Bangladesh Taka
BGN = 'BGN', // Bulgaria Lev
BHD = 'BHD', // Bahrain Dinar
BIF = 'BIF', // Burundi Franc
BMD = 'BMD', // Bermuda Dollar
BND = 'BND', // Brunei Darussalam Dollar
BOB = 'BOB', // Bolivia Bolíviano
BRL = 'BRL', // Brazil Real
BSD = 'BSD', // Bahamas Dollar
BTN = 'BTN', // Bhutan Ngultrum
BWP = 'BWP', // Botswana Pula
BYR = 'BYR', // Belarus Ruble
BZD = 'BZD', // Belize Dollar
CAD = 'CAD', // Canada Dollar
CDF = 'CDF', // Congo/Kinshasa Franc
CHF = 'CHF', // Switzerland Franc
CLP = 'CLP', // Chile Peso
CNY = 'CNY', // China Yuan Renminbi
COP = 'COP', // Colombia Peso
CRC = 'CRC', // Costa Rica Colon
CUC = 'CUC', // Cuba Convertible Peso
CUP = 'CUP', // Cuba Peso
CVE = 'CVE', // Cape Verde Escudo
CZK = 'CZK', // Czech Republic Koruna
DJF = 'DJF', // Djibouti Franc
DKK = 'DKK', // Denmark Krone
DOP = 'DOP', // Dominican Republic Peso
DZD = 'DZD', // Algeria Dinar
EGP = 'EGP', // Egypt Pound
ERN = 'ERN', // Eritrea Nakfa
ETB = 'ETB', // Ethiopia Birr
EUR = 'EUR', // Euro Member Countries
FJD = 'FJD', // Fiji Dollar
FKP = 'FKP', // Falkland Islands (Malvinas) Pound
GBP = 'GBP', // United Kingdom Pound
GEL = 'GEL', // Georgia Lari
GGP = 'GGP', // Guernsey Pound
GHS = 'GHS', // Ghana Cedi
GIP = 'GIP', // Gibraltar Pound
GMD = 'GMD', // Gambia Dalasi
GNF = 'GNF', // Guinea Franc
GTQ = 'GTQ', // Guatemala Quetzal
GYD = 'GYD', // Guyana Dollar
HKD = 'HKD', // Hong Kong Dollar
HNL = 'HNL', // Honduras Lempira
HRK = 'HRK', // Croatia Kuna
HTG = 'HTG', // Haiti Gourde
HUF = 'HUF', // Hungary Forint
IDR = 'IDR', // Indonesia Rupiah
ILS = 'ILS', // Israel Shekel
IMP = 'IMP', // Isle of Man Pound
INR = 'INR', // India Rupee
IQD = 'IQD', // Iraq Dinar
IRR = 'IRR', // Iran Rial
ISK = 'ISK', // Iceland Krona
JEP = 'JEP', // Jersey Pound
JMD = 'JMD', // Jamaica Dollar
JOD = 'JOD', // Jordan Dinar
JPY = 'JPY', // Japan Yen
KES = 'KES', // Kenya Shilling
KGS = 'KGS', // Kyrgyzstan Som
KHR = 'KHR', // Cambodia Riel
KMF = 'KMF', // Comoros Franc
KPW = 'KPW', // Korea (North) Won
KRW = 'KRW', // Korea (South) Won
KWD = 'KWD', // Kuwait Dinar
KYD = 'KYD', // Cayman Islands Dollar
KZT = 'KZT', // Kazakhstan Tenge
LAK = 'LAK', // Laos Kip
LBP = 'LBP', // Lebanon Pound
LKR = 'LKR', // Sri Lanka Rupee
LRD = 'LRD', // Liberia Dollar
LSL = 'LSL', // Lesotho Loti
LYD = 'LYD', // Libya Dinar
MAD = 'MAD', // Morocco Dirham
MDL = 'MDL', // Moldova Leu
MGA = 'MGA', // Madagascar Ariary
MKD = 'MKD', // Macedonia Denar
MMK = 'MMK', // Myanmar (Burma) Kyat
MNT = 'MNT', // Mongolia Tughrik
MOP = 'MOP', // Macau Pataca
MRO = 'MRO', // Mauritania Ouguiya
MUR = 'MUR', // Mauritius Rupee
MVR = 'MVR', // Maldives (Maldive Islands) Rufiyaa
MWK = 'MWK', // Malawi Kwacha
MXN = 'MXN', // Mexico Peso
MYR = 'MYR', // Malaysia Ringgit
MZN = 'MZN', // Mozambique Metical
NAD = 'NAD', // Namibia Dollar
NGN = 'NGN', // Nigeria Naira
NIO = 'NIO', // Nicaragua Cordoba
NOK = 'NOK', // Norway Krone
NPR = 'NPR', // Nepal Rupee
NZD = 'NZD', // New Zealand Dollar
OMR = 'OMR', // Oman Rial
PAB = 'PAB', // Panama Balboa
PEN = 'PEN', // Peru Sol
PGK = 'PGK', // Papua New Guinea Kina
PHP = 'PHP', // Philippines Peso
PKR = 'PKR', // Pakistan Rupee
PLN = 'PLN', // Poland Zloty
PYG = 'PYG', // Paraguay Guarani
QAR = 'QAR', // Qatar Riyal
RON = 'RON', // Romania New Leu
RSD = 'RSD', // Serbia Dinar
RUB = 'RUB', // Russia Ruble
RWF = 'RWF', // Rwanda Franc
SAR = 'SAR', // Saudi Arabia Riyal
SBD = 'SBD', // Solomon Islands Dollar
SCR = 'SCR', // Seychelles Rupee
SDG = 'SDG', // Sudan Pound
SEK = 'SEK', // Sweden Krona
SGD = 'SGD', // Singapore Dollar
SHP = 'SHP', // Saint Helena Pound
SLL = 'SLL', // Sierra Leone Leone
SOS = 'SOS', // Somalia Shilling
SPL = 'SPL', // Seborga Luigino
SRD = 'SRD', // Suriname Dollar
STD = 'STD', // São Tomé and Príncipe Dobra
SVC = 'SVC', // El Salvador Colon
SYP = 'SYP', // Syria Pound
SZL = 'SZL', // Swaziland Lilangeni
THB = 'THB', // Thailand Baht
TJS = 'TJS', // Tajikistan Somoni
TMT = 'TMT', // Turkmenistan Manat
TND = 'TND', // Tunisia Dinar
TOP = 'TOP', // Tonga Pa'anga
TRY = 'TRY', // Turkey Lira
TTD = 'TTD', // Trinidad and Tobago Dollar
TVD = 'TVD', // Tuvalu Dollar
TWD = 'TWD', // Taiwan New Dollar
TZS = 'TZS', // Tanzania Shilling
UAH = 'UAH', // Ukraine Hryvnia
UGX = 'UGX', // Uganda Shilling
USD = 'USD', // United States Dollar
UYU = 'UYU', // Uruguay Peso
UZS = 'UZS', // Uzbekistan Som
VEF = 'VEF', // Venezuela Bolivar
VND = 'VND', // Viet Nam Dong
VUV = 'VUV', // Vanuatu Vatu
WST = 'WST', // Samoa Tala
XAF = 'XAF', // Communauté Financière Africaine (BEAC) CFA Franc BEAC
XCD = 'XCD', // East Caribbean Dollar
XDR = 'XDR', // International Monetary Fund (IMF) Special Drawing Rights
XOF = 'XOF', // Communauté Financière Africaine (BCEAO) Franc
XPF = 'XPF', // Comptoirs Français du Pacifique (CFP) Franc
YER = 'YER', // Yemen Rial
ZAR = 'ZAR', // South Africa Rand
ZMW = 'ZMW', // Zambia Kwacha
ZWD = 'ZWD', // Zimbabwe Dollar
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'
}
export const CURRENCY_OPTIONS = Object.entries(Currency).map(

@ -0,0 +1,49 @@
// API from https://github.com/fawazahmed0/currency-api#readme
export const convert = async (
value: number,
fromCurrency: string,
toCurrency: string,
) => {
fromCurrency = fromCurrency.trim().toLowerCase();
toCurrency = toCurrency.trim().toLowerCase();
const url = [
'https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies',
fromCurrency,
toCurrency,
].join('/');
return await fetch(url + '.json')
.then((res) => res.json())
.then((data) => value * data[toCurrency]);
};
// https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@{apiVersion}/{date}/{endpoint}
export const convertWithDate = async (
value: number,
fromCurrency: string,
toCurrency: string,
date: Date,
) => {
if (new Date().toDateString === date.toDateString) {
return await convert(value, fromCurrency, toCurrency);
}
fromCurrency = fromCurrency.trim().toLowerCase();
toCurrency = toCurrency.trim().toLowerCase();
// Format date to YYYY-MM-DD
const formattedDate = date.toJSON().substring(0, 10);
const url = [
'https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1',
formattedDate,
'currencies',
fromCurrency,
toCurrency,
].join('/');
return await fetch(url + '.json')
.then((res) => res.json())
.then((data) => value * data[toCurrency]);
};

@ -1,5 +1,9 @@
import type { Money } from '~/components/offers/types';
import { Currency } from './CurrencyEnum';
export const baseCurrencyString = Currency.USD.toString();
export function convertMoneyToString({ currency, value }: Money) {
if (!value) {
return '-';

@ -0,0 +1,8 @@
export const createValidationRegex = (
keywordArray: Array<string>,
prepend: string | null | undefined,
) => {
const sortingKeysRegex = keywordArray.join('|');
prepend = prepend != null ? prepend : '';
return new RegExp('^' + prepend + '(' + sortingKeysRegex + ')$');
};

@ -0,0 +1,108 @@
import { Fragment, useEffect, useRef } from 'react';
import { Transition } from '@headlessui/react';
import { CheckIcon } from '@heroicons/react/24/outline';
import { XMarkIcon } from '@heroicons/react/24/solid';
type ToastVariant = 'failure' | 'success';
export type ToastMessage = {
duration?: number;
subtitle?: string;
title: string;
variant: ToastVariant;
};
type Props = Readonly<{
duration?: number;
onClose: () => void;
subtitle?: string;
title: string;
variant: ToastVariant;
}>;
const DEFAULT_DURATION = 5000;
function ToastIcon({ variant }: Readonly<{ variant: ToastVariant }>) {
switch (variant) {
case 'success':
return (
<CheckIcon aria-hidden="true" className="text-success-500 h-6 w-6" />
);
case 'failure':
return (
<XMarkIcon aria-hidden="true" className="text-error-500 h-6 w-6" />
);
}
}
export default function Toast({
duration = DEFAULT_DURATION,
title,
subtitle,
variant,
onClose,
}: Props) {
const timer = useRef<number | null>(null);
function clearTimer() {
if (timer.current == null) {
return;
}
window.clearTimeout(timer.current);
timer.current = null;
}
function close() {
onClose();
clearTimer();
}
useEffect(() => {
timer.current = window.setTimeout(() => {
close();
}, duration);
return () => {
clearTimer();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Transition
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-2 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
show={true}>
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<ToastIcon variant={variant} />
</div>
<div className="ml-3 w-0 flex-1 space-y-1 pt-0.5">
<p className="text-sm font-medium text-slate-900">{title}</p>
{subtitle && (
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
)}
</div>
<div className="ml-4 flex flex-shrink-0">
<button
className="focus:ring-brand-500 inline-flex rounded-md bg-white text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-offset-2"
type="button"
onClick={close}>
<span className="sr-only">Close</span>
<XMarkIcon aria-hidden="true" className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</Transition>
);
}

@ -0,0 +1,72 @@
import React, { createContext, useContext, useState } from 'react';
import type { ToastMessage } from './Toast';
import Toast from './Toast';
type Context = Readonly<{
showToast: (message: ToastMessage) => void;
}>;
export const ToastContext = createContext<Context>({
// eslint-disable-next-line @typescript-eslint/no-empty-function
showToast: (_: ToastMessage) => {},
});
const getID = (() => {
let id = 0;
return () => {
return id++;
};
})();
type ToastData = ToastMessage & {
id: number;
};
type Props = Readonly<{
children: React.ReactNode;
}>;
export function useToast() {
return useContext(ToastContext);
}
export default function ToastsProvider({ children }: Props) {
const [toasts, setToasts] = useState<Array<ToastData>>([]);
function showToast({ title, subtitle, variant }: ToastMessage) {
setToasts([{ id: getID(), subtitle, title, variant }, ...toasts]);
}
function closeToast(id: number) {
setToasts((oldToasts) => {
const newToasts = oldToasts.filter((toast) => toast.id !== id);
return newToasts;
});
}
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div
aria-live="assertive"
className="pointer-events-none fixed inset-0 z-10 flex items-end px-4 py-6 sm:p-6">
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
{/* Notification panel, dynamically insert this into the live region when it needs to be displayed */}
{toasts.map(({ id, title, subtitle, variant }) => (
<Toast
key={id}
subtitle={subtitle}
title={title}
variant={variant}
onClose={() => {
closeToast(id);
}}
/>
))}
</div>
</div>
</ToastContext.Provider>
);
}

@ -49,6 +49,12 @@ export { default as TextArea } from './TextArea/TextArea';
// TextInput
export * from './TextInput/TextInput';
export { default as TextInput } from './TextInput/TextInput';
// Toast
export * from './Toast/Toast';
export { default as Toast } from './Toast/Toast';
// ToastsProvider
export * from './Toast/ToastsProvider';
export { default as ToastsProvider } from './Toast/ToastsProvider';
// Typeahead
export * from './Typeahead/Typeahead';
export { default as Typeahead } from './Typeahead/Typeahead';

@ -4607,7 +4607,7 @@ atob@^2.1.2:
attr-accept@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b"
resolved "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz"
integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==
autoprefixer@^10.3.7, autoprefixer@^10.4.12, autoprefixer@^10.4.7:
@ -7740,7 +7740,7 @@ file-loader@^6.0.0, file-loader@^6.2.0:
file-selector@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.6.0.tgz#fa0a8d9007b829504db4d07dd4de0310b65287dc"
resolved "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz"
integrity sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==
dependencies:
tslib "^2.4.0"
@ -12176,7 +12176,7 @@ react-dom@18.2.0, react-dom@^18.2.0:
react-dropzone@^14.2.3:
version "14.2.3"
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.2.3.tgz#0acab68308fda2d54d1273a1e626264e13d4e84b"
resolved "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz"
integrity sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==
dependencies:
attr-accept "^2.2.2"
@ -14163,47 +14163,47 @@ tty-browserify@0.0.0:
resolved "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz"
integrity sha512-JVa5ijo+j/sOoHGjw0sxw734b1LhBkQ3bvUGNdxnVXDCX81Yx7TFgnZygxrIIWn23hbfTaMYLwRmAxFyDuFmIw==
turbo-darwin-64@1.5.5:
version "1.5.5"
resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-1.5.5.tgz#710d4e7999066bd4f500456f7cd1c30f6e6205ed"
integrity sha512-HvEn6P2B+NXDekq9LRpRgUjcT9/oygLTcK47U0qsAJZXRBSq/2hvD7lx4nAwgY/4W3rhYJeWtHTzbhoN6BXqGQ==
turbo-darwin-64@1.5.6:
version "1.5.6"
resolved "https://registry.yarnpkg.com/turbo-darwin-64/-/turbo-darwin-64-1.5.6.tgz#2e0e14343c84dde33b5a09ea5389ee6a9565779c"
integrity sha512-CWdXMwenBS2+QXIR2Czx7JPnAcoMzWx/QwTDcHVxZyeayMHgz8Oq5AHCtfaHDSfV8YhD3xa0GLSk6+cFt+W8BQ==
turbo-darwin-arm64@1.5.5:
version "1.5.5"
resolved "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-1.5.5.tgz"
integrity sha512-Dmxr09IUy6M0nc7/xWod9galIO2DD500B75sJSkHeT+CCdJOWnlinux0ZPF8CSygNqymwYO8AO2l15/6yxcycg==
turbo-darwin-arm64@1.5.6:
version "1.5.6"
resolved "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-1.5.6.tgz"
integrity sha512-c/aXgW9JuXT2bJSKf01pdSDQKnrdcdj3WFKmKiVldb9We6eqFzI0fLHBK97k5LM/OesmRMfCMQ2Cv2DU8RqBAA==
turbo-linux-64@1.5.5:
version "1.5.5"
resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-1.5.5.tgz#f31eb117a9b605f5731048c50473bff903850047"
integrity sha512-wd07TZ4zXXWjzZE00FcFMLmkybQQK/NV9ff66vvAV0vdiuacSMBCNLrD6Mm4ncfrUPW/rwFW5kU/7hyuEqqtDw==
turbo-linux-64@1.5.6:
version "1.5.6"
resolved "https://registry.yarnpkg.com/turbo-linux-64/-/turbo-linux-64-1.5.6.tgz#e7ddaf7a87084dfdd9c6d79efb41084d75439b31"
integrity sha512-y/jNF7SG+XJEwk2GxIqy3g4dj/a0PgZKDGyOkp24qp4KBRcHBl6dI1ZEfNed30EhEqmW4F5Dr7IpeCZoqgbrMg==
turbo-linux-arm64@1.5.5:
version "1.5.5"
resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-1.5.5.tgz#b9ce6912ae6477e829355d6f012500bfef58669d"
integrity sha512-q3q33tuo74R7gicnfvFbnZZvqmlq7Vakcvx0eshifnJw4PR+oMnTCb4w8ElVFx070zsb8DVTibq99y8NJH8T1Q==
turbo-linux-arm64@1.5.6:
version "1.5.6"
resolved "https://registry.yarnpkg.com/turbo-linux-arm64/-/turbo-linux-arm64-1.5.6.tgz#6445f00f84e0f356a6a369ba2d75ede43aaeb796"
integrity sha512-FRcxPtW7eFrbR3QaYBVX8cK7i+2Cerqi6F0t5ulcq+d1OGSdSW3l35rPPyJdwCzCy+k/S9sBcyCV0RtbS6RKCQ==
turbo-windows-64@1.5.5:
version "1.5.5"
resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-1.5.5.tgz#609098de3bc6178f733615d21b06d5c1602637eb"
integrity sha512-lPp9kHonNFfqgovbaW+UAPO5cLmoAN+m3G3FzqcrRPnlzt97vXYsDhDd/4Zy3oAKoAcprtP4CGy0ddisqsKTVw==
turbo-windows-64@1.5.6:
version "1.5.6"
resolved "https://registry.yarnpkg.com/turbo-windows-64/-/turbo-windows-64-1.5.6.tgz#3638d5297319157031e4dc906dbae53a1db8562c"
integrity sha512-/5KIExY7zbrbeL5fhKGuO85u5VtJ3Ue4kI0MbYCNnTGe7a10yTYkwswgtGihsgEF4AW0Nm0159aHmXZS2Le8IA==
turbo-windows-arm64@1.5.5:
version "1.5.5"
resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-1.5.5.tgz#60522e1e347a54c64bdddb68089fc322ee19c3d7"
integrity sha512-3AfGULKNZiZVrEzsIE+W79ZRW1+f5r4nM4wLlJ1PTBHyRxBZdD6KTH1tijGfy/uTlcV5acYnKHEkDc6Q9PAXGQ==
turbo-windows-arm64@1.5.6:
version "1.5.6"
resolved "https://registry.yarnpkg.com/turbo-windows-arm64/-/turbo-windows-arm64-1.5.6.tgz#9eff9d13721be0b905b0aad07667507380f738fe"
integrity sha512-p+LQN9O39+rZuOAyc6BzyVGvdEKo+v+XmtdeyZsZpfj4xuOLtsEptW1w6cUD439u0YcPknuccGq1MQ0lXQ6Xuw==
turbo@latest:
version "1.5.5"
resolved "https://registry.yarnpkg.com/turbo/-/turbo-1.5.5.tgz#9fc3a917c914ffa113c260a4eadb4bc632eee227"
integrity sha512-PVQSDl0STC9WXIyHcYUWs9gXsf8JjQig/FuHfuB8N6+XlgCGB3mPbfMEE6zrChGz2hufH4/guKRX1XJuNL6XTA==
version "1.5.6"
resolved "https://registry.npmjs.org/turbo/-/turbo-1.5.6.tgz"
integrity sha512-xJO/fhiMo4lI62iGR9OgUfJTC9tnnuoMwNC52IfvvBDEPlA8RWGMS8SFpDVG9bNCXvVRrtUTNJXMe6pJWBiOTA==
optionalDependencies:
turbo-darwin-64 "1.5.5"
turbo-darwin-arm64 "1.5.5"
turbo-linux-64 "1.5.5"
turbo-linux-arm64 "1.5.5"
turbo-windows-64 "1.5.5"
turbo-windows-arm64 "1.5.5"
turbo-darwin-64 "1.5.6"
turbo-darwin-arm64 "1.5.6"
turbo-linux-64 "1.5.6"
turbo-linux-arm64 "1.5.6"
turbo-windows-64 "1.5.6"
turbo-windows-arm64 "1.5.6"
type-check@^0.4.0, type-check@~0.4.0:
version "0.4.0"
@ -14641,7 +14641,7 @@ uuid-browser@^3.1.0:
uuid@^3.3.2:
version "3.4.0"
resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
uuid@^8.3.2:

Loading…
Cancel
Save