fix architecture and write new tests
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { API_BASE, apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
import { API_BASE, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface ReportRow {
|
||||
@@ -110,9 +110,8 @@ export default function ReportsPage() {
|
||||
setExporting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => null)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import InviteModal from '@/components/team/InviteModal'
|
||||
import EditMemberModal from '@/components/team/EditMemberModal'
|
||||
import PermissionsMatrix from '@/components/team/PermissionsMatrix'
|
||||
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
@@ -193,18 +193,17 @@ export default function TeamPage() {
|
||||
const [toast, setToast] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) return
|
||||
let cancelled = false
|
||||
|
||||
try {
|
||||
const encoded = token.split('.')[1] ?? ''
|
||||
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
const payload = JSON.parse(atob(padded))
|
||||
setCurrentEmployeeId(typeof payload?.sub === 'string' ? payload.sub : null)
|
||||
} catch {
|
||||
setCurrentEmployeeId(null)
|
||||
}
|
||||
apiFetch<{ employee: { id: string } }>('/auth/employee/me')
|
||||
.then(({ employee }) => {
|
||||
if (!cancelled) setCurrentEmployeeId(employee.id)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setCurrentEmployeeId(null)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const currentEmployee = members.find((member) => member.id === currentEmployeeId)
|
||||
|
||||
@@ -13,8 +13,8 @@ function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
|
||||
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = cookies()
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const cookieStore = await cookies()
|
||||
const initialLanguage = resolveInitialLanguage(
|
||||
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ?? cookieStore.get('dashboard-language')?.value,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ForgotPasswordPageClient from './forgot-password/ForgotPasswordPageClient'
|
||||
import ResetPasswordPageClient from './reset-password/ResetPasswordPageClient'
|
||||
import ForgotPasswordPage from './forgot-password/page'
|
||||
import ResetPasswordPage from './reset-password/page'
|
||||
import AcceptInvitePage from './onboarding/accept-invite/page'
|
||||
|
||||
function collectText(node: unknown): string[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return []
|
||||
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
|
||||
if (Array.isArray(node)) return node.flatMap(collectText)
|
||||
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
|
||||
return []
|
||||
}
|
||||
|
||||
function findElement(node: unknown, predicate: (element: React.ReactElement) => boolean): React.ReactElement | null {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return null
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findElement(child, predicate)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!isValidElement<{ children?: React.ReactNode }>(node)) return null
|
||||
if (predicate(node)) return node
|
||||
return findElement(node.props.children, predicate)
|
||||
}
|
||||
|
||||
describe('dashboard public auth pages', () => {
|
||||
it('renders forgot-password in embedded mode for the public shell route', () => {
|
||||
const page = ForgotPasswordPage()
|
||||
|
||||
expect(isValidElement(page)).toBe(true)
|
||||
expect(page.type).toBe(ForgotPasswordPageClient)
|
||||
expect(page.props.embedded).toBe(true)
|
||||
})
|
||||
|
||||
it('renders reset-password in embedded mode for the public shell route', () => {
|
||||
const page = ResetPasswordPage()
|
||||
|
||||
expect(isValidElement(page)).toBe(true)
|
||||
expect(page.type).toBe(ResetPasswordPageClient)
|
||||
expect(page.props.embedded).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps accept-invite copy and sign-in handoff visible', () => {
|
||||
const page = AcceptInvitePage()
|
||||
const text = collectText(page).join(' ')
|
||||
const signInLink = findElement(page, (element) => element.props.href === '/sign-in')
|
||||
|
||||
expect(text).toContain('Invitation accepted')
|
||||
expect(text).toContain('Sign in to dashboard')
|
||||
expect(signInLink).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
||||
import { API_BASE, EMPLOYEE_PROFILE_KEY } from '@/lib/api'
|
||||
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
@@ -235,14 +235,13 @@ function LocalSignInForm({
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const empJson = await empRes.json()
|
||||
|
||||
if (empRes.ok && empJson?.data?.token) {
|
||||
const token = empJson.data.token
|
||||
if (empRes.ok && empJson?.data?.employee) {
|
||||
const targetPath = toPublicDashboardPath(employeeRedirect)
|
||||
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
|
||||
if (empJson?.data?.employee) {
|
||||
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
|
||||
const prefLang = empJson.data.employee?.preferredLanguage
|
||||
@@ -251,7 +250,6 @@ function LocalSignInForm({
|
||||
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
|
||||
}
|
||||
}
|
||||
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
||||
// Use a document navigation here so the authenticated dashboard bootstraps
|
||||
@@ -268,12 +266,13 @@ function LocalSignInForm({
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.token) {
|
||||
redirectAdmin(adminJson.data.token)
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${adminUrl}/dashboard`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -296,15 +295,21 @@ function LocalSignInForm({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const normalizedCode = totpCode.trim().toUpperCase()
|
||||
const secondFactor = /^\d{6}$/.test(normalizedCode)
|
||||
? { totpCode: normalizedCode }
|
||||
: { recoveryCode: normalizedCode }
|
||||
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, totpCode }),
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password, ...secondFactor }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.token) {
|
||||
redirectAdmin(adminJson.data.token)
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${adminUrl}/dashboard`
|
||||
return
|
||||
}
|
||||
|
||||
@@ -393,13 +398,13 @@ function LocalSignInForm({
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.authCode}</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
inputMode="text"
|
||||
pattern="[0-9A-Za-z-]{6,14}"
|
||||
maxLength={14}
|
||||
required
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
|
||||
placeholder="000000 or XXXX-XXXX-XXXX"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
calendarGridDays,
|
||||
daysBetween,
|
||||
eventsForCalendarDay,
|
||||
isSameDay,
|
||||
parseDateStr,
|
||||
selectedBlockRange,
|
||||
toDateStr,
|
||||
type CalendarEvent,
|
||||
} from './VehicleCalendar'
|
||||
|
||||
describe('VehicleCalendar helpers', () => {
|
||||
it('formats and parses local date-only strings without shifting the calendar day', () => {
|
||||
const parsed = parseDateStr('2026-02-09')
|
||||
|
||||
expect(parsed.getFullYear()).toBe(2026)
|
||||
expect(parsed.getMonth()).toBe(1)
|
||||
expect(parsed.getDate()).toBe(9)
|
||||
expect(toDateStr(parsed)).toBe('2026-02-09')
|
||||
})
|
||||
|
||||
it('builds a full week-aligned month grid with leading and trailing blanks', () => {
|
||||
const grid = calendarGridDays(2026, 2)
|
||||
|
||||
expect(grid).toHaveLength(28)
|
||||
expect(grid[0]?.getDate()).toBe(1)
|
||||
expect(grid.at(-1)?.getDate()).toBe(28)
|
||||
|
||||
const march = calendarGridDays(2026, 3)
|
||||
expect(march).toHaveLength(35)
|
||||
expect(march[0]?.getDate()).toBe(1)
|
||||
expect(march[6]?.getDate()).toBe(7)
|
||||
expect(march.at(-1)).toBeNull()
|
||||
})
|
||||
|
||||
it('treats reservation end dates as exclusive when mapping events to days', () => {
|
||||
const events: CalendarEvent[] = [
|
||||
{ id: 'r1', type: 'RESERVATION', startDate: '2026-06-10T10:00:00.000Z', endDate: '2026-06-13T10:00:00.000Z', status: 'CONFIRMED', label: 'Reservation' },
|
||||
{ id: 'b1', type: 'BLOCK', startDate: '2026-06-13T00:00:00.000Z', endDate: '2026-06-14T00:00:00.000Z', status: null, label: 'Block' },
|
||||
]
|
||||
|
||||
expect(eventsForCalendarDay(events, parseDateStr('2026-06-10')).map((event) => event.id)).toEqual(['r1'])
|
||||
expect(eventsForCalendarDay(events, parseDateStr('2026-06-12')).map((event) => event.id)).toEqual(['r1'])
|
||||
expect(eventsForCalendarDay(events, parseDateStr('2026-06-13')).map((event) => event.id)).toEqual(['b1'])
|
||||
})
|
||||
|
||||
it('normalizes two clicked days into an inclusive visual range with exclusive end date', () => {
|
||||
expect(selectedBlockRange('2026-06-12', parseDateStr('2026-06-10'))).toEqual({
|
||||
startDate: '2026-06-10',
|
||||
endDate: '2026-06-13',
|
||||
})
|
||||
expect(selectedBlockRange('2026-06-10', parseDateStr('2026-06-12'))).toEqual({
|
||||
startDate: '2026-06-10',
|
||||
endDate: '2026-06-13',
|
||||
})
|
||||
})
|
||||
|
||||
it('compares calendar days and calculates day spans independent of clock time', () => {
|
||||
expect(isSameDay(new Date(2026, 5, 9, 1), new Date(2026, 5, 9, 23))).toBe(true)
|
||||
expect(isSameDay(new Date(2026, 5, 9), new Date(2026, 5, 10))).toBe(false)
|
||||
expect(daysBetween(parseDateStr('2026-06-09'), parseDateStr('2026-06-12'))).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK'
|
||||
|
||||
interface CalendarEvent {
|
||||
export interface CalendarEvent {
|
||||
id: string
|
||||
type: EventType
|
||||
startDate: string
|
||||
@@ -26,23 +26,56 @@ const EVENT_STYLES: Record<EventType, { bg: string; text: string; border: string
|
||||
BLOCK: { bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-300', dot: 'bg-red-500' },
|
||||
}
|
||||
|
||||
function toDateStr(d: Date): string {
|
||||
export function toDateStr(d: Date): string {
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function parseDateStr(s: string): Date {
|
||||
export function parseDateStr(s: string): Date {
|
||||
const [y, m, day] = s.split('-').map(Number)
|
||||
return new Date(y, m - 1, day)
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
export function isSameDay(a: Date, b: Date): boolean {
|
||||
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
function daysBetween(start: Date, end: Date): number {
|
||||
export function daysBetween(start: Date, end: Date): number {
|
||||
return Math.round((end.getTime() - start.getTime()) / 86400000)
|
||||
}
|
||||
|
||||
|
||||
export function calendarGridDays(year: number, month: number): (Date | null)[] {
|
||||
const firstDay = new Date(year, month - 1, 1)
|
||||
const daysInMonth = new Date(year, month, 0).getDate()
|
||||
const startOffset = firstDay.getDay()
|
||||
|
||||
const gridDays: (Date | null)[] = []
|
||||
for (let i = 0; i < startOffset; i++) gridDays.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
|
||||
while (gridDays.length % 7 !== 0) gridDays.push(null)
|
||||
return gridDays
|
||||
}
|
||||
|
||||
export function eventsForCalendarDay(events: CalendarEvent[], day: Date): CalendarEvent[] {
|
||||
const dayStr = toDateStr(day)
|
||||
return events.filter((e) => {
|
||||
const start = e.startDate.slice(0, 10)
|
||||
const end = e.endDate.slice(0, 10)
|
||||
return start <= dayStr && dayStr < end
|
||||
})
|
||||
}
|
||||
|
||||
export function selectedBlockRange(selectStart: string, clickedDay: Date): { startDate: string; endDate: string } {
|
||||
const clicked = toDateStr(clickedDay)
|
||||
const start = selectStart < clicked ? selectStart : clicked
|
||||
const end = selectStart < clicked ? clicked : selectStart
|
||||
return {
|
||||
startDate: start,
|
||||
endDate: toDateStr(new Date(parseDateStr(end).getTime() + 86400000)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const c = dict.calendar
|
||||
@@ -92,24 +125,10 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
}
|
||||
|
||||
// Build calendar grid (6 rows × 7 cols)
|
||||
const firstDay = new Date(year, month - 1, 1)
|
||||
const daysInMonth = new Date(year, month, 0).getDate()
|
||||
const startOffset = firstDay.getDay() // 0=Sun
|
||||
|
||||
const gridDays: (Date | null)[] = []
|
||||
for (let i = 0; i < startOffset; i++) gridDays.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d))
|
||||
while (gridDays.length % 7 !== 0) gridDays.push(null)
|
||||
const gridDays = calendarGridDays(year, month)
|
||||
|
||||
// Map events to date strings for fast lookup
|
||||
const eventsOnDay = (day: Date): CalendarEvent[] => {
|
||||
const dayStr = toDateStr(day)
|
||||
return events.filter((e) => {
|
||||
const s = e.startDate.slice(0, 10)
|
||||
const end = e.endDate.slice(0, 10)
|
||||
return s <= dayStr && dayStr < end
|
||||
})
|
||||
}
|
||||
const eventsOnDay = (day: Date): CalendarEvent[] => eventsForCalendarDay(events, day)
|
||||
|
||||
const isToday = (day: Date) => isSameDay(day, today)
|
||||
|
||||
@@ -118,12 +137,9 @@ export default function VehicleCalendar({ vehicleId }: Props) {
|
||||
if (!selectStart) {
|
||||
setSelectStart(str)
|
||||
} else {
|
||||
const start = selectStart < str ? selectStart : str
|
||||
const end = selectStart < str ? str : selectStart
|
||||
// end date in the modal is the last night, so add one day
|
||||
const endDate = toDateStr(new Date(parseDateStr(end).getTime() + 86400000))
|
||||
setBlockStart(start)
|
||||
setBlockEnd(endDate)
|
||||
const range = selectedBlockRange(selectStart, day)
|
||||
setBlockStart(range.startDate)
|
||||
setBlockEnd(range.endDate)
|
||||
setSelectStart(null)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildConfigForm,
|
||||
buildRuleForm,
|
||||
centsToInput,
|
||||
inputToCents,
|
||||
numericInputToInt,
|
||||
toDateInput,
|
||||
type PricingConfiguration,
|
||||
type PricingRule,
|
||||
} from './VehiclePricingManager'
|
||||
|
||||
const configuration: PricingConfiguration = {
|
||||
id: 'cfg_1',
|
||||
vehicleId: 'veh_1',
|
||||
pricingMode: 'AUTOMATIC',
|
||||
baseDailyRate: 50000,
|
||||
weeklyRate: 280000,
|
||||
weekendRate: 65000,
|
||||
holidayRate: null,
|
||||
monthlyRate: 950000,
|
||||
longTermDailyRate: null,
|
||||
minimumDailyRate: 40000,
|
||||
maximumDailyRate: 80000,
|
||||
maxDailyPriceMovementPct: 12,
|
||||
automaticPricingEnabled: true,
|
||||
approvalRequired: false,
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-02T00:00:00.000Z',
|
||||
}
|
||||
|
||||
const rule: PricingRule = {
|
||||
id: 'rule_1',
|
||||
name: 'Summer',
|
||||
ruleType: 'SEASONAL',
|
||||
startDate: '2026-07-01T00:00:00.000Z',
|
||||
endDate: '2026-08-31T00:00:00.000Z',
|
||||
dailyRate: 70000,
|
||||
weeklyRate: null,
|
||||
weekendRate: 90000,
|
||||
monthlyRate: null,
|
||||
minDailyRate: 60000,
|
||||
maxDailyRate: 110000,
|
||||
automaticAdjustmentPct: 15,
|
||||
priceAdjustment: 5000,
|
||||
isActive: false,
|
||||
sortOrder: 4,
|
||||
createdAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-02T00:00:00.000Z',
|
||||
}
|
||||
|
||||
describe('VehiclePricingManager helpers', () => {
|
||||
it('converts cents to display input strings and empty nullable values', () => {
|
||||
expect(centsToInput(123456)).toBe('1234.56')
|
||||
expect(centsToInput(0)).toBe('0.00')
|
||||
expect(centsToInput(null)).toBe('')
|
||||
expect(centsToInput(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('converts currency inputs to cents while rejecting blank or invalid values', () => {
|
||||
expect(inputToCents('499.99')).toBe(49999)
|
||||
expect(inputToCents(' 50 ')).toBe(5000)
|
||||
expect(inputToCents('')).toBeNull()
|
||||
expect(inputToCents('abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses integer-only fields without accepting blank values', () => {
|
||||
expect(numericInputToInt('12')).toBe(12)
|
||||
expect(numericInputToInt('12.8')).toBe(12)
|
||||
expect(numericInputToInt('')).toBeNull()
|
||||
expect(numericInputToInt('nope')).toBeNull()
|
||||
})
|
||||
|
||||
it('builds a stable editable configuration form from API pricing data', () => {
|
||||
expect(buildConfigForm(configuration)).toEqual({
|
||||
pricingMode: 'AUTOMATIC',
|
||||
baseDailyRate: '500.00',
|
||||
weeklyRate: '2800.00',
|
||||
weekendRate: '650.00',
|
||||
holidayRate: '',
|
||||
monthlyRate: '9500.00',
|
||||
longTermDailyRate: '',
|
||||
minimumDailyRate: '400.00',
|
||||
maximumDailyRate: '800.00',
|
||||
maxDailyPriceMovementPct: '12',
|
||||
automaticPricingEnabled: true,
|
||||
approvalRequired: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds an editable rule form from an existing pricing rule', () => {
|
||||
expect(buildRuleForm(rule)).toEqual({
|
||||
id: 'rule_1',
|
||||
name: 'Summer',
|
||||
ruleType: 'SEASONAL',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-08-31',
|
||||
dailyRate: '700.00',
|
||||
weeklyRate: '',
|
||||
weekendRate: '900.00',
|
||||
monthlyRate: '',
|
||||
minDailyRate: '600.00',
|
||||
maxDailyRate: '1100.00',
|
||||
automaticAdjustmentPct: '15',
|
||||
priceAdjustment: '50.00',
|
||||
isActive: false,
|
||||
sortOrder: '4',
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults new rules to today through six days later', () => {
|
||||
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
||||
|
||||
expect(buildRuleForm()).toMatchObject({
|
||||
name: '',
|
||||
ruleType: 'DATE_RANGE',
|
||||
startDate: '2026-06-09',
|
||||
endDate: '2026-06-15',
|
||||
isActive: true,
|
||||
sortOrder: '0',
|
||||
})
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('trims ISO timestamps for date input controls', () => {
|
||||
expect(toDateInput('2026-10-04T22:15:00.000Z')).toBe('2026-10-04')
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface PricingConfiguration {
|
||||
export interface PricingConfiguration {
|
||||
id: string
|
||||
vehicleId: string
|
||||
pricingMode: 'MANUAL' | 'AUTOMATIC'
|
||||
@@ -25,7 +25,7 @@ interface PricingConfiguration {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface PricingRule {
|
||||
export interface PricingRule {
|
||||
id: string
|
||||
name: string
|
||||
ruleType: 'DATE_RANGE' | 'SEASONAL' | 'HOLIDAY' | 'WEEKEND' | 'SPECIAL_EVENT' | 'MANUAL_OVERRIDE'
|
||||
@@ -45,7 +45,7 @@ interface PricingRule {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface PricingHistoryEntry {
|
||||
export interface PricingHistoryEntry {
|
||||
id: string
|
||||
source: 'CONFIG_UPDATE' | 'RULE_CREATED' | 'RULE_UPDATED' | 'RULE_DELETED' | 'MANUAL_OVERRIDE'
|
||||
changedByEmployeeId: string | null
|
||||
@@ -58,14 +58,14 @@ interface PricingHistoryEntry {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface PricingPreviewItem {
|
||||
export interface PricingPreviewItem {
|
||||
date: string
|
||||
dailyRate: number
|
||||
pricingType: 'MANUAL_BASE' | 'AUTOMATIC_BASE' | 'WEEKEND_RATE' | 'RULE_RATE' | 'AUTOMATIC_RULE'
|
||||
matchedRuleName: string | null
|
||||
}
|
||||
|
||||
interface PricingResponse {
|
||||
export interface PricingResponse {
|
||||
configuration: PricingConfiguration
|
||||
rules: PricingRule[]
|
||||
history: PricingHistoryEntry[]
|
||||
@@ -75,7 +75,7 @@ interface PricingResponse {
|
||||
}
|
||||
}
|
||||
|
||||
interface ConfigForm {
|
||||
export interface ConfigForm {
|
||||
pricingMode: 'MANUAL' | 'AUTOMATIC'
|
||||
baseDailyRate: string
|
||||
weeklyRate: string
|
||||
@@ -90,7 +90,7 @@ interface ConfigForm {
|
||||
approvalRequired: boolean
|
||||
}
|
||||
|
||||
interface RuleForm {
|
||||
export interface RuleForm {
|
||||
id?: string
|
||||
name: string
|
||||
ruleType: PricingRule['ruleType']
|
||||
@@ -108,28 +108,28 @@ interface RuleForm {
|
||||
sortOrder: string
|
||||
}
|
||||
|
||||
function centsToInput(value: number | null | undefined) {
|
||||
export function centsToInput(value: number | null | undefined) {
|
||||
return value == null ? '' : (value / 100).toFixed(2)
|
||||
}
|
||||
|
||||
function inputToCents(value: string) {
|
||||
export function inputToCents(value: string) {
|
||||
if (!value.trim()) return null
|
||||
const parsed = Number.parseFloat(value)
|
||||
if (!Number.isFinite(parsed)) return null
|
||||
return Math.round(parsed * 100)
|
||||
}
|
||||
|
||||
function numericInputToInt(value: string) {
|
||||
export function numericInputToInt(value: string) {
|
||||
if (!value.trim()) return null
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function toDateInput(value: string) {
|
||||
export function toDateInput(value: string) {
|
||||
return value.slice(0, 10)
|
||||
}
|
||||
|
||||
function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
|
||||
export function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
|
||||
return {
|
||||
pricingMode: configuration.pricingMode,
|
||||
baseDailyRate: centsToInput(configuration.baseDailyRate),
|
||||
@@ -146,7 +146,7 @@ function buildConfigForm(configuration: PricingConfiguration): ConfigForm {
|
||||
}
|
||||
}
|
||||
|
||||
function buildRuleForm(rule?: PricingRule): RuleForm {
|
||||
export function buildRuleForm(rule?: PricingRule): RuleForm {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const nextWeek = new Date(Date.now() + 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/components/layout/PublicHeader', () => ({ default: function MockPublicHeader() { return React.createElement('mock-header') } }))
|
||||
vi.mock('@/components/layout/PublicFooter', () => ({ default: function MockPublicFooter() { return React.createElement('mock-footer') } }))
|
||||
|
||||
import PublicShell from './PublicShell'
|
||||
|
||||
function childTypes(node: React.ReactElement): string[] {
|
||||
return React.Children.toArray(node.props.children).filter(isValidElement).map((child) => {
|
||||
const type = child.type as any
|
||||
if (typeof type === 'string') return type
|
||||
return type.displayName ?? type.name ?? 'anonymous'
|
||||
})
|
||||
}
|
||||
|
||||
describe('dashboard PublicShell', () => {
|
||||
it('wraps public pages with header, content, and footer by default', () => {
|
||||
const shell = PublicShell({ children: React.createElement('main', { id: 'content' }) })
|
||||
|
||||
expect(isValidElement(shell)).toBe(true)
|
||||
expect(childTypes(shell)).toEqual(['MockPublicHeader', 'div', 'MockPublicFooter'])
|
||||
})
|
||||
|
||||
it('omits chrome when embedded so partner surfaces do not get nested headers', () => {
|
||||
const shell = PublicShell({ embedded: true, children: React.createElement('main', { id: 'content' }) })
|
||||
|
||||
expect(childTypes(shell)).toEqual(['div'])
|
||||
const contentWrapper = React.Children.toArray(shell.props.children).find(isValidElement) as React.ReactElement
|
||||
expect(contentWrapper.props.children).toEqual(React.createElement('main', { id: 'content' }))
|
||||
})
|
||||
})
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { DashboardLanguageSwitcher, DashboardThemeSwitcher, useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
|
||||
|
||||
@@ -166,9 +166,6 @@ export default function Sidebar() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function loadBrand() {
|
||||
@@ -202,16 +199,6 @@ export default function Sidebar() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) {
|
||||
setUser({
|
||||
displayName: dict.workspaceUser,
|
||||
subtitle: dict.localAuth,
|
||||
initials: 'W',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
@@ -344,9 +331,8 @@ export default function Sidebar() {
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
|
||||
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
|
||||
document.cookie = 'employee_token=; path=/; max-age=0; samesite=lax'
|
||||
void fetch('/dashboard/api/v1/auth/employee/logout', { method: 'POST', credentials: 'include' })
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-logout' })
|
||||
window.location.href = marketplaceUrl
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Bell } from 'lucide-react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
|
||||
@@ -52,11 +52,6 @@ export default function TopBar() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
|
||||
function getEmployeeToken() {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
}
|
||||
|
||||
const title = (() => {
|
||||
if (!mounted) return dict.titles['/']
|
||||
if (dict.titles[appPath]) return dict.titles[appPath]
|
||||
@@ -65,15 +60,12 @@ export default function TopBar() {
|
||||
return dict.titles['/']
|
||||
})()
|
||||
async function refreshUnreadCount() {
|
||||
if (!getEmployeeToken()) {
|
||||
setUnreadCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
setUnreadCount(data.unread)
|
||||
} catch {}
|
||||
} catch {
|
||||
setUnreadCount(0)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,15 +81,12 @@ export default function TopBar() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) return
|
||||
|
||||
const socketOrigin = resolveSocketOrigin()
|
||||
if (!socketOrigin) return
|
||||
|
||||
const socket = io(socketOrigin, {
|
||||
autoConnect: false,
|
||||
auth: { token },
|
||||
withCredentials: true,
|
||||
transports: ['polling', 'websocket'],
|
||||
})
|
||||
|
||||
@@ -121,11 +110,6 @@ export default function TopBar() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNotifs) return
|
||||
if (!getEmployeeToken()) {
|
||||
setNotifications([])
|
||||
setLoadingNotifs(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoadingNotifs(true)
|
||||
@@ -157,12 +141,6 @@ export default function TopBar() {
|
||||
}, [pathname])
|
||||
|
||||
useEffect(() => {
|
||||
const token = window.localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
if (!token) {
|
||||
setUserInitials(computeInitials(dict.workspaceUser))
|
||||
return
|
||||
}
|
||||
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildInspectionSavePayload,
|
||||
comparisonPointsForCheckout,
|
||||
editableDamagePointsForInspection,
|
||||
pointKey,
|
||||
type DamageInspection,
|
||||
type DamagePoint,
|
||||
} from './DamageInspectionCard'
|
||||
|
||||
function point(overrides: Partial<DamagePoint> = {}): DamagePoint {
|
||||
return {
|
||||
viewType: 'TOP',
|
||||
x: 10,
|
||||
y: 20,
|
||||
damageType: 'SCRATCH',
|
||||
severity: 'MINOR',
|
||||
description: null,
|
||||
isPreExisting: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function inspection(overrides: Partial<DamageInspection> = {}): DamageInspection {
|
||||
return {
|
||||
id: 'inspection-1',
|
||||
type: 'CHECKOUT',
|
||||
mileage: 45000,
|
||||
fuelLevel: 'FULL',
|
||||
fuelCharge: null,
|
||||
generalCondition: 'Clean',
|
||||
employeeNotes: 'No issues',
|
||||
customerAgreed: true,
|
||||
damagePoints: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('DamageInspectionCard helpers', () => {
|
||||
it('builds stable duplicate keys using rounded coordinates and damage metadata', () => {
|
||||
expect(pointKey(point({ x: 12.3456, y: 78.994 }))).toBe('TOP:12.35:78.99:SCRATCH:MINOR')
|
||||
expect(pointKey(point({ viewType: 'LEFT', damageType: 'DENT', severity: 'MAJOR' }))).toBe('LEFT:10.00:20.00:DENT:MAJOR')
|
||||
})
|
||||
|
||||
it('deduplicates checkout comparison points against stored pre-existing points', () => {
|
||||
const duplicate = point({ id: 'cmp-1', isPreExisting: true, description: 'old scratch' })
|
||||
const storedDuplicate = point({ id: 'stored-1', isPreExisting: true, description: 'stored copy' })
|
||||
const storedUnique = point({ id: 'stored-2', viewType: 'REAR', x: 33, y: 44, isPreExisting: true })
|
||||
|
||||
const result = comparisonPointsForCheckout(
|
||||
inspection({ damagePoints: [storedDuplicate, storedUnique] }),
|
||||
[duplicate]
|
||||
)
|
||||
|
||||
expect(result).toEqual([duplicate, storedUnique])
|
||||
})
|
||||
|
||||
it('keeps checkout editing focused on new damage while check-in exposes all points', () => {
|
||||
const oldDamage = point({ id: 'old', isPreExisting: true })
|
||||
const newDamage = point({ id: 'new', isPreExisting: false })
|
||||
const record = inspection({ damagePoints: [oldDamage, newDamage] })
|
||||
|
||||
expect(editableDamagePointsForInspection('CHECKOUT', record)).toEqual([newDamage])
|
||||
expect(editableDamagePointsForInspection('CHECKIN', record)).toEqual([oldDamage, newDamage])
|
||||
})
|
||||
|
||||
it('builds compact save payloads and omits nullable/empty fields', () => {
|
||||
const payload = buildInspectionSavePayload(
|
||||
inspection({
|
||||
mileage: null,
|
||||
fuelCharge: null,
|
||||
generalCondition: '',
|
||||
employeeNotes: '',
|
||||
customerAgreed: false,
|
||||
damagePoints: [point({ description: '' })],
|
||||
}),
|
||||
[point({ id: 'pre', viewType: 'FRONT', description: 'pre-existing chip', isPreExisting: true })]
|
||||
)
|
||||
|
||||
expect(payload).toEqual({
|
||||
fuelLevel: 'FULL',
|
||||
customerAgreed: false,
|
||||
damagePoints: [
|
||||
{
|
||||
viewType: 'FRONT',
|
||||
x: 10,
|
||||
y: 20,
|
||||
damageType: 'SCRATCH',
|
||||
severity: 'MINOR',
|
||||
description: 'pre-existing chip',
|
||||
isPreExisting: true,
|
||||
},
|
||||
{
|
||||
viewType: 'TOP',
|
||||
x: 10,
|
||||
y: 20,
|
||||
damageType: 'SCRATCH',
|
||||
severity: 'MINOR',
|
||||
isPreExisting: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -41,10 +41,54 @@ const severityColors: Record<DamageSeverity, string> = {
|
||||
MAJOR: '#dc2626',
|
||||
}
|
||||
|
||||
function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
|
||||
export function pointKey(point: Pick<DamagePoint, 'viewType' | 'x' | 'y' | 'damageType' | 'severity'>) {
|
||||
return `${point.viewType}:${point.x.toFixed(2)}:${point.y.toFixed(2)}:${point.damageType}:${point.severity}`
|
||||
}
|
||||
|
||||
|
||||
export function comparisonPointsForCheckout(initialInspection: DamageInspection | null | undefined, comparisonPoints: DamagePoint[]): DamagePoint[] {
|
||||
const storedPreExistingPoints = initialInspection?.damagePoints?.filter((point) => point.isPreExisting) ?? []
|
||||
return [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
|
||||
}
|
||||
|
||||
export function editableDamagePointsForInspection(type: InspectionType, initialInspection: DamageInspection | null | undefined): DamagePoint[] {
|
||||
const points = initialInspection?.damagePoints ?? []
|
||||
return type === 'CHECKOUT' ? points.filter((point) => !point.isPreExisting) : points
|
||||
}
|
||||
|
||||
export function buildInspectionSavePayload(inspection: DamageInspection, overlayComparisonPoints: DamagePoint[]) {
|
||||
const payloadPoints = [
|
||||
...overlayComparisonPoints.map(({ viewType, x, y, damageType, severity, description }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
...(description ? { description } : {}),
|
||||
isPreExisting: true,
|
||||
})),
|
||||
...inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
...(description ? { description } : {}),
|
||||
isPreExisting,
|
||||
})),
|
||||
]
|
||||
|
||||
return {
|
||||
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
|
||||
fuelLevel: inspection.fuelLevel,
|
||||
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
|
||||
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
|
||||
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
|
||||
customerAgreed: inspection.customerAgreed,
|
||||
damagePoints: payloadPoints,
|
||||
}
|
||||
}
|
||||
|
||||
export default function DamageInspectionCard({
|
||||
reservationId,
|
||||
type,
|
||||
@@ -64,11 +108,8 @@ export default function DamageInspectionCard({
|
||||
}) {
|
||||
const { dict } = useDashboardI18n()
|
||||
const r = dict.reservations
|
||||
const storedPreExistingPoints = type === 'CHECKOUT'
|
||||
? (initialInspection?.damagePoints ?? []).filter((point) => point.isPreExisting)
|
||||
: []
|
||||
const overlayComparisonPoints = type === 'CHECKOUT'
|
||||
? [...comparisonPoints, ...storedPreExistingPoints].filter((point, index, all) => all.findIndex((item) => pointKey(item) === pointKey(point)) === index)
|
||||
? comparisonPointsForCheckout(initialInspection, comparisonPoints)
|
||||
: []
|
||||
|
||||
const [inspection, setInspection] = useState<DamageInspection>({
|
||||
@@ -80,9 +121,7 @@ export default function DamageInspectionCard({
|
||||
generalCondition: initialInspection?.generalCondition ?? '',
|
||||
employeeNotes: initialInspection?.employeeNotes ?? '',
|
||||
customerAgreed: initialInspection?.customerAgreed ?? false,
|
||||
damagePoints: type === 'CHECKOUT'
|
||||
? (initialInspection?.damagePoints ?? []).filter((point) => !point.isPreExisting)
|
||||
: (initialInspection?.damagePoints ?? []),
|
||||
damagePoints: editableDamagePointsForInspection(type, initialInspection),
|
||||
})
|
||||
const [damageType, setDamageType] = useState<DamageType>('SCRATCH')
|
||||
const [severity, setSeverity] = useState<DamageSeverity>('MINOR')
|
||||
@@ -93,36 +132,7 @@ export default function DamageInspectionCard({
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payloadPoints = [
|
||||
...overlayComparisonPoints.map(({ viewType, x, y, damageType, severity, description }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
...(description ? { description } : {}),
|
||||
isPreExisting: true,
|
||||
})),
|
||||
...inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
|
||||
viewType,
|
||||
x,
|
||||
y,
|
||||
damageType,
|
||||
severity,
|
||||
...(description ? { description } : {}),
|
||||
isPreExisting,
|
||||
})),
|
||||
]
|
||||
|
||||
const payload = {
|
||||
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
|
||||
fuelLevel: inspection.fuelLevel,
|
||||
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
|
||||
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
|
||||
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
|
||||
customerAgreed: inspection.customerAgreed,
|
||||
damagePoints: payloadPoints,
|
||||
}
|
||||
const payload = buildInspectionSavePayload(inspection, overlayComparisonPoints)
|
||||
|
||||
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
|
||||
method: 'PUT',
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SHEET_HEIGHT,
|
||||
SHEET_WIDTH,
|
||||
resolvePointPosition,
|
||||
resolveVehicleSheetClick,
|
||||
severityColors,
|
||||
viewBoxes,
|
||||
type VehicleSheetPoint,
|
||||
} from './VehicleConditionSheet'
|
||||
|
||||
const rect = { left: 10, top: 20, width: SHEET_WIDTH * 2, height: SHEET_HEIGHT * 2 } as Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>
|
||||
|
||||
function sheetClientPoint(x: number, y: number) {
|
||||
return {
|
||||
clientX: rect.left + (x / SHEET_WIDTH) * rect.width,
|
||||
clientY: rect.top + (y / SHEET_HEIGHT) * rect.height,
|
||||
}
|
||||
}
|
||||
|
||||
describe('VehicleConditionSheet geometry helpers', () => {
|
||||
it('maps percentage positions into absolute sheet coordinates per view', () => {
|
||||
expect(resolvePointPosition({ viewType: 'TOP', x: 50, y: 50, severity: 'MINOR' })).toEqual({
|
||||
cx: viewBoxes.TOP.x + viewBoxes.TOP.w / 2,
|
||||
cy: viewBoxes.TOP.y + viewBoxes.TOP.h / 2,
|
||||
})
|
||||
expect(resolvePointPosition({ viewType: 'LEFT', x: 0, y: 100, severity: 'MAJOR' })).toEqual({
|
||||
cx: viewBoxes.LEFT.x,
|
||||
cy: viewBoxes.LEFT.y + viewBoxes.LEFT.h,
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults missing viewType to TOP for legacy damage points', () => {
|
||||
const legacyPoint = { x: 25, y: 10, severity: 'MODERATE' } satisfies VehicleSheetPoint
|
||||
|
||||
expect(resolvePointPosition(legacyPoint)).toEqual({
|
||||
cx: viewBoxes.TOP.x + viewBoxes.TOP.w * 0.25,
|
||||
cy: viewBoxes.TOP.y + viewBoxes.TOP.h * 0.1,
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves clicks inside each vehicle region to local percentages', () => {
|
||||
const topCenter = sheetClientPoint(viewBoxes.TOP.x + viewBoxes.TOP.w / 2, viewBoxes.TOP.y + viewBoxes.TOP.h / 2)
|
||||
const frontCorner = sheetClientPoint(viewBoxes.FRONT.x, viewBoxes.FRONT.y)
|
||||
const rearCorner = sheetClientPoint(viewBoxes.REAR.x + viewBoxes.REAR.w, viewBoxes.REAR.y + viewBoxes.REAR.h)
|
||||
|
||||
expect(resolveVehicleSheetClick(topCenter.clientX, topCenter.clientY, rect)).toEqual({ viewType: 'TOP', x: 50, y: 50 })
|
||||
expect(resolveVehicleSheetClick(frontCorner.clientX, frontCorner.clientY, rect)).toEqual({ viewType: 'FRONT', x: 0, y: 0 })
|
||||
expect(resolveVehicleSheetClick(rearCorner.clientX, rearCorner.clientY, rect)).toEqual({ viewType: 'REAR', x: 100, y: 100 })
|
||||
})
|
||||
|
||||
it('rejects clicks outside vehicle regions instead of creating impossible damage points', () => {
|
||||
const outside = sheetClientPoint(40, 40)
|
||||
|
||||
expect(resolveVehicleSheetClick(outside.clientX, outside.clientY, rect)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps severity color mapping explicit for rendering and comparison markers', () => {
|
||||
expect(severityColors).toEqual({
|
||||
MINOR: '#f59e0b',
|
||||
MODERATE: '#f97316',
|
||||
MAJOR: '#dc2626',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -9,17 +9,17 @@ export type VehicleSheetPoint = {
|
||||
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
|
||||
}
|
||||
|
||||
const SHEET_WIDTH = 573
|
||||
const SHEET_HEIGHT = 876
|
||||
export const SHEET_WIDTH = 573
|
||||
export const SHEET_HEIGHT = 876
|
||||
const SHEET_IMAGE_PATH = '/dashboard/vehicle-condition-template.png'
|
||||
|
||||
const severityColors: Record<VehicleSheetPoint['severity'], string> = {
|
||||
export const severityColors: Record<VehicleSheetPoint['severity'], string> = {
|
||||
MINOR: '#f59e0b',
|
||||
MODERATE: '#f97316',
|
||||
MAJOR: '#dc2626',
|
||||
}
|
||||
|
||||
const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
|
||||
export const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h: number }> = {
|
||||
FRONT: { x: 161, y: 4, w: 251, h: 108 },
|
||||
TOP: { x: 150, y: 112, w: 273, h: 613 },
|
||||
REAR: { x: 156, y: 724, w: 252, h: 123 },
|
||||
@@ -27,7 +27,7 @@ const viewBoxes: Record<VehicleSheetView, { x: number; y: number; w: number; h:
|
||||
RIGHT: { x: 423, y: 114, w: 146, h: 618 },
|
||||
}
|
||||
|
||||
function resolvePointPosition(point: VehicleSheetPoint) {
|
||||
export function resolvePointPosition(point: VehicleSheetPoint) {
|
||||
const viewType = point.viewType ?? 'TOP'
|
||||
const box = viewBoxes[viewType]
|
||||
|
||||
@@ -37,6 +37,25 @@ function resolvePointPosition(point: VehicleSheetPoint) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function resolveVehicleSheetClick(clientX: number, clientY: number, rect: Pick<DOMRect, 'left' | 'top' | 'width' | 'height'>): { viewType: VehicleSheetView; x: number; y: number } | null {
|
||||
const x = ((clientX - rect.left) / rect.width) * SHEET_WIDTH
|
||||
const y = ((clientY - rect.top) / rect.height) * SHEET_HEIGHT
|
||||
|
||||
const target = (Object.entries(viewBoxes) as Array<[VehicleSheetView, { x: number; y: number; w: number; h: number }]>).find(([, box]) =>
|
||||
x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h
|
||||
)
|
||||
|
||||
if (!target) return null
|
||||
|
||||
const [viewType, box] = target
|
||||
return {
|
||||
viewType,
|
||||
x: ((x - box.x) / box.w) * 100,
|
||||
y: ((y - box.y) / box.h) * 100,
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarker(point: VehicleSheetPoint, index: number, variant: 'current' | 'comparison') {
|
||||
const { cx, cy } = resolvePointPosition(point)
|
||||
const color = severityColors[point.severity]
|
||||
@@ -79,20 +98,10 @@ export default function VehicleConditionSheet({
|
||||
function handleClick(event: React.MouseEvent<SVGSVGElement>) {
|
||||
if (!interactive || !onAddPoint) return
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const x = ((event.clientX - rect.left) / rect.width) * SHEET_WIDTH
|
||||
const y = ((event.clientY - rect.top) / rect.height) * SHEET_HEIGHT
|
||||
const resolved = resolveVehicleSheetClick(event.clientX, event.clientY, event.currentTarget.getBoundingClientRect())
|
||||
if (!resolved) return
|
||||
|
||||
const target = (Object.entries(viewBoxes) as Array<[VehicleSheetView, { x: number; y: number; w: number; h: number }]>)
|
||||
.find(([, box]) => x >= box.x && x <= box.x + box.w && y >= box.y && y <= box.y + box.h)
|
||||
|
||||
if (!target) return
|
||||
|
||||
const [viewType, box] = target
|
||||
const localX = ((x - box.x) / box.w) * 100
|
||||
const localY = ((y - box.y) / box.h) * 100
|
||||
|
||||
onAddPoint(viewType, localX, localY)
|
||||
onAddPoint(resolved.viewType, resolved.x, resolved.y)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bilingualPrimary, emptyBilingual } from './BilingualInput'
|
||||
|
||||
describe('bilingual field helpers', () => {
|
||||
it('returns a stable empty field shape for French and Arabic labels', () => {
|
||||
expect(emptyBilingual()).toEqual({ fr: '', ar: '' })
|
||||
expect(emptyBilingual()).not.toBe(emptyBilingual())
|
||||
})
|
||||
|
||||
it('prefers French as the primary display value when both translations exist', () => {
|
||||
expect(bilingualPrimary({ fr: 'Assurance', ar: 'تأمين' })).toBe('Assurance')
|
||||
})
|
||||
|
||||
it('falls back to Arabic when the French value is empty', () => {
|
||||
expect(bilingualPrimary({ fr: '', ar: 'تأمين' })).toBe('تأمين')
|
||||
})
|
||||
|
||||
it('does not trim values implicitly so form state remains lossless', () => {
|
||||
expect(bilingualPrimary({ fr: ' Service premium ', ar: 'خدمة' })).toBe(' Service premium ')
|
||||
expect(bilingualPrimary({ fr: '', ar: ' خدمة ' })).toBe(' خدمة ')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import StatCard from './StatCard'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
function collectText(node: unknown): string[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return []
|
||||
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
|
||||
if (Array.isArray(node)) return node.flatMap(collectText)
|
||||
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
|
||||
return []
|
||||
}
|
||||
|
||||
function collectElements(node: unknown): React.ReactElement[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return []
|
||||
if (Array.isArray(node)) return node.flatMap(collectElements)
|
||||
if (!isValidElement<{ children?: React.ReactNode }>(node)) return []
|
||||
return [node, ...collectElements(node.props.children)]
|
||||
}
|
||||
|
||||
function FakeIcon(props: { className?: string }) {
|
||||
return React.createElement('svg', props)
|
||||
}
|
||||
|
||||
const icon = FakeIcon as unknown as LucideIcon
|
||||
|
||||
describe('StatCard', () => {
|
||||
it('renders title, numeric value, and positive changes with a plus sign', () => {
|
||||
const element = StatCard({ title: 'Revenue', value: 12450, change: 8.238, icon })
|
||||
const text = collectText(element)
|
||||
const renderedText = text.join('')
|
||||
|
||||
expect(text).toContain('Revenue')
|
||||
expect(text).toContain('12450')
|
||||
expect(renderedText).toContain('+8.2%')
|
||||
expect(text).toContain('vs last month')
|
||||
})
|
||||
|
||||
it('renders negative changes without forcing a positive marker', () => {
|
||||
const element = StatCard({ title: 'Cancellations', value: '12', change: -3.15, vsLastMonthLabel: 'vs previous period', icon })
|
||||
const text = collectText(element)
|
||||
const renderedText = text.join('')
|
||||
|
||||
expect(renderedText).toContain('-3.1%')
|
||||
expect(text).toContain('vs previous period')
|
||||
expect(renderedText).not.toContain('+-3.1')
|
||||
})
|
||||
|
||||
it('omits the trend row when no change is provided', () => {
|
||||
const element = StatCard({ title: 'Active cars', value: 31, icon })
|
||||
const text = collectText(element)
|
||||
|
||||
expect(text).toContain('Active cars')
|
||||
expect(text).toContain('31')
|
||||
expect(text).not.toContain('vs last month')
|
||||
expect(text.join('')).not.toContain('%')
|
||||
})
|
||||
|
||||
it('passes custom icon color and background classes to the rendered icon shell', () => {
|
||||
const element = StatCard({
|
||||
title: 'Fleet',
|
||||
value: 9,
|
||||
icon,
|
||||
iconColor: 'text-violet-700',
|
||||
iconBg: 'bg-violet-50',
|
||||
})
|
||||
const elements = collectElements(element)
|
||||
|
||||
expect(elements.some((node) => String(node.props.className ?? '').includes('bg-violet-50'))).toBe(true)
|
||||
expect(elements.some((node) => String(node.props.className ?? '').includes('text-violet-700'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
appendInvitedMember,
|
||||
applyActivationStats,
|
||||
applyMemberActivation,
|
||||
applyMemberRoleUpdate,
|
||||
incrementInvitedStats,
|
||||
removeTeamMemberState,
|
||||
type TeamMember,
|
||||
type TeamStats,
|
||||
} from './useTeam'
|
||||
|
||||
function member(overrides: Partial<TeamMember>): TeamMember {
|
||||
return {
|
||||
id: 'member-1',
|
||||
firstName: 'Amina',
|
||||
lastName: 'Bennis',
|
||||
email: 'amina@example.com',
|
||||
phone: null,
|
||||
role: 'AGENT',
|
||||
isActive: true,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
lastActiveAt: null,
|
||||
invitationStatus: 'accepted',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const stats: TeamStats = { total: 3, active: 2, pending: 1, inactive: 0 }
|
||||
|
||||
describe('useTeam state helpers', () => {
|
||||
it('appends invited employees and increments the pending counters immutably', () => {
|
||||
const existing = [member({ id: 'owner', role: 'OWNER' })]
|
||||
const invited = member({ id: 'invite-1', role: 'MANAGER', invitationStatus: 'pending', isActive: false })
|
||||
|
||||
expect(appendInvitedMember(existing, invited)).toEqual([...existing, invited])
|
||||
expect(existing).toHaveLength(1)
|
||||
expect(incrementInvitedStats(stats)).toEqual({ total: 4, active: 2, pending: 2, inactive: 0 })
|
||||
})
|
||||
|
||||
it('updates roles and activation flags only on the targeted member', () => {
|
||||
const members = [member({ id: 'a' }), member({ id: 'b', role: 'MANAGER', isActive: false })]
|
||||
|
||||
expect(applyMemberRoleUpdate(members, 'a', 'MANAGER')).toEqual([
|
||||
{ ...members[0], role: 'MANAGER' },
|
||||
members[1],
|
||||
])
|
||||
expect(applyMemberActivation(members, 'b', true)).toEqual([
|
||||
members[0],
|
||||
{ ...members[1], isActive: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('moves counters between active and inactive for deactivate/reactivate actions', () => {
|
||||
expect(applyActivationStats({ total: 5, active: 4, pending: 0, inactive: 1 }, 'deactivate')).toEqual({
|
||||
total: 5,
|
||||
active: 3,
|
||||
pending: 0,
|
||||
inactive: 2,
|
||||
})
|
||||
expect(applyActivationStats({ total: 5, active: 3, pending: 0, inactive: 2 }, 'reactivate')).toEqual({
|
||||
total: 5,
|
||||
active: 4,
|
||||
pending: 0,
|
||||
inactive: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('removes active, pending, and inactive accepted members with the correct counter impact', () => {
|
||||
const members = [
|
||||
member({ id: 'active', isActive: true, invitationStatus: 'accepted' }),
|
||||
member({ id: 'pending', isActive: false, invitationStatus: 'pending' }),
|
||||
member({ id: 'inactive', isActive: false, invitationStatus: 'accepted' }),
|
||||
]
|
||||
|
||||
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'active').stats).toEqual({
|
||||
total: 2,
|
||||
active: 0,
|
||||
pending: 1,
|
||||
inactive: 1,
|
||||
})
|
||||
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'pending').stats).toEqual({
|
||||
total: 2,
|
||||
active: 1,
|
||||
pending: 0,
|
||||
inactive: 1,
|
||||
})
|
||||
expect(removeTeamMemberState(members, { total: 3, active: 1, pending: 1, inactive: 1 }, 'inactive').stats).toEqual({
|
||||
total: 2,
|
||||
active: 1,
|
||||
pending: 1,
|
||||
inactive: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves counters untouched when asked to remove an unknown member', () => {
|
||||
const members = [member({ id: 'only' })]
|
||||
const result = removeTeamMemberState(members, { total: 1, active: 1, pending: 0, inactive: 0 }, 'missing')
|
||||
|
||||
expect(result.members).toEqual(members)
|
||||
expect(result.stats).toEqual({ total: 1, active: 1, pending: 0, inactive: 0 })
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,46 @@ export interface InvitePayload {
|
||||
role: 'MANAGER' | 'AGENT'
|
||||
}
|
||||
|
||||
|
||||
export function appendInvitedMember(members: TeamMember[], employee: TeamMember): TeamMember[] {
|
||||
return [...members, employee]
|
||||
}
|
||||
|
||||
export function incrementInvitedStats(stats: TeamStats): TeamStats {
|
||||
return { ...stats, total: stats.total + 1, pending: stats.pending + 1 }
|
||||
}
|
||||
|
||||
export function applyMemberRoleUpdate(members: TeamMember[], memberId: string, role: 'MANAGER' | 'AGENT'): TeamMember[] {
|
||||
return members.map((member) => (member.id === memberId ? { ...member, role } : member))
|
||||
}
|
||||
|
||||
export function applyMemberActivation(members: TeamMember[], memberId: string, isActive: boolean): TeamMember[] {
|
||||
return members.map((member) => (member.id === memberId ? { ...member, isActive } : member))
|
||||
}
|
||||
|
||||
export function applyActivationStats(stats: TeamStats, direction: 'deactivate' | 'reactivate'): TeamStats {
|
||||
return direction === 'deactivate'
|
||||
? { ...stats, active: stats.active - 1, inactive: stats.inactive + 1 }
|
||||
: { ...stats, active: stats.active + 1, inactive: stats.inactive - 1 }
|
||||
}
|
||||
|
||||
export function removeTeamMemberState(members: TeamMember[], stats: TeamStats, memberId: string): { members: TeamMember[]; stats: TeamStats } {
|
||||
const target = members.find((member) => member.id === memberId)
|
||||
|
||||
return {
|
||||
members: members.filter((member) => member.id !== memberId),
|
||||
stats: {
|
||||
...stats,
|
||||
total: stats.total - (target ? 1 : 0),
|
||||
active: target?.isActive ? stats.active - 1 : stats.active,
|
||||
pending: target?.invitationStatus === 'pending' ? stats.pending - 1 : stats.pending,
|
||||
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
|
||||
? stats.inactive - 1
|
||||
: stats.inactive,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useTeam() {
|
||||
const [members, setMembers] = useState<TeamMember[]>([])
|
||||
const [stats, setStats] = useState<TeamStats>({ total: 0, active: 0, pending: 0, inactive: 0 })
|
||||
@@ -64,12 +104,8 @@ export function useTeam() {
|
||||
'/team/invite',
|
||||
{ method: 'POST', body: JSON.stringify(payload) }
|
||||
)
|
||||
setMembers((prev) => [...prev, result.employee])
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
total: prev.total + 1,
|
||||
pending: prev.pending + 1,
|
||||
}))
|
||||
setMembers((prev) => appendInvitedMember(prev, result.employee))
|
||||
setStats(incrementInvitedStats)
|
||||
return result
|
||||
}, [])
|
||||
|
||||
@@ -78,9 +114,7 @@ export function useTeam() {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ role }),
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, role: updated.role } : m))
|
||||
)
|
||||
setMembers((prev) => applyMemberRoleUpdate(prev, memberId, updated.role as 'MANAGER' | 'AGENT'))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
@@ -88,10 +122,8 @@ export function useTeam() {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/deactivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, isActive: false } : m))
|
||||
)
|
||||
setStats((prev) => ({ ...prev, active: prev.active - 1, inactive: prev.inactive + 1 }))
|
||||
setMembers((prev) => applyMemberActivation(prev, memberId, false))
|
||||
setStats((prev) => applyActivationStats(prev, 'deactivate'))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
@@ -99,10 +131,8 @@ export function useTeam() {
|
||||
const updated = await apiFetch<TeamMember>(`/team/${memberId}/reactivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
setMembers((prev) =>
|
||||
prev.map((m) => (m.id === memberId ? { ...m, isActive: true } : m))
|
||||
)
|
||||
setStats((prev) => ({ ...prev, active: prev.active + 1, inactive: prev.inactive - 1 }))
|
||||
setMembers((prev) => applyMemberActivation(prev, memberId, true))
|
||||
setStats((prev) => applyActivationStats(prev, 'reactivate'))
|
||||
return updated
|
||||
}, [])
|
||||
|
||||
@@ -110,18 +140,10 @@ export function useTeam() {
|
||||
await apiFetch<{ success: boolean }>(`/team/${memberId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
const target = members.find((m) => m.id === memberId)
|
||||
setMembers((prev) => prev.filter((m) => m.id !== memberId))
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
total: prev.total - 1,
|
||||
active: target?.isActive ? prev.active - 1 : prev.active,
|
||||
pending: target?.invitationStatus === 'pending' ? prev.pending - 1 : prev.pending,
|
||||
inactive: !target?.isActive && target?.invitationStatus === 'accepted'
|
||||
? prev.inactive - 1
|
||||
: prev.inactive,
|
||||
}))
|
||||
}, [members])
|
||||
const nextState = removeTeamMemberState(members, stats, memberId)
|
||||
setMembers(nextState.members)
|
||||
setStats(nextState.stats)
|
||||
}, [members, stats])
|
||||
|
||||
return {
|
||||
members,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installBrowser(token?: string) {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: vi.fn(() => token ?? null),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'fetch')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('dashboard apiFetch', () => {
|
||||
it('adds JSON headers and sends cookies for browser requests', async () => {
|
||||
installBrowser()
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ data: { ok: true } }),
|
||||
}))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { apiFetch } = await import('./api')
|
||||
await expect(apiFetch('/team')).resolves.toEqual({ ok: true })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/dashboard/api/v1/team', expect.objectContaining({
|
||||
credentials: 'include',
|
||||
headers: expect.objectContaining({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('does not force JSON content type for FormData payloads', async () => {
|
||||
installBrowser()
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { apiFetch } = await import('./api')
|
||||
await apiFetch('/uploads', { method: 'POST', body: new FormData() })
|
||||
|
||||
expect((fetchMock as any).mock.calls[0]?.[1].headers).not.toHaveProperty('Content-Type')
|
||||
expect((fetchMock as any).mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: 'include' }))
|
||||
})
|
||||
|
||||
it('surfaces API error details on failed responses', async () => {
|
||||
installBrowser()
|
||||
Object.defineProperty(globalThis, 'fetch', {
|
||||
configurable: true,
|
||||
value: vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: async () => ({ message: 'Forbidden', error: 'FORBIDDEN' }),
|
||||
})),
|
||||
})
|
||||
|
||||
const { apiFetch } = await import('./api')
|
||||
await expect(apiFetch('/team')).rejects.toMatchObject({ message: 'Forbidden', code: 'FORBIDDEN', statusCode: 403 })
|
||||
})
|
||||
|
||||
it('uses explicit server token and disables caching for server requests', async () => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { ok: true } }) }))
|
||||
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
|
||||
|
||||
const { apiFetchServer } = await import('./api')
|
||||
await apiFetchServer('/fleet', 'server-token')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('http://localhost:4000/api/v1/fleet', expect.objectContaining({
|
||||
cache: 'no-store',
|
||||
headers: expect.objectContaining({ Authorization: 'Bearer server-token' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -3,20 +3,9 @@ export const API_BASE =
|
||||
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1')
|
||||
: (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1')
|
||||
|
||||
export const EMPLOYEE_TOKEN_KEY = 'employee_token'
|
||||
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
|
||||
|
||||
async function getAuthToken(): Promise<string | null> {
|
||||
if (typeof window === 'undefined') return null
|
||||
try {
|
||||
return localStorage.getItem(EMPLOYEE_TOKEN_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const token = await getAuthToken()
|
||||
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
@@ -27,10 +16,6 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { resolveBrowserAppUrl } from './appUrls'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
})
|
||||
|
||||
describe('dashboard resolveBrowserAppUrl', () => {
|
||||
it('returns the fallback untouched on the server', () => {
|
||||
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('http://localhost:3001/dashboard')
|
||||
})
|
||||
|
||||
it('keeps local browser fallbacks local and removes trailing slash', () => {
|
||||
installWindow('localhost')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard/')).toBe('http://localhost:3001/dashboard')
|
||||
})
|
||||
|
||||
it('rewrites fallback origin to the current production host', () => {
|
||||
installWindow('tenant.example.com', 'https:')
|
||||
expect(resolveBrowserAppUrl('http://localhost:3001/dashboard')).toBe('https://tenant.example.com/dashboard')
|
||||
})
|
||||
|
||||
it('falls back safely when the configured value is not a URL', () => {
|
||||
installWindow('tenant.example.com')
|
||||
expect(resolveBrowserAppUrl('/dashboard/')).toBe('/dashboard/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toDashboardAppPath, toPublicDashboardPath } from './dashboardPaths'
|
||||
|
||||
describe('dashboard path normalization', () => {
|
||||
it('collapses empty and root values to the app root', () => {
|
||||
expect(toDashboardAppPath()).toBe('/')
|
||||
expect(toDashboardAppPath('')).toBe('/')
|
||||
expect(toDashboardAppPath('/')).toBe('/')
|
||||
})
|
||||
|
||||
it('strips repeated dashboard base prefixes', () => {
|
||||
expect(toDashboardAppPath('/dashboard')).toBe('/')
|
||||
expect(toDashboardAppPath('/dashboard/fleet')).toBe('/fleet')
|
||||
expect(toDashboardAppPath('/dashboard/dashboard/reservations')).toBe('/reservations')
|
||||
})
|
||||
|
||||
it('adds the public deployment prefix exactly once', () => {
|
||||
expect(toPublicDashboardPath('/')).toBe('/dashboard')
|
||||
expect(toPublicDashboardPath('/dashboard/fleet')).toBe('/dashboard/fleet')
|
||||
expect(toPublicDashboardPath('customers')).toBe('/dashboard/customers')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getScopedPreferenceCookieName,
|
||||
getScopedPreferenceKey,
|
||||
readCurrentUserScopedPreference,
|
||||
readScopedPreference,
|
||||
writeScopedPreference,
|
||||
} from './preferences'
|
||||
|
||||
function installBrowser(cookie = '') {
|
||||
const store = new Map<string, string>()
|
||||
let cookieValue = cookie
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
configurable: true,
|
||||
value: {
|
||||
get cookie() {
|
||||
return cookieValue
|
||||
},
|
||||
set cookie(value: string) {
|
||||
const [pair] = value.split(';')
|
||||
const [name, val] = pair.split('=')
|
||||
const existing = cookieValue
|
||||
.split(';')
|
||||
.map((chunk) => chunk.trim())
|
||||
.filter(Boolean)
|
||||
.filter((chunk) => !chunk.startsWith(`${name}=`))
|
||||
existing.push(`${name}=${val}`)
|
||||
cookieValue = existing.join('; ')
|
||||
},
|
||||
},
|
||||
})
|
||||
Object.defineProperty(globalThis, 'atob', {
|
||||
configurable: true,
|
||||
value: (value: string) => Buffer.from(value, 'base64').toString('binary'),
|
||||
})
|
||||
return { store, get cookie() { return cookieValue } }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installBrowser()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
Reflect.deleteProperty(globalThis, 'atob')
|
||||
})
|
||||
|
||||
describe('dashboard scoped preferences', () => {
|
||||
it('uses base keys when no employee token is available', () => {
|
||||
expect(getScopedPreferenceKey('theme')).toBe('theme')
|
||||
expect(getScopedPreferenceCookieName('theme')).toBe('theme')
|
||||
})
|
||||
|
||||
it('prefers scoped cookie/current user values before local storage', () => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
const browser = installBrowser('theme=dark')
|
||||
browser.store.set('theme', 'local-dark')
|
||||
|
||||
expect(readCurrentUserScopedPreference('theme')).toBe('dark')
|
||||
expect(readScopedPreference('theme')).toBe('dark')
|
||||
})
|
||||
|
||||
it('falls back through shared cookie, scoped local storage, base key, and legacy keys', () => {
|
||||
const browser = installBrowser('theme=dark')
|
||||
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('dark')
|
||||
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
Reflect.deleteProperty(globalThis, 'document')
|
||||
const second = installBrowser()
|
||||
second.store.set('theme', 'base')
|
||||
second.store.set('legacy-theme', 'legacy')
|
||||
expect(readScopedPreference('theme', ['legacy-theme'])).toBe('base')
|
||||
})
|
||||
|
||||
it('writes shared and legacy preference values without auth-token scoping', () => {
|
||||
const browser = installBrowser()
|
||||
|
||||
writeScopedPreference('language', 'fr', ['old-language'])
|
||||
|
||||
expect(browser.store.get('language')).toBe('fr')
|
||||
expect(browser.store.get('old-language')).toBe('fr')
|
||||
expect(browser.cookie).toContain('language=fr')
|
||||
})
|
||||
})
|
||||
@@ -3,16 +3,7 @@ export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
|
||||
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
|
||||
|
||||
function readEmployeeToken() {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
const localToken = window.localStorage.getItem('employee_token')
|
||||
if (localToken) return localToken
|
||||
|
||||
return document.cookie
|
||||
.split(';')
|
||||
.map((chunk) => chunk.trim())
|
||||
.find((chunk) => chunk.startsWith('employee_token='))
|
||||
?.split('=')[1] ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
function decodeEmployeeId(token: string | null) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
function installWindow(hostname: string, protocol = 'https:') {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { location: { hostname, protocol } },
|
||||
})
|
||||
}
|
||||
|
||||
async function loadUrls(env: Record<string, string | undefined> = {}) {
|
||||
vi.resetModules()
|
||||
for (const key of ['NEXT_PUBLIC_MARKETPLACE_URL', 'NEXT_PUBLIC_ADMIN_URL']) {
|
||||
if (env[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = env[key]
|
||||
}
|
||||
}
|
||||
return import('./urls')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'window')
|
||||
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
|
||||
delete process.env.NEXT_PUBLIC_ADMIN_URL
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('dashboard cross-app URLs', () => {
|
||||
it('uses default marketplace and admin bases on the server', async () => {
|
||||
const { marketplaceUrl, adminUrl } = await loadUrls()
|
||||
|
||||
expect(marketplaceUrl).toBe('http://localhost:3000')
|
||||
expect(adminUrl).toBe('http://localhost:3000/admin')
|
||||
})
|
||||
|
||||
it('strips trailing slashes from configured absolute URLs', async () => {
|
||||
const { marketplaceUrl, adminUrl } = await loadUrls({
|
||||
NEXT_PUBLIC_MARKETPLACE_URL: 'https://market.example.com/',
|
||||
NEXT_PUBLIC_ADMIN_URL: 'https://ops.example.com/admin/',
|
||||
})
|
||||
|
||||
expect(marketplaceUrl).toBe('https://market.example.com')
|
||||
expect(adminUrl).toBe('https://ops.example.com/admin')
|
||||
})
|
||||
|
||||
it('rewrites local browser fallbacks onto the current production host', async () => {
|
||||
installWindow('tenant.rentaldrivego.com', 'https:')
|
||||
const { marketplaceUrl, adminUrl } = await loadUrls()
|
||||
|
||||
expect(marketplaceUrl).toBe('https://tenant.rentaldrivego.com')
|
||||
expect(adminUrl).toBe('https://tenant.rentaldrivego.com/admin')
|
||||
})
|
||||
|
||||
it('preserves non-URL values after normalizing their trailing slash', async () => {
|
||||
const { marketplaceUrl, adminUrl } = await loadUrls({
|
||||
NEXT_PUBLIC_MARKETPLACE_URL: '/marketplace/',
|
||||
NEXT_PUBLIC_ADMIN_URL: '/admin/',
|
||||
})
|
||||
|
||||
expect(marketplaceUrl).toBe('/marketplace')
|
||||
expect(adminUrl).toBe('/admin')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const nextServer = vi.hoisted(() => {
|
||||
const redirect = vi.fn((url: URL) => ({ kind: 'redirect', url: url.toString() }))
|
||||
const next = vi.fn(() => ({ kind: 'next' }))
|
||||
const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
|
||||
kind: 'response',
|
||||
body,
|
||||
status: init?.status,
|
||||
})) as any
|
||||
MockNextResponse.redirect = redirect
|
||||
MockNextResponse.next = next
|
||||
return { redirect, next, MockNextResponse }
|
||||
})
|
||||
|
||||
vi.mock('next/server', () => ({
|
||||
NextResponse: nextServer.MockNextResponse,
|
||||
}))
|
||||
|
||||
function cloneableUrl(input: string): URL {
|
||||
const url = new URL(input)
|
||||
;(url as URL & { clone: () => URL }).clone = () => cloneableUrl(url.toString())
|
||||
return url
|
||||
}
|
||||
|
||||
function request(input: string, options: { token?: string; headers?: Record<string, string> } = {}) {
|
||||
const headers = new Map(Object.entries(options.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]))
|
||||
return {
|
||||
nextUrl: cloneableUrl(input),
|
||||
cookies: {
|
||||
get: vi.fn((name: string) => (name === 'employee_session' && options.token ? { value: options.token } : undefined)),
|
||||
},
|
||||
headers: {
|
||||
get: vi.fn((name: string) => headers.get(name.toLowerCase()) ?? null),
|
||||
has: vi.fn((name: string) => headers.has(name.toLowerCase())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMiddleware(marketplaceUrl = 'https://market.example.com') {
|
||||
vi.resetModules()
|
||||
process.env.NEXT_PUBLIC_MARKETPLACE_URL = marketplaceUrl
|
||||
return import('./middleware')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
nextServer.redirect.mockClear()
|
||||
nextServer.next.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.NEXT_PUBLIC_MARKETPLACE_URL
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
describe('dashboard middleware', () => {
|
||||
|
||||
it('rejects middleware subrequest headers at the app layer', async () => {
|
||||
const { default: middleware } = await loadMiddleware()
|
||||
|
||||
const response = middleware(request('https://workspace.example.com/dashboard', {
|
||||
headers: { 'x-middleware-subrequest': 'middleware:middleware' },
|
||||
}) as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
|
||||
})
|
||||
it('deduplicates accidental repeated /dashboard prefixes before auth handling', async () => {
|
||||
const { default: middleware } = await loadMiddleware()
|
||||
|
||||
const response = middleware(request('https://workspace.example.com/dashboard/dashboard/fleet') as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard/fleet' })
|
||||
expect(nextServer.redirect).toHaveBeenCalledTimes(1)
|
||||
expect(nextServer.next).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects unauthenticated protected internal-host requests to dashboard sign-in on the resolved origin', async () => {
|
||||
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
|
||||
|
||||
const response = middleware(request('http://dashboard:3001/dashboard/team') as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example:3001/dashboard/sign-in?redirect=%2Fdashboard%2Fteam' })
|
||||
})
|
||||
|
||||
it('uses trusted forwarded host/proto when building the dashboard sign-in redirect', async () => {
|
||||
const { default: middleware } = await loadMiddleware('https://market.example.com')
|
||||
|
||||
const response = middleware(request('http://dashboard:3001/dashboard/billing', {
|
||||
headers: {
|
||||
'x-forwarded-host': 'workspace.customer.example',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
}) as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.customer.example:3001/dashboard/sign-in?redirect=%2Fdashboard%2Fbilling' })
|
||||
})
|
||||
|
||||
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
|
||||
const { default: middleware } = await loadMiddleware('https://market.example.com')
|
||||
|
||||
const response = middleware(request('http://dashboard:3001/dashboard/fleet', {
|
||||
headers: {
|
||||
'x-forwarded-host': 'api:4000',
|
||||
'x-forwarded-proto': 'https',
|
||||
},
|
||||
}) as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com:3001/dashboard/sign-in?redirect=%2Fdashboard%2Ffleet' })
|
||||
})
|
||||
|
||||
it('redirects signed-in users away from the sign-in page', async () => {
|
||||
const { default: middleware } = await loadMiddleware()
|
||||
|
||||
const response = middleware(request('https://workspace.example.com/dashboard/sign-in?redirect=/dashboard/fleet', { token: 'employee-token' }) as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard' })
|
||||
})
|
||||
|
||||
it('allows public dashboard auth pages without a token', async () => {
|
||||
const { default: middleware } = await loadMiddleware()
|
||||
|
||||
const response = middleware(request('https://workspace.example.com/dashboard/forgot-password') as never)
|
||||
|
||||
expect(response).toEqual({ kind: 'next' })
|
||||
expect(nextServer.next).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,12 @@ import type { NextRequest } from 'next/server'
|
||||
const PUBLIC_DASHBOARD_PREFIXES = ['/dashboard/sign-in', '/dashboard/forgot-password']
|
||||
const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
|
||||
|
||||
|
||||
function rejectInternalSubrequest(req: NextRequest): NextResponse | null {
|
||||
if (!req.headers.has('x-middleware-subrequest')) return null
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
}
|
||||
|
||||
function dedupeDashboardPath(pathname: string): string {
|
||||
return pathname.replace(/^\/dashboard\/dashboard(?=\/|$)/, '/dashboard')
|
||||
}
|
||||
@@ -21,7 +27,7 @@ function isProtectedRoute(req: NextRequest) {
|
||||
}
|
||||
|
||||
function localJwtMiddleware(req: NextRequest): NextResponse {
|
||||
const token = req.cookies.get('employee_token')?.value
|
||||
const token = req.cookies.get('employee_session')?.value
|
||||
if (token && req.nextUrl.pathname === '/dashboard/sign-in') {
|
||||
const dashboardUrl = req.nextUrl.clone()
|
||||
dashboardUrl.pathname = '/dashboard'
|
||||
@@ -54,6 +60,9 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
|
||||
}
|
||||
|
||||
export default function middleware(req: NextRequest) {
|
||||
const rejected = rejectInternalSubrequest(req)
|
||||
if (rejected) return rejected
|
||||
|
||||
const dedupedPath = dedupeDashboardPath(req.nextUrl.pathname)
|
||||
if (dedupedPath !== req.nextUrl.pathname) {
|
||||
const redirectUrl = req.nextUrl.clone()
|
||||
|
||||
Reference in New Issue
Block a user