mirror of
https://github.com/yangshun/tech-interview-handbook.git
synced 2025-07-07 17:48:26 +08:00
[offers][feat] add default filters and more income columns (#495)
* [offers][feat] add yoe query param and display all by default * [offers][feat] add base bonus stocks to table and default homepage * [offers][style] style loading spinner
This commit is contained in:
@ -1,5 +1,6 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { JobType } from '@prisma/client';
|
||||||
|
|
||||||
import type { JobTitleType } from '~/components/shared/JobTitles';
|
import type { JobTitleType } from '~/components/shared/JobTitles';
|
||||||
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
|
import { getLabelForJobTitleType } from '~/components/shared/JobTitles';
|
||||||
@ -9,25 +10,47 @@ import { formatDate } from '~/utils/offers/time';
|
|||||||
|
|
||||||
import type { DashboardOffer } from '~/types/offers';
|
import type { DashboardOffer } from '~/types/offers';
|
||||||
|
|
||||||
export type OfferTableRowProps = Readonly<{ row: DashboardOffer }>;
|
export type OfferTableRowProps = Readonly<{
|
||||||
|
jobType: JobType;
|
||||||
|
row: DashboardOffer;
|
||||||
|
}>;
|
||||||
|
|
||||||
export default function OfferTableRow({
|
export default function OfferTableRow({
|
||||||
row: { company, id, income, monthYearReceived, profileId, title, totalYoe },
|
jobType,
|
||||||
|
row: {
|
||||||
|
baseSalary,
|
||||||
|
bonus,
|
||||||
|
company,
|
||||||
|
id,
|
||||||
|
income,
|
||||||
|
monthYearReceived,
|
||||||
|
profileId,
|
||||||
|
stocks,
|
||||||
|
title,
|
||||||
|
totalYoe,
|
||||||
|
},
|
||||||
}: OfferTableRowProps) {
|
}: OfferTableRowProps) {
|
||||||
return (
|
return (
|
||||||
<tr key={id} className="divide-x divide-slate-200 border-b bg-white">
|
<tr key={id} className="divide-x divide-slate-200 border-b bg-white">
|
||||||
<th className="whitespace-nowrap py-4 px-6 font-medium" scope="row">
|
<th className="whitespace-nowrap py-4 px-4 font-medium" scope="row">
|
||||||
{company.name}
|
{company.name}
|
||||||
</th>
|
</th>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-4">
|
||||||
{getLabelForJobTitleType(title as JobTitleType)}
|
{getLabelForJobTitleType(title as JobTitleType)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6">{totalYoe}</td>
|
<td className="py-4 px-4">{totalYoe}</td>
|
||||||
<td className="py-4 px-6">{convertMoneyToString(income)}</td>
|
<td className="py-4 px-4">{convertMoneyToString(income)}</td>
|
||||||
<td className="py-4 px-6">{formatDate(monthYearReceived)}</td>
|
{jobType === JobType.FULLTIME && (
|
||||||
|
<td className="py-4 px-4">
|
||||||
|
{`${baseSalary && convertMoneyToString(baseSalary)} / ${
|
||||||
|
bonus && convertMoneyToString(bonus)
|
||||||
|
} / ${stocks && convertMoneyToString(stocks)}`}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="py-4 px-4">{formatDate(monthYearReceived)}</td>
|
||||||
<td
|
<td
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'sticky right-0 bg-white px-6 py-4 drop-shadow lg:drop-shadow-none',
|
'sticky right-0 bg-white px-4 py-4 drop-shadow lg:drop-shadow-none',
|
||||||
)}>
|
)}>
|
||||||
<Link
|
<Link
|
||||||
className="text-primary-600 dark:text-primary-500 font-medium hover:underline"
|
className="text-primary-600 dark:text-primary-500 font-medium hover:underline"
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { JobType } from '@prisma/client';
|
||||||
import { DropdownMenu, Spinner } from '@tih/ui';
|
import { DropdownMenu, Spinner } from '@tih/ui';
|
||||||
|
|
||||||
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
|
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
|
||||||
@ -9,6 +11,7 @@ import {
|
|||||||
OfferTableSortBy,
|
OfferTableSortBy,
|
||||||
OfferTableYoeOptions,
|
OfferTableYoeOptions,
|
||||||
YOE_CATEGORY,
|
YOE_CATEGORY,
|
||||||
|
YOE_CATEGORY_PARAM,
|
||||||
} from '~/components/offers/table/types';
|
} from '~/components/offers/table/types';
|
||||||
|
|
||||||
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
|
import { Currency } from '~/utils/offers/currency/CurrencyEnum';
|
||||||
@ -21,17 +24,18 @@ import type { DashboardOffer, GetOffersResponse, Paging } from '~/types/offers';
|
|||||||
|
|
||||||
const NUMBER_OF_OFFERS_IN_PAGE = 10;
|
const NUMBER_OF_OFFERS_IN_PAGE = 10;
|
||||||
export type OffersTableProps = Readonly<{
|
export type OffersTableProps = Readonly<{
|
||||||
cityFilter: string;
|
|
||||||
companyFilter: string;
|
companyFilter: string;
|
||||||
|
countryFilter: string;
|
||||||
jobTitleFilter: string;
|
jobTitleFilter: string;
|
||||||
}>;
|
}>;
|
||||||
export default function OffersTable({
|
export default function OffersTable({
|
||||||
cityFilter,
|
countryFilter,
|
||||||
companyFilter,
|
companyFilter,
|
||||||
jobTitleFilter,
|
jobTitleFilter,
|
||||||
}: OffersTableProps) {
|
}: OffersTableProps) {
|
||||||
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
|
const [currency, setCurrency] = useState(Currency.SGD.toString()); // TODO: Detect location
|
||||||
const [selectedYoe, setSelectedYoe] = useState(YOE_CATEGORY.ENTRY);
|
const [selectedYoe, setSelectedYoe] = useState('');
|
||||||
|
const [jobType, setJobType] = useState<JobType>(JobType.FULLTIME);
|
||||||
const [pagination, setPagination] = useState<Paging>({
|
const [pagination, setPagination] = useState<Paging>({
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
numOfItems: 0,
|
numOfItems: 0,
|
||||||
@ -43,6 +47,10 @@ export default function OffersTable({
|
|||||||
OfferTableFilterOptions[0].value,
|
OfferTableFilterOptions[0].value,
|
||||||
);
|
);
|
||||||
const { event: gaEvent } = useGoogleAnalytics();
|
const { event: gaEvent } = useGoogleAnalytics();
|
||||||
|
const router = useRouter();
|
||||||
|
const { yoeCategory = '' } = router.query;
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPagination({
|
setPagination({
|
||||||
currentPage: 0,
|
currentPage: 0,
|
||||||
@ -50,20 +58,26 @@ export default function OffersTable({
|
|||||||
numOfPages: 0,
|
numOfPages: 0,
|
||||||
totalItems: 0,
|
totalItems: 0,
|
||||||
});
|
});
|
||||||
}, [selectedYoe, currency]);
|
setIsLoading(true);
|
||||||
const offersQuery = trpc.useQuery(
|
}, [selectedYoe, currency, countryFilter, companyFilter, jobTitleFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedYoe(yoeCategory as YOE_CATEGORY);
|
||||||
|
event?.preventDefault();
|
||||||
|
}, [yoeCategory]);
|
||||||
|
|
||||||
|
trpc.useQuery(
|
||||||
[
|
[
|
||||||
'offers.list',
|
'offers.list',
|
||||||
{
|
{
|
||||||
companyId: companyFilter,
|
companyId: companyFilter,
|
||||||
// Location: 'Singapore, Singapore', // TODO: Geolocation
|
countryId: countryFilter,
|
||||||
countryId: cityFilter,
|
|
||||||
currency,
|
currency,
|
||||||
limit: NUMBER_OF_OFFERS_IN_PAGE,
|
limit: NUMBER_OF_OFFERS_IN_PAGE,
|
||||||
offset: pagination.currentPage,
|
offset: pagination.currentPage,
|
||||||
sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
|
sortBy: OfferTableSortBy[selectedFilter] ?? '-monthYearReceived',
|
||||||
title: jobTitleFilter,
|
title: jobTitleFilter,
|
||||||
yoeCategory: selectedYoe,
|
yoeCategory: YOE_CATEGORY_PARAM[yoeCategory as string] ?? undefined,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
{
|
{
|
||||||
@ -73,6 +87,8 @@ export default function OffersTable({
|
|||||||
onSuccess: (response: GetOffersResponse) => {
|
onSuccess: (response: GetOffersResponse) => {
|
||||||
setOffers(response.data);
|
setOffers(response.data);
|
||||||
setPagination(response.paging);
|
setPagination(response.paging);
|
||||||
|
setJobType(response.jobType);
|
||||||
|
setIsLoading(false);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -80,14 +96,43 @@ export default function OffersTable({
|
|||||||
function renderFilters() {
|
function renderFilters() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-4 text-sm sm:grid-cols-4 md:text-base">
|
<div className="flex items-center justify-between p-4 text-sm sm:grid-cols-4 md:text-base">
|
||||||
<DropdownMenu align="start" label="Filters" size="inherit">
|
<DropdownMenu
|
||||||
|
align="start"
|
||||||
|
label={
|
||||||
|
OfferTableYoeOptions.filter(
|
||||||
|
({ value: itemValue }) => itemValue === selectedYoe,
|
||||||
|
)[0].label
|
||||||
|
}
|
||||||
|
size="inherit">
|
||||||
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
|
{OfferTableYoeOptions.map(({ label: itemLabel, value }) => (
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
key={value}
|
key={value}
|
||||||
isSelected={value === selectedYoe}
|
isSelected={value === selectedYoe}
|
||||||
label={itemLabel}
|
label={itemLabel}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedYoe(value);
|
if (value === '') {
|
||||||
|
router.replace(
|
||||||
|
{
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: undefined,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
// Do not refresh the page
|
||||||
|
{ shallow: true },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
['yoeCategory']: value,
|
||||||
|
});
|
||||||
|
router.replace(
|
||||||
|
{
|
||||||
|
pathname: location.pathname,
|
||||||
|
search: params.toString(),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ shallow: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
gaEvent({
|
gaEvent({
|
||||||
action: `offers.table_filter_yoe_category_${value}`,
|
action: `offers.table_filter_yoe_category_${value}`,
|
||||||
category: 'engagement',
|
category: 'engagement',
|
||||||
@ -98,7 +143,7 @@ export default function OffersTable({
|
|||||||
))}
|
))}
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<div className="divide-x-slate-200 col-span-3 flex items-center justify-end space-x-4 divide-x">
|
<div className="divide-x-slate-200 col-span-3 flex items-center justify-end space-x-4 divide-x">
|
||||||
<div className="justify-left flex items-center space-x-2">
|
<div className="justify-left flex items-center space-x-2 font-medium text-slate-700">
|
||||||
<span className="sr-only sm:not-sr-only sm:inline">
|
<span className="sr-only sm:not-sr-only sm:inline">
|
||||||
Display offers in
|
Display offers in
|
||||||
</span>
|
</span>
|
||||||
@ -134,7 +179,7 @@ export default function OffersTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderHeader() {
|
function renderHeader() {
|
||||||
const columns = [
|
let columns = [
|
||||||
'Company',
|
'Company',
|
||||||
'Title',
|
'Title',
|
||||||
'YOE',
|
'YOE',
|
||||||
@ -142,6 +187,18 @@ export default function OffersTable({
|
|||||||
'Date Offered',
|
'Date Offered',
|
||||||
'Actions',
|
'Actions',
|
||||||
];
|
];
|
||||||
|
if (jobType === JobType.FULLTIME) {
|
||||||
|
columns = [
|
||||||
|
'Company',
|
||||||
|
'Title',
|
||||||
|
'YOE',
|
||||||
|
'Annual TC',
|
||||||
|
'Annual Base / Bonus / Stocks',
|
||||||
|
'Date Offered',
|
||||||
|
'Actions',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<thead className="text-slate-700">
|
<thead className="text-slate-700">
|
||||||
<tr className="divide-x divide-slate-200">
|
<tr className="divide-x divide-slate-200">
|
||||||
@ -149,7 +206,7 @@ export default function OffersTable({
|
|||||||
<th
|
<th
|
||||||
key={header}
|
key={header}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'bg-slate-100 py-3 px-6',
|
'bg-slate-100 py-3 px-4',
|
||||||
// Make last column sticky.
|
// Make last column sticky.
|
||||||
index === columns.length - 1 &&
|
index === columns.length - 1 &&
|
||||||
'sticky right-0 drop-shadow md:drop-shadow-none',
|
'sticky right-0 drop-shadow md:drop-shadow-none',
|
||||||
@ -172,20 +229,29 @@ export default function OffersTable({
|
|||||||
return (
|
return (
|
||||||
<div className="relative w-full border border-slate-200">
|
<div className="relative w-full border border-slate-200">
|
||||||
{renderFilters()}
|
{renderFilters()}
|
||||||
{offersQuery.isLoading ? (
|
{isLoading ? (
|
||||||
<div className="col-span-10 pt-4">
|
<div className="col-span-10 py-32">
|
||||||
<Spinner display="block" size="lg" />
|
<Spinner display="block" size="lg" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto text-slate-600">
|
||||||
<table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left text-slate-600">
|
<table className="w-full divide-y divide-slate-200 border-y border-slate-200 text-left">
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
<tbody>
|
<tbody>
|
||||||
{offers.map((offer) => (
|
{offers.map((offer) => (
|
||||||
<OffersRow key={offer.id} row={offer} />
|
<OffersRow key={offer.id} jobType={jobType} row={offer} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{!offers ||
|
||||||
|
(offers.length === 0 && (
|
||||||
|
<div className="py-16 text-lg">
|
||||||
|
<div className="flex justify-center">No data yet🥺</div>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
Please try another set of filters.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<OffersTablePagination
|
<OffersTablePagination
|
||||||
|
@ -1,12 +1,20 @@
|
|||||||
// eslint-disable-next-line no-shadow
|
// eslint-disable-next-line no-shadow
|
||||||
export enum YOE_CATEGORY {
|
export enum YOE_CATEGORY {
|
||||||
INTERN = 0,
|
ENTRY = 'entry',
|
||||||
ENTRY = 1,
|
INTERN = 'intern',
|
||||||
MID = 2,
|
MID = 'mid',
|
||||||
SENIOR = 3,
|
SENIOR = 'senior',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const YOE_CATEGORY_PARAM: Record<string, number> = {
|
||||||
|
entry: 1,
|
||||||
|
intern: 0,
|
||||||
|
mid: 2,
|
||||||
|
senior: 3,
|
||||||
|
};
|
||||||
|
|
||||||
export const OfferTableYoeOptions = [
|
export const OfferTableYoeOptions = [
|
||||||
|
{ label: 'All Full Time YOE', value: '' },
|
||||||
{
|
{
|
||||||
label: 'Fresh Grad (0-2 YOE)',
|
label: 'Fresh Grad (0-2 YOE)',
|
||||||
value: YOE_CATEGORY.ENTRY,
|
value: YOE_CATEGORY.ENTRY,
|
||||||
|
@ -5,18 +5,16 @@ import { Banner } from '@tih/ui';
|
|||||||
|
|
||||||
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
|
import { useGoogleAnalytics } from '~/components/global/GoogleAnalytics';
|
||||||
import OffersTable from '~/components/offers/table/OffersTable';
|
import OffersTable from '~/components/offers/table/OffersTable';
|
||||||
import CitiesTypeahead from '~/components/shared/CitiesTypeahead';
|
|
||||||
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
|
import CompaniesTypeahead from '~/components/shared/CompaniesTypeahead';
|
||||||
import Container from '~/components/shared/Container';
|
import Container from '~/components/shared/Container';
|
||||||
|
import CountriesTypeahead from '~/components/shared/CountriesTypeahead';
|
||||||
import type { JobTitleType } from '~/components/shared/JobTitles';
|
import type { JobTitleType } from '~/components/shared/JobTitles';
|
||||||
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
|
import JobTitlesTypeahead from '~/components/shared/JobTitlesTypahead';
|
||||||
|
|
||||||
export default function OffersHomePage() {
|
export default function OffersHomePage() {
|
||||||
const [jobTitleFilter, setJobTitleFilter] = useState<JobTitleType | ''>(
|
const [jobTitleFilter, setJobTitleFilter] = useState<JobTitleType | ''>('');
|
||||||
'software-engineer',
|
|
||||||
);
|
|
||||||
const [companyFilter, setCompanyFilter] = useState('');
|
const [companyFilter, setCompanyFilter] = useState('');
|
||||||
const [cityFilter, setCityFilter] = useState('');
|
const [countryFilter, setCountryFilter] = useState('');
|
||||||
const { event: gaEvent } = useGoogleAnalytics();
|
const { event: gaEvent } = useGoogleAnalytics();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -28,21 +26,23 @@ export default function OffersHomePage() {
|
|||||||
</Link>
|
</Link>
|
||||||
. ⭐
|
. ⭐
|
||||||
</Banner>
|
</Banner>
|
||||||
<div className="text-primary-600 flex items-center justify-end space-x-1 bg-slate-100 px-4 pt-4">
|
<div className="text-primary-600 flex items-center justify-end space-x-1 bg-slate-100 px-4 pt-4 sm:text-lg">
|
||||||
<span>
|
<span>
|
||||||
<MapPinIcon className="flex h-7 w-7" />
|
<MapPinIcon className="flex h-7 w-7" />
|
||||||
</span>
|
</span>
|
||||||
<CitiesTypeahead
|
<CountriesTypeahead
|
||||||
isLabelHidden={true}
|
isLabelHidden={true}
|
||||||
placeholder="All Cities"
|
placeholder="All Countries"
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (option) {
|
if (option) {
|
||||||
setCityFilter(option.value);
|
setCountryFilter(option.value);
|
||||||
gaEvent({
|
gaEvent({
|
||||||
action: `offers.table_filter_city_${option.value}`,
|
action: `offers.table_filter_country_${option.value}`,
|
||||||
category: 'engagement',
|
category: 'engagement',
|
||||||
label: 'Filter by city',
|
label: 'Filter by country',
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setCountryFilter('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -64,7 +64,7 @@ export default function OffersHomePage() {
|
|||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<JobTitlesTypeahead
|
<JobTitlesTypeahead
|
||||||
isLabelHidden={true}
|
isLabelHidden={true}
|
||||||
placeholder="Software Engineer"
|
placeholder="All Job Titles"
|
||||||
textSize="inherit"
|
textSize="inherit"
|
||||||
onSelect={(option) => {
|
onSelect={(option) => {
|
||||||
if (option) {
|
if (option) {
|
||||||
@ -102,8 +102,8 @@ export default function OffersHomePage() {
|
|||||||
</div>
|
</div>
|
||||||
<Container className="pb-20 pt-10">
|
<Container className="pb-20 pt-10">
|
||||||
<OffersTable
|
<OffersTable
|
||||||
cityFilter={cityFilter}
|
|
||||||
companyFilter={companyFilter}
|
companyFilter={companyFilter}
|
||||||
|
countryFilter={countryFilter}
|
||||||
jobTitleFilter={jobTitleFilter}
|
jobTitleFilter={jobTitleFilter}
|
||||||
/>
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
|
@ -195,7 +195,7 @@ export default function OfferProfile() {
|
|||||||
)}
|
)}
|
||||||
{getProfileQuery.isLoading && (
|
{getProfileQuery.isLoading && (
|
||||||
<div className="flex h-screen w-screen">
|
<div className="flex h-screen w-screen">
|
||||||
<div className="m-auto mx-auto w-screen justify-center">
|
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
|
||||||
<Spinner display="block" size="lg" />
|
<Spinner display="block" size="lg" />
|
||||||
<div className="text-center">Loading...</div>
|
<div className="text-center">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -71,7 +71,7 @@ export default function OffersSubmissionResult() {
|
|||||||
<>
|
<>
|
||||||
{getAnalysis.isLoading && (
|
{getAnalysis.isLoading && (
|
||||||
<div className="flex h-screen w-screen">
|
<div className="flex h-screen w-screen">
|
||||||
<div className="m-auto mx-auto w-screen justify-center">
|
<div className="m-auto mx-auto w-screen justify-center font-medium text-slate-500">
|
||||||
<Spinner display="block" size="lg" />
|
<Spinner display="block" size="lg" />
|
||||||
<div className="text-center">Loading...</div>
|
<div className="text-center">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user