commit
b78973fb9f
@ -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'>;
|
||||
export type ContributeQuestionCardProps = Pick<
|
||||
ContributeQuestionFormProps,
|
||||
'onSubmit'
|
||||
>;
|
||||
|
||||
function FormTextInputWithRef(
|
||||
props: FormTextInputProps,
|
||||
ref?: ForwardedRef<HTMLInputElement>,
|
||||
) {
|
||||
const { onChange, ...rest } = props;
|
||||
return (
|
||||
<TextInput
|
||||
{...(rest as TextInputProps)}
|
||||
ref={ref}
|
||||
onChange={(_, event) => onChange(event)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export default function ContributeQuestionCard({
|
||||
onSubmit,
|
||||
}: ContributeQuestionCardProps) {
|
||||
const [showDraftDialog, setShowDraftDialog] = useState(false);
|
||||
|
||||
const FormTextInput = forwardRef(FormTextInputWithRef);
|
||||
const handleDraftDialogCancel = () => {
|
||||
setShowDraftDialog(false);
|
||||
};
|
||||
|
||||
export type ContributeQuestionCardProps = {
|
||||
onSubmit: (data: ContributeQuestionData) => void;
|
||||
const handleOpenContribute = () => {
|
||||
setShowDraftDialog(true);
|
||||
};
|
||||
|
||||
export default function ContributeQuestionCard({
|
||||
onSubmit,
|
||||
}: ContributeQuestionCardProps) {
|
||||
const { register, handleSubmit } = useForm<ContributeQuestionData>();
|
||||
const [isOpen, setOpen] = useState<boolean>(false);
|
||||
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>
|
||||
</button>
|
||||
<ContributeQuestionDialog
|
||||
show={showDraftDialog}
|
||||
onCancel={handleDraftDialogCancel}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ContributeQuestionModal
|
||||
contributeState={isOpen}
|
||||
setContributeState={setOpen}></ContributeQuestionModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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&repo=tech-interview-handbook&type=star&count=true&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>
|
||||
);
|
||||
}
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
@ -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;
|
Loading…
Reference in new issue