Merge branch 'main' into hongpo/add-question-encounter-crud

pull/343/head
hpkoh 3 years ago
commit b78973fb9f

@ -175,7 +175,7 @@ export default function AppShell({ children }: Props) {
/>
{/* Content area */}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex h-screen flex-1 flex-col overflow-hidden">
<header className="w-full">
<div className="relative z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white shadow-sm">
<button

@ -1,23 +1,14 @@
import type { ProductNavigationItems } from '~/components/global/ProductNavigation';
const navigation: ProductNavigationItems = [
{
children: [
{ href: '#', name: 'Technical Support' },
{ href: '#', name: 'Sales' },
{ href: '#', name: 'General' },
],
href: '#',
name: 'Inboxes',
},
{ children: [], href: '#', name: 'Reporting' },
{ children: [], href: '#', name: 'Settings' },
{ href: '/offers', name: 'Home' },
{ href: '/offers/submit', name: 'Benchmark your offer' },
];
const config = {
navigation,
showGlobalNav: true,
title: 'Offers',
showGlobalNav: false,
title: 'Tech Offers Repo',
};
export default config;

@ -0,0 +1,190 @@
import { useState } from 'react';
import { HorizontalDivider, Pagination, Select, Tabs } from '@tih/ui';
import CurrencySelector from '~/components/offers/util/currency/CurrencySelector';
type TableRow = {
company: string;
date: string;
salary: string;
title: string;
yoe: string;
};
// eslint-disable-next-line no-shadow
enum YOE_CATEGORY {
INTERN = 0,
ENTRY = 1,
MID = 2,
SENIOR = 3,
}
export default function OffersTable() {
const [currency, setCurrency] = useState('SGD');
const [selectedTab, setSelectedTab] = useState(YOE_CATEGORY.ENTRY);
const [selectedPage, setSelectedPage] = useState(1);
function renderTabs() {
return (
<div className="flex justify-center">
<div className="w-fit">
<Tabs
label="Table Navigation"
tabs={[
{
label: 'Fresh Grad (0-3 YOE)',
value: YOE_CATEGORY.ENTRY,
},
{
label: 'Mid (4-7 YOE)',
value: YOE_CATEGORY.MID,
},
{
label: 'Senior (8+ YOE)',
value: YOE_CATEGORY.SENIOR,
},
{
label: 'Internship',
value: YOE_CATEGORY.INTERN,
},
]}
value={selectedTab}
onChange={(value) => setSelectedTab(value)}
/>
</div>
</div>
);
}
function renderFilters() {
return (
<div className="m-4 flex items-center justify-between">
<div className="justify-left flex items-center space-x-2">
<span>All offers in</span>
<CurrencySelector
handleCurrencyChange={(value: string) => setCurrency(value)}
selectedCurrency={currency}
/>
</div>
<Select
disabled={true}
isLabelHidden={true}
label=""
options={[
{
label: 'Latest Submitted',
value: 'latest-submitted',
},
]}
value="latest-submitted"
/>
</div>
);
}
function renderHeader() {
return (
<thead className="bg-gray-50 text-xs uppercase text-gray-700 dark:bg-gray-700 dark:text-gray-400">
<tr>
{[
'Company',
'Title',
'YOE',
'TC/year',
'Date offered',
'Actions',
].map((header) => (
<th key={header} className="py-3 px-6" scope="col">
{header}
</th>
))}
</tr>
</thead>
);
}
function renderRow({ company, title, yoe, salary, date }: TableRow) {
return (
<tr className="border-b bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-600">
<th
className="whitespace-nowrap py-4 px-6 font-medium text-gray-900 dark:text-white"
scope="row">
{company}
</th>
<td className="py-4 px-6">{title}</td>
<td className="py-4 px-6">{yoe}</td>
<td className="py-4 px-6">{salary}</td>
<td className="py-4 px-6">{date}</td>
<td className="space-x-4 py-4 px-6">
<a
className="font-medium text-indigo-600 hover:underline dark:text-indigo-500"
href="#">
View Profile
</a>
<a
className="font-medium text-indigo-600 hover:underline dark:text-indigo-500"
href="#">
Comment
</a>
</td>
</tr>
);
}
function renderPagination() {
return (
<nav
aria-label="Table navigation"
className="flex items-center justify-between p-4">
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
Showing{' '}
<span className="font-semibold text-gray-900 dark:text-white">
1-10
</span>{' '}
of{' '}
<span className="font-semibold text-gray-900 dark:text-white">
1000
</span>
</span>
<Pagination
current={selectedPage}
end={10}
label="Pagination"
pagePadding={1}
start={1}
onSelect={(page) => setSelectedPage(page)}
/>
</nav>
);
}
return (
<div className="w-5/6">
{renderTabs()}
<HorizontalDivider />
<div className="relative w-full overflow-x-auto shadow-md sm:rounded-lg">
{renderFilters()}
<table className="w-full text-left text-sm text-gray-500 dark:text-gray-400">
{renderHeader()}
<tbody>
{renderRow({
company: 'Shopee',
date: 'May 2022',
salary: 'TC/yr',
title: 'SWE',
yoe: '5',
})}
{renderRow({
company: 'Shopee',
date: 'May 2022',
salary: 'TC/yr',
title: 'SWE',
yoe: '5',
})}
</tbody>
</table>
{renderPagination()}
</div>
</div>
);
}

@ -1,3 +1,18 @@
export default function OffersTitle() {
return <h1 className="text-center text-4xl font-bold">Offers Research</h1>;
return (
<>
<div className="flex items-end justify-center">
<h1 className="mt-16 text-center text-4xl font-bold text-indigo-600">
Tech Handbook Offers Repo
</h1>
</div>
<div className="mt-2 text-center text-2xl font-normal text-indigo-500">
Reveal profile stories behind offers
</div>
<div className="items-top flex justify-center text-xl font-normal">
Benchmark your offers and profiles, learn from other's offer profile,
and discuss with the community
</div>
</>
);
}

@ -0,0 +1,136 @@
import { EducationBackgroundType } from './types';
const emptyOption = {
label: '----',
value: '',
};
// TODO: use enums
export const titleOptions = [
emptyOption,
{
label: 'Software engineer',
value: 'Software engineer',
},
{
label: 'Frontend engineer',
value: 'Frontend engineer',
},
{
label: 'Backend engineer',
value: 'Backend engineer',
},
{
label: 'Full-stack engineer',
value: 'Full-stack engineer',
},
];
export const companyOptions = [
emptyOption,
{
label: 'Bytedance',
value: 'id-abc123',
},
{
label: 'Google',
value: 'id-abc567',
},
{
label: 'Meta',
value: 'id-abc456',
},
{
label: 'Shopee',
value: 'id-abc345',
},
{
label: 'Tik Tok',
value: 'id-abc678',
},
];
export const locationOptions = [
emptyOption,
{
label: 'Singapore, Singapore',
value: 'Singapore, Singapore',
},
{
label: 'New York, US',
value: 'New York, US',
},
{
label: 'San Francisco, US',
value: 'San Francisco, US',
},
];
export const internshipCycleOptions = [
emptyOption,
{
label: 'Summer',
value: 'Summer',
},
{
label: 'Winter',
value: 'Winter',
},
{
label: 'Spring',
value: 'Spring',
},
{
label: 'Fall',
value: 'Fall',
},
{
label: 'Full year',
value: 'Full year',
},
];
export const yearOptions = [
emptyOption,
{
label: '2021',
value: '2021',
},
{
label: '2022',
value: '2022',
},
{
label: '2023',
value: '2023',
},
{
label: '2024',
value: '2024',
},
];
const educationBackgroundTypes = Object.entries(EducationBackgroundType).map(
([key, value]) => ({
label: key,
value,
}),
);
export const educationLevelOptions = [emptyOption, ...educationBackgroundTypes];
export const educationFieldOptions = [
emptyOption,
{
label: 'Computer Science',
value: 'Computer Science',
},
{
label: 'Information Security',
value: 'Information Security',
},
{
label: 'Business Analytics',
value: 'Business Analytics',
},
];

@ -0,0 +1,288 @@
import { useFormContext, useWatch } from 'react-hook-form';
import { Collapsible, RadioList } from '@tih/ui';
import FormRadioList from './FormRadioList';
import FormSelect from './FormSelect';
import FormTextInput from './FormTextInput';
import {
companyOptions,
educationFieldOptions,
educationLevelOptions,
locationOptions,
titleOptions,
} from '../constants';
import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../util/currency/CurrencyEnum';
function YoeSection() {
const { register } = useFormContext();
return (
<>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400">
Years of Experience (YOE)
</h6>
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-2 grid grid-cols-3 space-x-3">
<FormTextInput
label="Total YOE"
placeholder="0"
type="number"
{...register(`background.totalYoe`)}
/>
</div>
<div className="grid grid-cols-1 space-x-3">
<Collapsible label="Add specific YOEs by domain">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
label="Specific YOE 1"
type="number"
{...register(`background.specificYoes.0.yoe`)}
/>
<FormTextInput
label="Specific Domain 1"
placeholder="e.g. Frontend"
{...register(`background.specificYoes.0.domain`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
label="Specific YOE 2"
type="number"
{...register(`background.specificYoes.1.yoe`)}
/>
<FormTextInput
label="Specific Domain 2"
placeholder="e.g. Backend"
{...register(`background.specificYoes.1.domain`)}
/>
</div>
</Collapsible>
</div>
</div>
</>
);
}
function FullTimeJobFields() {
const { register } = useFormContext();
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Title"
options={titleOptions}
{...register(`background.experience.title`)}
/>
<FormSelect
display="block"
label="Company"
options={companyOptions}
{...register(`background.experience.companyId`)}
/>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`background.experience.totalCompensation.currency`)}
/>
}
endAddOnType="element"
label="Total Compensation (Annual)"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experience.totalCompensation.value`)}
/>
</div>
<Collapsible label="Add more details">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
label="Focus / Specialization"
placeholder="e.g. Front End"
{...register(`background.experience.specialization`)}
/>
<FormTextInput
label="Level"
placeholder="e.g. L4, Junior"
{...register(`background.experience.level`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Location"
options={locationOptions}
{...register(`background.experience.location`)}
/>
<FormTextInput
label="Duration (months)"
type="number"
{...register(`background.experience.durationInMonths`)}
/>
</div>
</Collapsible>
</>
);
}
function InternshipJobFields() {
const { register } = useFormContext();
return (
<>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Title"
options={titleOptions}
{...register(`background.experience.title`)}
/>
<FormSelect
display="block"
label="Company"
options={companyOptions}
{...register(`background.experience.company`)}
/>
</div>
<div className="mb-5 grid grid-cols-1 space-x-3">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`background.experience.monthlySalary.currency`)}
/>
}
endAddOnType="element"
label="Salary (Monthly)"
placeholder="0.00"
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`background.experience.monthlySalary.value`)}
/>
</div>
<Collapsible label="Add more details">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
label="Focus / Specialization"
placeholder="e.g. Front End"
{...register(`background.experience.specialization`)}
/>
<FormSelect
display="block"
label="Location"
options={locationOptions}
{...register(`background.experience.location`)}
/>
</div>
</Collapsible>
</>
);
}
function CurrentJobSection() {
const { register } = useFormContext();
const watchJobType = useWatch({
defaultValue: JobType.FullTime,
name: 'background.experience.jobType',
});
return (
<>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400">
Current / Previous Job
</h6>
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5">
<FormRadioList
defaultValue={JobType.FullTime}
isLabelHidden={true}
label="Job Type"
orientation="horizontal"
{...register('background.experience.jobType')}>
<RadioList.Item
key="Full-time"
label="Full-time"
value={JobType.FullTime}
/>
<RadioList.Item
key="Internship"
label="Internship"
value={JobType.Internship}
/>
</FormRadioList>
</div>
{watchJobType === JobType.FullTime ? (
<FullTimeJobFields />
) : (
<InternshipJobFields />
)}
</div>
</>
);
}
function EducationSection() {
const { register } = useFormContext();
return (
<>
<h6 className="mb-2 text-left text-xl font-medium text-gray-400">
Education
</h6>
<div className="mb-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Education Level"
options={educationLevelOptions}
{...register(`background.education.type`)}
/>
<FormSelect
display="block"
label="Field"
options={educationFieldOptions}
{...register(`background.education.field`)}
/>
</div>
<Collapsible label="Add more details">
<div className="mb-5">
<FormTextInput
label="School"
placeholder="e.g. National University of Singapore"
{...register(`background.experience.specialization`)}
/>
</div>
</Collapsible>
</div>
</>
);
}
export default function BackgroundForm() {
return (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Help us better gauge your offers
</h5>
<h6 className="mx-10 mb-8 text-center text-lg font-light text-gray-600">
This section is optional, but your background information helps us
benchmark your offers.
</h6>
<div>
<YoeSection />
<CurrentJobSection />
<EducationSection />
</div>
</div>
);
}

@ -0,0 +1,23 @@
import type { ComponentProps } from 'react';
import { forwardRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { RadioList } from '@tih/ui';
type RadioListProps = ComponentProps<typeof RadioList>;
type FormRadioListProps = Omit<RadioListProps, 'onChange'>;
function FormRadioListWithRef({ name, ...rest }: FormRadioListProps) {
const { setValue } = useFormContext();
return (
<RadioList
{...(rest as RadioListProps)}
name={name}
onChange={(val) => setValue(name || '', val)}
/>
);
}
const FormRadioList = forwardRef(FormRadioListWithRef);
export default FormRadioList;

@ -0,0 +1,26 @@
import type { ComponentProps, ForwardedRef } from 'react';
import { forwardRef } from 'react';
import { useFormContext } from 'react-hook-form';
import { Select } from '@tih/ui';
type SelectProps = ComponentProps<typeof Select>;
type FormSelectProps = Omit<SelectProps, 'onChange'>;
function FormSelectWithRef(
{ name, ...rest }: FormSelectProps,
ref?: ForwardedRef<HTMLSelectElement>,
) {
const { setValue } = useFormContext();
return (
<Select
{...(rest as SelectProps)}
ref={ref}
onChange={(val) => setValue(name || '', val)}
/>
);
}
const FormSelect = forwardRef(FormSelectWithRef);
export default FormSelect;

@ -0,0 +1,27 @@
import type { ComponentProps, ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';
import { TextArea } from '~/../../../packages/ui/dist';
type TextAreaProps = ComponentProps<typeof TextArea>;
type FormTextAreaProps = Omit<TextAreaProps, 'onChange'> &
Pick<UseFormRegisterReturn<never>, 'onChange'>;
function FormTextAreaWithRef(
{ onChange, ...rest }: FormTextAreaProps,
ref?: ForwardedRef<HTMLTextAreaElement>,
) {
return (
<TextArea
{...(rest as TextAreaProps)}
ref={ref}
onChange={(_, event) => onChange(event)}
/>
);
}
const FormTextArea = forwardRef(FormTextAreaWithRef);
export default FormTextArea;

@ -0,0 +1,26 @@
import type { ComponentProps, ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';
import { TextInput } from '@tih/ui';
type TextInputProps = ComponentProps<typeof TextInput>;
type FormTextInputProps = Omit<TextInputProps, 'onChange'> &
Pick<UseFormRegisterReturn<never>, 'onChange'>;
function FormTextInputWithRef(
{ onChange, ...rest }: FormTextInputProps,
ref?: ForwardedRef<HTMLInputElement>,
) {
return (
<TextInput
{...(rest as TextInputProps)}
ref={ref}
onChange={(_, event) => onChange(event)}
/>
);
}
const FormTextInput = forwardRef(FormTextInputWithRef);
export default FormTextInput;

@ -0,0 +1,102 @@
import { useState } from 'react';
import { UserCircleIcon } from '@heroicons/react/20/solid';
import { HorizontalDivider, Tabs } from '~/../../../packages/ui/dist';
const tabs = [
{
label: 'Overall',
value: 'overall',
},
{
label: 'Shopee',
value: 'company-id',
},
];
function OfferPercentileAnalysis() {
const result = {
company: 'Shopee',
numberOfOffers: 105,
percentile: 56,
};
return (
<p>
Your highest offer is from {result.company}, which is {result.percentile}{' '}
percentile out of {result.numberOfOffers} offers received in Singapore for
the same job type, same level, and same YOE in the last year.
</p>
);
}
function OfferProfileCard() {
return (
<div className="my-5 block rounded-lg border p-4">
<div className="grid grid-flow-col grid-cols-12 gap-x-10">
<div className="col-span-1">
<UserCircleIcon width={50} />
</div>
<div className="col-span-10">
<p className="text-sm font-semibold">profile-name</p>
<p className="text-xs ">Previous company: Meta, Singapore</p>
<p className="text-xs ">YOE: 4 years</p>
</div>
</div>
<HorizontalDivider />
<div className="grid grid-flow-col grid-cols-2 gap-x-10">
<div className="col-span-1 row-span-3">
<p className="text-sm font-semibold">Software engineer</p>
<p className="text-xs ">Company: Google, Singapore</p>
<p className="text-xs ">Level: G4</p>
</div>
<div className="col-span-1 row-span-3">
<p className="text-end text-sm">Sept 2022</p>
<p className="text-end text-xl">$125,000 / year</p>
</div>
</div>
</div>
);
}
function TopOfferProfileList() {
return (
<>
<OfferProfileCard />
<OfferProfileCard />
</>
);
}
function OfferAnalysisContent() {
return (
<>
<OfferPercentileAnalysis />
<TopOfferProfileList />
</>
);
}
export default function OfferAnalysis() {
const [tab, setTab] = useState('Overall');
return (
<div>
<h5 className="mb-2 text-center text-4xl font-bold text-gray-900">
Result
</h5>
<div className="mx-40">
<Tabs
label="Result Navigation"
tabs={tabs}
value={tab}
onChange={setTab}
/>
<HorizontalDivider className="mb-5" />
<OfferAnalysisContent />
</div>
</div>
);
}

@ -0,0 +1,412 @@
import { useState } from 'react';
import type {
FieldValues,
UseFieldArrayRemove,
UseFieldArrayReturn,
} from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useFieldArray } from 'react-hook-form';
import { PlusIcon } from '@heroicons/react/20/solid';
import { TrashIcon } from '@heroicons/react/24/outline';
import { Button } from '@tih/ui';
import FormSelect from './FormSelect';
import FormTextArea from './FormTextArea';
import FormTextInput from './FormTextInput';
import {
companyOptions,
internshipCycleOptions,
locationOptions,
titleOptions,
yearOptions,
} from '../constants';
import type { FullTimeOfferFormData, InternshipOfferFormData } from '../types';
import { JobType } from '../types';
import { CURRENCY_OPTIONS } from '../util/currency/CurrencyEnum';
type FullTimeOfferDetailsFormProps = Readonly<{
index: number;
remove: UseFieldArrayRemove;
}>;
function FullTimeOfferDetailsForm({
index,
remove,
}: FullTimeOfferDetailsFormProps) {
const { register } = useFormContext<{
offers: Array<FullTimeOfferFormData>;
}>();
return (
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Title"
options={titleOptions}
required={true}
{...register(`offers.${index}.job.title`, {
required: true,
})}
/>
<FormTextInput
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`, {
required: true,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Company"
options={companyOptions}
required={true}
{...register(`offers.${index}.companyId`, { required: true })}
/>
<FormTextInput
label="Level"
placeholder="e.g. L4, Junior"
required={true}
{...register(`offers.${index}.job.level`, { required: true })}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Location"
options={locationOptions}
required={true}
{...register(`offers.${index}.location`, { required: true })}
/>
<FormTextInput
label="Month Received"
placeholder="MMM/YYYY"
required={true}
{...register(`offers.${index}.monthYearReceived`, { required: true })}
/>
</div>
<div className="mb-5">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.totalCompensation.currency`, {
required: true,
})}
/>
}
endAddOnType="element"
label="Total Compensation (Annual)"
placeholder="0.00"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.totalCompensation.value`, {
required: true,
})}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.base.currency`)}
/>
}
endAddOnType="element"
label="Base Salary (Annual)"
placeholder="0.00"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.base.value`)}
/>
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.bonus.currency`)}
/>
}
endAddOnType="element"
label="Bonus (Annual)"
placeholder="0.00"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.bonus.value`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.stocks.currency`)}
/>
}
endAddOnType="element"
label="Stocks (Annual)"
placeholder="0.00"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.stocks.value`)}
/>
</div>
<div className="mb-5">
<FormTextArea
label="Negotiation Strategy / Interview Performance"
placeholder="e.g. Did well in the behavioral interview / Used competing offers to negotiate for a higher salary"
{...register(`offers.${index}.negotiationStrategy`)}
/>
</div>
<div className="mb-5">
<FormTextArea
label="Comments"
placeholder="e.g. Benefits offered by the company"
{...register(`offers.${index}.comments`)}
/>
</div>
<div className="flex justify-end">
{index > 0 && (
<Button
icon={TrashIcon}
label="Delete"
variant="secondary"
onClick={() => remove(index)}
/>
)}
</div>
</div>
);
}
type OfferDetailsFormArrayProps = Readonly<{
fieldArrayValues: UseFieldArrayReturn<FieldValues, 'offers', 'id'>;
jobType: JobType;
}>;
function OfferDetailsFormArray({
fieldArrayValues,
jobType,
}: OfferDetailsFormArrayProps) {
const { append, remove, fields } = fieldArrayValues;
return (
<div>
{fields.map((item, index) =>
jobType === JobType.FullTime ? (
<FullTimeOfferDetailsForm
key={`offer.${item.id}`}
index={index}
remove={remove}
/>
) : (
<InternshipOfferDetailsForm
key={`offer.${item.id}`}
index={index}
remove={remove}
/>
),
)}
<Button
display="block"
icon={PlusIcon}
label="Add another offer"
size="lg"
variant="tertiary"
onClick={() => append({})}
/>
</div>
);
}
type InternshipOfferDetailsFormProps = Readonly<{
index: number;
remove: UseFieldArrayRemove;
}>;
function InternshipOfferDetailsForm({
index,
remove,
}: InternshipOfferDetailsFormProps) {
const { register } = useFormContext<{
offers: Array<InternshipOfferFormData>;
}>();
return (
<div className="my-5 rounded-lg border border-gray-200 px-10 py-5">
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Title"
options={titleOptions}
required={true}
{...register(`offers.${index}.job.title`)}
/>
<FormTextInput
label="Focus / Specialization"
placeholder="e.g. Front End"
required={true}
{...register(`offers.${index}.job.specialization`)}
/>
</div>
<div className="mb-5 grid grid-cols-2 space-x-3">
<FormSelect
display="block"
label="Company"
options={companyOptions}
required={true}
value="Shopee"
{...register(`offers.${index}.companyId`)}
/>
<FormSelect
display="block"
label="Location"
options={locationOptions}
required={true}
value="Singapore, Singapore"
{...register(`offers.${index}.location`)}
/>
</div>
<div className="mb-5 grid grid-cols-3 space-x-3">
<FormTextInput
label="Date Received"
placeholder="MMM/YYYY"
required={true}
{...register(`offers.${index}.monthYearReceived`)}
/>
<FormSelect
display="block"
label="Internship Cycle"
options={internshipCycleOptions}
required={true}
value="Summer"
{...register(`offers.${index}.job.internshipCycle`)}
/>
<FormSelect
display="block"
label="Internship Year"
options={yearOptions}
required={true}
value="2023"
{...register(`offers.${index}.job.startYear`)}
/>
</div>
<div className="mb-5">
<FormTextInput
endAddOn={
<FormSelect
borderStyle="borderless"
isLabelHidden={true}
label="Currency"
options={CURRENCY_OPTIONS}
{...register(`offers.${index}.job.monthlySalary.currency`)}
/>
}
endAddOnType="element"
label="Salary (Monthly)"
placeholder="0.00"
required={true}
startAddOn="$"
startAddOnType="label"
type="number"
{...register(`offers.${index}.job.monthlySalary.value`)}
/>
</div>
<div className="mb-5">
<FormTextArea
label="Negotiation Strategy / Interview Performance"
placeholder="e.g. Did well in the behavioral interview. Used competing offers to negotiate for a higher salary."
{...register(`offers.${index}.negotiationStrategy`)}
/>
</div>
<div className="mb-5">
<FormTextArea
label="Comments"
placeholder="e.g. Encountered similar questions using the Technical Interview Handbook."
{...register(`offers.${index}.comments`)}
/>
</div>
<div className="flex justify-end">
{index > 0 && (
<Button
icon={TrashIcon}
label="Delete"
variant="secondary"
onClick={() => remove(index)}
/>
)}
</div>
</div>
);
}
export default function OfferDetailsForm() {
const [jobType, setJobType] = useState(JobType.FullTime);
const { control, register } = useFormContext();
const fieldArrayValues = useFieldArray({ control, name: 'offers' });
const changeJobType = (jobTypeChosen: JobType) => () => {
if (jobType === jobTypeChosen) {
return;
}
setJobType(jobTypeChosen);
fieldArrayValues.remove();
};
return (
<div className="mb-5">
<h5 className="mb-8 text-center text-4xl font-bold text-gray-900">
Fill in your offer details
</h5>
<div className="flex w-full justify-center">
<div className="mx-5 w-1/3">
<Button
display="block"
label="Full-time"
size="md"
variant={jobType === JobType.FullTime ? 'secondary' : 'tertiary'}
onClick={changeJobType(JobType.FullTime)}
{...register(`offers.${0}.jobType`)}
/>
</div>
<div className="mx-5 w-1/3">
<Button
display="block"
label="Internship"
size="md"
variant={jobType === JobType.Internship ? 'secondary' : 'tertiary'}
onClick={changeJobType(JobType.Internship)}
{...register(`offers.${0}.jobType`)}
/>
</div>
</div>
<OfferDetailsFormArray
fieldArrayValues={fieldArrayValues}
jobType={jobType}
/>
</div>
);
}

@ -0,0 +1,73 @@
import { useState } from 'react';
import { setTimeout } from 'timers';
import { CheckIcon, DocumentDuplicateIcon } from '@heroicons/react/20/solid';
import { BookmarkSquareIcon, EyeIcon } from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui';
export default function OfferProfileSave() {
const [linkCopied, setLinkCopied] = useState(false);
const [isSaving, setSaving] = useState(false);
const [isSaved, setSaved] = useState(false);
const saveProfile = () => {
setSaving(true);
setTimeout(() => {
setSaving(false);
setSaved(true);
}, 5);
};
return (
<div className="flex w-full justify-center">
<div className="max-w-2xl text-center">
<h5 className="mb-6 text-4xl font-bold text-gray-900">
Save for future edits
</h5>
<p className="mb-2 text-gray-900">We value your privacy.</p>
<p className="mb-5 text-gray-900">
To keep you offer profile strictly anonymous, only people who have the
link below can edit it.
</p>
<div className="mb-20 grid grid-cols-12 gap-4">
<div className="col-span-11">
<TextInput
disabled={true}
isLabelHidden={true}
label="Edit link"
value="link.myprofile-auto-generate..."
/>
</div>
<Button
icon={DocumentDuplicateIcon}
isLabelHidden={true}
label="Copy"
variant="primary"
onClick={() => setLinkCopied(true)}
/>
</div>
<div className="mb-5">
{linkCopied && (
<p className="text-purple-700">Link copied to clipboard!</p>
)}
</div>
<p className="mb-5 text-gray-900">
If you do not want to keep the edit link, you can opt to save this
profile under your user accont. It will still only be editable by you.
</p>
<div className="mb-20">
<Button
disabled={isSaved}
icon={isSaved ? CheckIcon : BookmarkSquareIcon}
isLoading={isSaving}
label="Save to user profile"
variant="primary"
onClick={saveProfile}
/>
</div>
<div className="mb-10">
<Button icon={EyeIcon} label="View your profile" variant="special" />
</div>
</div>
</div>
);
}

@ -0,0 +1,50 @@
import {
BuildingLibraryIcon,
LightBulbIcon,
} from '@heroicons/react/24/outline';
import type { EducationBackgroundType } from '../types';
type EducationEntity = {
backgroundType?: EducationBackgroundType;
field?: string;
fromMonth?: string;
school?: string;
toMonth?: string;
};
type Props = Readonly<{
education: EducationEntity;
}>;
export default function EducationCard({
education: { backgroundType, field, fromMonth, school, toMonth },
}: Props) {
return (
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md">
<div className="flex justify-between px-8">
<div className="flex flex-col">
<div className="flex flex-row">
<LightBulbIcon className="mr-1 h-5" />
<span className="ml-1 font-bold">
{field
? `${backgroundType ?? 'N/A'}, ${field}`
: backgroundType ?? `N/A`}
</span>
</div>
{school && (
<div className="flex flex-row">
<BuildingLibraryIcon className="mr-1 h-5" />
<span className="ml-1">{school}</span>
</div>
)}
</div>
{(fromMonth || toMonth) && (
<div className="font-light text-gray-400">
<p>{`${fromMonth ?? 'N/A'} - ${toMonth ?? 'N/A'}`}</p>
</div>
)}
</div>
</div>
);
}

@ -0,0 +1,124 @@
import {
BuildingOffice2Icon,
ChatBubbleBottomCenterTextIcon,
CurrencyDollarIcon,
ScaleIcon,
} from '@heroicons/react/24/outline';
import { HorizontalDivider } from '@tih/ui';
type OfferEntity = {
base?: string;
bonus?: string;
companyName: string;
duration?: string; // For background
jobLevel?: string;
jobTitle: string;
location: string;
monthlySalary?: string;
negotiationStrategy?: string;
otherComment?: string;
receivedMonth: string;
stocks?: string;
totalCompensation?: string;
};
type Props = Readonly<{
offer: OfferEntity;
}>;
export default function OfferCard({
offer: {
companyName = 'Meta',
jobTitle = 'Senior Engineer',
jobLevel,
location = 'Singapore',
receivedMonth = 'Jun 2021',
totalCompensation = '350.1k',
base = '0k',
stocks = '0k',
bonus = '0k',
duration,
monthlySalary,
negotiationStrategy,
otherComment,
},
}: Props) {
function UpperSection() {
return (
<div className="flex justify-between px-8">
<div className="flex flex-col">
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-1 h-5" />
<span className="font-bold">
{location ? `${companyName}, ${location}` : companyName}
</span>
</div>
<div className="ml-6 flex flex-row">
<p>{jobLevel ? `${jobTitle}, ${jobLevel}` : jobTitle}</p>
</div>
</div>
{receivedMonth && (
<div className="font-light text-gray-400">
<p>{receivedMonth}</p>
</div>
)}
{duration && (
<div className="font-light text-gray-400">
<p>{duration}</p>
</div>
)}
</div>
);
}
function BottomSection() {
return (
<div className="px-8">
<div className="flex flex-col py-2">
<div className="flex flex-row">
<CurrencyDollarIcon className="mr-1 h-5" />
<p>
{totalCompensation
? `TC: ${totalCompensation}`
: `Monthly Salary: ${monthlySalary}`}
</p>
</div>
{totalCompensation && (
<div className="ml-6 flex flex-row font-light text-gray-400">
<p>
Base / year: {base} Stocks / year: {stocks} Bonus / year:{' '}
{bonus}
</p>
</div>
)}
</div>
{negotiationStrategy && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<ScaleIcon className="h-5 w-5" />
<span className="overflow-wrap ml-2">
"{negotiationStrategy}"
</span>
</div>
</div>
)}
{otherComment && (
<div className="flex flex-col py-2">
<div className="flex flex-row">
<ChatBubbleBottomCenterTextIcon className="h-8 w-8" />
<span className="overflow-wrap ml-2">"{otherComment}"</span>
</div>
</div>
)}
</div>
);
}
return (
<div className="mx-8 my-4 block rounded-lg bg-white py-4 shadow-md">
<UpperSection />
<HorizontalDivider />
<BottomSection />
</div>
);
}

@ -0,0 +1,12 @@
export default function ProfilePhotoHolder() {
return (
<span className="inline-block 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>
);
}

@ -0,0 +1,110 @@
/* eslint-disable no-shadow */
/*
* Offer Profile
*/
export enum JobType {
FullTime = 'FULLTIME',
Internship = 'INTERNSHIP',
}
export enum EducationBackgroundType {
Bachelor = 'Bachelor',
Diploma = 'Diploma',
Masters = 'Masters',
PhD = 'PhD',
Professional = 'Professional',
Seconday = 'Secondary',
SelfTaught = 'Self-taught',
}
type Money = {
currency: string;
value: number;
};
type FullTimeJobData = {
base: Money;
bonus: Money;
level: string;
specialization: string;
stocks: Money;
title: string;
totalCompensation: Money;
};
export type FullTimeOfferFormData = {
comments: string;
companyId: string;
job: FullTimeJobData;
jobType: string;
location: string;
monthYearReceived: string;
negotiationStrategy: string;
};
type InternshipJobData = {
internshipCycle: string;
monthlySalary: Money;
specialization: string;
startYear: number;
title: string;
};
export type InternshipOfferFormData = {
comments: string;
companyId: string;
job: InternshipJobData;
jobType: string;
location: string;
monthYearReceived: string;
negotiationStrategy: string;
};
type OfferDetailsFormData = FullTimeOfferFormData | InternshipOfferFormData;
type SpecificYoe = {
domain: string;
yoe: number;
};
type FullTimeExperience = {
level: string;
totalCompensation: Money;
};
type InternshipExperience = {
monthlySalary: Money;
};
type GeneralExperience = {
companyId: string;
durationInMonths: number;
jobType: string;
specialization: string;
title: string;
};
type Experience =
| (FullTimeExperience & GeneralExperience)
| (GeneralExperience & InternshipExperience);
type Education = {
endDate: Date;
field: string;
school: string;
startDate: Date;
type: string;
};
type BackgroundFormData = {
education: Education;
experience: Experience;
specificYoes: Array<SpecificYoe>;
totalYoe: number;
};
export type SubmitOfferFormData = {
background: BackgroundFormData;
offers: Array<OfferDetailsFormData>;
};

@ -0,0 +1,172 @@
// 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 const CURRENCY_OPTIONS = Object.entries(Currency).map(
([key, value]) => ({
label: key,
value,
}),
);

@ -0,0 +1,30 @@
import { Select } from '@tih/ui';
import { Currency } from '~/components/offers/util/currency/CurrencyEnum';
const currencyOptions = Object.entries(Currency).map(([key, value]) => ({
label: key,
value,
}));
type Props = Readonly<{
handleCurrencyChange: (currency: string) => void;
selectedCurrency: string;
}>;
export default function CurrencySelector({
selectedCurrency,
handleCurrencyChange,
}: Props) {
return (
<Select
display="inline"
isLabelHidden={true}
label="Select fruit"
name=""
options={currencyOptions}
value={selectedCurrency}
onChange={(currency: string) => handleCurrencyChange(currency)}
/>
);
}

@ -0,0 +1,7 @@
export function formatDate(value: Date | number | string) {
const date = new Date(value);
// Const day = date.toLocaleString('default', { day: '2-digit' });
const month = date.toLocaleString('default', { month: 'short' });
const year = date.toLocaleString('default', { year: 'numeric' });
return `${month} ${year}`;
}

@ -0,0 +1,38 @@
import { format } from 'date-fns';
import VotingButtons from './VotingButtons';
export type CommentListItemProps = {
authorImageUrl: string;
authorName: string;
content: string;
createdAt: Date;
upvoteCount: number;
};
export default function CommentListItem({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: CommentListItemProps) {
return (
<div className="flex gap-4 border bg-white p-2 ">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
</div>
</div>
);
}

@ -1,102 +1,83 @@
import type { ComponentProps, ForwardedRef } from 'react';
import { useState } from 'react';
import { forwardRef } from 'react';
import type { UseFormRegisterReturn } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import { Button, TextInput } from '@tih/ui';
import { TextInput } from '@tih/ui';
import ContributeQuestionModal from './ContributeQuestionModal';
import ContributeQuestionDialog from './ContributeQuestionDialog';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
export type ContributeQuestionData = {
company: string;
date: Date;
questionContent: string;
questionType: string;
};
type TextInputProps = ComponentProps<typeof TextInput>;
type FormTextInputProps = Omit<TextInputProps, 'onChange'> &
Pick<UseFormRegisterReturn<never>, 'onChange'>;
function FormTextInputWithRef(
props: FormTextInputProps,
ref?: ForwardedRef<HTMLInputElement>,
) {
const { onChange, ...rest } = props;
return (
<TextInput
{...(rest as TextInputProps)}
ref={ref}
onChange={(_, event) => onChange(event)}
/>
);
}
const FormTextInput = forwardRef(FormTextInputWithRef);
export type ContributeQuestionCardProps = {
onSubmit: (data: ContributeQuestionData) => void;
};
export type ContributeQuestionCardProps = Pick<
ContributeQuestionFormProps,
'onSubmit'
>;
export default function ContributeQuestionCard({
onSubmit,
}: ContributeQuestionCardProps) {
const { register, handleSubmit } = useForm<ContributeQuestionData>();
const [isOpen, setOpen] = useState<boolean>(false);
const [showDraftDialog, setShowDraftDialog] = useState(false);
const handleDraftDialogCancel = () => {
setShowDraftDialog(false);
};
const handleOpenContribute = () => {
setShowDraftDialog(true);
};
return (
<>
<form
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 p-4"
onSubmit={handleSubmit(onSubmit)}>
<FormTextInput
<div>
<button
className="flex flex-col items-stretch justify-center gap-2 rounded-md border border-slate-300 bg-white p-4 text-left hover:bg-gray-100"
type="button"
onClick={handleOpenContribute}>
<TextInput
disabled={true}
isLabelHidden={true}
label="Question"
placeholder="Contribute a question"
{...register('questionContent')}
onChange={handleOpenContribute}
/>
<div className="flex items-end justify-center gap-x-2">
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Company"
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
{...register('company')}
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Question type"
startAddOn={QuestionMarkCircleIcon}
startAddOnType="icon"
{...register('questionType')}
onChange={handleOpenContribute}
/>
</div>
<div className="min-w-[150px] flex-1">
<FormTextInput
<TextInput
disabled={true}
label="Date"
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('date')}
onChange={handleOpenContribute}
/>
</div>
<Button
label="Contribute"
type="submit"
variant="primary"
onClick={() => setOpen(true)}
/>
<h1 className="bg-primary-600 hover:bg-primary-700 rounded-full px-3 py-2 text-white">
Contribute
</h1>
</div>
</form>
<ContributeQuestionModal
contributeState={isOpen}
setContributeState={setOpen}></ContributeQuestionModal>
</>
</button>
<ContributeQuestionDialog
show={showDraftDialog}
onCancel={handleDraftDialogCancel}
onSubmit={onSubmit}
/>
</div>
);
}

@ -0,0 +1,100 @@
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { HorizontalDivider } from '~/../../../packages/ui/dist';
import type { ContributeQuestionFormProps } from './ContributeQuestionForm';
import ContributeQuestionForm from './ContributeQuestionForm';
import DiscardDraftDialog from './DiscardDraftDialog';
export type ContributeQuestionDialogProps = Pick<
ContributeQuestionFormProps,
'onSubmit'
> & {
onCancel: () => void;
show: boolean;
};
export default function ContributeQuestionDialog({
show,
onSubmit,
onCancel,
}: ContributeQuestionDialogProps) {
const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const handleDraftDiscard = () => {
setShowDiscardDialog(false);
onCancel();
};
const handleDiscardCancel = () => {
setShowDiscardDialog(false);
};
return (
<div>
<Transition.Root as={Fragment} show={show}>
<Dialog
as="div"
className="relative z-10"
onClose={() => {
// Todo: save state
onCancel();
}}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative max-w-5xl transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full">
<div className="bg-white p-6 pt-5 sm:pb-4">
<div className="flex flex-1 items-stretch">
<div className="mt-3 w-full sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900">
Question Draft
</Dialog.Title>
<div className="w-full">
<HorizontalDivider />
</div>
<div className="mt-2">
<ContributeQuestionForm
onDiscard={() => setShowDiscardDialog(true)}
onSubmit={(data) => {
onSubmit(data);
onCancel();
}}
/>
</div>
</div>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
<DiscardDraftDialog
show={showDiscardDialog}
onCancel={handleDiscardCancel}
onDiscard={handleDraftDiscard}></DiscardDraftDialog>
</div>
);
}

@ -0,0 +1,155 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import {
BuildingOffice2Icon,
CalendarDaysIcon,
UserIcon,
} from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Collapsible, Select, TextArea, TextInput } from '@tih/ui';
import { QUESTION_TYPES } from '~/utils/questions/constants';
import {
useFormRegister,
useSelectRegister,
} from '~/utils/questions/useFormRegister';
import Checkbox from './ui-patch/Checkbox';
export type ContributeQuestionData = {
company: string;
date: Date;
location: string;
position: string;
questionContent: string;
questionType: QuestionsQuestionType;
role: string;
};
export type ContributeQuestionFormProps = {
onDiscard: () => void;
onSubmit: (data: ContributeQuestionData) => void;
};
export default function ContributeQuestionForm({
onSubmit,
onDiscard,
}: ContributeQuestionFormProps) {
const { register: formRegister, handleSubmit } =
useForm<ContributeQuestionData>();
const register = useFormRegister(formRegister);
const selectRegister = useSelectRegister(formRegister);
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return (
<form
className=" flex flex-1 flex-col items-stretch justify-center pb-[50px]"
onSubmit={handleSubmit(onSubmit)}>
<TextArea
label="Question Prompt"
placeholder="Contribute a question"
required={true}
rows={5}
{...register('questionContent')}
/>
<div className="mt-3 mb-1 flex flex-wrap items-end gap-2">
<div className="mr-2 min-w-[113px] max-w-[113px] flex-1">
<Select
defaultValue="coding"
label="Type"
options={QUESTION_TYPES}
required={true}
{...selectRegister('questionType')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Company"
required={true}
startAddOn={BuildingOffice2Icon}
startAddOnType="icon"
{...register('company')}
/>
</div>
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Date"
required={true}
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('date', {
valueAsDate: true,
})}
/>
</div>
</div>
<Collapsible defaultOpen={true} label="Additional info">
<div className="justify-left flex flex-wrap items-end gap-2">
<div className="min-w-[150px] max-w-[300px] flex-1">
<TextInput
label="Location"
required={true}
startAddOn={CalendarDaysIcon}
startAddOnType="icon"
{...register('location')}
/>
</div>
<div className="min-w-[150px] max-w-[200px] flex-1">
<TextInput
label="Role"
required={true}
startAddOn={UserIcon}
startAddOnType="icon"
{...register('role')}
/>
</div>
</div>
</Collapsible>
{/* <div className="w-full">
<HorizontalDivider />
</div>
<h1 className="mb-3">
Are these questions the same as yours? TODO:Change to list
</h1>
<div>
<SimilarQuestionCard
content="Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices"
location="Menlo Park, CA"
receivedCount={0}
role="Senior Engineering Manager"
timestamp="Today"
onSimilarQuestionClick={() => {
// eslint-disable-next-line no-console
console.log('hi!');
}}
/>
</div> */}
<div className="bg-primary-50 fixed bottom-0 left-0 w-full px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6">
<div className="mb-1 flex">
<Checkbox
checked={canSubmit}
label="I have checked that my question is new"
onChange={handleCheckSimilarQuestions}></Checkbox>
</div>
<div className=" flex gap-x-2">
<button
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button"
onClick={onDiscard}>
Discard
</button>
<Button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-gray-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!canSubmit}
label="Contribute"
type="submit"
variant="primary"></Button>
</div>
</div>
</form>
);
}

@ -1,96 +0,0 @@
import type { Dispatch, SetStateAction } from 'react';
import { Fragment, useState } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import Checkbox from './ui-patch/Checkbox';
export type ContributeQuestionModalProps = {
contributeState: boolean;
setContributeState: Dispatch<SetStateAction<boolean>>;
};
export default function ContributeQuestionModal({
contributeState,
setContributeState,
}: ContributeQuestionModalProps) {
const [canSubmit, setCanSubmit] = useState<boolean>(false);
const handleCheckSimilarQuestions = (checked: boolean) => {
setCanSubmit(checked);
};
return (
<Transition.Root as={Fragment} show={contributeState}>
<Dialog
as="div"
className="relative z-10"
onClose={() => setContributeState(false)}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<Dialog.Title
as="h3"
className="text-lg font-medium leading-6 text-gray-900">
Question Draft
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Question Contribution form
</p>
</div>
</div>
</div>
</div>
<div className="bg-primary-50 px-4 py-3 sm:flex sm:flex-row sm:justify-between sm:px-6">
<div className="mb-1 flex">
<Checkbox
checked={canSubmit}
label="I have checked that my question is new"
onChange={handleCheckSimilarQuestions}></Checkbox>
</div>
<div className=" flex gap-x-2">
<button
className="inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
type="button"
onClick={() => setContributeState(false)}>
Discard
</button>
<button
className="bg-primary-600 hover:bg-primary-700 focus:ring-primary-500 inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:bg-gray-400 sm:ml-3 sm:w-auto sm:text-sm"
disabled={!canSubmit}
type="button"
onClick={() => setContributeState(false)}>
Contribute
</button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

@ -0,0 +1,30 @@
import { Button, Dialog } from '@tih/ui';
export type DiscardDraftDialogProps = {
onCancel: () => void;
onDiscard: () => void;
show: boolean;
};
export default function DiscardDraftDialog({
show,
onCancel,
onDiscard,
}: DiscardDraftDialogProps) {
return (
<Dialog
isShown={show}
primaryButton={
<Button label="Discard" variant="primary" onClick={onDiscard} />
}
secondaryButton={
<Button label="Cancel" variant="tertiary" onClick={onCancel} />
}
title="Discard draft"
onClose={onCancel}>
<p>
Are you sure you want to discard the current draft? This action cannot
be undone.
</p>
</Dialog>
);
}

@ -0,0 +1,9 @@
import { Spinner } from '@tih/ui';
export default function FullScreenSpinner() {
return (
<div className="flex h-full w-full items-center justify-center">
<Spinner size="lg" />
</div>
);
}

@ -0,0 +1,110 @@
import { useState } from 'react';
import { ArrowSmallRightIcon } from '@heroicons/react/24/outline';
import type { QuestionsQuestionType } from '@prisma/client';
import { Button, Select } from '@tih/ui';
import {
COMPANIES,
LOCATIONS,
QUESTION_TYPES,
} from '~/utils/questions/constants';
export type LandingQueryData = {
company: string;
location: string;
questionType: QuestionsQuestionType;
};
export type LandingComponentProps = {
onLanded: (data: LandingQueryData) => void;
};
export default function LandingComponent({
onLanded: handleLandingQuery,
}: LandingComponentProps) {
const [landingQueryData, setLandingQueryData] = useState<LandingQueryData>({
company: 'Google',
location: 'Singapore',
questionType: 'CODING',
});
const handleChangeCompany = (company: string) => {
setLandingQueryData((prev) => ({ ...prev, company }));
};
const handleChangeLocation = (location: string) => {
setLandingQueryData((prev) => ({ ...prev, location }));
};
const handleChangeType = (questionType: QuestionsQuestionType) => {
setLandingQueryData((prev) => ({ ...prev, questionType }));
};
return (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto bg-white">
<div className="pb-4"></div>
<div className="flex flex-1 flex-col justify-center gap-3">
<div className="flex items-center justify-center">
<img alt="app logo" className=" h-20 w-20" src="/logo.svg"></img>
<h1 className="text-primary-600 p-4 text-center text-5xl font-bold">
Tech Interview Question Bank
</h1>
</div>
<p className="mx-auto max-w-lg p-6 text-center text-xl text-black sm:max-w-3xl">
Get to know the latest SWE interview questions asked by top companies
</p>
<div className="mx-auto flex max-w-lg items-baseline gap-3 p-4 text-center text-xl text-black sm:max-w-3xl">
<p>Find</p>
<div className=" space-x-2">
<Select
isLabelHidden={true}
label="Type"
options={QUESTION_TYPES}
value={landingQueryData.questionType}
onChange={(value) => {
handleChangeType(value.toUpperCase() as QuestionsQuestionType);
}}
/>
</div>
<p>questions from</p>
<Select
isLabelHidden={true}
label="Company"
options={COMPANIES}
value={landingQueryData.company}
onChange={handleChangeCompany}
/>
<p>in</p>
<Select
isLabelHidden={true}
label="Location"
options={LOCATIONS}
value={landingQueryData.location}
onChange={handleChangeLocation}
/>
<Button
addonPosition="end"
icon={ArrowSmallRightIcon}
label="Go"
size="md"
variant="primary"
onClick={() => handleLandingQuery(landingQueryData)}></Button>
</div>
<div className="flex justify-center p-4">
<iframe
height={30}
src="https://ghbtns.com/github-btn.html?user=yangshun&amp;repo=tech-interview-handbook&amp;type=star&amp;count=true&amp;size=large"
title="GitHub Stars"
width={160}
/>
</div>
<div>
<p className="py-20 text-center text-white ">
TODO questions Carousel
</p>
</div>
</div>
</main>
);
}

@ -1,72 +0,0 @@
import {
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
ChevronUpIcon,
EyeIcon,
} from '@heroicons/react/24/outline';
import { Badge, Button } from '@tih/ui';
export type QuestionOverviewCardProps = {
answerCount: number;
content: string;
location: string;
role: string;
similarCount: number;
timestamp: string;
upvoteCount: number;
};
export default function QuestionOverviewCard({
answerCount,
content,
similarCount,
upvoteCount,
timestamp,
role,
location,
}: QuestionOverviewCardProps) {
return (
<article className="flex gap-2 rounded-md border border-slate-300 p-4">
<div className="flex flex-col items-center">
<Button
icon={ChevronUpIcon}
isLabelHidden={true}
label="Upvote"
variant="tertiary"
/>
<p>{upvoteCount}</p>
<Button
icon={ChevronDownIcon}
isLabelHidden={true}
label="Downvote"
variant="tertiary"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-slate-500">
<Badge label="Technical" variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
<p className="line-clamp-2 text-ellipsis">{content}</p>
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
<Button
addonPosition="start"
icon={EyeIcon}
label={`${similarCount} received this`}
size="sm"
variant="tertiary"
/>
</div>
</div>
</article>
);
}

@ -30,10 +30,13 @@ export default function QuestionSearchBar<
startAddOnType="icon"
/>
</div>
<span className="pl-3 pr-1 pt-1 text-sm">Sort by:</span>
<span aria-hidden={true} className="pl-3 pr-1 pt-1 text-sm">
Sort by:
</span>
<Select
display="inline"
label=""
isLabelHidden={true}
label="Sort by"
options={sortOptions}
value={sortValue}
onChange={onSortChange}></Select>

@ -2,14 +2,14 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [
{ href: '/questions', name: 'Home' },
{ href: '#', name: 'My Lists' },
{ href: '#', name: 'My Questions' },
{ href: '#', name: 'History' },
{ href: '/questions', name: 'My Lists' },
{ href: '/questions', name: 'My Questions' },
{ href: '/questions', name: 'History' },
];
const config = {
navigation,
showGlobalNav: true,
showGlobalNav: false,
title: 'Questions Bank',
};

@ -0,0 +1,33 @@
import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline';
import type { ButtonSize } from '@tih/ui';
import { Button } from '@tih/ui';
export type VotingButtonsProps = {
size?: ButtonSize;
upvoteCount: number;
};
export default function VotingButtons({
upvoteCount,
size = 'md',
}: VotingButtonsProps) {
return (
<div className="flex flex-col items-center">
<Button
icon={ChevronUpIcon}
isLabelHidden={true}
label="Upvote"
size={size}
variant="tertiary"
/>
<p>{upvoteCount}</p>
<Button
icon={ChevronDownIcon}
isLabelHidden={true}
label="Downvote"
size={size}
variant="tertiary"
/>
</div>
);
}

@ -0,0 +1,48 @@
import { format } from 'date-fns';
import withHref from '~/utils/questions/withHref';
import VotingButtons from '../VotingButtons';
export type AnswerCardProps = {
authorImageUrl: string;
authorName: string;
commentCount: number;
content: string;
createdAt: Date;
upvoteCount: number;
};
function AnswerCardWithoutHref({
authorName,
authorImageUrl,
upvoteCount,
content,
createdAt,
commentCount,
}: AnswerCardProps) {
return (
<div className="flex gap-4 rounded-md border bg-white p-2 hover:bg-slate-50">
<VotingButtons size="sm" upvoteCount={upvoteCount} />
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
<p className="py-1 pl-3 text-sm font-light underline underline-offset-4">
{commentCount} comment(s)
</p>
</div>
</div>
);
}
const AnswerCard = withHref(AnswerCardWithoutHref);
export default AnswerCard;

@ -0,0 +1,38 @@
import { format } from 'date-fns';
import VotingButtons from '../VotingButtons';
export type FullAnswerCardProps = {
authorImageUrl: string;
authorName: string;
content: string;
createdAt: Date;
upvoteCount: number;
};
export default function FullAnswerCard({
authorImageUrl,
authorName,
content,
createdAt,
upvoteCount,
}: FullAnswerCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
<VotingButtons upvoteCount={upvoteCount}></VotingButtons>
<div className="mt-1 flex flex-col gap-1">
<div className="flex items-center gap-2">
<img
alt={`${authorName} profile picture`}
className="h-8 w-8 rounded-full"
src={authorImageUrl}></img>
<h1 className="font-bold">{authorName}</h1>
<p className="pt-1 text-xs font-extralight">
Posted on: {format(createdAt, 'Pp')}
</p>
</div>
<p className="pl-1 pt-1">{content}</p>
</div>
</article>
);
}

@ -0,0 +1,58 @@
import { Badge } from '@tih/ui';
import VotingButtons from '../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
export type FullQuestionCardProps = UpvoteProps & {
company: string;
content: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
type: string;
};
export default function FullQuestionCard({
company,
content,
showVoteButtons,
upvoteCount,
timestamp,
role,
location,
type,
}: FullQuestionCardProps) {
const altText = company + ' logo';
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4">
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<img alt={altText} src="https://logo.clearbit.com/google.com"></img>
<h2 className="ml-2 text-xl">{company}</h2>
</div>
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500">
<Badge label={type} variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
</div>
<div className="mx-2 mb-2">
<p>{content}</p>
</div>
</div>
</article>
);
}

@ -0,0 +1,112 @@
import {
ChatBubbleBottomCenterTextIcon,
// EyeIcon,
} from '@heroicons/react/24/outline';
import { Badge, Button } from '@tih/ui';
import VotingButtons from '../VotingButtons';
type UpvoteProps =
| {
showVoteButtons: true;
upvoteCount: number;
}
| {
showVoteButtons?: false;
upvoteCount?: never;
};
type StatisticsProps =
| {
answerCount: number;
showUserStatistics: true;
}
| {
answerCount?: never;
showUserStatistics?: false;
};
type ActionButtonProps =
| {
actionButtonLabel: string;
onActionButtonClick: () => void;
showActionButton: true;
}
| {
actionButtonLabel?: never;
onActionButtonClick?: never;
showActionButton?: false;
};
export type QuestionCardProps = ActionButtonProps &
StatisticsProps &
UpvoteProps & {
content: string;
href?: string;
location: string;
receivedCount: number;
role: string;
timestamp: string;
type: string;
};
export default function QuestionCard({
answerCount,
content,
// ReceivedCount,
type,
showVoteButtons,
showUserStatistics,
showActionButton,
actionButtonLabel,
onActionButtonClick,
upvoteCount,
timestamp,
role,
location,
}: QuestionCardProps) {
return (
<article className="flex gap-4 rounded-md border border-slate-300 bg-white p-4 hover:bg-slate-50">
{showVoteButtons && <VotingButtons upvoteCount={upvoteCount} />}
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between">
<div className="flex items-center gap-2 text-slate-500">
<Badge label={type} variant="primary" />
<p className="text-xs">
{timestamp} · {location} · {role}
</p>
</div>
{showActionButton && (
<Button
label={actionButtonLabel}
size="sm"
variant="tertiary"
onClick={onActionButtonClick}
/>
)}
</div>
<div className="ml-2">
<p className="line-clamp-2 text-ellipsis ">{content}</p>
</div>
{showUserStatistics && (
<div className="flex gap-2">
<Button
addonPosition="start"
icon={ChatBubbleBottomCenterTextIcon}
label={`${answerCount} answers`}
size="sm"
variant="tertiary"
/>
{/* <Button
addonPosition="start"
icon={EyeIcon}
label={`${receivedCount} received this`}
size="sm"
variant="tertiary"
/> */}
</div>
)}
</div>
</article>
);
}

@ -0,0 +1,31 @@
import withHref from '~/utils/questions/withHref';
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type QuestionOverviewCardProps = Omit<
QuestionCardProps & {
showActionButton: false;
showUserStatistics: true;
showVoteButtons: true;
},
| 'actionButtonLabel'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
>;
function QuestionOverviewCardWithoutHref(props: QuestionOverviewCardProps) {
return (
<QuestionCard
{...props}
showActionButton={false}
showUserStatistics={true}
showVoteButtons={true}
/>
);
}
const QuestionOverviewCard = withHref(QuestionOverviewCardWithoutHref);
export default QuestionOverviewCard;

@ -0,0 +1,31 @@
import type { QuestionCardProps } from './QuestionCard';
import QuestionCard from './QuestionCard';
export type SimilarQuestionCardProps = Omit<
QuestionCardProps & {
showActionButton: true;
showUserStatistics: false;
showVoteButtons: false;
},
| 'actionButtonLabel'
| 'answerCount'
| 'onActionButtonClick'
| 'showActionButton'
| 'showUserStatistics'
| 'showVoteButtons'
| 'upvoteCount'
> & {
onSimilarQuestionClick: () => void;
};
export default function SimilarQuestionCard(props: SimilarQuestionCardProps) {
const { onSimilarQuestionClick, ...rest } = props;
return (
<QuestionCard
{...rest}
actionButtonLabel="Yes, this is my question"
showActionButton={true}
onActionButtonClick={onSimilarQuestionClick}
/>
);
}

@ -2,35 +2,56 @@ import { MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { Collapsible, TextInput } from '@tih/ui';
import Checkbox from '../ui-patch/Checkbox';
import RadioGroup from '../ui-patch/RadioGroup';
export type FilterOptions = {
export type FilterOption<V extends string = string> = {
checked: boolean;
label: string;
value: string;
value: V;
};
export type FilterSectionProps = {
label: string;
onOptionChange: (optionValue: string, checked: boolean) => void;
options: Array<FilterOptions>;
} & (
export type FilterChoices<V extends string = string> = ReadonlyArray<
Omit<FilterOption<V>, 'checked'>
>;
type FilterSectionType<FilterOptions extends Array<FilterOption>> =
| {
searchPlaceholder: string;
showAll?: never;
isSingleSelect: true;
onOptionChange: (optionValue: FilterOptions[number]['value']) => void;
}
| {
searchPlaceholder?: never;
showAll: true;
}
);
isSingleSelect?: false;
onOptionChange: (
optionValue: FilterOptions[number]['value'],
checked: boolean,
) => void;
};
export default function FilterSection({
export type FilterSectionProps<FilterOptions extends Array<FilterOption>> =
FilterSectionType<FilterOptions> & {
label: string;
options: FilterOptions;
} & (
| {
searchPlaceholder: string;
showAll?: never;
}
| {
searchPlaceholder?: never;
showAll: true;
}
);
export default function FilterSection<
FilterOptions extends Array<FilterOption>,
>({
label,
options,
searchPlaceholder,
showAll,
onOptionChange,
}: FilterSectionProps) {
isSingleSelect,
}: FilterSectionProps<FilterOptions>) {
return (
<div className="mx-2">
<Collapsible defaultOpen={true} label={label}>
@ -44,17 +65,25 @@ export default function FilterSection({
startAddOnType="icon"
/>
)}
<div className="mx-1">
{options.map((option) => (
<Checkbox
key={option.value}
{...option}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
{isSingleSelect ? (
<RadioGroup
radioData={options}
onChange={(value) => {
onOptionChange(value);
}}></RadioGroup>
) : (
<div className="mx-1">
{options.map((option) => (
<Checkbox
key={option.value}
{...option}
onChange={(checked) => {
onOptionChange(option.value, checked);
}}
/>
))}
</div>
)}
</div>
</Collapsible>
</div>

@ -0,0 +1,36 @@
export type RadioProps = {
onChange: (value: string) => void;
radioData: Array<RadioData>;
};
export type RadioData = {
checked: boolean;
label: string;
value: string;
};
export default function RadioGroup({ radioData, onChange }: RadioProps) {
return (
<div className="mx-1 space-y-1">
{radioData.map((radio) => (
<div key={radio.value} className="flex items-center">
<input
checked={radio.checked}
className="text-primary-600 focus:ring-primary-500 h-4 w-4 border-gray-300"
type="radio"
value={radio.value}
onChange={(event) => {
const target = event.target as HTMLInputElement;
onChange(target.value);
}}
/>
<label
className="ml-3 min-w-0 flex-1 text-gray-700"
htmlFor={radio.value}>
{radio.label}
</label>
</div>
))}
</div>
);
}

@ -1,12 +1,9 @@
import axios from 'axios';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import type { PDFDocumentProxy } from 'react-pdf/node_modules/pdfjs-dist';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button, Spinner } from '@tih/ui';
import { RESUME_STORAGE_KEY } from '~/constants/file-storage-keys';
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`;
type Props = Readonly<{
@ -16,30 +13,16 @@ type Props = Readonly<{
export default function ResumePdf({ url }: Props) {
const [numPages, setNumPages] = useState(0);
const [pageNumber, setPageNumber] = useState(1);
const [file, setFile] = useState<File>();
const onPdfLoadSuccess = (pdf: PDFDocumentProxy) => {
setNumPages(pdf.numPages);
};
useEffect(() => {
async function fetchData() {
await axios
.get(`/api/file-storage?key=${RESUME_STORAGE_KEY}&url=${url}`, {
responseType: 'blob',
})
.then((res) => {
setFile(res.data);
});
}
fetchData();
}, [url]);
return (
<div>
<Document
className="flex h-[calc(100vh-17rem)] flex-row justify-center overflow-scroll"
file={file}
file={url}
loading={<Spinner display="block" label="" size="lg" />}
noData=""
onLoadSuccess={onPdfLoadSuccess}>

@ -2,16 +2,11 @@ import type { ProductNavigationItems } from '~/components/global/ProductNavigati
const navigation: ProductNavigationItems = [
{
children: [
{ href: '#', name: 'Technical Support' },
{ href: '#', name: 'Sales' },
{ href: '#', name: 'General' },
],
href: '#',
name: 'Inboxes',
children: [],
href: '/resumes',
name: 'Browse',
},
{ children: [], href: '#', name: 'Reporting' },
{ children: [], href: '#', name: 'Settings' },
{ children: [], href: '/resumes/submit', name: 'Submit for review' },
];
const config = {

@ -89,6 +89,7 @@ export default function CommentsForm({
<div className="mt-4 space-y-4">
<TextArea
{...(register('general'), {})}
disabled={reviewCreateMutation.isLoading}
label="General"
placeholder="General comments about the resume"
onChange={(value) => onValueChange('general', value)}
@ -96,6 +97,7 @@ export default function CommentsForm({
<TextArea
{...(register('education'), {})}
disabled={reviewCreateMutation.isLoading}
label="Education"
placeholder="Comments about the Education section"
onChange={(value) => onValueChange('education', value)}
@ -103,6 +105,7 @@ export default function CommentsForm({
<TextArea
{...(register('experience'), {})}
disabled={reviewCreateMutation.isLoading}
label="Experience"
placeholder="Comments about the Experience section"
onChange={(value) => onValueChange('experience', value)}
@ -110,6 +113,7 @@ export default function CommentsForm({
<TextArea
{...(register('projects'), {})}
disabled={reviewCreateMutation.isLoading}
label="Projects"
placeholder="Comments about the Projects section"
onChange={(value) => onValueChange('projects', value)}
@ -117,6 +121,7 @@ export default function CommentsForm({
<TextArea
{...(register('skills'), {})}
disabled={reviewCreateMutation.isLoading}
label="Skills"
placeholder="Comments about the Skills section"
onChange={(value) => onValueChange('skills', value)}
@ -125,6 +130,7 @@ export default function CommentsForm({
<div className="flex justify-end space-x-2 pt-4">
<Button
disabled={reviewCreateMutation.isLoading}
label="Cancel"
type="button"
variant="tertiary"
@ -132,7 +138,8 @@ export default function CommentsForm({
/>
<Button
disabled={!isDirty}
disabled={!isDirty || reviewCreateMutation.isLoading}
isLoading={reviewCreateMutation.isLoading}
label="Submit"
type="submit"
variant="primary"

@ -1,6 +1,6 @@
import { Select } from '@tih/ui';
type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type Month = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type MonthYear = Readonly<{
month: Month;

@ -2,6 +2,7 @@ import formidable from 'formidable';
import * as fs from 'fs';
import type { NextApiRequest, NextApiResponse } from 'next';
import { env } from '~/env/server.mjs';
import { supabase } from '~/utils/supabase';
export const config = {
@ -10,6 +11,8 @@ export const config = {
},
};
const BASE_FILE_URL = `${env.SUPABASE_URL}/storage/v1/object/public`;
export default async function handler(
req: NextApiRequest,
res: NextApiResponse,
@ -38,28 +41,12 @@ export default async function handler(
throw error;
}
return res.status(200).json({
url: filePath,
return res.status(201).json({
url: `${BASE_FILE_URL}/${key}/${filePath}`,
});
});
} catch (error: unknown) {
return Promise.reject(error);
}
}
if (req.method === 'GET') {
const { key, url } = req.query;
const { data, error } = await supabase.storage
.from(`public/${key as string}`)
.download(url as string);
if (error || data == null) {
throw error;
}
const arrayBuffer = await data.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
res.status(200).send(buffer);
}
}

@ -3,14 +3,14 @@ import type { TypeaheadOption } from '@tih/ui';
import { HorizontalDivider } from '@tih/ui';
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
import type { MonthYear } from '~/components/shared/MonthYearPicker';
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,
month: (new Date().getMonth() + 1) as Month,
year: new Date().getFullYear(),
});

@ -1,10 +1,74 @@
import { useState } from 'react';
import { Select } from '@tih/ui';
import OffersTable from '~/components/offers/OffersTable';
import OffersTitle from '~/components/offers/OffersTitle';
export default function OffersHomePage() {
const [jobTitleFilter, setjobTitleFilter] = useState('Software engineers');
const [companyFilter, setCompanyFilter] = useState('All companies');
return (
<main className="flex-1 overflow-y-auto">
<div className="flex h-full items-center justify-center">
<div className="grid-rows grid h-1/2 bg-gray-100">
<OffersTitle />
<div className="flex items-start justify-center">
<div className="mt-4 flex items-center">
Viewing offers for
<div className="mx-4">
<Select
isLabelHidden={true}
label="Select a job title"
options={[
{
label: 'Software engineers',
value: 'Software engineers',
},
{
label: 'Frontend engineers',
value: 'Frontend engineers',
},
{
label: 'Backend engineers',
value: 'Backend engineers',
},
{
label: 'Full-stack engineers',
value: 'Full-stack engineers',
},
]}
value={jobTitleFilter}
onChange={setjobTitleFilter}
/>
</div>
in
<div className="ml-4">
<Select
isLabelHidden={true}
label="Select a company"
options={[
{
label: 'All companies',
value: 'All companies',
},
{
label: 'Shopee',
value: 'Shopee',
},
{
label: 'Meta',
value: 'Meta',
},
]}
value={companyFilter}
onChange={setCompanyFilter}
/>
</div>
</div>
</div>
</div>
<div className="flex justify-center bg-white pb-20 pt-10">
<OffersTable />
</div>
</main>
);

@ -0,0 +1,256 @@
import { useState } from 'react';
import {
AcademicCapIcon,
BookmarkSquareIcon,
BriefcaseIcon,
BuildingOffice2Icon,
CalendarDaysIcon,
ClipboardDocumentIcon,
PencilSquareIcon,
ShareIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { Button, Dialog, Tabs } from '@tih/ui';
import EducationCard from '~/components/offers/profile/EducationCard';
import OfferCard from '~/components/offers/profile/OfferCard';
import ProfilePhotoHolder from '~/components/offers/profile/ProfilePhotoHolder';
import { EducationBackgroundType } from '~/components/offers/types';
export default function OfferProfile() {
const [selectedTab, setSelectedTab] = useState('offers');
const [isDialogOpen, setIsDialogOpen] = useState(false);
function renderActionList() {
return (
<div className="space-x-2">
<Button
icon={BookmarkSquareIcon}
isLabelHidden={true}
label="Save to user account"
size="md"
variant="tertiary"
/>
<Button
icon={PencilSquareIcon}
isLabelHidden={true}
label="Edit"
size="md"
variant="tertiary"
/>
<Button
icon={TrashIcon}
isLabelHidden={true}
label="Delete"
size="md"
variant="tertiary"
onClick={() => setIsDialogOpen(true)}
/>
{isDialogOpen && (
<Dialog
isShown={isDialogOpen}
primaryButton={
<Button
display="block"
label="Delete"
variant="primary"
onClick={() => setIsDialogOpen(false)}
/>
}
secondaryButton={
<Button
display="block"
label="Cancel"
variant="tertiary"
onClick={() => setIsDialogOpen(false)}
/>
}
title="Are you sure you want to delete this offer profile?"
onClose={() => setIsDialogOpen(false)}>
<div>
All comments will gone. You will not be able to access or recover
it.
</div>
</Dialog>
)}
</div>
);
}
function ProfileHeader() {
return (
<div className="relative h-40 bg-white p-4">
<div className="justify-left flex h-1/2">
<div className="mx-4 mt-2">
<ProfilePhotoHolder />
</div>
<div className="w-full">
<div className="justify-left flex ">
<h2 className="flex w-4/5 text-2xl font-bold">anonymised-name</h2>
<div className="flex h-8 w-1/5 justify-end">
{renderActionList()}
</div>
</div>
<div className="flex flex-row">
<BuildingOffice2Icon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">Current:</span>
<span>Level 4 Google</span>
</div>
<div className="flex flex-row">
<CalendarDaysIcon className="mr-2.5 h-5" />
<span className="mr-2 font-bold">YOE:</span>
<span>4</span>
</div>
</div>
</div>
<div className="absolute left-8 bottom-1 content-center">
<Tabs
label="Profile Detail Navigation"
tabs={[
{
label: 'Offers',
value: 'offers',
},
{
label: 'Background',
value: 'background',
},
{
label: 'Offer Engine Analysis',
value: 'offerEngineAnalysis',
},
]}
value={selectedTab}
onChange={(value) => setSelectedTab(value)}
/>
</div>
</div>
);
}
function ProfileDetails() {
if (selectedTab === 'offers') {
return (
<>
{[
{
base: undefined,
bonus: undefined,
companyName: 'Meta',
id: 1,
jobLevel: 'G5',
jobTitle: 'Software Engineer',
location: 'Singapore',
monthlySalary: undefined,
negotiationStrategy:
'Nostrud nulla aliqua deserunt commodo id aute.',
otherComment:
'Pariatur ut est voluptate incididunt consequat do veniam quis irure adipisicing. Deserunt laborum dolor quis voluptate enim.',
receivedMonth: 'Jun 2022',
stocks: undefined,
totalCompensation: undefined,
},
{
companyName: 'Meta',
id: 2,
jobLevel: 'G5',
jobTitle: 'Software Engineer',
location: 'Singapore',
receivedMonth: 'Jun 2022',
},
{
companyName: 'Meta',
id: 3,
jobLevel: 'G5',
jobTitle: 'Software Engineer',
location: 'Singapore',
receivedMonth: 'Jun 2022',
},
].map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</>
);
}
if (selectedTab === 'background') {
return (
<>
<div className="mx-8 my-4 flex flex-row">
<BriefcaseIcon className="mr-1 h-5" />
<span className="font-bold">Work Experience</span>
</div>
<OfferCard
offer={{
base: undefined,
bonus: undefined,
companyName: 'Prefer not to say',
jobLevel: 'G4',
jobTitle: 'N/A',
location: '',
monthlySalary: '1,400k',
receivedMonth: '',
stocks: undefined,
totalCompensation: undefined,
}}
/>
<div className="mx-8 my-4 flex flex-row">
<AcademicCapIcon className="mr-1 h-5" />
<span className="font-bold">Education</span>
</div>
<EducationCard
education={{
backgroundType: EducationBackgroundType.Bachelor,
field: 'CS',
fromMonth: 'Aug 2019',
school: 'NUS',
toMonth: 'May 2021',
}}
/>
</>
);
}
return <div>Detail page for {selectedTab}</div>;
}
function ProfileComments() {
return (
<div className="m-4">
<div className="flex-end flex justify-end space-x-4">
<Button
addonPosition="start"
icon={ClipboardDocumentIcon}
isLabelHidden={false}
label="Copy profile edit link"
size="sm"
variant="secondary"
/>
<Button
addonPosition="start"
icon={ShareIcon}
isLabelHidden={false}
label="Copy public link"
size="sm"
variant="secondary"
/>
</div>
<h2 className="mt-2 text-2xl font-bold">
Discussions feature coming soon
</h2>
{/* <TextArea label="Comment" placeholder="Type your comment here" /> */}
</div>
);
}
return (
<div className="mb-4 flex flex h-screen w-screen items-center justify-center divide-x">
<div className="h-full w-2/3 divide-y">
<ProfileHeader />
<div className="h-4/5 w-full overflow-y-scroll pb-32">
<ProfileDetails />
</div>
</div>
<div className="h-full w-1/3 bg-white">
<ProfileComments />
</div>
</div>
);
}

@ -0,0 +1,111 @@
import { useState } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { FormProvider, useForm } from 'react-hook-form';
import { ArrowLeftIcon, ArrowRightIcon } from '@heroicons/react/20/solid';
import { Button } from '@tih/ui';
import BackgroundForm from '~/components/offers/forms/BackgroundForm';
import OfferAnalysis from '~/components/offers/forms/OfferAnalysis';
import OfferDetailsForm from '~/components/offers/forms/OfferDetailsForm';
import OfferProfileSave from '~/components/offers/forms/OfferProfileSave';
import type { SubmitOfferFormData } from '~/components/offers/types';
function Breadcrumbs() {
return (
<p className="mb-4 text-right text-sm text-gray-400">
{'Offer details > Background > Analysis > Save'}
</p>
);
}
const defaultOfferValues = {
offers: [
{
comments: '',
companyId: '',
job: {
base: {
currency: 'USD',
value: 0,
},
bonus: {
currency: 'USD',
value: 0,
},
level: '',
specialization: '',
stocks: {
currency: 'USD',
value: 0,
},
title: '',
totalCompensation: {
currency: 'USD',
value: 0,
},
},
jobType: 'FULLTIME',
location: '',
monthYearReceived: '',
negotiationStrategy: '',
},
],
};
export default function OffersSubmissionPage() {
const [formStep, setFormStep] = useState(0);
const formMethods = useForm<SubmitOfferFormData>({
defaultValues: defaultOfferValues,
});
const nextStep = () => setFormStep(formStep + 1);
const previousStep = () => setFormStep(formStep - 1);
const formComponents = [
<OfferDetailsForm key={0} />,
<BackgroundForm key={1} />,
<OfferAnalysis key={2} />,
<OfferProfileSave key={3} />,
];
const onSubmit: SubmitHandler<SubmitOfferFormData> = async () => {
nextStep();
};
return (
<div className="fixed h-full w-full overflow-y-scroll">
<div className="mb-20 flex justify-center">
<div className="my-5 block w-full max-w-screen-md rounded-lg bg-white py-10 px-10 shadow-lg">
<Breadcrumbs />
<FormProvider {...formMethods}>
<form onSubmit={formMethods.handleSubmit(onSubmit)}>
{formComponents[formStep]}
{/* <pre>{JSON.stringify(formMethods.watch(), null, 2)}</pre> */}
{(formStep === 0 || formStep === 2) && (
<div className="flex justify-end">
<Button
icon={ArrowRightIcon}
label="Next"
variant="secondary"
onClick={nextStep}
/>
</div>
)}
{formStep === 1 && (
<div className="flex items-center justify-between">
<Button
icon={ArrowLeftIcon}
label="Previous"
variant="secondary"
onClick={previousStep}
/>
<Button label="Submit" type="submit" variant="primary" />{' '}
</div>
)}
</form>
</FormProvider>
</div>
</div>
</div>
);
}

@ -0,0 +1,159 @@
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Select, TextArea } from '@tih/ui';
import FullAnswerCard from '~/components/questions/card/FullAnswerCard';
import CommentListItem from '~/components/questions/CommentListItem';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import {
SAMPLE_ANSWER,
SAMPLE_ANSWER_COMMENT,
} from '~/utils/questions/constants';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
export type AnswerCommentData = {
commentContent: string;
};
export default function QuestionPage() {
const router = useRouter();
const {
register: comRegister,
reset: resetComment,
handleSubmit: handleCommentSubmit,
formState: { isDirty: isCommentDirty, isValid: isCommentValid },
} = useForm<AnswerCommentData>({ mode: 'onChange' });
const commentRegister = useFormRegister(comRegister);
const { answerId } = router.query;
const utils = trpc.useContext();
const { data: answer } = trpc.useQuery([
'questions.answers.getAnswerById',
{ answerId: answerId as string },
]);
const { data: comments } = trpc.useQuery([
'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string },
]);
const { mutate: addComment } = trpc.useMutation(
'questions.answers.comments.create',
{
onSuccess: () => {
utils.invalidateQuery([
'questions.answers.comments.getAnswerComments',
{ answerId: answerId as string },
]);
},
},
);
const handleBackNavigation = () => {
router.back();
};
const handleSubmitComment = (data: AnswerCommentData) => {
resetComment();
addComment({
answerId: answerId as string,
content: data.commentContent,
});
};
if (!answer) {
return <FullScreenSpinner />;
}
return (
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
onClick={handleBackNavigation}></Button>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullAnswerCard
authorImageUrl={SAMPLE_ANSWER.authorImageUrl}
authorName={answer.user}
content={answer.content}
createdAt={answer.createdAt}
upvoteCount={0}
/>
<div className="mx-2">
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<CommentListItem
key={comment.id}
authorImageUrl={SAMPLE_ANSWER_COMMENT.authorImageUrl}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={comment.numVotes}
/>
))}
</div>
</div>
</div>
</div>
);
}

@ -0,0 +1,252 @@
import { useRouter } from 'next/router';
import { useForm } from 'react-hook-form';
import { ArrowSmallLeftIcon } from '@heroicons/react/24/outline';
import { Button, Collapsible, Select, TextArea } from '@tih/ui';
import AnswerCard from '~/components/questions/card/AnswerCard';
import FullQuestionCard from '~/components/questions/card/FullQuestionCard';
import CommentListItem from '~/components/questions/CommentListItem';
import FullScreenSpinner from '~/components/questions/FullScreenSpinner';
import {
SAMPLE_ANSWER,
SAMPLE_QUESTION_COMMENT,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import { useFormRegister } from '~/utils/questions/useFormRegister';
import { trpc } from '~/utils/trpc';
export type AnswerQuestionData = {
answerContent: string;
};
export type QuestionCommentData = {
commentContent: string;
};
export default function QuestionPage() {
const router = useRouter();
const {
register: ansRegister,
handleSubmit,
formState: { isDirty, isValid },
} = useForm<AnswerQuestionData>({ mode: 'onChange' });
const answerRegister = useFormRegister(ansRegister);
const {
register: comRegister,
handleSubmit: handleCommentSubmit,
reset: resetComment,
formState: { isDirty: isCommentDirty, isValid: isCommentValid },
} = useForm<QuestionCommentData>({ mode: 'onChange' });
const commentRegister = useFormRegister(comRegister);
const { questionId } = router.query;
const { data: question } = trpc.useQuery([
'questions.questions.getQuestionById',
{ id: questionId as string },
]);
const utils = trpc.useContext();
const { data: comments } = trpc.useQuery([
'questions.questions.comments.getQuestionComments',
{ questionId: questionId as string },
]);
const { mutate: addComment } = trpc.useMutation(
'questions.questions.comments.create',
{
onSuccess: () => {
utils.invalidateQueries(
'questions.questions.comments.getQuestionComments',
);
},
},
);
const { data: answers } = trpc.useQuery([
'questions.answers.getAnswers',
{ questionId: questionId as string },
]);
const { mutate: addAnswer } = trpc.useMutation('questions.answers.create', {
onSuccess: () => {
utils.invalidateQueries('questions.answers.getAnswers');
},
});
const handleBackNavigation = () => {
router.back();
};
const handleSubmitAnswer = (data: AnswerQuestionData) => {
addAnswer({
content: data.answerContent,
questionId: questionId as string,
});
};
const handleSubmitComment = (data: QuestionCommentData) => {
addComment({
content: data.commentContent,
questionId: questionId as string,
});
resetComment();
};
if (!question) {
return <FullScreenSpinner />;
}
return (
<div className="flex w-full flex-1 items-stretch pb-4">
<div className="flex items-baseline gap-2 py-4 pl-4">
<Button
addonPosition="start"
display="inline"
icon={ArrowSmallLeftIcon}
label="Back"
variant="secondary"
onClick={handleBackNavigation}></Button>
</div>
<div className="flex w-full justify-center overflow-y-auto py-4 px-5">
<div className="flex max-w-7xl flex-1 flex-col gap-2">
<FullQuestionCard
{...question}
receivedCount={0} // TODO: Change to actual value
showVoteButtons={true}
timestamp={question.seenAt.toLocaleDateString()}
upvoteCount={question.numVotes}
/>
<div className="mx-2">
<Collapsible label={`${question.numComments} comment(s)`}>
<form
className="mb-2"
onSubmit={handleCommentSubmit(handleSubmitComment)}>
<TextArea
{...commentRegister('commentContent', {
minLength: 1,
required: true,
})}
label="Post a comment"
required={true}
resize="vertical"
rows={2}
/>
<div className="my-3 flex justify-between">
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
<Button
disabled={!isCommentDirty || !isCommentValid}
label="Post"
type="submit"
variant="primary"
/>
</div>
</form>
{(comments ?? []).map((comment) => (
<CommentListItem
key={comment.id}
authorImageUrl={SAMPLE_QUESTION_COMMENT.authorImageUrl}
authorName={comment.user}
content={comment.content}
createdAt={comment.createdAt}
upvoteCount={0}
/>
))}
</Collapsible>
</div>
<form onSubmit={handleSubmit(handleSubmitAnswer)}>
<TextArea
{...answerRegister('answerContent', {
minLength: 1,
required: true,
})}
label="Contribute your answer"
required={true}
resize="vertical"
rows={5}
/>
<div className="mt-3 mb-1 flex justify-between">
<div className="flex items-baseline justify-start gap-2">
<p>{question.numAnswers} answers</p>
<div className="flex items-baseline gap-2">
<span aria-hidden={true} className="text-sm">
Sort by:
</span>
<Select
display="inline"
isLabelHidden={true}
label="Sort by"
options={[
{
label: 'Most recent',
value: 'most-recent',
},
{
label: 'Most upvotes',
value: 'most-upvotes',
},
]}
value="most-recent"
onChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
</div>
</div>
<Button
disabled={!isDirty || !isValid}
label="Contribute"
type="submit"
variant="primary"
/>
</div>
</form>
{(answers ?? []).map((answer) => (
<AnswerCard
key={answer.id}
authorImageUrl={SAMPLE_ANSWER.authorImageUrl}
authorName={answer.user}
commentCount={answer.numComments}
content={answer.content}
createdAt={answer.createdAt}
href={`${router.asPath}/answer/${answer.id}/${createSlug(
answer.content,
)}`}
upvoteCount={answer.numVotes}
/>
))}
</div>
</div>
</div>
);
}

@ -1,104 +1,164 @@
import { useMemo, useState } from 'react';
import { subMonths, subYears } from 'date-fns';
import { useRouter } from 'next/router';
import { useEffect, useMemo, useState } from 'react';
import type { QuestionsQuestionType } from '@prisma/client';
import QuestionOverviewCard from '~/components/questions/card/QuestionOverviewCard';
import ContributeQuestionCard from '~/components/questions/ContributeQuestionCard';
import type { FilterOptions } from '~/components/questions/filter/FilterSection';
import FilterSection from '~/components/questions/filter/FilterSection';
import QuestionOverviewCard from '~/components/questions/QuestionOverviewCard';
import type { LandingQueryData } from '~/components/questions/LandingComponent';
import LandingComponent from '~/components/questions/LandingComponent';
import QuestionSearchBar from '~/components/questions/QuestionSearchBar';
type FilterChoices = Array<Omit<FilterOptions, 'checked'>>;
const companies: FilterChoices = [
{
label: 'Google',
value: 'Google',
},
{
label: 'Meta',
value: 'meta',
},
];
// Code, design, behavioral
const questionTypes: FilterChoices = [
{
label: 'Code',
value: 'code',
},
{
label: 'Design',
value: 'design',
},
{
label: 'Behavioral',
value: 'behavioral',
},
];
const questionAges: FilterChoices = [
{
label: 'Last month',
value: 'last-month',
},
{
label: 'Last 6 months',
value: 'last-6-months',
},
{
label: 'Last year',
value: 'last-year',
},
];
const locations: FilterChoices = [
{
label: 'Singapore',
value: 'singapore',
},
];
import type { QuestionAge } from '~/utils/questions/constants';
import {
COMPANIES,
LOCATIONS,
QUESTION_AGES,
QUESTION_TYPES,
} from '~/utils/questions/constants';
import createSlug from '~/utils/questions/createSlug';
import {
useSearchFilter,
useSearchFilterSingle,
} from '~/utils/questions/useSearchFilter';
import { trpc } from '~/utils/trpc';
export default function QuestionsHomePage() {
const [selectedCompanies, setSelectedCompanies] = useState<Array<string>>([]);
const [selectedQuestionTypes, setSelectedQuestionTypes] = useState<
Array<string>
>([]);
const [selectedQuestionAges, setSelectedQuestionAges] = useState<
Array<string>
>([]);
const [selectedLocations, setSelectedLocations] = useState<Array<string>>([]);
const router = useRouter();
const [selectedCompanies, setSelectedCompanies, areCompaniesInitialized] =
useSearchFilter('companies');
const [
selectedQuestionTypes,
setSelectedQuestionTypes,
areQuestionTypesInitialized,
] = useSearchFilter<QuestionsQuestionType>('questionTypes', {
queryParamToValue: (param) => {
return param.toUpperCase() as QuestionsQuestionType;
},
});
const [
selectedQuestionAge,
setSelectedQuestionAge,
isQuestionAgeInitialized,
] = useSearchFilterSingle<QuestionAge>('questionAge', {
defaultValue: 'all',
});
const [selectedLocations, setSelectedLocations, areLocationsInitialized] =
useSearchFilter('locations');
const today = useMemo(() => new Date(), []);
const startDate = useMemo(() => {
return selectedQuestionAge === 'last-year'
? subYears(new Date(), 1)
: selectedQuestionAge === 'last-6-months'
? subMonths(new Date(), 6)
: selectedQuestionAge === 'last-month'
? subMonths(new Date(), 1)
: undefined;
}, [selectedQuestionAge]);
const { data: questions } = trpc.useQuery([
'questions.questions.getQuestionsByFilter',
{
companies: selectedCompanies,
endDate: today,
locations: selectedLocations,
questionTypes: selectedQuestionTypes,
roles: [],
startDate,
},
]);
const utils = trpc.useContext();
const { mutate: createQuestion } = trpc.useMutation(
'questions.questions.create',
{
onSuccess: () => {
utils.invalidateQueries('questions.questions.getQuestionsByFilter');
},
},
);
const [hasLanded, setHasLanded] = useState(false);
const [loaded, setLoaded] = useState(false);
const companyFilterOptions = useMemo(() => {
return companies.map((company) => ({
return COMPANIES.map((company) => ({
...company,
checked: selectedCompanies.includes(company.value),
}));
}, [selectedCompanies]);
const questionTypeFilterOptions = useMemo(() => {
return questionTypes.map((questionType) => ({
return QUESTION_TYPES.map((questionType) => ({
...questionType,
checked: selectedQuestionTypes.includes(questionType.value),
}));
}, [selectedQuestionTypes]);
const questionAgeFilterOptions = useMemo(() => {
return questionAges.map((questionAge) => ({
return QUESTION_AGES.map((questionAge) => ({
...questionAge,
checked: selectedQuestionAges.includes(questionAge.value),
checked: selectedQuestionAge === questionAge.value,
}));
}, [selectedQuestionAges]);
}, [selectedQuestionAge]);
const locationFilterOptions = useMemo(() => {
return locations.map((location) => ({
return LOCATIONS.map((location) => ({
...location,
checked: selectedLocations.includes(location.value),
}));
}, [selectedLocations]);
return (
const handleLandingQuery = (data: LandingQueryData) => {
const { company, location, questionType } = data;
setSelectedCompanies([company]);
setSelectedLocations([location]);
setSelectedQuestionTypes([questionType as QuestionsQuestionType]);
setHasLanded(true);
};
const areFiltersInitialized = useMemo(() => {
return (
areCompaniesInitialized &&
areQuestionTypesInitialized &&
isQuestionAgeInitialized &&
areLocationsInitialized
);
}, [
areCompaniesInitialized,
areQuestionTypesInitialized,
isQuestionAgeInitialized,
areLocationsInitialized,
]);
useEffect(() => {
if (areFiltersInitialized) {
const hasFilter =
router.query.companies ||
router.query.questionTypes ||
router.query.questionAge ||
router.query.locations;
if (hasFilter) {
setHasLanded(true);
}
// Console.log('landed', hasLanded);
setLoaded(true);
}
}, [areFiltersInitialized, hasLanded, router.query]);
if (!loaded) {
return null;
}
return !hasLanded ? (
<LandingComponent onLanded={handleLandingQuery}></LandingComponent>
) : (
<main className="flex flex-1 flex-col items-stretch overflow-y-auto">
<div className="flex pt-4">
<section className="w-[300px] border-r px-4">
<aside className="w-[300px] border-r px-4">
<h2 className="text-xl font-semibold">Filter by</h2>
<div className="divide-y divide-slate-200">
<FilterSection
@ -107,10 +167,12 @@ export default function QuestionsHomePage() {
searchPlaceholder="Add company filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedCompanies((prev) => [...prev, optionValue]);
setSelectedCompanies([...selectedCompanies, optionValue]);
} else {
setSelectedCompanies((prev) =>
prev.filter((company) => company !== optionValue),
setSelectedCompanies(
selectedCompanies.filter(
(company) => company !== optionValue,
),
);
}
}}
@ -121,26 +183,26 @@ export default function QuestionsHomePage() {
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionTypes((prev) => [...prev, optionValue]);
setSelectedQuestionTypes([
...selectedQuestionTypes,
optionValue,
]);
} else {
setSelectedQuestionTypes((prev) =>
prev.filter((questionType) => questionType !== optionValue),
setSelectedQuestionTypes(
selectedQuestionTypes.filter(
(questionType) => questionType !== optionValue,
),
);
}
}}
/>
<FilterSection
isSingleSelect={true}
label="Question age"
options={questionAgeFilterOptions}
showAll={true}
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedQuestionAges((prev) => [...prev, optionValue]);
} else {
setSelectedQuestionAges((prev) =>
prev.filter((questionAge) => questionAge !== optionValue),
);
}
onOptionChange={(optionValue) => {
setSelectedQuestionAge(optionValue);
}}
/>
<FilterSection
@ -149,23 +211,31 @@ export default function QuestionsHomePage() {
searchPlaceholder="Add location filter"
onOptionChange={(optionValue, checked) => {
if (checked) {
setSelectedLocations((prev) => [...prev, optionValue]);
setSelectedLocations([...selectedLocations, optionValue]);
} else {
setSelectedLocations((prev) =>
prev.filter((location) => location !== optionValue),
setSelectedLocations(
selectedLocations.filter(
(location) => location !== optionValue,
),
);
}
}}
/>
</div>
</section>
<div className="flex flex-1 justify-center">
<div className="flex max-w-3xl flex-1 gap-x-4">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4">
</aside>
<section className="flex min-h-0 flex-1 flex-col items-center overflow-auto pt-4">
<div className="flex min-h-0 max-w-3xl flex-1">
<div className="flex flex-1 flex-col items-stretch justify-start gap-4 pb-4">
<ContributeQuestionCard
onSubmit={(data) => {
// eslint-disable-next-line no-console
console.log(data);
createQuestion({
company: data.company,
content: data.questionContent,
location: data.location,
questionType: data.questionType,
role: data.role,
seenAt: data.date,
});
}}
/>
<QuestionSearchBar
@ -180,19 +250,31 @@ export default function QuestionsHomePage() {
},
]}
sortValue="most-recent"
onSortChange={(value) => {
// eslint-disable-next-line no-console
console.log(value);
}}
/>
<QuestionOverviewCard
answerCount={0}
content="Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and"
location="Menlo Park, CA"
role="Senior Engineering Manager"
similarCount={0}
timestamp="Last month"
upvoteCount={0}
/>
{(questions ?? []).map((question) => (
<QuestionOverviewCard
// eslint-disable-next-line react/no-array-index-key
key={question.id}
answerCount={question.numAnswers}
content={question.content}
href={`/questions/${question.id}/${createSlug(
question.content,
)}`}
location={question.location}
receivedCount={0} // TODO: Implement received count
role={question.role}
timestamp={question.seenAt.toLocaleDateString()}
type={question.type}
upvoteCount={question.numVotes}
/>
))}
</div>
</div>
</div>
</section>
</div>
</main>
);

@ -140,7 +140,7 @@ export default function SubmitResumeForm() {
<div className="mb-4">
<TextInput
{...register('title', { required: true })}
errorMessage={errors?.title && 'Title cannot be empty!'}
disabled={isLoading}
label="Title"
placeholder={TITLE_PLACEHOLDER}
required={true}
@ -150,6 +150,7 @@ export default function SubmitResumeForm() {
<div className="mb-4">
<Select
{...register('role', { required: true })}
disabled={isLoading}
label="Role"
options={ROLES}
required={true}
@ -159,6 +160,7 @@ export default function SubmitResumeForm() {
<div className="mb-4">
<Select
{...register('experience', { required: true })}
disabled={isLoading}
label="Experience Level"
options={EXPERIENCE}
required={true}
@ -168,6 +170,7 @@ export default function SubmitResumeForm() {
<div className="mb-4">
<Select
{...register('location', { required: true })}
disabled={isLoading}
label="Location"
name="location"
options={LOCATION}
@ -204,6 +207,7 @@ export default function SubmitResumeForm() {
{...register('file', { required: true })}
accept="application/pdf"
className="sr-only"
disabled={isLoading}
id="file-upload"
name="file-upload"
type="file"
@ -223,6 +227,7 @@ export default function SubmitResumeForm() {
<div className="mb-8">
<TextArea
{...register('additionalInfo')}
disabled={isLoading}
label="Additional Information"
placeholder={ADDITIONAL_INFO_PLACEHOLDER}
onChange={(val) => setValue('additionalInfo', val)}
@ -259,12 +264,14 @@ export default function SubmitResumeForm() {
</div>
<CheckboxInput
{...register('isChecked', { required: true })}
disabled={isLoading}
label="I have read and will follow the guidelines stated."
onChange={(val) => setValue('isChecked', val)}
/>
<div className="mt-4 flex justify-end gap-4">
<Button
addonPosition="start"
disabled={isLoading}
display="inline"
label="Clear"
size="md"
@ -273,6 +280,7 @@ export default function SubmitResumeForm() {
/>
<Button
addonPosition="start"
disabled={isLoading}
display="inline"
isLoading={isLoading}
label="Submit"

@ -3,6 +3,9 @@ import superjson from 'superjson';
import { companiesRouter } from './companies-router';
import { createRouter } from './context';
import { protectedExampleRouter } from './protected-example-router';
import { questionsAnswerCommentRouter } from './questions-answer-comment-router';
import { questionsAnswerRouter } from './questions-answer-router';
import { questionsQuestionCommentRouter } from './questions-question-comment-router';
import { questionsQuestionRouter } from './questions-question-router';
import { resumesRouter } from './resumes/resumes-resume-router';
import { resumesResumeUserRouter } from './resumes/resumes-resume-user-router';
@ -26,6 +29,9 @@ export const appRouter = createRouter()
.merge('resumes.star.user.', resumesStarUserRouter)
.merge('resumes.reviews.', resumeReviewsRouter)
.merge('resumes.reviews.user.', resumesReviewsUserRouter)
.merge('questions.answers.comments.', questionsAnswerCommentRouter)
.merge('questions.answers.', questionsAnswerRouter)
.merge('questions.questions.comments.', questionsQuestionCommentRouter)
.merge('questions.questions.', questionsQuestionRouter);
// Export type definition of API

@ -0,0 +1,229 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import type { AnswerComment } from '~/types/questions';
export const questionsAnswerCommentRouter = createProtectedRouter()
.query('getAnswerComments', {
input: z.object({
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const questionAnswerCommentsData =
await ctx.prisma.questionsAnswerComment.findMany({
include: {
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
...input,
},
});
return questionAnswerCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answerComment: AnswerComment = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numVotes: votes,
updatedAt: data.updatedAt,
user: data.user?.name ?? '',
};
return answerComment;
});
},
})
.mutation('create', {
input: z.object({
answerId: z.string(),
content: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsAnswerComment.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const answerCommentToUpdate =
await ctx.prisma.questionsAnswerComment.findUnique({
where: {
id: input.id,
},
});
if (answerCommentToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerComment.update({
data: {
...input,
},
where: {
id: input.id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const answerCommentToDelete =
await ctx.prisma.questionsAnswerComment.findUnique({
where: {
id: input.id,
},
});
if (answerCommentToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerComment.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
answerCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerCommentId } = input;
return await ctx.prisma.questionsAnswerCommentVote.findUnique({
where: {
answerCommentId_userId: { answerCommentId, userId },
},
});
},
})
.mutation('createVote', {
input: z.object({
answerCommentId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsAnswerCommentVote.create({
data: {
...input,
userId,
},
});
},
})
.mutation('updateVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const voteToUpdate =
await ctx.prisma.questionsAnswerCommentVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerCommentVote.update({
data: {
vote,
},
where: {
id,
},
});
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete =
await ctx.prisma.questionsAnswerCommentVote.findUnique({
where: {
id: input.id,
},
});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerCommentVote.delete({
where: {
id: input.id,
},
});
},
});

@ -0,0 +1,287 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import type { Answer } from '~/types/questions';
export const questionsAnswerRouter = createProtectedRouter()
.query('getAnswers', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const answersData = await ctx.prisma.questionsAnswer.findMany({
include: {
_count: {
select: {
comments: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
...input,
},
});
return answersData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answer: Answer = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numComments: data._count.comments,
numVotes: votes,
user: data.user?.name ?? '',
};
return answer;
});
},
})
.query('getAnswerById', {
input: z.object({
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const answerData = await ctx.prisma.questionsAnswer.findUnique({
include: {
_count: {
select: {
comments: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
where: {
id: input.answerId,
},
});
if (!answerData) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Answer not found',
});
}
const votes: number = answerData.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const answer: Answer = {
content: answerData.content,
createdAt: answerData.createdAt,
id: answerData.id,
numComments: answerData._count.comments,
numVotes: votes,
user: answerData.user?.name ?? '',
};
return answer;
},
})
.mutation('create', {
input: z.object({
content: z.string(),
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsAnswer.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { content, id } = input;
const answerToUpdate = await ctx.prisma.questionsAnswer.findUnique({
where: {
id: input.id,
},
});
if (answerToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswer.update({
data: {
content,
},
where: {
id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const answerToDelete = await ctx.prisma.questionsAnswer.findUnique({
where: {
id: input.id,
},
});
if (answerToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswer.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
answerId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { answerId } = input;
return await ctx.prisma.questionsAnswerVote.findUnique({
where: {
answerId_userId: { answerId, userId },
},
});
},
})
.mutation('createVote', {
input: z.object({
answerId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsAnswerVote.create({
data: {
...input,
userId,
},
});
},
})
.mutation('updateVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const voteToUpdate = await ctx.prisma.questionsAnswerVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerVote.update({
data: {
vote,
},
where: {
id,
},
});
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete = await ctx.prisma.questionsAnswerVote.findUnique({
where: {
id: input.id,
},
});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsAnswerVote.delete({
where: {
id: input.id,
},
});
},
});

@ -0,0 +1,228 @@
import { z } from 'zod';
import { Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
import type { QuestionComment } from '~/types/questions';
export const questionsQuestionCommentRouter = createProtectedRouter()
.query('getQuestionComments', {
input: z.object({
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const questionCommentsData =
await ctx.prisma.questionsQuestionComment.findMany({
include: {
user: {
select: {
name: true,
},
},
votes: true,
},
orderBy: {
createdAt: 'desc',
},
where: {
...input,
},
});
return questionCommentsData.map((data) => {
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const questionComment: QuestionComment = {
content: data.content,
createdAt: data.createdAt,
id: data.id,
numVotes: votes,
user: data.user?.name ?? '',
};
return questionComment;
});
},
})
.mutation('create', {
input: z.object({
content: z.string(),
questionId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestionComment.create({
data: {
...input,
userId,
},
});
},
})
.mutation('update', {
input: z.object({
content: z.string().optional(),
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionCommentToUpdate =
await ctx.prisma.questionsQuestionComment.findUnique({
where: {
id: input.id,
},
});
if (questionCommentToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionComment.update({
data: {
...input,
},
where: {
id: input.id,
},
});
},
})
.mutation('delete', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const questionCommentToDelete =
await ctx.prisma.questionsQuestionComment.findUnique({
where: {
id: input.id,
},
});
if (questionCommentToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionComment.delete({
where: {
id: input.id,
},
});
},
})
.query('getVote', {
input: z.object({
questionCommentId: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { questionCommentId } = input;
return await ctx.prisma.questionsQuestionCommentVote.findUnique({
where: {
questionCommentId_userId: { questionCommentId, userId },
},
});
},
})
.mutation('createVote', {
input: z.object({
questionCommentId: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestionCommentVote.create({
data: {
...input,
userId,
},
});
},
})
.mutation('updateVote', {
input: z.object({
id: z.string(),
vote: z.nativeEnum(Vote),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const { id, vote } = input;
const voteToUpdate =
await ctx.prisma.questionsQuestionCommentVote.findUnique({
where: {
id: input.id,
},
});
if (voteToUpdate?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionCommentVote.update({
data: {
vote,
},
where: {
id,
},
});
},
})
.mutation('deleteVote', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const voteToDelete =
await ctx.prisma.questionsQuestionCommentVote.findUnique({
where: {
id: input.id,
},
});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User have no authorization to record.',
});
}
return await ctx.prisma.questionsQuestionCommentVote.delete({
where: {
id: input.id,
},
});
},
});

@ -1,5 +1,5 @@
import { z } from 'zod';
import {QuestionsQuestionType, Vote } from '@prisma/client';
import { QuestionsQuestionType, Vote } from '@prisma/client';
import { TRPCError } from '@trpc/server';
import { createProtectedRouter } from './context';
@ -9,106 +9,223 @@ import type { Question } from '~/types/questions';
export const questionsQuestionRouter = createProtectedRouter()
.query('getQuestionsByFilter', {
input: z.object({
company: z.string().optional(),
location: z.string().optional(),
questionType: z.nativeEnum(QuestionsQuestionType),
role: z.string().optional(),
companies: z.string().array(),
endDate: z.date(),
locations: z.string().array(),
questionTypes: z.nativeEnum(QuestionsQuestionType).array(),
roles: z.string().array(),
startDate: z.date().optional(),
}),
async resolve({ ctx, input }) {
const questionsData = await ctx.prisma.questionsQuestion.findMany({
include: {
_count: {
select: {
answers: true,
comments: true,
_count: {
select: {
answers: true,
comments: true,
},
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
},
user: {
select: {
name: true,
user: {
select: {
name: true,
},
},
},
votes: true,
votes: true,
},
orderBy: {
createdAt: 'desc',
createdAt: 'desc',
},
where: {
questionType: input.questionType,
...(input.questionTypes.length > 0
? {
questionType: {
in: input.questionTypes,
},
}
: {}),
},
});
return questionsData
.filter((data) => {
for (let i = 0; i < data.encounters.length; i++) {
const encounter = data.encounters[i]
const matchCompany = (!input.company || (encounter.company === input.company));
const matchLocation = (!input.location || (encounter.location === input.location));
const matchRole = (!input.company || (encounter.role === input.role));
if (matchCompany && matchLocation && matchRole) {return true};
const encounter = data.encounters[i];
const matchCompany =
input.companies.length === 0 ||
input.companies.includes(encounter.company);
const matchLocation =
input.locations.length === 0 ||
input.locations.includes(encounter.location);
const matchRole =
input.roles.length === 0 || input.roles.includes(encounter.role);
const matchDate =
(!input.startDate || encounter.seenAt >= input.startDate) &&
encounter.seenAt <= input.endDate;
if (matchCompany && matchLocation && matchRole && matchDate) {
return true;
}
}
return false;
})
.map((data) => {
const votes:number = data.votes.reduce(
(previousValue:number, currentValue) => {
let result:number = previousValue;
const votes: number = data.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch(currentValue.vote) {
case Vote.UPVOTE:
result += 1
break;
case Vote.DOWNVOTE:
result -= 1
break;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0
);
let userName = "";
if (data.user) {
userName = data.user.name!;
}
0,
);
const question: Question = {
company: "",
company: data.encounters[0].company,
content: data.content,
id: data.id,
location: "",
location: data.encounters[0].location ?? 'Unknown location',
numAnswers: data._count.answers,
numComments: data._count.comments,
numVotes: votes,
role: "",
role: data.encounters[0].role ?? 'Unknown role',
seenAt: data.encounters[0].seenAt,
type: data.questionType,
updatedAt: data.updatedAt,
user: userName,
user: data.user?.name ?? '',
};
return question;
});
},
})
.query('getQuestionById', {
input: z.object({
id: z.string(),
}),
async resolve({ ctx, input }) {
const questionData = await ctx.prisma.questionsQuestion.findUnique({
include: {
_count: {
select: {
answers: true,
comments: true,
},
},
encounters: {
select: {
company: true,
location: true,
role: true,
seenAt: true,
},
},
user: {
select: {
name: true,
},
},
votes: true,
},
where: {
id: input.id,
},
});
}
if (!questionData) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Question not found',
});
}
const votes: number = questionData.votes.reduce(
(previousValue: number, currentValue) => {
let result: number = previousValue;
switch (currentValue.vote) {
case Vote.UPVOTE:
result += 1;
break;
case Vote.DOWNVOTE:
result -= 1;
break;
}
return result;
},
0,
);
const question: Question = {
company: questionData.encounters[0].company,
content: questionData.content,
id: questionData.id,
location: questionData.encounters[0].location ?? 'Unknown location',
numAnswers: questionData._count.answers,
numComments: questionData._count.comments,
numVotes: votes,
role: questionData.encounters[0].role ?? 'Unknown role',
seenAt: questionData.encounters[0].seenAt,
type: questionData.questionType,
updatedAt: questionData.updatedAt,
user: questionData.user?.name ?? '',
};
return question;
},
})
.mutation('create', {
input: z.object({
company: z.string(),
content: z.string(),
location: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType),
role: z.string(),
seenAt: z.date(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
return await ctx.prisma.questionsQuestion.create({
const question = await ctx.prisma.questionsQuestion.create({
data: {
...input,
content: input.content,
encounters: {
create: [
{
company: input.company,
location: input.location,
role: input.role,
seenAt: input.seenAt,
userId,
},
],
},
questionType: input.questionType,
userId,
},
});
// Create question encounter
await ctx.prisma.questionsQuestionEncounter.create({
data: {
company: input.company,
location: input.location,
questionId: question.id,
role: input.role,
seenAt: input.seenAt,
userId,
},
});
return question;
},
})
.mutation('update', {
@ -116,7 +233,6 @@ export const questionsQuestionRouter = createProtectedRouter()
content: z.string().optional(),
id: z.string(),
questionType: z.nativeEnum(QuestionsQuestionType).optional(),
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
@ -179,11 +295,11 @@ export const questionsQuestionRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const {questionId} = input
const { questionId } = input;
return await ctx.prisma.questionsQuestionVote.findUnique({
where: {
questionId_userId : {questionId,userId }
questionId_userId: { questionId, userId },
},
});
},
@ -211,7 +327,7 @@ export const questionsQuestionRouter = createProtectedRouter()
}),
async resolve({ ctx, input }) {
const userId = ctx.session?.user?.id;
const {id, vote} = input
const { id, vote } = input;
const voteToUpdate = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
@ -246,7 +362,8 @@ export const questionsQuestionRouter = createProtectedRouter()
const voteToDelete = await ctx.prisma.questionsQuestionVote.findUnique({
where: {
id: input.id,
},});
},
});
if (voteToDelete?.id !== userId) {
throw new TRPCError({
@ -261,4 +378,4 @@ export const questionsQuestionRouter = createProtectedRouter()
},
});
},
});
});

@ -8,6 +8,8 @@ export type Question = {
numComments: number;
numVotes: number;
role: string;
seenAt: Date;
type: stringl;
updatedAt: Date;
user: string;
};
@ -16,4 +18,30 @@ export type AggregatedQuestionEncounter = {
companyCount: Record<string, number>;
locationCount: Record<string, number>;
roleCount:Record<string, number>;
}
}
export type AnswerComment = {
content: string;
createdAt: Date;
id: string;
numVotes: number;
updatedAt: Date;
user: string;
};
export type Answer = {
content: string;
createdAt: Date;
id: string;
numComments: number;
numVotes: number;
user: string;
};
export type QuestionComment = {
content: string;
createdAt: Date;
id: string;
numVotes: number;
user: string;
};

@ -0,0 +1,112 @@
import type { QuestionsQuestionType } from '@prisma/client';
import type { FilterChoices } from '~/components/questions/filter/FilterSection';
export const COMPANIES: FilterChoices = [
{
label: 'Google',
value: 'Google',
},
{
label: 'Meta',
value: 'Meta',
},
] as const;
// Code, design, behavioral
export const QUESTION_TYPES: FilterChoices<QuestionsQuestionType> = [
{
label: 'Coding',
value: 'CODING',
},
{
label: 'Design',
value: 'SYSTEM_DESIGN',
},
{
label: 'Behavioral',
value: 'BEHAVIORAL',
},
] as const;
export type QuestionAge = 'all' | 'last-6-months' | 'last-month' | 'last-year';
export const QUESTION_AGES: FilterChoices<QuestionAge> = [
{
label: 'Last month',
value: 'last-month',
},
{
label: 'Last 6 months',
value: 'last-6-months',
},
{
label: 'Last year',
value: 'last-year',
},
{
label: 'All',
value: 'all',
},
] as const;
export const LOCATIONS: FilterChoices = [
{
label: 'Singapore',
value: 'Singapore',
},
{
label: 'Menlo Park',
value: 'Menlo Park',
},
{
label: 'California',
value: 'california',
},
{
label: 'Hong Kong',
value: 'Hong Kong',
},
{
label: 'Taiwan',
value: 'Taiwan',
},
] as const;
export const SAMPLE_QUESTION = {
answerCount: 10,
commentCount: 10,
company: 'Google',
content:
'Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums andiven an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and an integer target, return indices of the two numbers such that they add up. Given an array of integers nums and',
location: 'Menlo Park, CA',
receivedCount: 12,
role: 'Software Engineer',
timestamp: 'Last month',
upvoteCount: 5,
};
export const SAMPLE_ANSWER = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
commentCount: 10,
content: 'This is a sample answer',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};
export const SAMPLE_QUESTION_COMMENT = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
content: 'This is a sample question comment',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};
export const SAMPLE_ANSWER_COMMENT = {
authorImageUrl: 'https://avatars.githubusercontent.com/u/66356390?v=4',
authorName: 'Jeff Sieu',
content: 'This is an sample answer comment',
createdAt: new Date(2014, 8, 1, 11, 30, 40),
upvoteCount: 10,
};

@ -0,0 +1,7 @@
export default function createSlug(content: string) {
return content
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)+/g, '')
.substring(0, 100);
}

@ -0,0 +1,43 @@
import type { ChangeEvent } from 'react';
import { useCallback } from 'react';
import type { FieldValues, UseFormRegister } from 'react-hook-form';
export const useFormRegister = <TFieldValues extends FieldValues>(
register: UseFormRegister<TFieldValues>,
) => {
const formRegister = useCallback(
(...args: Parameters<typeof register>) => {
const { onChange, ...rest } = register(...args);
return {
...rest,
onChange: (value: string, event: ChangeEvent<unknown>) => {
onChange(event);
},
};
},
[register],
);
return formRegister;
};
export const useSelectRegister = <TFieldValues extends FieldValues>(
register: UseFormRegister<TFieldValues>,
) => {
const formRegister = useCallback(
(...args: Parameters<typeof register>) => {
const { onChange, ...rest } = register(...args);
return {
...rest,
onChange: (value: string) => {
onChange({
target: {
value,
},
});
},
};
},
[register],
);
return formRegister;
};

@ -0,0 +1,81 @@
import { useRouter } from 'next/router';
import { useCallback, useEffect, useState } from 'react';
export const useSearchFilter = <Value extends string = string>(
name: string,
opts: {
defaultValues?: Array<Value>;
queryParamToValue?: (param: string) => Value;
} = {},
) => {
const { defaultValues, queryParamToValue = (param) => param } = opts;
const [isInitialized, setIsInitialized] = useState(false);
const router = useRouter();
const [filters, setFilters] = useState<Array<Value>>(defaultValues || []);
useEffect(() => {
if (router.isReady && !isInitialized) {
// Initialize from query params
const query = router.query[name];
if (query) {
const queryValues = Array.isArray(query) ? query : [query];
setFilters(queryValues.map(queryParamToValue) as Array<Value>);
} else {
// Try to load from local storage
const localStorageValue = localStorage.getItem(name);
if (localStorageValue !== null) {
const loadedFilters = JSON.parse(localStorageValue);
setFilters(loadedFilters);
router.replace({
pathname: router.pathname,
query: {
...router.query,
[name]: loadedFilters,
},
});
}
}
setIsInitialized(true);
}
}, [isInitialized, name, queryParamToValue, router]);
const setFiltersCallback = useCallback(
(newFilters: Array<Value>) => {
setFilters(newFilters);
localStorage.setItem(name, JSON.stringify(newFilters));
router.replace({
pathname: router.pathname,
query: {
...router.query,
[name]: newFilters,
},
});
},
[name, router],
);
return [filters, setFiltersCallback, isInitialized] as const;
};
export const useSearchFilterSingle = <Value extends string = string>(
name: string,
opts: {
defaultValue?: Value;
queryParamToValue?: (param: string) => Value;
} = {},
) => {
const { defaultValue, queryParamToValue } = opts;
const [filters, setFilters, isInitialized] = useSearchFilter(name, {
defaultValues: defaultValue !== undefined ? [defaultValue] : undefined,
queryParamToValue,
});
return [
filters[0],
(value: Value) => {
setFilters([value]);
},
isInitialized,
] as const;
};

@ -0,0 +1,21 @@
const withHref = <Props extends Record<string, unknown>>(
Component: React.ComponentType<Props>,
) => {
return (
props: Props & {
href: string;
},
) => {
const { href, ...others } = props;
return (
<a
className="ring-primary-500 rounded-md focus:ring-2 focus-visible:outline-none active:bg-slate-100"
href={href}>
<Component {...(others as unknown as Props)} />
</a>
);
};
};
export default withHref;

@ -23,6 +23,9 @@ const buttonVariants: ReadonlyArray<ButtonVariant> = [
'tertiary',
'special',
'success',
'danger',
'warning',
'info',
];
export default {

@ -9,11 +9,14 @@ export type ButtonDisplay = 'block' | 'inline';
export type ButtonSize = 'lg' | 'md' | 'sm';
export type ButtonType = 'button' | 'reset' | 'submit';
export type ButtonVariant =
| 'danger'
| 'info'
| 'primary'
| 'secondary'
| 'special'
| 'success'
| 'tertiary';
| 'tertiary'
| 'warning';
type Props = Readonly<{
addonPosition?: ButtonAddOnPosition;
@ -69,20 +72,32 @@ const sizeIconClasses: Record<ButtonSize, string> = {
};
const variantClasses: Record<ButtonVariant, string> = {
primary: 'border-transparent text-white bg-primary-600 hover:bg-primary-500',
danger:
'border-transparent text-white bg-danger-600 hover:bg-danger-500 focus:ring-danger-500',
info: 'border-transparent text-white bg-info-600 hover:bg-info-500 focus:ring-info-500',
primary:
'border-transparent text-white bg-primary-600 hover:bg-primary-500 focus:ring-primary-500',
secondary:
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200',
special: 'border-slate-900 text-white bg-slate-900 hover:bg-slate-700',
success: 'border-transparent text-white bg-success-600 hover:bg-success-500',
tertiary: 'border-slate-300 text-slate-700 bg-white hover:bg-slate-50',
'border-transparent text-primary-700 bg-primary-100 hover:bg-primary-200 focus:ring-primary-500',
special:
'border-slate-900 text-white bg-slate-900 hover:bg-slate-700 focus:ring-slate-900',
success:
'border-transparent text-white bg-success-600 hover:bg-success-500 focus:ring-success-500',
tertiary:
'border-slate-300 text-slate-700 bg-white hover:bg-slate-50 focus:ring-slate-600',
warning:
'border-transparent text-white bg-warning-600 hover:bg-warning-500 focus:ring-warning-500',
};
const variantDisabledClasses: Record<ButtonVariant, string> = {
danger: 'border-transparent text-slate-500 bg-slate-300',
info: 'border-transparent text-slate-500 bg-slate-300',
primary: 'border-transparent text-slate-500 bg-slate-300',
secondary: 'border-transparent text-slate-400 bg-slate-200',
special: 'border-transparent text-slate-500 bg-slate-300',
success: 'border-transparent text-slate-500 bg-slate-300',
tertiary: 'border-slate-300 text-slate-400 bg-slate-100',
warning: 'border-transparent text-slate-500 bg-slate-300',
};
export default function Button({
@ -132,7 +147,7 @@ export default function Button({
children,
className: clsx(
display === 'block' ? 'flex w-full justify-center' : 'inline-flex',
'whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500',
'whitespace-nowrap items-center border font-medium focus:outline-none focus:ring-2 focus:ring-offset-2',
disabled ? variantDisabledClasses[variant] : variantClasses[variant],
disabled && 'pointer-events-none',
isLabelHidden ? iconOnlySizeClasses[size] : sizeClasses[size],

@ -7,7 +7,8 @@
".next/**",
"build/**",
"api/**",
"public/build/**"
"public/build/**",
"storybook-static/**"
],
"dependsOn": ["^build"]
},

Loading…
Cancel
Save