Merge branch 'main' into hongpo/fix-sorting-bug

pull/530/head
Jeff Sieu 3 years ago
commit aa7de95d0e

Binary file not shown.

@ -38,16 +38,16 @@ function ProfileJewel() {
if (session == null) {
return router.pathname !== loginHref.pathname ? (
<Link className="text-base" href={loginHref}>
Log In
Sign In
</Link>
) : null;
}
const userNavigation = [
// { href: '/profile', name: 'Profile' },
{ href: '/settings', name: 'Settings' },
{
href: '/api/auth/signout',
name: 'Log out',
name: 'Sign Out',
onClick: (event: MouseEvent) => {
event.preventDefault();
signOut();
@ -80,6 +80,15 @@ function ProfileJewel() {
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{!!session?.user?.name && (
<Menu.Item>
{() => (
<span className="block px-4 py-2 text-sm font-semibold text-slate-700">
{session?.user?.name ?? ''}
</span>
)}
</Menu.Item>
)}
{userNavigation.map((item) => (
<Menu.Item key={item.name}>
{({ active }) => (

@ -17,13 +17,11 @@ function pageview(measurementID: string, url: string) {
return;
}
window.gtag('config', measurementID, {
window.gtag('event', 'page_view', {
page_location: window.location.href,
page_path: url,
});
window.gtag('event', url, {
event_category: 'pageview',
event_label: document.title,
page_title: document.title,
send_to: measurementID,
});
}

@ -8,7 +8,7 @@ const navigation: ProductNavigationItems = [
const navigationAuthenticated: ProductNavigationItems = [
{ href: '/offers/submit', name: 'Analyze your offers' },
{ href: '/offers/dashboard', name: 'Your dashboard' },
{ href: '/offers/dashboard', name: 'My dashboard' },
{ href: '/offers/features', name: 'Features' },
{ href: '/offers/about', name: 'About' },
];

@ -3,6 +3,8 @@ import {
LightBulbIcon,
} from '@heroicons/react/24/outline';
import type { EducationType } from '~/components/offers/EducationFields';
import { getLabelForEducationFieldType } from '~/components/offers/EducationFields';
import type { EducationDisplayData } from '~/components/offers/types';
type Props = Readonly<{
@ -19,7 +21,16 @@ export default function EducationCard({
<div className="flex items-center">
<LightBulbIcon className="mr-1 h-5" />
<span className="text-semibold ml-1">
{field ? `${type ?? 'N/A'}, ${field}` : type ?? `N/A`}
{field
? `${
type ? type.charAt(0).toUpperCase() + type.slice(1) : 'N/A'
}, ${
getLabelForEducationFieldType(field as EducationType) ??
'N/A'
}`
: type
? type.charAt(0).toUpperCase() + type.slice(1)
: `N/A`}
</span>
</div>
{school && (

@ -91,7 +91,7 @@ export default function ProfileComments({
},
);
} else {
// If not the OP and not logged in, direct users to log in
// If not the OP and not logged in, direct users to sign in
signIn();
}
}

@ -89,7 +89,7 @@ export default function CommentCard({
},
);
} else {
// If not the OP and not logged in, direct users to log in
// If not the OP and not logged in, direct users to sign in
signIn();
}
}

@ -23,6 +23,7 @@ export default function OfferTableRow({
company,
id,
income,
location,
monthYearReceived,
profileId,
stocks,
@ -32,9 +33,12 @@ export default function OfferTableRow({
}: OfferTableRowProps) {
return (
<tr key={id} className="divide-x divide-slate-200 border-b bg-white">
<th className="whitespace-nowrap py-4 px-4 font-medium" scope="row">
{company.name}
</th>
<td className="space-y-0.5 py-4 px-4" scope="row">
<div className="font-medium">{company.name}</div>
<div className="text-xs text-slate-400">
{location.cityName} ({location.countryCode})
</div>
</td>
<td className="py-4 px-4">
{getLabelForJobTitleType(title as JobTitleType)}
</td>

@ -235,7 +235,8 @@ export default function OffersTable({
<th
key={header}
className={clsx(
'whitespace-nowrap bg-slate-100 py-3 px-4',
'bg-slate-100 py-3 px-4',
header !== 'Company' && 'whitespace-nowrap',
// Make last column sticky.
index === columns.length - 1 &&
'sticky right-0 drop-shadow md:drop-shadow-none',

@ -1,5 +1,4 @@
import { useState } from 'react';
import { TextInput } from '@tih/ui';
import { useProtectedCallback } from '~/utils/questions/useProtectedCallback';
@ -27,22 +26,13 @@ export default function ContributeQuestionCard({
return (
<div className="w-full">
<button
className="flex w-full flex-1 justify-between gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-slate-100"
className="flex w-full flex-1 justify-between gap-2 rounded-md border border-slate-300 bg-white p-4 text-left transition hover:bg-slate-100"
type="button"
onClick={handleOpenContribute}>
<div className="w-full">
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
onChange={handleOpenContribute}
/>
</div>
<div className="flex flex-wrap items-end justify-start gap-2">
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
<div className="w-full rounded-md border border-slate-300 bg-slate-100 py-2 px-4 text-slate-400">
<p className="font-semibold">
Just completed your interview? Contribute your questions
</p>
</div>
</button>
<ContributeQuestionDialog

@ -21,7 +21,7 @@ export default function CreateListDialog({
const {
register: formRegister,
handleSubmit,
formState: { isSubmitting },
formState: { isSubmitting, isDirty },
reset,
} = useForm<CreateListFormData>();
const register = useFormRegister(formRegister);
@ -51,6 +51,7 @@ export default function CreateListDialog({
autoComplete="off"
label="Name"
placeholder="List name"
required={true}
type="text"
/>
</div>
@ -62,6 +63,7 @@ export default function CreateListDialog({
onClick={handleDialogCancel}
/>
<Button
disabled={!isDirty}
display="inline"
isLoading={isSubmitting}
label="Create"

@ -2,10 +2,13 @@ import {
AdjustmentsHorizontalIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui';
import { Button, Tabs, TextInput } from '@tih/ui';
import { SORT_ORDERS } from '~/utils/questions/constants';
import type { SortOptionsSelectProps } from './SortOptionsSelect';
import SortOptionsSelect from './SortOptionsSelect';
import { SortOrder, SortType } from '~/types/questions.d';
export type QuestionSearchBarProps = SortOptionsSelectProps & {
onFilterOptionsToggle: () => void;
@ -13,6 +16,22 @@ export type QuestionSearchBarProps = SortOptionsSelectProps & {
query: string;
};
function getSortOrderLabel(sortOrder: SortOrder, sortType: SortType): string {
switch (sortType) {
case SortType.NEW:
return sortOrder === SortOrder.ASC ? 'Oldest first' : 'Newest first';
case SortType.TOP:
return sortOrder === SortOrder.ASC
? 'Least upvotes first'
: 'Most upvotes first';
case SortType.ENCOUNTERS:
return sortOrder === SortOrder.ASC
? 'Least received first'
: 'Most received first';
}
return '';
}
export default function QuestionSearchBar({
onFilterOptionsToggle,
onQueryChange,
@ -20,8 +39,10 @@ export default function QuestionSearchBar({
...sortOptionsSelectProps
}: QuestionSearchBarProps) {
return (
<div className="flex flex-col items-stretch gap-x-4 gap-y-2 lg:flex-row lg:items-end">
<div className="flex-1 ">
<div className="flex flex-col gap-4">
<div className="flex flex-col items-stretch gap-x-2 gap-y-4 lg:flex-row lg:items-end">
<div className="flex flex-1 gap-2">
<div className="flex-1">
<TextInput
isLabelHidden={true}
label="Search by content"
@ -34,18 +55,59 @@ export default function QuestionSearchBar({
}}
/>
</div>
<div className="flex items-end justify-end gap-4">
<SortOptionsSelect {...sortOptionsSelectProps} />
<div className="lg:hidden">
<div className="sm:hidden">
<Button
addonPosition="start"
icon={AdjustmentsHorizontalIcon}
isLabelHidden={true}
label="Filters"
variant="tertiary"
onClick={onFilterOptionsToggle}
/>
</div>
<div className="hidden sm:block lg:hidden">
<Button
addonPosition="start"
icon={AdjustmentsHorizontalIcon}
label="Filter options"
label="Filters"
variant="tertiary"
onClick={onFilterOptionsToggle}
/>
</div>
</div>
{/* <SortOptionsSelect {...sortOptionsSelectProps} /> */}
</div>
<div className="flex justify-start gap-4">
<div>
<Tabs
label="Sort by"
tabs={sortOptionsSelectProps.sortTypeOptions ?? []}
value={sortOptionsSelectProps.sortTypeValue}
onChange={sortOptionsSelectProps.onSortTypeChange}
/>
</div>
<div className="border-l" />
<div>
<Tabs
label="Order by"
tabs={(sortOptionsSelectProps.sortOrderOptions ?? SORT_ORDERS).map(
(option) => {
const newLabel = getSortOrderLabel(
option.value,
sortOptionsSelectProps.sortTypeValue,
);
return {
...option,
label: newLabel,
};
},
)}
value={sortOptionsSelectProps.sortOrderValue}
onChange={sortOptionsSelectProps.onSortOrderChange}
/>
</div>
</div>
</div>
);
}

@ -35,7 +35,7 @@ export default function SortOptionsSelect({
const sortOrders = sortOrderOptions ?? SORT_ORDERS;
return (
<div className="flex items-end justify-end gap-4">
<div className="flex items-end justify-end gap-2">
<div className="flex items-center gap-2">
<Select
display="inline"

@ -119,7 +119,7 @@ export type BaseQuestionCardProps = ActionButtonProps &
hideCard?: boolean;
questionId: string;
showHover?: boolean;
timestamp: string | null;
timestamp: Date | null;
truncateContent?: boolean;
type: QuestionsQuestionType;
};
@ -177,12 +177,26 @@ export default function BaseQuestionCard({
const cardContent = (
<>
{showVoteButtons && (
<>
<div className="md:hidden">
<VotingButtons
size="sm"
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
</div>
<div className="hidden md:block">
<VotingButtons
size="md"
upvoteCount={upvoteCount}
vote={vote}
onDownvote={handleDownvote}
onUpvote={handleUpvote}
/>
</div>
</>
)}
<div className="flex flex-1 flex-col items-start gap-2">
<div className="flex items-baseline justify-between self-stretch">
@ -201,7 +215,14 @@ export default function BaseQuestionCard({
<QuestionAggregateBadge statistics={roles} variant="danger" />
</>
)}
{timestamp !== null && <p className="text-xs">{timestamp}</p>}
{timestamp !== null && (
<p className="text-xs">
{timestamp.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
</p>
)}
{showAddToList && (
<div className="pl-4">
<AddToListDropdown questionId={questionId} />
@ -230,6 +251,17 @@ export default function BaseQuestionCard({
showCreateEncounterButton) && (
<div className="flex gap-2">
{showAnswerStatistics && (
<>
<div className="sm:hidden">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount}`}
size="sm"
variant="tertiary"
/>
</div>
<div className="hidden sm:block">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
@ -237,8 +269,21 @@ export default function BaseQuestionCard({
size="sm"
variant="tertiary"
/>
</div>
</>
)}
{showReceivedStatistics && (
<>
<div className="sm:hidden">
<Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount}`}
size="sm"
variant="tertiary"
/>
</div>
<div className="hidden sm:block">
<Button
addonPosition="start"
icon={EyeIcon}
@ -246,6 +291,8 @@ export default function BaseQuestionCard({
size="sm"
variant="tertiary"
/>
</div>
</>
)}
{showCreateEncounterButton && (
<Button
@ -277,7 +324,7 @@ export default function BaseQuestionCard({
<article
className={clsx(
'group flex gap-4 border-slate-300',
showHover && 'hover:bg-slate-50',
showHover && 'transition hover:bg-slate-50',
!hideCard && 'rounded-md border bg-white p-4',
)}>
{cardContent}

@ -151,7 +151,9 @@ export default function ContributeQuestionForm({
}}
yearRequired={true}
onChange={({ month, year }) => {
field.onChange(startOfMonth(new Date(year!, month! - 1)));
field.onChange(
new Date(Date.UTC(year!, month! - 1, 1, 0, 0, 0, 0)),
);
}}
/>
)}
@ -221,12 +223,7 @@ export default function ContributeQuestionForm({
createEncounterButtonText="Yes, this is my question"
questionId={question.id}
roles={roleCounts}
timestamp={
question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
}) ?? null
}
timestamp={question.seenAt}
type={question.type}
onReceivedSubmit={async (data) => {
await addEncounterAsync({
@ -271,18 +268,13 @@ export default function ContributeQuestionForm({
/>
</div>
<div className="flex gap-x-2">
<button
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 label="Discard" variant="tertiary" onClick={onDiscard} />
<Button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-slate-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!checkedSimilar}
label="Contribute"
type="submit"
variant="primary"></Button>
variant="primary"
/>
</div>
</div>
</form>

@ -130,7 +130,7 @@ export default function CreateQuestionEncounterForm({
yearLabel=""
onChange={(value) => {
setSelectedDate(
startOfMonth(new Date(value.year!, value.month! - 1)),
new Date(Date.UTC(value.year!, value.month! - 1, 1, 0, 0, 0, 0)),
);
}}
/>

@ -1,7 +1,6 @@
import clsx from 'clsx';
import { formatDistanceToNow } from 'date-fns';
import { useState } from 'react';
import { FaceSmileIcon } from '@heroicons/react/24/outline';
import ResumeCommentEditForm from './comment/ResumeCommentEditForm';
import ResumeCommentReplyForm from './comment/ResumeCommentReplyForm';
@ -29,24 +28,16 @@ export default function ResumeCommentListItem({
<div className="min-w-fit">
<div className="flex flex-row space-x-3 align-top">
{/* Image Icon */}
{comment.user.image ? (
<img
alt={comment.user.name ?? 'Reviewer'}
className={clsx(
'mt-1 rounded-full',
comment.parentId ? 'h-8 w-8' : 'h-10 w-10',
comment.parentId ? 'h-7 w-7' : 'h-9 w-9',
)}
src={comment.user.image!}
src={`https://avatars.dicebear.com/api/gridy/${
comment.user.name ?? 'random'
}.svg`}
/>
) : (
<FaceSmileIcon
className={clsx(
'mt-1 rounded-full',
comment.parentId ? 'h-6 w-6' : 'h-8 w-8 ',
)}
/>
)}
<div className="flex w-full flex-col space-y-1">
{/* Name and creation time */}
<div className="flex flex-row items-center space-x-2">

@ -15,7 +15,7 @@ export default function ResumeSignInButton({ text, className }: Props) {
<Link
className="text-primary-500 hover:text-primary-600"
href={loginPageHref()}>
Log in
Sign in
</Link>{' '}
{text}
</p>

@ -2,7 +2,7 @@ export default function loginPageHref(redirectUrl?: string) {
return {
pathname: '/login',
query: {
redirect:
callbackUrl:
typeof window !== 'undefined'
? redirectUrl ?? window.location.href
: null,

@ -330,7 +330,7 @@ export const profileAnalysisDtoMapper = (
})
| null,
) => {
if (!analysis) {
if (analysis == null) {
return null;
}
@ -763,6 +763,7 @@ export const addToProfileResponseMapper = (updatedProfile: {
export const dashboardOfferDtoMapper = (
offer: OffersOffer & {
company: Company;
location: City & { state: State & { country: Country } };
offersFullTime:
| (OffersFullTime & {
baseSalary: OffersCurrency | null;
@ -785,6 +786,7 @@ export const dashboardOfferDtoMapper = (
id: '',
value: -1,
}),
location: locationDtoMapper(offer.location),
monthYearReceived: offer.monthYearReceived,
profileId: offer.profileId,
title: offer.offersFullTime?.title || offer.offersIntern?.title || '',

@ -20,6 +20,9 @@ export const authOptions: NextAuthOptions = {
return session;
},
},
pages: {
signIn: '/login',
},
providers: [
GitHubProvider({
clientId: env.GITHUB_CLIENT_ID,

@ -1,3 +1,4 @@
import Head from 'next/head';
import { Button } from '@tih/ui';
import Container from '~/components/shared/Container';
@ -12,7 +13,7 @@ const features = [
},
{
description:
'Lorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores impedit perferendis suscipit eaque, iste dolor cupiditate blanditiis ratione.',
'Reveal stories behind offers. Help job seekers benchmark and analyse their anonymous offers with more context. Encourage discussions around offer profiles.',
href: '/offers',
img: '/logos/offers-logo.svg',
name: 'Tech Offers',
@ -28,17 +29,25 @@ const features = [
export default function HomePage() {
return (
<div className="bg-white py-12">
<Container className="space-y-24">
<div className="text-center">
<>
<Head>
<title>Tech Interview Handbook Portal</title>
</Head>
<div className="bg-white pb-24">
<div className="space-y-12">
<div className="bg-slate-100 py-8 text-center sm:py-16">
<Container>
<h1 className="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl md:text-6xl">
<span className="block">Tech Interview Handbook</span>
<span className="text-primary-600 block">Portal</span>
</h1>
<p className="mx-auto mt-3 max-w-md text-base text-slate-500 sm:text-lg md:mt-5 md:max-w-3xl md:text-xl">
Suite of products to help you get better at technical interviews.
Suite of products to help you get better at technical
interviews.
</p>
</Container>
</div>
<Container>
<div>
<h2 className="sr-only">Products.</h2>
<dl className="space-y-10 lg:grid lg:grid-cols-3 lg:gap-12 lg:space-y-0">
@ -71,5 +80,7 @@ export default function HomePage() {
</div>
</Container>
</div>
</div>
</>
);
}

@ -32,11 +32,11 @@ export default function LoginPage({
src="/logo.svg"
/>
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-slate-900">
Tech Interview Handbook Portal
Sign in to Tech Interview Handbook Portal
</h2>
<p className="mt-2 text-center text-slate-600">
Get your resumes peer-reviewed, discuss solutions to tech interview
questions, get offer data points.
questions, explore offer data points.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
@ -54,9 +54,9 @@ export default function LoginPage({
onClick={() =>
signIn(
provider.id,
router.query.redirect != null
router.query.callbackUrl != null
? {
callbackUrl: String(router.query.redirect),
callbackUrl: String(router.query.callbackUrl),
}
: undefined,
)

@ -1,3 +1,5 @@
import Head from 'next/head';
import Container from '~/components/shared/Container';
const people = [
@ -29,6 +31,10 @@ const people = [
export default function AboutPage() {
return (
<>
<Head>
<title>About us - Tech Offers Repo</title>
</Head>
<div className="lg:py-18 bg-white py-12">
<Container variant="xs">
<div className="space-y-12">
@ -40,8 +46,8 @@ export default function AboutPage() {
Tech Offers Repo, a project under the series of Tech Interview
Handbook (TIH), reveals the stories behind offers by focusing on
the profiles of the offer receivers. It helps job seekers
benchmark and analyse their anonymous offers with more context and
encourages discussions around offer profiles.
benchmark and analyse their anonymous offers with more context
and encourages discussions around offer profiles.
</p>
</div>
{/* Feedback */}
@ -99,5 +105,6 @@ export default function AboutPage() {
</div>
</Container>
</div>
</>
);
}

@ -1,3 +1,4 @@
import Head from 'next/head';
import { useRouter } from 'next/router';
import { signIn, useSession } from 'next-auth/react';
import { useState } from 'react';
@ -45,6 +46,10 @@ export default function ProfilesDashboard() {
if (userProfiles.length === 0) {
return (
<>
<Head>
<title>My Dashboard - Tech Offers Repo</title>
</Head>
<div className="flex w-full">
<div className="w-full justify-center space-y-8 py-16 text-xl">
<div className="flex w-full flex-row justify-center">
@ -60,10 +65,15 @@ export default function ProfilesDashboard() {
</div>
</div>
</div>
</>
);
}
return (
<>
<Head>
<title>My Dashboard - Tech Offers Repo</title>
</Head>
<Container variant="xs">
{userProfilesQuery.isLoading && (
<div className="flex h-screen">
@ -75,11 +85,11 @@ export default function ProfilesDashboard() {
{!userProfilesQuery.isLoading && (
<div className="overflow-y-auto py-8">
<h1 className="mx-auto mb-4 text-start text-4xl font-bold text-slate-900">
Your dashboard
My dashboard
</h1>
<p className="mt-4 text-xl leading-8 text-slate-500">
Save your offer profiles to your dashboard to easily access and edit
them later.
Save your offer profiles to your dashboard to easily access and
edit them later.
</p>
<div className="mt-8 flex justify-center">
<ul className="w-full space-y-4" role="list">
@ -93,5 +103,6 @@ export default function ProfilesDashboard() {
</div>
)}
</Container>
</>
);
}

@ -1,3 +1,4 @@
import Head from 'next/head';
import type { SVGProps } from 'react';
import {
BookmarkSquareIcon,
@ -38,32 +39,32 @@ const features = [
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://www.linkedin.com/company/tech-offers-repo',
icon: (props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>) => (
<svg fill="currentColor" viewBox="0 0 50 50" {...props}>
<path
clipRule="evenodd"
d="M41,4H9C6.24,4,4,6.24,4,9v32c0,2.76,2.24,5,5,5h32c2.76,0,5-2.24,5-5V9C46,6.24,43.76,4,41,4z M17,20v19h-6V20H17z M11,14.47c0-1.4,1.2-2.47,3-2.47s2.93,1.07,3,2.47c0,1.4-1.12,2.53-3,2.53C12.2,17,11,15.87,11,14.47z M39,39h-6c0,0,0-9.26,0-10 c0-2-1-4-3.5-4.04h-0.08C27,24.96,26,27.02,26,29c0,0.91,0,10,0,10h-6V20h6v2.56c0,0,1.93-2.56,5.81-2.56 c3.97,0,7.19,2.73,7.19,8.26V39z"
fillRule="evenodd"
/>
</svg>
),
name: 'LinkedIn',
},
{
href: 'https://www.instagram.com/techinterviewhandbook',
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>) => (
@ -82,6 +83,10 @@ const footerNavigation = {
export default function LandingPage() {
return (
<>
<Head>
<title>Features - Tech Offers Repo</title>
</Head>
<div className="mx-auto w-full overflow-y-auto bg-white">
<main>
{/* Hero section */}
@ -99,20 +104,21 @@ export default function LandingPage() {
</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.
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-1 sm:gap-5 sm:space-y-0">
<div className="space-y-4 sm:mx-auto sm:inline-grid sm:grid-cols-1 sm:gap-5 sm:space-y-0 md:grid-cols-2">
<a
className="border-grey-600 flex items-center justify-center rounded-md border 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
<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> */}
href="https://youtu.be/e4g1lS6zWGA">
Promo Video
</a>
</div>
</div>
</div>
@ -181,8 +187,8 @@ export default function LandingPage() {
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.
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) => (
@ -253,5 +259,6 @@ export default function LandingPage() {
</div>
</footer>
</div>
</>
);
}

@ -1,3 +1,4 @@
import Head from 'next/head';
import Link from 'next/link';
import { useState } from 'react';
import { MapPinIcon } from '@heroicons/react/24/outline';
@ -27,6 +28,10 @@ export default function OffersHomePage() {
useSearchParamSingle<JobTitleType | null>('jobTitleId');
return (
<>
<Head>
<title>Home - Tech Offers Repo</title>
</Head>
<main className="flex-1 overflow-y-auto">
<Banner size="sm">
Check if your offer is competitive by submitting it{' '}
@ -140,5 +145,6 @@ export default function OffersHomePage() {
/>
</Container>
</main>
</>
);
}

@ -1,4 +1,5 @@
import Error from 'next/error';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useState } from 'react';
@ -201,6 +202,12 @@ export default function OfferProfile() {
</div>
</div>
) : (
<>
<Head>
<title>
{background?.profileName ? background.profileName : 'View profile'}
</title>
</Head>
<div className="w-full divide-x lg:flex">
<div className="divide-y lg:w-2/3">
<div className="h-fit">
@ -238,5 +245,6 @@ export default function OfferProfile() {
/>
</div>
</div>
</>
);
}

@ -1,4 +1,5 @@
import Error from 'next/error';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { JobType } from '@prisma/client';
@ -88,6 +89,9 @@ export default function OffersEditPage() {
return (
<>
<Head>
<title>Edit profile</title>
</Head>
{getProfileResult.isError && (
<div className="flex w-full justify-center">
<Error statusCode={404} title="Requested profile does not exist" />

@ -1,5 +1,14 @@
import Head from 'next/head';
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
export default function OffersSubmissionPage() {
return <OffersSubmissionForm />;
return (
<>
<Head>
<title>Analyze your offers</title>
</Head>
<OffersSubmissionForm />
</>
);
}

@ -1,5 +1,14 @@
import Head from 'next/head';
import OffersSubmissionForm from '~/components/offers/offersSubmission/OffersSubmissionForm';
export default function OffersSubmissionPage() {
return <OffersSubmissionForm />;
return (
<>
<Head>
<title>Analyze your offers</title>
</Head>
<OffersSubmissionForm />
</>
);
}

@ -1,4 +1,5 @@
import Error from 'next/error';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useSession } from 'next-auth/react';
import { useEffect, useRef, useState } from 'react';
@ -92,6 +93,10 @@ export default function OffersSubmissionResult() {
title="You do not have permissions to access this page"
/>
) : (
<>
<Head>
<title>View the result</title>
</Head>
<div ref={pageRef} className="w-full">
<div className="flex justify-center">
<div className="block w-full max-w-screen-md overflow-hidden rounded-lg sm:shadow-lg md:my-10">
@ -139,5 +144,6 @@ export default function OffersSubmissionResult() {
</div>
</div>
</div>
</>
);
}

@ -210,10 +210,7 @@ export default function QuestionPage() {
questionId={question.id}
receivedCount={undefined}
roles={relabeledAggregatedEncounters?.roleCounts ?? {}}
timestamp={question.seenAt.toLocaleDateString(undefined, {
month: 'short',
year: 'numeric',
})}
timestamp={question.seenAt}
upvoteCount={question.numVotes}
onReceivedSubmit={async (data) => {
await addEncounterAsync({

@ -503,7 +503,7 @@ export default function QuestionsBrowsePage() {
<main className="flex h-[calc(100vh_-_64px)] flex-1 flex-col items-stretch">
<div className="flex h-full flex-1">
<section className="min-h-0 flex-1 overflow-auto">
<div className="my-4 mx-auto flex max-w-3xl flex-col items-stretch justify-start gap-6">
<div className="my-4 mx-auto flex max-w-3xl flex-col items-stretch justify-start gap-6 p-4">
<ContributeQuestionCard
onSubmit={(data) => {
const { cityId, countryId, stateId } = data.location;
@ -558,13 +558,9 @@ export default function QuestionsBrowsePage() {
questionId={question.id}
receivedCount={question.receivedCount}
roles={roleCounts}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
},
)}
timestamp={
question.aggregatedQuestionEncounters.latestSeenAt
}
type={question.type}
upvoteCount={question.numVotes}
/>

@ -220,13 +220,7 @@ export default function ListPage() {
questionId={question.id}
receivedCount={question.receivedCount}
roles={roleCounts}
timestamp={question.seenAt.toLocaleDateString(
undefined,
{
month: 'short',
year: 'numeric',
},
)}
timestamp={question.seenAt}
type={question.type}
onDelete={() => {
deleteQuestionEntry({ id: entryId });

@ -176,7 +176,7 @@ export default function ResumeReviewPage() {
<Button
display="block"
href={loginPageHref()}
label="Log in to join discussion"
label="Sign in to comment"
variant="primary"
/>
);

@ -0,0 +1,141 @@
import Head from 'next/head';
import type { Session } from 'next-auth';
import { useSession } from 'next-auth/react';
import { useState } from 'react';
import { Button, HorizontalDivider, TextInput, useToast } from '@tih/ui';
import Container from '~/components/shared/Container';
import { trpc } from '~/utils/trpc';
function SettingsForm({
session,
}: Readonly<{
session: Session;
}>) {
const { showToast } = useToast();
const updateProfileMutation = trpc.useMutation(
['user.settings.profile.update'],
{
onError: () => {
showToast({
subtitle: 'Please check that you are logged in.',
title: 'Failed to update profile',
variant: 'failure',
});
},
onSuccess: () => {
showToast({
title: 'Updated profile!',
variant: 'success',
});
},
},
);
const [name, setName] = useState(session?.user?.name);
const [email, setEmail] = useState(session?.user?.email);
return (
<div className="lg:py-18 bg-white py-12">
<Container variant="xs">
<div className="space-y-8">
<h1 className="text-4xl font-bold">Settings</h1>
<HorizontalDivider />
<p className="text-sm text-gray-500">
This information will be displayed publicly so be careful what you
share.
</p>
<form
className="space-y-8"
onSubmit={(event) => {
event.preventDefault();
updateProfileMutation.mutate({
email: email ? email : undefined,
name: name ? name : undefined,
});
}}>
<div className="grid grid-cols-1 gap-y-8 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-3">
<TextInput
description="This name will be used across the entire platform"
label="Name"
placeholder="John Doe"
value={name ?? undefined}
onChange={(val) => setName(val)}
/>
</div>
<div className="sm:col-span-3">
<TextInput
label="Email"
placeholder="john.doe@example.com"
type="email"
value={email ?? undefined}
onChange={(val) => setEmail(val)}
/>
</div>
{/* <div className="sm:col-span-6">
<label
className="block text-sm font-medium text-gray-700"
htmlFor="photo">
Profile Image
</label>
<div className="mt-1 flex items-center space-x-4">
{session?.user?.image ? (
<img
alt={session?.user?.email ?? session?.user?.name ?? ''}
className="h-16 w-16 rounded-full"
src={session?.user.image}
/>
) : (
<span className="h-16 w-16 overflow-hidden rounded-full bg-gray-100">
<svg
className="h-full w-full text-gray-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" />
</svg>
</span>
)}
<Button label="Change" size="sm" variant="tertiary" />
</div>
</div> */}
</div>
<HorizontalDivider />
<div className="flex justify-end">
<Button
disabled={updateProfileMutation.isLoading}
isLoading={updateProfileMutation.isLoading}
label="Save"
type="submit"
variant="primary"
/>
</div>
</form>
</div>
</Container>
</div>
);
}
export default function SettingsPage() {
const { data: session, status } = useSession();
const isSessionLoading = status === 'loading';
if (isSessionLoading) {
return null;
}
if (session == null) {
return <p>You are not signed in</p>;
}
return (
<>
<Head>
<title>Settings | Tech Interview Handbook</title>
</Head>
<SettingsForm session={session} />
</>
);
}

@ -29,6 +29,7 @@ import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
import { resumesStarUserRouter } from './resumes/resumes-star-user-router';
import { todosRouter } from './todos';
import { todosUserRouter } from './todos-user-router';
import { userRouter } from './user-router';
export const appRouter = createRouter()
.transformer(superjson)
@ -36,6 +37,7 @@ export const appRouter = createRouter()
// All keys should be delimited by a period and end with a period.
// Example routers. Learn more about tRPC routers: https://trpc.io/docs/v9/router
.merge('auth.', protectedExampleRouter)
.merge('user.', userRouter)
.merge('todos.', todosRouter)
.merge('todos.user.', todosUserRouter)
.merge('companies.', companiesRouter)

@ -78,6 +78,15 @@ export const offersRouter = createRouter().query('list', {
// Internship
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
baseSalary: true,
@ -198,6 +207,15 @@ export const offersRouter = createRouter().query('list', {
// Junior, Mid, Senior
include: {
company: true,
location: {
include: {
state: {
include: {
country: true,
},
},
},
},
offersFullTime: {
include: {
baseSalary: true,

@ -0,0 +1,25 @@
import { z } from 'zod';
import { createProtectedRouter } from './context';
export const userRouter = createProtectedRouter().mutation(
'settings.profile.update',
{
input: z.object({
email: z.string().optional(),
name: z.string().optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.user.update({
data: {
email: input.email,
name: input.name,
},
where: {
id: userId,
},
});
},
},
);

@ -70,6 +70,7 @@ export type DashboardOffer = {
company: OffersCompany;
id: string;
income: Valuation;
location: Location;
monthYearReceived: Date;
profileId: string;
stocks?: Valuation;

@ -3,6 +3,8 @@ import type {
City,
Company,
Country,
OffersAnalysis,
OffersAnalysisUnit,
OffersBackground,
OffersCurrency,
OffersExperience,
@ -20,6 +22,98 @@ import { TRPCError } from '@trpc/server';
import { analysisInclusion } from './analysisInclusion';
import { profileAnalysisDtoMapper } from '../../../mappers/offers-mappers';
type Analysis =
| (OffersAnalysis & {
companyAnalysis: Array<
OffersAnalysisUnit & {
analysedOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topSimilarOffers: Array<
OffersOffer & {
company: Company;
location: City & { state: State & { country: Country } };
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & {
company: Company | null;
location:
| (City & { state: State & { country: Country } })
| null;
}
>;
})
| null;
};
}
>;
}
>;
overallAnalysis: OffersAnalysisUnit & {
analysedOffer: OffersOffer & {
company: Company;
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & { background: OffersBackground | null };
};
topSimilarOffers: Array<
OffersOffer & {
company: Company;
location: City & { state: State & { country: Country } };
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern:
| (OffersIntern & { monthlySalary: OffersCurrency })
| null;
profile: OffersProfile & {
background:
| (OffersBackground & {
experiences: Array<
OffersExperience & {
company: Company | null;
location:
| (City & { state: State & { country: Country } })
| null;
}
>;
})
| null;
};
}
>;
};
overallHighestOffer: OffersOffer & {
company: Company;
location: City & { state: State & { country: Country } };
offersFullTime:
| (OffersFullTime & { totalCompensation: OffersCurrency })
| null;
offersIntern: (OffersIntern & { monthlySalary: OffersCurrency }) | null;
profile: OffersProfile & { background: OffersBackground | null };
};
})
| null;
type Offer = OffersOffer & {
company: Company;
location: City & { state: State & { country: Country } };
@ -285,6 +379,8 @@ export const generateAnalysis = async (params: {
};
input: { profileId: string };
}) => {
let analysis: Analysis = null;
const { ctx, input } = params;
await ctx.prisma.offersAnalysis.deleteMany({
where: {
@ -352,17 +448,8 @@ export const generateAnalysis = async (params: {
}
const overallHighestOffer = offers[0];
const usersOfferIds = offers.map((offer) => offer.id);
// OVERALL ANALYSIS
const overallAnalysisUnit = await generateAnalysisUnit(
ctx.prisma,
overallHighestOffer,
usersOfferIds,
);
// COMPANY ANALYSIS
const companyMap = new Map<string, Offer>();
offers.forEach((offer) => {
if (companyMap.get(offer.companyId) == null) {
@ -370,7 +457,9 @@ export const generateAnalysis = async (params: {
}
});
const companyAnalysis = await Promise.all(
Promise.all([
generateAnalysisUnit(ctx.prisma, overallHighestOffer, usersOfferIds),
Promise.all(
Array.from(companyMap.values()).map(async (companyOffer) => {
return await generateAnalysisUnit(
ctx.prisma,
@ -379,9 +468,11 @@ export const generateAnalysis = async (params: {
true,
);
}),
);
),
]).then(async (analyses) => {
const [overallAnalysisUnit, companyAnalysis] = analyses;
const analysis = await ctx.prisma.offersAnalysis.create({
analysis = await ctx.prisma.offersAnalysis.create({
data: {
companyAnalysis: {
create: companyAnalysis.map((analysisUnit) => {
@ -430,6 +521,7 @@ export const generateAnalysis = async (params: {
},
include: analysisInclusion,
});
});
return profileAnalysisDtoMapper(analysis);
};

@ -69,38 +69,38 @@ export const QUESTION_AGES: FilterChoices<QuestionAge> = [
] as const;
export const SORT_ORDERS = [
{
label: 'Ascending',
value: SortOrder.ASC,
},
{
label: 'Descending',
value: SortOrder.DESC,
},
{
label: 'Ascending',
value: SortOrder.ASC,
},
];
export const SORT_TYPES = [
{
label: 'New',
value: SortType.NEW,
label: 'Upvotes',
value: SortType.TOP,
},
{
label: 'Top',
value: SortType.TOP,
label: 'Date',
value: SortType.NEW,
},
];
export const QUESTION_SORT_TYPES = [
{
label: 'New',
value: SortType.NEW,
label: 'Upvotes',
value: SortType.TOP,
},
{
label: 'Top',
value: SortType.TOP,
label: 'Age',
value: SortType.NEW,
},
{
label: 'Encounters',
label: 'Received',
value: SortType.ENCOUNTERS,
},
];

@ -198,6 +198,8 @@ function TextInput(
onChange(event.target.value, event);
}}
// To prevent scrolling from changing number input value
onWheel={(event) => event.currentTarget.blur()}
{...props}
/>
{(() => {

Loading…
Cancel
Save