redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import Link from 'next/link'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
interface OnlineReservation {
|
||||
id: string
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'CANCELLED' | 'ACTIVE' | 'COMPLETED' | 'NO_SHOW'
|
||||
source: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
totalAmount: number
|
||||
totalDays: number
|
||||
dailyRate: number
|
||||
notes: string | null
|
||||
cancelReason: string | null
|
||||
createdAt: string
|
||||
vehicle: { make: string; model: string; year: number; licensePlate: string }
|
||||
customer: { firstName: string; lastName: string; email: string; phone: string | null }
|
||||
bookingRequest: {
|
||||
stage: string
|
||||
documentsRequired: boolean
|
||||
paymentRequired: boolean
|
||||
documentsMissing: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
DRAFT: 'bg-orange-100 text-orange-800',
|
||||
CONFIRMED: 'bg-emerald-100 text-emerald-700',
|
||||
CANCELLED: 'bg-rose-100 text-rose-700',
|
||||
ACTIVE: 'bg-blue-100 text-blue-700',
|
||||
COMPLETED: 'bg-slate-100 text-slate-600',
|
||||
NO_SHOW: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
DRAFT: 'Pending',
|
||||
CONFIRMED: 'Confirmed',
|
||||
CANCELLED: 'Declined',
|
||||
ACTIVE: 'Active',
|
||||
COMPLETED: 'Completed',
|
||||
NO_SHOW: 'No show',
|
||||
}
|
||||
|
||||
const REQUEST_STAGE_STYLE: Record<string, string> = {
|
||||
REQUEST_SENT: 'bg-orange-100 text-orange-800',
|
||||
COMPANY_CONFIRMED: 'bg-blue-100 text-blue-800',
|
||||
DOCUMENTS_REQUIRED: 'bg-amber-100 text-amber-800',
|
||||
PAYMENT_REQUIRED: 'bg-purple-100 text-purple-800',
|
||||
BOOKING_CONFIRMED: 'bg-emerald-100 text-emerald-700',
|
||||
CANCELLED: 'bg-rose-100 text-rose-700',
|
||||
}
|
||||
|
||||
const REQUEST_STAGE_LABEL: Record<string, string> = {
|
||||
REQUEST_SENT: 'Waiting for company review',
|
||||
COMPANY_CONFIRMED: 'Availability confirmed',
|
||||
DOCUMENTS_REQUIRED: 'Driver documents missing',
|
||||
PAYMENT_REQUIRED: 'Payment or deposit pending',
|
||||
BOOKING_CONFIRMED: 'Ready to proceed',
|
||||
CANCELLED: 'Request closed',
|
||||
}
|
||||
|
||||
const MISSING_ITEM_LABEL: Record<string, string> = {
|
||||
DATE_OF_BIRTH: 'Date of birth',
|
||||
IDENTITY_DOCUMENT: 'Identity document',
|
||||
FULL_ADDRESS: 'Full address',
|
||||
DRIVER_LICENSE: 'Driver license number',
|
||||
LICENSE_IMAGE: 'License image',
|
||||
LICENSE_APPROVAL: 'License approval',
|
||||
}
|
||||
|
||||
function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) {
|
||||
const [reason, setReason] = useState('')
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40 p-4" onClick={onCancel}>
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="text-base font-semibold text-slate-900">Decline reservation</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">Optionally provide a reason — it will be visible in the reservation record.</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="e.g. Vehicle not available for these dates"
|
||||
className="mt-4 w-full rounded-xl border border-slate-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900 resize-none"
|
||||
/>
|
||||
<div className="mt-4 flex gap-3">
|
||||
<button onClick={onCancel} className="flex-1 rounded-full border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700">Cancel</button>
|
||||
<button
|
||||
disabled={loading}
|
||||
onClick={() => onConfirm(reason)}
|
||||
className="flex-1 rounded-full bg-rose-600 px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Declining…' : 'Decline'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function OnlineReservationsPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const copy = {
|
||||
en: {
|
||||
title: 'Online Reservations',
|
||||
subtitle: 'Requests submitted by customers through the marketplace. Confirm or decline each one.',
|
||||
refresh: 'Refresh',
|
||||
pendingApproval: 'Pending approval',
|
||||
loading: 'Loading…',
|
||||
allCaughtUp: 'All caught up — no pending requests.',
|
||||
customer: 'Customer',
|
||||
vehicle: 'Vehicle',
|
||||
dates: 'Dates',
|
||||
status: 'Status',
|
||||
total: 'Total',
|
||||
},
|
||||
fr: {
|
||||
title: 'Réservations en ligne',
|
||||
subtitle: 'Demandes envoyées par les clients via la marketplace. Confirmez ou refusez chaque demande.',
|
||||
refresh: 'Actualiser',
|
||||
pendingApproval: 'En attente de validation',
|
||||
loading: 'Chargement…',
|
||||
allCaughtUp: 'Tout est à jour. Aucune demande en attente.',
|
||||
customer: 'Client',
|
||||
vehicle: 'Véhicule',
|
||||
dates: 'Dates',
|
||||
status: 'Statut',
|
||||
total: 'Total',
|
||||
},
|
||||
ar: {
|
||||
title: 'الحجوزات الإلكترونية',
|
||||
subtitle: 'طلبات أرسلها العملاء عبر السوق. أكّد كل طلب أو ارفضه.',
|
||||
refresh: 'تحديث',
|
||||
pendingApproval: 'في انتظار الموافقة',
|
||||
loading: 'جارٍ التحميل…',
|
||||
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
|
||||
customer: 'العميل',
|
||||
vehicle: 'المركبة',
|
||||
dates: 'التواريخ',
|
||||
status: 'الحالة',
|
||||
total: 'الإجمالي',
|
||||
},
|
||||
}[language]
|
||||
const [rows, setRows] = useState<OnlineReservation[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [acting, setActing] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
const [declineTarget, setDeclineTarget] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&status=DRAFT&pageSize=100')
|
||||
setRows(
|
||||
(result ?? []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
)
|
||||
setError(null)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load reservations')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function confirm(id: string) {
|
||||
setActing(id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiFetch(`/reservations/${id}/confirm`, { method: 'POST', body: JSON.stringify({}) })
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? 'Failed to confirm reservation')
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function decline(id: string, reason: string) {
|
||||
setActing(id)
|
||||
setActionError(null)
|
||||
try {
|
||||
await apiFetch(`/reservations/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason: reason || undefined }) })
|
||||
setDeclineTarget(null)
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
setActionError(err.message ?? 'Failed to decline reservation')
|
||||
} finally {
|
||||
setActing(null)
|
||||
}
|
||||
}
|
||||
|
||||
const pending = rows
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{declineTarget && (
|
||||
<DeclineModal
|
||||
loading={acting === declineTarget}
|
||||
onConfirm={(reason) => decline(declineTarget, reason)}
|
||||
onCancel={() => setDeclineTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="rdg-page-heading flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
|
||||
{pending.length > 0 && (
|
||||
<span className="rounded-full bg-orange-500 px-2.5 py-0.5 text-xs font-bold text-white">{pending.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
|
||||
</div>
|
||||
<button onClick={load} className="rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 hover:bg-slate-50">
|
||||
{copy.refresh}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">{actionError}</div>
|
||||
)}
|
||||
|
||||
{/* Pending section */}
|
||||
<section>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||
<Clock className="h-4 w-4" /> {copy.pendingApproval}
|
||||
{pending.length > 0 && <span className="ml-1 text-orange-600">({pending.length})</span>}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<div className="card p-8 text-center text-sm text-slate-400">{copy.loading}</div>
|
||||
) : pending.length === 0 ? (
|
||||
<div className="card flex flex-col items-center gap-3 p-10 text-center">
|
||||
<CheckCircle className="h-10 w-10 text-emerald-400" />
|
||||
<p className="text-sm font-medium text-slate-600">{copy.allCaughtUp}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pending.map((r) => (
|
||||
<ReservationCard
|
||||
key={r.id}
|
||||
r={r}
|
||||
acting={acting === r.id}
|
||||
onConfirm={() => confirm(r.id)}
|
||||
onDecline={() => setDeclineTarget(r.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReservationCard({ r, acting, onConfirm, onDecline }: {
|
||||
r: OnlineReservation
|
||||
acting: boolean
|
||||
onConfirm: () => void
|
||||
onDecline: () => void
|
||||
}) {
|
||||
const days = r.totalDays
|
||||
const missingItems = r.bookingRequest.documentsMissing.map((item) => MISSING_ITEM_LABEL[item] ?? item)
|
||||
|
||||
return (
|
||||
<div className="card overflow-hidden border-l-4 border-l-orange-400">
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
{/* Left: customer + vehicle + dates */}
|
||||
<div className="space-y-3 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 flex-shrink-0 text-slate-400" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
|
||||
<p className="text-xs text-slate-500">{r.customer.email}{r.customer.phone ? ` · ${r.customer.phone}` : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Car className="h-4 w-4 flex-shrink-0 text-slate-400" />
|
||||
<p className="text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model} <span className="text-slate-400">· {r.vehicle.licensePlate}</span></p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 flex-shrink-0 text-slate-400" />
|
||||
<p className="text-sm text-slate-700">
|
||||
{dayjs(r.startDate).format('MMM D, YYYY')} → {dayjs(r.endDate).format('MMM D, YYYY')}
|
||||
<span className="ml-2 text-slate-400">{days} day{days > 1 ? 's' : ''}</span>
|
||||
</p>
|
||||
</div>
|
||||
{r.notes && (
|
||||
<div className="flex items-start gap-2">
|
||||
<MessageSquare className="h-4 w-4 flex-shrink-0 text-slate-400 mt-0.5" />
|
||||
<p className="text-sm text-slate-600 italic">"{r.notes}"</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex rounded-full px-2.5 py-1 text-xs font-semibold ${REQUEST_STAGE_STYLE[r.bookingRequest.stage] ?? 'bg-slate-100 text-slate-700'}`}>
|
||||
{REQUEST_STAGE_LABEL[r.bookingRequest.stage] ?? r.bookingRequest.stage}
|
||||
</span>
|
||||
{r.bookingRequest.paymentRequired ? (
|
||||
<span className="inline-flex rounded-full bg-purple-100 px-2.5 py-1 text-xs font-semibold text-purple-800">
|
||||
Payment pending
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{missingItems.length > 0 ? (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{missingItems.map((item) => (
|
||||
<span key={item} className="inline-flex rounded-full bg-amber-100 px-2.5 py-1 text-xs font-medium text-amber-800">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: total + actions */}
|
||||
<div className="flex flex-col items-end gap-3 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-slate-400">Estimated total</p>
|
||||
<p className="text-xl font-bold text-slate-900">{formatCurrency(r.totalAmount, 'MAD')}</p>
|
||||
<p className="text-xs text-slate-400">{formatCurrency(r.dailyRate, 'MAD')}/day</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onDecline}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-1.5 rounded-full border border-rose-200 bg-rose-50 px-4 py-2 text-sm font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
Decline
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={acting}
|
||||
className="flex items-center gap-1.5 rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{acting ? 'Confirming…' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">Received {dayjs(r.createdAt).format('MMM D [at] HH:mm')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-100 bg-slate-50 px-5 py-2.5 flex items-center justify-between">
|
||||
<p className="text-xs text-slate-400">Ref: {r.id.slice(-10).toUpperCase()}</p>
|
||||
<Link href={`/reservations/${r.id}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-700">
|
||||
View full detail <ChevronRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user