add review and booking policies
This commit is contained in:
@@ -8,7 +8,7 @@ export type DashboardLanguage = 'en' | 'fr' | 'ar'
|
||||
export type DashboardTheme = 'light' | 'dark'
|
||||
|
||||
type FleetDict = {
|
||||
statusLabels: Record<'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE', string>
|
||||
statusLabels: Record<'AVAILABLE' | 'RESERVED' | 'READY' | 'RENTED' | 'RETURNED' | 'NEEDS_CLEANING' | 'MAINTENANCE' | 'DAMAGE_REVIEW' | 'OUT_OF_SERVICE', string>
|
||||
logMaintenance: string
|
||||
maintenanceSubtitle: (name: string) => string
|
||||
maintenanceTypeRequired: string
|
||||
@@ -330,6 +330,8 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
contracts: 'Contracts',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Settings',
|
||||
reviews: 'Reviews',
|
||||
complaints: 'Complaints',
|
||||
},
|
||||
titles: {
|
||||
'/': 'Dashboard',
|
||||
@@ -358,7 +360,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
fleet: {
|
||||
statusLabels: { AVAILABLE: 'Available', RENTED: 'Rented', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Out of Service' },
|
||||
statusLabels: { AVAILABLE: 'Available', RESERVED: 'Reserved', READY: 'Ready for pickup', RENTED: 'On rent', RETURNED: 'Returned', NEEDS_CLEANING: 'Needs cleaning', MAINTENANCE: 'Maintenance', DAMAGE_REVIEW: 'Damage review', OUT_OF_SERVICE: 'Out of service' },
|
||||
logMaintenance: 'Log Maintenance',
|
||||
maintenanceSubtitle: (name) => `${name} has been set to maintenance. Add details below.`,
|
||||
maintenanceTypeRequired: 'Please enter a maintenance type.',
|
||||
@@ -682,6 +684,8 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
contracts: 'Contrats',
|
||||
notifications: 'Notifications',
|
||||
settings: 'Paramètres',
|
||||
reviews: 'Avis',
|
||||
complaints: 'Plaintes',
|
||||
},
|
||||
titles: {
|
||||
'/': 'Tableau de bord',
|
||||
@@ -710,7 +714,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
fleet: {
|
||||
statusLabels: { AVAILABLE: 'Disponible', RENTED: 'Loué', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Hors service' },
|
||||
statusLabels: { AVAILABLE: 'Disponible', RESERVED: 'Réservé', READY: 'Prêt pour remise', RENTED: 'En location', RETURNED: 'Rendu', NEEDS_CLEANING: 'Nettoyage requis', MAINTENANCE: 'Maintenance', DAMAGE_REVIEW: 'Révision dommages', OUT_OF_SERVICE: 'Hors service' },
|
||||
logMaintenance: 'Journal de maintenance',
|
||||
maintenanceSubtitle: (name) => `${name} a été mis en maintenance. Ajoutez les détails ci-dessous.`,
|
||||
maintenanceTypeRequired: 'Veuillez saisir un type de maintenance.',
|
||||
@@ -1034,6 +1038,8 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
contracts: 'العقود',
|
||||
notifications: 'الإشعارات',
|
||||
settings: 'الإعدادات',
|
||||
reviews: 'التقييمات',
|
||||
complaints: 'الشكاوى',
|
||||
},
|
||||
titles: {
|
||||
'/': 'لوحة التحكم',
|
||||
@@ -1062,7 +1068,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
fleet: {
|
||||
statusLabels: { AVAILABLE: 'متاح', RENTED: 'مؤجر', MAINTENANCE: 'صيانة', OUT_OF_SERVICE: 'خارج الخدمة' },
|
||||
statusLabels: { AVAILABLE: 'متاح', RESERVED: 'محجوز', READY: 'جاهز للتسليم', RENTED: 'قيد التأجير', RETURNED: 'مُعاد', NEEDS_CLEANING: 'يحتاج تنظيف', MAINTENANCE: 'صيانة', DAMAGE_REVIEW: 'مراجعة أضرار', OUT_OF_SERVICE: 'خارج الخدمة' },
|
||||
logMaintenance: 'تسجيل الصيانة',
|
||||
maintenanceSubtitle: (name) => `تم تعيين ${name} في وضع الصيانة. أضف التفاصيل أدناه.`,
|
||||
maintenanceTypeRequired: 'الرجاء إدخال نوع الصيانة.',
|
||||
@@ -1531,78 +1537,161 @@ export function useDashboardI18n() {
|
||||
}
|
||||
|
||||
export function DashboardLanguageSwitcher() {
|
||||
const { language, setLanguage, dict } = useDashboardI18n()
|
||||
const { language, setLanguage } = useDashboardI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [open])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
|
||||
function handleLanguageChange(value: DashboardLanguage) {
|
||||
setLanguage(value)
|
||||
apiFetch('/auth/employee/me/language', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ language: value }),
|
||||
}).catch(() => {})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const current = languageMeta[language]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2 py-1 shadow-sm transition-colors dark:border-blue-800 dark:bg-[#0d1b38]/85">
|
||||
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
|
||||
{dict.language}
|
||||
</span>
|
||||
{(['en', 'fr', 'ar'] as DashboardLanguage[]).map((value) => {
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => handleLanguageChange(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div ref={ref} className="relative flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span aria-hidden="true">{current.flag}</span>
|
||||
<span>{current.shortLabel}</span>
|
||||
</span>
|
||||
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
||||
{(['en', 'fr', 'ar'] as DashboardLanguage[]).map((value) => {
|
||||
const meta = languageMeta[value]
|
||||
const active = value === language
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => handleLanguageChange(value)}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{meta.flag}</span>
|
||||
<span>{meta.shortLabel}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DashboardThemeSwitcher() {
|
||||
const { theme, setTheme, dict } = useDashboardI18n()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [embedded, setEmbedded] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setEmbedded(window.self !== window.top)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function onMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown)
|
||||
}, [open])
|
||||
|
||||
if (embedded) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 rounded-full border border-stone-200/80 bg-white/95 px-2 py-1 shadow-sm transition-colors dark:border-blue-800 dark:bg-[#0d1b38]/85">
|
||||
{(['light', 'dark'] as DashboardTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTheme(value)}
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-400 dark:text-white'
|
||||
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? dict.light : dict.dark}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div ref={ref} className="relative flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex w-full items-center justify-between gap-1.5 rounded-xl border border-stone-200/80 bg-white/95 px-3 py-2 text-xs font-semibold text-stone-700 shadow-sm transition hover:bg-stone-50 dark:border-blue-800 dark:bg-[#0d1b38]/85 dark:text-stone-200 dark:hover:bg-[#162038]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{theme === 'light' ? (
|
||||
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{theme === 'light' ? dict.light : dict.dark}</span>
|
||||
</span>
|
||||
<svg className={`h-3 w-3 text-stone-400 transition-transform dark:text-stone-500 ${open ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute bottom-full left-0 mb-1.5 w-full overflow-hidden rounded-xl border border-stone-200/80 bg-white shadow-lg dark:border-blue-800 dark:bg-[#0d1b38]">
|
||||
{(['light', 'dark'] as DashboardTheme[]).map((value) => {
|
||||
const active = value === theme
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => { setTheme(value); setOpen(false) }}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors ${
|
||||
active
|
||||
? 'bg-blue-900 text-white dark:bg-orange-500 dark:text-white'
|
||||
: 'text-stone-700 hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-[#162038]'
|
||||
}`}
|
||||
>
|
||||
{value === 'light' ? (
|
||||
<svg className="h-3.5 w-3.5 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 text-blue-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
)}
|
||||
<span>{value === 'light' ? dict.light : dict.dark}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ import {
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Star,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
@@ -73,6 +75,8 @@ const NAV_ITEMS = [
|
||||
{ href: '/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
|
||||
{ href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
|
||||
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
|
||||
{ href: '/reviews', key: 'reviews', icon: Star, minRole: 'AGENT' },
|
||||
{ href: '/complaints', key: 'complaints', icon: AlertTriangle, minRole: 'AGENT' },
|
||||
{ href: '/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
|
||||
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
|
||||
] as const
|
||||
@@ -256,6 +260,10 @@ export default function Sidebar() {
|
||||
<LogOut className="h-4 w-4" />
|
||||
{dict.signOut}
|
||||
</button>
|
||||
<div className="mt-3 flex items-center gap-2 border-t border-blue-900/50 pt-3">
|
||||
<DashboardLanguageSwitcher />
|
||||
<DashboardThemeSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
|
||||
interface ReservationPhoto {
|
||||
id: string
|
||||
type: 'PICKUP' | 'DROPOFF'
|
||||
url: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
reservationId: string
|
||||
type: 'PICKUP' | 'DROPOFF'
|
||||
editable: boolean
|
||||
title: string
|
||||
readOnlyMessage?: string
|
||||
}
|
||||
|
||||
export default function ReservationPhotoSection({ reservationId, type, editable, title, readOnlyMessage }: Props) {
|
||||
const [photos, setPhotos] = useState<ReservationPhoto[]>([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const loadPhotos = useCallback(async () => {
|
||||
try {
|
||||
const all = await apiFetch<ReservationPhoto[]>(`/reservations/${reservationId}/photos`)
|
||||
setPhotos(all.filter((p) => p.type === type))
|
||||
} catch {
|
||||
// silent — don't block the page if photos fail to load
|
||||
}
|
||||
}, [reservationId, type])
|
||||
|
||||
useEffect(() => { loadPhotos() }, [loadPhotos])
|
||||
|
||||
async function handleFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const body = new FormData()
|
||||
body.append('photo', file)
|
||||
body.append('type', type)
|
||||
await apiFetch(`/reservations/${reservationId}/photos`, { method: 'POST', body })
|
||||
}
|
||||
await loadPhotos()
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Upload failed')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(photoId: string) {
|
||||
setError(null)
|
||||
try {
|
||||
await apiFetch(`/reservations/${reservationId}/photos/${photoId}`, { method: 'DELETE' })
|
||||
setPhotos((prev) => prev.filter((p) => p.id !== photoId))
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Delete failed')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn-secondary text-xs"
|
||||
>
|
||||
{uploading ? 'Uploading…' : '+ Add photos'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!editable && readOnlyMessage && (
|
||||
<p className="mb-4 text-xs text-slate-500">{readOnlyMessage}</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
/>
|
||||
|
||||
{error && <p className="mb-3 text-xs text-red-600">{error}</p>}
|
||||
|
||||
{photos.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">No photos yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.id} className="group relative aspect-square overflow-hidden rounded-xl border border-slate-200">
|
||||
<img src={photo.url} alt="" className="h-full w-full object-cover" />
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(photo.id)}
|
||||
className="absolute right-1 top-1 hidden h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white group-hover:flex"
|
||||
aria-label="Delete photo"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" className="h-3 w-3">
|
||||
<path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user