add review and booking policies

This commit is contained in:
root
2026-05-25 18:29:05 -04:00
parent 8ed572b3bd
commit 0d969ab095
37 changed files with 3775 additions and 113 deletions
+133 -44
View File
@@ -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>
)
}