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
@@ -0,0 +1,567 @@
'use client'
import { useEffect, useState } from 'react'
import { AlertTriangle, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
// ─── i18n ─────────────────────────────────────────────────────────────────────
const copy = {
en: {
heading: 'Complaints',
subtitle: 'Manage customer complaints and escalations',
newComplaint: 'New complaint',
filterStatus: 'Status',
filterSeverity: 'Severity',
allStatuses: 'All statuses',
allSeverities: 'All severities',
statusOpen: 'Open',
statusInvestigating: 'Investigating',
statusResolved: 'Resolved',
statusClosed: 'Closed',
sevL1: 'Level 1',
sevL2: 'Level 2',
sevL3: 'Level 3',
noComplaints: 'No complaints found',
category: 'Category',
subject: 'Subject',
reservation: 'Reservation',
date: 'Date',
status: 'Status',
severity: 'Severity',
details: 'Details',
description: 'Description',
notes: 'Notes',
resolution: 'Resolution',
updateStatus: 'Update status',
saving: 'Saving…',
save: 'Save',
cancel: 'Cancel',
createTitle: 'Create complaint',
fieldReservationId: 'Reservation ID (optional)',
fieldCategory: 'Category',
fieldSeverity: 'Severity',
fieldSubject: 'Subject',
fieldDescription: 'Description (optional)',
create: 'Create',
creating: 'Creating…',
errorLoad: 'Failed to load complaints',
errorCreate: 'Failed to create complaint',
errorUpdate: 'Failed to update complaint',
categories: {
BOOKING: 'Booking', PICKUP: 'Pickup', VEHICLE_CLEANLINESS: 'Vehicle Cleanliness',
VEHICLE_CONDITION: 'Vehicle Condition', STAFF_SERVICE: 'Staff Service', PRICING: 'Pricing',
DEPOSIT: 'Deposit', INSURANCE: 'Insurance', DAMAGE_CLAIM: 'Damage Claim',
RETURN_PROCESS: 'Return Process', COMMUNICATION: 'Communication',
ROADSIDE_ASSISTANCE: 'Roadside Assistance', BILLING: 'Billing', OTHER: 'Other',
} as Record<string, string>,
},
fr: {
heading: 'Plaintes',
subtitle: 'Gérez les plaintes et escalades clients',
newComplaint: 'Nouvelle plainte',
filterStatus: 'Statut',
filterSeverity: 'Sévérité',
allStatuses: 'Tous les statuts',
allSeverities: 'Toutes les sévérités',
statusOpen: 'Ouvert',
statusInvestigating: 'En cours',
statusResolved: 'Résolu',
statusClosed: 'Fermé',
sevL1: 'Niveau 1',
sevL2: 'Niveau 2',
sevL3: 'Niveau 3',
noComplaints: 'Aucune plainte trouvée',
category: 'Catégorie',
subject: 'Sujet',
reservation: 'Réservation',
date: 'Date',
status: 'Statut',
severity: 'Sévérité',
details: 'Détails',
description: 'Description',
notes: 'Notes',
resolution: 'Résolution',
updateStatus: 'Mettre à jour le statut',
saving: 'Enregistrement…',
save: 'Enregistrer',
cancel: 'Annuler',
createTitle: 'Créer une plainte',
fieldReservationId: 'ID de réservation (optionnel)',
fieldCategory: 'Catégorie',
fieldSeverity: 'Sévérité',
fieldSubject: 'Sujet',
fieldDescription: 'Description (optionnel)',
create: 'Créer',
creating: 'Création…',
errorLoad: 'Échec du chargement des plaintes',
errorCreate: 'Échec de la création de la plainte',
errorUpdate: 'Échec de la mise à jour de la plainte',
categories: {
BOOKING: 'Réservation', PICKUP: 'Prise en charge', VEHICLE_CLEANLINESS: 'Propreté du véhicule',
VEHICLE_CONDITION: 'État du véhicule', STAFF_SERVICE: 'Service du personnel', PRICING: 'Tarification',
DEPOSIT: 'Dépôt de garantie', INSURANCE: 'Assurance', DAMAGE_CLAIM: 'Réclamation dommages',
RETURN_PROCESS: 'Processus de retour', COMMUNICATION: 'Communication',
ROADSIDE_ASSISTANCE: 'Assistance routière', BILLING: 'Facturation', OTHER: 'Autre',
} as Record<string, string>,
},
ar: {
heading: 'الشكاوى',
subtitle: 'إدارة شكاوى العملاء والتصعيدات',
newComplaint: 'شكوى جديدة',
filterStatus: 'الحالة',
filterSeverity: 'الخطورة',
allStatuses: 'جميع الحالات',
allSeverities: 'جميع مستويات الخطورة',
statusOpen: 'مفتوح',
statusInvestigating: 'قيد التحقيق',
statusResolved: 'تم الحل',
statusClosed: 'مغلق',
sevL1: 'المستوى 1',
sevL2: 'المستوى 2',
sevL3: 'المستوى 3',
noComplaints: 'لا توجد شكاوى',
category: 'الفئة',
subject: 'الموضوع',
reservation: 'الحجز',
date: 'التاريخ',
status: 'الحالة',
severity: 'الخطورة',
details: 'التفاصيل',
description: 'الوصف',
notes: 'ملاحظات',
resolution: 'الحل',
updateStatus: 'تحديث الحالة',
saving: 'جار الحفظ…',
save: 'حفظ',
cancel: 'إلغاء',
createTitle: 'إنشاء شكوى',
fieldReservationId: 'رقم الحجز (اختياري)',
fieldCategory: 'الفئة',
fieldSeverity: 'الخطورة',
fieldSubject: 'الموضوع',
fieldDescription: 'الوصف (اختياري)',
create: 'إنشاء',
creating: 'جار الإنشاء…',
errorLoad: 'فشل تحميل الشكاوى',
errorCreate: 'فشل إنشاء الشكوى',
errorUpdate: 'فشل تحديث الشكوى',
categories: {
BOOKING: 'الحجز', PICKUP: 'الاستلام', VEHICLE_CLEANLINESS: 'نظافة المركبة',
VEHICLE_CONDITION: 'حالة المركبة', STAFF_SERVICE: 'خدمة الموظفين', PRICING: 'التسعير',
DEPOSIT: 'التأمين', INSURANCE: 'التأمين', DAMAGE_CLAIM: 'مطالبة الأضرار',
RETURN_PROCESS: 'عملية الإرجاع', COMMUNICATION: 'التواصل',
ROADSIDE_ASSISTANCE: 'المساعدة على الطريق', BILLING: 'الفواتير', OTHER: 'أخرى',
} as Record<string, string>,
},
} as const
// ─── Types ─────────────────────────────────────────────────────────────────────
interface Complaint {
id: string
companyId: string
reservationId: string | null
reviewId: string | null
customerId: string | null
severity: string
status: string
category: string
subject: string
description: string | null
resolution: string | null
notes: string | null
assignedTo: string | null
resolvedAt: string | null
createdAt: string
reservation: { id: string; startDate: string; status: string; vehicle: { make: string; model: string; year: number } } | null
customer: { id: string; firstName: string; lastName: string; email: string } | null
review: { id: string; overallRating: number } | null
}
interface ComplaintsResponse {
data: Complaint[]
meta: { total: number; page: number; pageSize: number; totalPages: number }
}
const CATEGORIES = [
'BOOKING', 'PICKUP', 'VEHICLE_CLEANLINESS', 'VEHICLE_CONDITION', 'STAFF_SERVICE',
'PRICING', 'DEPOSIT', 'INSURANCE', 'DAMAGE_CLAIM', 'RETURN_PROCESS',
'COMMUNICATION', 'ROADSIDE_ASSISTANCE', 'BILLING', 'OTHER',
]
function severityBadgeClass(sev: string) {
if (sev === 'LEVEL_3') return 'badge-red'
if (sev === 'LEVEL_2') return 'badge-amber'
return 'badge-gray'
}
function statusBadgeClass(status: string) {
if (status === 'RESOLVED' || status === 'CLOSED') return 'badge-green'
if (status === 'INVESTIGATING') return 'badge-blue'
return 'badge-amber'
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function ComplaintsPage() {
const { language } = useDashboardI18n()
const t = copy[language as keyof typeof copy] ?? copy.en
const [complaints, setComplaints] = useState<Complaint[]>([])
const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 })
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState('')
const [filterSeverity, setFilterSeverity] = useState('')
const [page, setPage] = useState(1)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
// Create form state
const [formReservationId, setFormReservationId] = useState('')
const [formCategory, setFormCategory] = useState('')
const [formSeverity, setFormSeverity] = useState('LEVEL_1')
const [formSubject, setFormSubject] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formCreating, setFormCreating] = useState(false)
// Edit state (for expanded complaint)
const [editStatus, setEditStatus] = useState<Record<string, string>>({})
const [editNotes, setEditNotes] = useState<Record<string, string>>({})
const [editResolution, setEditResolution] = useState<Record<string, string>>({})
const [savingId, setSavingId] = useState<string | null>(null)
async function loadData() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (filterStatus) params.set('status', filterStatus)
if (filterSeverity) params.set('severity', filterSeverity)
const data = await apiFetch<ComplaintsResponse>(`/complaints?${params}`)
setComplaints(data.data ?? [])
setMeta(data.meta ?? { total: 0, page: 1, pageSize: 20, totalPages: 1 })
} catch {
setError(t.errorLoad)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [page, filterStatus, filterSeverity])
function toggleExpand(id: string, complaint: Complaint) {
if (expandedId === id) {
setExpandedId(null)
} else {
setExpandedId(id)
setEditStatus((prev) => ({ ...prev, [id]: complaint.status }))
setEditNotes((prev) => ({ ...prev, [id]: complaint.notes ?? '' }))
setEditResolution((prev) => ({ ...prev, [id]: complaint.resolution ?? '' }))
}
}
async function handleCreate() {
if (!formCategory || !formSubject.trim()) return
setFormCreating(true)
try {
await apiFetch('/complaints', {
method: 'POST',
body: JSON.stringify({
reservationId: formReservationId.trim() || undefined,
category: formCategory,
severity: formSeverity,
subject: formSubject.trim(),
description: formDescription.trim() || undefined,
}),
})
setShowCreate(false)
setFormReservationId('')
setFormCategory('')
setFormSeverity('LEVEL_1')
setFormSubject('')
setFormDescription('')
await loadData()
} catch {
alert(t.errorCreate)
} finally {
setFormCreating(false)
}
}
async function handleUpdate(id: string) {
setSavingId(id)
try {
await apiFetch(`/complaints/${id}`, {
method: 'PATCH',
body: JSON.stringify({
status: editStatus[id],
notes: editNotes[id] || undefined,
resolution: editResolution[id] || undefined,
}),
})
await loadData()
} catch {
alert(t.errorUpdate)
} finally {
setSavingId(null)
}
}
const isRtl = language === 'ar'
return (
<div className={`space-y-6 p-6 ${isRtl ? 'rtl' : 'ltr'}`}>
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-blue-950 dark:text-white">{t.heading}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{t.subtitle}</p>
</div>
<button onClick={() => setShowCreate((v) => !v)} className="btn-primary">
<Plus className="h-4 w-4" />
{t.newComplaint}
</button>
</div>
{/* Create form */}
{showCreate && (
<div className="card p-6 space-y-4">
<h2 className="text-lg font-semibold text-blue-950 dark:text-white">{t.createTitle}</h2>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldReservationId}</label>
<input
type="text"
value={formReservationId}
onChange={(e) => setFormReservationId(e.target.value)}
className="input-field"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldCategory}</label>
<select
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
className="input-field"
>
<option value=""> select </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{t.categories[c] ?? c}</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldSeverity}</label>
<select value={formSeverity} onChange={(e) => setFormSeverity(e.target.value)} className="input-field">
<option value="LEVEL_1">{t.sevL1}</option>
<option value="LEVEL_2">{t.sevL2}</option>
<option value="LEVEL_3">{t.sevL3}</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldSubject}</label>
<input
type="text"
value={formSubject}
onChange={(e) => setFormSubject(e.target.value)}
className="input-field"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldDescription}</label>
<textarea
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
rows={3}
className="input-field resize-none"
/>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCreate}
disabled={formCreating || !formCategory || !formSubject.trim()}
className="btn-primary"
>
{formCreating ? t.creating : t.create}
</button>
<button onClick={() => setShowCreate(false)} className="btn-secondary">{t.cancel}</button>
</div>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterStatus}</label>
<select
value={filterStatus}
onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allStatuses}</option>
<option value="OPEN">{t.statusOpen}</option>
<option value="INVESTIGATING">{t.statusInvestigating}</option>
<option value="RESOLVED">{t.statusResolved}</option>
<option value="CLOSED">{t.statusClosed}</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterSeverity}</label>
<select
value={filterSeverity}
onChange={(e) => { setFilterSeverity(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allSeverities}</option>
<option value="LEVEL_1">{t.sevL1}</option>
<option value="LEVEL_2">{t.sevL2}</option>
<option value="LEVEL_3">{t.sevL3}</option>
</select>
</div>
</div>
</div>
{/* List */}
{loading ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">Loading</div>
) : error ? (
<div className="card p-10 text-center text-red-500">{error}</div>
) : complaints.length === 0 ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">{t.noComplaints}</div>
) : (
<div className="space-y-3">
{complaints.map((complaint) => {
const isExpanded = expandedId === complaint.id
const customerName = complaint.customer
? `${complaint.customer.firstName} ${complaint.customer.lastName}`
: null
return (
<div key={complaint.id} className="card overflow-hidden">
<button
onClick={() => toggleExpand(complaint.id, complaint)}
className="flex w-full items-start justify-between p-5 text-left hover:bg-stone-50 dark:hover:bg-blue-950/30"
>
<div className="flex flex-1 flex-wrap items-center gap-3">
<span className={severityBadgeClass(complaint.severity)}>
<AlertTriangle className="mr-1 h-3 w-3" />
{complaint.severity === 'LEVEL_1' ? t.sevL1 : complaint.severity === 'LEVEL_2' ? t.sevL2 : t.sevL3}
</span>
<span className={statusBadgeClass(complaint.status)}>
{complaint.status === 'OPEN' ? t.statusOpen
: complaint.status === 'INVESTIGATING' ? t.statusInvestigating
: complaint.status === 'RESOLVED' ? t.statusResolved
: t.statusClosed}
</span>
<span className="badge-gray">{t.categories[complaint.category] ?? complaint.category}</span>
<span className="font-medium text-blue-950 dark:text-white">{complaint.subject}</span>
{customerName && <span className="text-sm text-stone-500 dark:text-slate-400">{customerName}</span>}
{complaint.reservationId && (
<span className="font-mono text-xs text-stone-400 dark:text-slate-500">
{complaint.reservationId.slice(0, 8)}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-3">
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(complaint.createdAt).toLocaleDateString()}
</span>
{isExpanded ? <ChevronUp className="h-4 w-4 text-stone-400" /> : <ChevronDown className="h-4 w-4 text-stone-400" />}
</div>
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="border-t border-stone-100 p-5 dark:border-blue-900/30 space-y-4">
{complaint.description && (
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.description}</p>
<p className="text-sm text-stone-700 dark:text-slate-300">{complaint.description}</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.updateStatus}</label>
<select
value={editStatus[complaint.id] ?? complaint.status}
onChange={(e) => setEditStatus((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
className="input-field"
>
<option value="OPEN">{t.statusOpen}</option>
<option value="INVESTIGATING">{t.statusInvestigating}</option>
<option value="RESOLVED">{t.statusResolved}</option>
<option value="CLOSED">{t.statusClosed}</option>
</select>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.notes}</label>
<textarea
value={editNotes[complaint.id] ?? ''}
onChange={(e) => setEditNotes((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.resolution}</label>
<textarea
value={editResolution[complaint.id] ?? ''}
onChange={(e) => setEditResolution((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
/>
</div>
<div className="flex gap-2">
<button
onClick={() => handleUpdate(complaint.id)}
disabled={savingId === complaint.id}
className="btn-primary text-sm"
>
{savingId === complaint.id ? t.saving : t.save}
</button>
<button onClick={() => setExpandedId(null)} className="btn-secondary text-sm">{t.cancel}</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="btn-secondary text-sm"
>
</button>
<span className="text-sm text-stone-600 dark:text-slate-400">
{page} / {meta.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages}
className="btn-secondary text-sm"
>
</button>
</div>
)}
</div>
)
}
@@ -673,7 +673,7 @@ export default function FleetDetailPage() {
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.statusLabel}</label>
<select className="input-field" value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
{(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE'] as const).map((s) => (
{(['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE'] as const).map((s) => (
<option key={s} value={s}>{fl.statusLabels[s]}</option>
))}
</select>
@@ -14,7 +14,7 @@ interface Vehicle {
model: string
year: number
category: string
status: 'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE'
status: 'AVAILABLE' | 'RESERVED' | 'READY' | 'RENTED' | 'RETURNED' | 'NEEDS_CLEANING' | 'MAINTENANCE' | 'DAMAGE_REVIEW' | 'OUT_OF_SERVICE'
dailyRate: number
isPublished: boolean
primaryPhoto: string | null
@@ -31,13 +31,33 @@ interface AddVehicleModalProps {
}
const STATUS_BADGE: Record<Vehicle['status'], string> = {
AVAILABLE: 'badge-green',
RENTED: 'badge-blue',
MAINTENANCE: 'badge-amber',
AVAILABLE: 'badge-green',
RESERVED: 'badge-blue',
READY: 'badge-purple',
RENTED: 'badge-indigo',
RETURNED: 'badge-amber',
NEEDS_CLEANING: 'badge-amber',
MAINTENANCE: 'badge-amber',
DAMAGE_REVIEW: 'badge-red',
OUT_OF_SERVICE: 'badge-red',
}
const ALL_STATUSES: Vehicle['status'][] = ['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']
const ALL_STATUSES: Vehicle['status'][] = [
'AVAILABLE', 'RESERVED', 'READY', 'RENTED',
'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE',
]
const STATUS_DOT: Record<Vehicle['status'], string> = {
AVAILABLE: 'bg-green-500',
RESERVED: 'bg-blue-500',
READY: 'bg-teal-500',
RENTED: 'bg-indigo-500',
RETURNED: 'bg-amber-400',
NEEDS_CLEANING: 'bg-amber-500',
MAINTENANCE: 'bg-orange-500',
DAMAGE_REVIEW: 'bg-red-400',
OUT_OF_SERVICE: 'bg-red-600',
}
interface MaintenanceModalProps {
vehicleId: string
@@ -178,7 +198,7 @@ function StatusDropdown({ vehicleId, status, onStatusChange }: {
setOpen(false)
setSaving(true)
try {
await apiFetch(`/vehicles/${vehicleId}`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
await apiFetch(`/vehicles/${vehicleId}/status`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
onStatusChange(vehicleId, next)
} catch (err: any) {
alert(err.message)
@@ -210,11 +230,7 @@ function StatusDropdown({ vehicleId, status, onStatusChange }: {
onClick={() => select(s)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-start hover:bg-slate-50 transition-colors ${s === status ? 'font-semibold' : ''}`}
>
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${
s === 'AVAILABLE' ? 'bg-green-500' :
s === 'RENTED' ? 'bg-blue-500' :
s === 'MAINTENANCE' ? 'bg-orange-500' : 'bg-red-500'
}`} />
<span className={`inline-block w-2 h-2 rounded-full flex-shrink-0 ${STATUS_DOT[s]}`} />
{f.statusLabels[s]}
</button>
))}
@@ -620,8 +636,10 @@ export default function FleetPage() {
}
}
const PUBLISHED = new Set<Vehicle['status']>(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
const changeStatus = (id: string, status: Vehicle['status']) => {
const isPublished = status !== 'MAINTENANCE' && status !== 'OUT_OF_SERVICE'
const isPublished = PUBLISHED.has(status)
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, status, isPublished } : v))
setStatusFilter('')
if (status === 'MAINTENANCE') {
+1 -13
View File
@@ -1,10 +1,9 @@
import Sidebar from '@/components/layout/Sidebar'
import TopBar from '@/components/layout/TopBar'
import { DashboardLanguageSwitcher, DashboardThemeSwitcher } from '@/components/I18nProvider'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-[linear-gradient(180deg,#f8f5ef_0%,#f4efe6_28%,#fffdf8_58%,#ffffff_100%)] transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] print:block print:min-h-0 print:bg-white">
<div className="flex min-h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] transition-colors dark:bg-[linear-gradient(180deg,#0a1128_0%,#0d1b38_35%,#07101e_100%)] print:block print:min-h-0 print:bg-white">
<div className="print:hidden">
<Sidebar />
</div>
@@ -13,17 +12,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
<TopBar />
</div>
<main className="flex-1 overflow-y-auto p-6 print:p-0 print:overflow-visible">{children}</main>
<footer className="border-t border-stone-200/80 bg-white/72 px-6 py-4 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/72 print:hidden">
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-stone-500">
Workspace preferences
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
</div>
</div>
</footer>
</div>
</div>
)
@@ -6,6 +6,7 @@ import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import DamageInspectionCard, { DamageInspection } from '@/components/reservations/DamageInspectionCard'
import ReservationPhotoSection from '@/components/reservations/ReservationPhotoSection'
interface ReservationDetail {
id: string
@@ -118,6 +119,18 @@ const detailCopy = {
inspectionClosed: 'This reservation is closed. Inspection edits are disabled.',
licenseImageLabel: 'License image',
noLicenseImage: 'No license image uploaded.',
pickupPhotosTitle: 'Pickup photos',
dropoffPhotosTitle: 'Return photos',
pickupPhotosReadOnly: 'Pickup photos can be added once the reservation is confirmed.',
dropoffPhotosReadOnly: 'Return photos become available once the vehicle is checked out.',
extendBtn: 'Extend rental',
extendTitle: 'Extend rental',
extendNewEnd: 'New return date & time',
extendReason: 'Reason for extension',
extendReasonPlaceholder: 'e.g. Customer requested 2 extra days',
extendSubmit: 'Confirm extension',
extendCancel: 'Cancel',
extendFailed: 'Failed to extend reservation',
},
fr: {
editBooking: 'Modifier la réservation',
@@ -155,6 +168,18 @@ const detailCopy = {
inspectionClosed: 'Cette réservation est clôturée. Les modifications dinspection sont désactivées.',
licenseImageLabel: 'Image du permis',
noLicenseImage: 'Aucune image de permis téléversée.',
pickupPhotosTitle: 'Photos de départ',
dropoffPhotosTitle: 'Photos de retour',
pickupPhotosReadOnly: 'Les photos de départ sont disponibles après confirmation de la réservation.',
dropoffPhotosReadOnly: 'Les photos de retour sont disponibles après la restitution du véhicule.',
extendBtn: 'Prolonger la location',
extendTitle: 'Prolonger la location',
extendNewEnd: 'Nouvelle date de retour',
extendReason: 'Motif de prolongation',
extendReasonPlaceholder: 'ex. Le client a demandé 2 jours supplémentaires',
extendSubmit: 'Confirmer la prolongation',
extendCancel: 'Annuler',
extendFailed: 'Échec de la prolongation',
},
ar: {
editBooking: 'تعديل الحجز',
@@ -192,6 +217,18 @@ const detailCopy = {
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
licenseImageLabel: 'صورة الرخصة',
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
pickupPhotosTitle: 'صور الاستلام',
dropoffPhotosTitle: 'صور الإرجاع',
pickupPhotosReadOnly: 'تتوفر صور الاستلام بعد تأكيد الحجز.',
dropoffPhotosReadOnly: 'تتوفر صور الإرجاع بعد إعادة السيارة.',
extendBtn: 'تمديد التأجير',
extendTitle: 'تمديد التأجير',
extendNewEnd: 'تاريخ الإرجاع الجديد',
extendReason: 'سبب التمديد',
extendReasonPlaceholder: 'مثال: طلب العميل يومين إضافيين',
extendSubmit: 'تأكيد التمديد',
extendCancel: 'إلغاء',
extendFailed: 'فشل تمديد الحجز',
},
} as const
@@ -233,6 +270,9 @@ export default function ReservationDetailPage() {
const [error, setError] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [acting, setActing] = useState(false)
const [showExtend, setShowExtend] = useState(false)
const [extendForm, setExtendForm] = useState({ newEndDate: '', reason: '' })
const [extendError, setExtendError] = useState<string | null>(null)
async function loadReservation() {
try {
@@ -337,6 +377,25 @@ export default function ReservationDetailPage() {
}
}
async function submitExtend() {
if (!extendForm.newEndDate || !extendForm.reason.trim()) return
setActing(true)
setExtendError(null)
try {
await apiFetch(`/reservations/${params.id}/extend`, {
method: 'POST',
body: JSON.stringify({ newEndDate: new Date(extendForm.newEndDate).toISOString(), reason: extendForm.reason }),
})
setShowExtend(false)
setExtendForm({ newEndDate: '', reason: '' })
await loadReservation()
} catch (err: any) {
setExtendError(err.message ?? copy.extendFailed)
} finally {
setActing(false)
}
}
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
@@ -424,9 +483,49 @@ export default function ReservationDetailPage() {
{acting ? r.working : r.checkOutVehicle}
</button>
)}
{['CONFIRMED', 'ACTIVE'].includes(reservation.status) && !reservation.workflow.closed && (
<button disabled={acting} onClick={() => { setShowExtend(true); setExtendError(null) }} className="btn-secondary">
{copy.extendBtn}
</button>
)}
</div>
</div>
{showExtend && (
<div className="card border border-blue-200 bg-blue-50 p-5 space-y-4">
<h4 className="text-sm font-semibold text-slate-900">{copy.extendTitle}</h4>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-700">{copy.extendNewEnd}</label>
<input
type="datetime-local"
className="input-field"
value={extendForm.newEndDate}
onChange={(e) => setExtendForm((f) => ({ ...f, newEndDate: e.target.value }))}
/>
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-700">{copy.extendReason}</label>
<input
className="input-field"
placeholder={copy.extendReasonPlaceholder}
value={extendForm.reason}
onChange={(e) => setExtendForm((f) => ({ ...f, reason: e.target.value }))}
/>
</div>
</div>
{extendError && <p className="text-xs text-red-600">{extendError}</p>}
<div className="flex gap-3">
<button disabled={acting} onClick={submitExtend} className="btn-primary text-sm">
{acting ? r.working : copy.extendSubmit}
</button>
<button onClick={() => { setShowExtend(false); setExtendForm({ newEndDate: '', reason: '' }) }} className="btn-secondary text-sm">
{copy.extendCancel}
</button>
</div>
</div>
)}
{workflowMessage && (
<div className="card border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
{workflowMessage}
@@ -550,6 +649,14 @@ export default function ReservationDetailPage() {
</div>
</div>
<ReservationPhotoSection
reservationId={reservation.id}
type="PICKUP"
editable={reservation.workflow.checkInInspectionEditable}
title={copy.pickupPhotosTitle}
readOnlyMessage={copy.pickupPhotosReadOnly}
/>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.returnDetails}</h3>
<div className="grid gap-4 md:grid-cols-2">
@@ -593,6 +700,14 @@ export default function ReservationDetailPage() {
</div>
</div>
<ReservationPhotoSection
reservationId={reservation.id}
type="DROPOFF"
editable={reservation.workflow.checkOutInspectionEditable}
title={copy.dropoffPhotosTitle}
readOnlyMessage={copy.dropoffPhotosReadOnly}
/>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCharges}</h3>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
@@ -0,0 +1,422 @@
'use client'
import { useEffect, useState } from 'react'
import { Star, MessageSquare, AlertTriangle } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
// ─── i18n ─────────────────────────────────────────────────────────────────────
const copy = {
en: {
heading: 'Reviews',
subtitle: 'Customer feedback for your company',
totalReviews: 'Total Reviews',
avgRating: 'Average Rating',
fiveStars: '5-Star Reviews',
negative: 'Negative Reviews',
filterRating: 'Filter by rating',
allRatings: 'All ratings',
filterDate: 'Date range',
last7: 'Last 7 days',
last30: 'Last 30 days',
last90: 'Last 90 days',
allTime: 'All time',
customer: 'Customer',
vehicle: 'Vehicle',
date: 'Date',
rating: 'Rating',
comment: 'Comment',
reply: 'Reply',
replied: 'Replied',
noReply: 'No reply yet',
saveReply: 'Save reply',
cancel: 'Cancel',
saving: 'Saving…',
noReviews: 'No reviews yet',
newComplaint: 'New complaint',
replyPlaceholder: 'Write your reply…',
errorLoad: 'Failed to load reviews',
errorReply: 'Failed to save reply',
stars: (n: number) => n === 1 ? '1 star' : `${n} stars`,
},
fr: {
heading: 'Avis',
subtitle: 'Retours clients pour votre entreprise',
totalReviews: 'Total des avis',
avgRating: 'Note moyenne',
fiveStars: 'Avis 5 étoiles',
negative: 'Avis négatifs',
filterRating: 'Filtrer par note',
allRatings: 'Toutes les notes',
filterDate: 'Période',
last7: '7 derniers jours',
last30: '30 derniers jours',
last90: '90 derniers jours',
allTime: 'Tout le temps',
customer: 'Client',
vehicle: 'Véhicule',
date: 'Date',
rating: 'Note',
comment: 'Commentaire',
reply: 'Répondre',
replied: 'Répondu',
noReply: 'Pas encore de réponse',
saveReply: 'Enregistrer',
cancel: 'Annuler',
saving: 'Enregistrement…',
noReviews: 'Aucun avis pour l\'instant',
newComplaint: 'Nouvelle plainte',
replyPlaceholder: 'Rédigez votre réponse…',
errorLoad: 'Échec du chargement des avis',
errorReply: 'Échec de l\'enregistrement de la réponse',
stars: (n: number) => n === 1 ? '1 étoile' : `${n} étoiles`,
},
ar: {
heading: 'التقييمات',
subtitle: 'آراء العملاء لشركتك',
totalReviews: 'إجمالي التقييمات',
avgRating: 'متوسط التقييم',
fiveStars: 'تقييمات 5 نجوم',
negative: 'تقييمات سلبية',
filterRating: 'تصفية حسب التقييم',
allRatings: 'جميع التقييمات',
filterDate: 'النطاق الزمني',
last7: 'آخر 7 أيام',
last30: 'آخر 30 يوماً',
last90: 'آخر 90 يوماً',
allTime: 'كل الوقت',
customer: 'العميل',
vehicle: 'المركبة',
date: 'التاريخ',
rating: 'التقييم',
comment: 'تعليق',
reply: 'رد',
replied: 'تم الرد',
noReply: 'لم يتم الرد بعد',
saveReply: 'حفظ الرد',
cancel: 'إلغاء',
saving: 'جار الحفظ…',
noReviews: 'لا توجد تقييمات بعد',
newComplaint: 'شكوى جديدة',
replyPlaceholder: 'اكتب ردك هنا…',
errorLoad: 'فشل تحميل التقييمات',
errorReply: 'فشل حفظ الرد',
stars: (n: number) => `${n} نجوم`,
},
} as const
// ─── Types ─────────────────────────────────────────────────────────────────────
interface ReviewReservation {
id: string
startDate: string
endDate: string
customer: { id: string; firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; year: number }
}
interface Review {
id: string
reservationId: string
overallRating: number
vehicleRating: number | null
serviceRating: number | null
comment: string | null
companyReply: string | null
companyRepliedAt: string | null
isPublished: boolean
createdAt: string
reservation: ReviewReservation
}
interface ReviewsResponse {
data: Review[]
meta: { total: number; page: number; pageSize: number; totalPages: number }
}
interface Stats {
total: number
averageOverall: number
averageVehicle: number
averageService: number
byRating: Record<string, number>
}
// ─── Star display ──────────────────────────────────────────────────────────────
function StarDisplay({ rating, size = 'sm' }: { rating: number; size?: 'sm' | 'md' }) {
const sz = size === 'md' ? 'h-5 w-5' : 'h-4 w-4'
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((s) => (
<Star
key={s}
className={`${sz} ${s <= rating ? 'fill-amber-400 text-amber-400' : 'text-stone-300 dark:text-blue-900'}`}
/>
))}
</div>
)
}
// ─── Main page ─────────────────────────────────────────────────────────────────
export default function ReviewsPage() {
const { language } = useDashboardI18n()
const t = copy[language as keyof typeof copy] ?? copy.en
const [reviews, setReviews] = useState<Review[]>([])
const [meta, setMeta] = useState({ total: 0, page: 1, pageSize: 20, totalPages: 1 })
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filterRating, setFilterRating] = useState<string>('')
const [filterDays, setFilterDays] = useState<string>('')
const [page, setPage] = useState(1)
const [replyingId, setReplyingId] = useState<string | null>(null)
const [replyText, setReplyText] = useState('')
const [replySaving, setReplySaving] = useState(false)
async function loadData() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' })
if (filterRating) params.set('rating', filterRating)
const [reviewsData, statsData] = await Promise.all([
apiFetch<ReviewsResponse>(`/reviews?${params}`),
apiFetch<Stats>('/reviews/stats'),
])
let data = reviewsData.data ?? []
if (filterDays) {
const cutoff = new Date()
cutoff.setDate(cutoff.getDate() - Number(filterDays))
data = data.filter((r) => new Date(r.createdAt) >= cutoff)
}
setReviews(data)
setMeta(reviewsData.meta ?? { total: 0, page: 1, pageSize: 20, totalPages: 1 })
setStats(statsData)
} catch {
setError(t.errorLoad)
} finally {
setLoading(false)
}
}
useEffect(() => { loadData() }, [page, filterRating, filterDays])
async function handleSaveReply(reviewId: string) {
if (!replyText.trim()) return
setReplySaving(true)
try {
await apiFetch(`/reviews/${reviewId}/reply`, {
method: 'PATCH',
body: JSON.stringify({ companyReply: replyText.trim() }),
})
setReplyingId(null)
setReplyText('')
await loadData()
} catch {
alert(t.errorReply)
} finally {
setReplySaving(false)
}
}
function startReply(review: Review) {
setReplyingId(review.id)
setReplyText(review.companyReply ?? '')
}
const isRtl = language === 'ar'
return (
<div className={`space-y-6 p-6 ${isRtl ? 'rtl' : 'ltr'}`}>
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-blue-950 dark:text-white">{t.heading}</h1>
<p className="mt-1 text-sm text-stone-500 dark:text-slate-400">{t.subtitle}</p>
</div>
{/* Stats cards */}
{stats && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.totalReviews}</p>
<p className="mt-2 text-3xl font-bold text-blue-950 dark:text-white">{stats.total}</p>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.avgRating}</p>
<div className="mt-2 flex items-center gap-2">
<p className="text-3xl font-bold text-blue-950 dark:text-white">{stats.averageOverall.toFixed(1)}</p>
<StarDisplay rating={Math.round(stats.averageOverall)} size="md" />
</div>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.fiveStars}</p>
<p className="mt-2 text-3xl font-bold text-emerald-600 dark:text-emerald-400">{stats.byRating['5'] ?? 0}</p>
</div>
<div className="card p-5">
<p className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.negative}</p>
<p className="mt-2 text-3xl font-bold text-red-600 dark:text-red-400">
{(stats.byRating['1'] ?? 0) + (stats.byRating['2'] ?? 0)}
</p>
</div>
</div>
)}
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterRating}</label>
<select
value={filterRating}
onChange={(e) => { setFilterRating(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allRatings}</option>
{[5, 4, 3, 2, 1].map((r) => (
<option key={r} value={String(r)}>{t.stars(r)}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-stone-700 dark:text-slate-300">{t.filterDate}</label>
<select
value={filterDays}
onChange={(e) => { setFilterDays(e.target.value); setPage(1) }}
className="input-field w-auto"
>
<option value="">{t.allTime}</option>
<option value="7">{t.last7}</option>
<option value="30">{t.last30}</option>
<option value="90">{t.last90}</option>
</select>
</div>
</div>
</div>
{/* Reviews list */}
{loading ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">Loading</div>
) : error ? (
<div className="card p-10 text-center text-red-500">{error}</div>
) : reviews.length === 0 ? (
<div className="card p-10 text-center text-stone-400 dark:text-slate-500">{t.noReviews}</div>
) : (
<div className="space-y-4">
{reviews.map((review) => {
const customer = review.reservation?.customer
const vehicle = review.reservation?.vehicle
const vehicleLabel = vehicle ? `${vehicle.year} ${vehicle.make} ${vehicle.model}` : '—'
const customerName = customer ? `${customer.firstName} ${customer.lastName}` : '—'
const isReplying = replyingId === review.id
const isNegative = review.overallRating <= 2
return (
<div key={review.id} className={`card p-5 ${isNegative ? 'border-red-200 dark:border-red-900/40' : ''}`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-3">
<span className="font-semibold text-blue-950 dark:text-white">{customerName}</span>
<span className="text-sm text-stone-400 dark:text-slate-500">{vehicleLabel}</span>
<span className="text-xs text-stone-400 dark:text-slate-500">
{new Date(review.createdAt).toLocaleDateString()}
</span>
</div>
<StarDisplay rating={review.overallRating} />
{review.comment && (
<p className="mt-2 line-clamp-3 text-sm text-stone-600 dark:text-slate-300">{review.comment}</p>
)}
{review.companyReply && !isReplying && (
<div className="mt-3 rounded-2xl border border-blue-100 bg-blue-50 p-3 dark:border-blue-900/40 dark:bg-blue-950/30">
<div className="mb-1 flex items-center gap-2">
<MessageSquare className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-medium text-blue-600 dark:text-blue-400">{t.replied}</span>
</div>
<p className="text-sm text-stone-600 dark:text-slate-300">{review.companyReply}</p>
</div>
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<button
onClick={() => startReply(review)}
className="btn-secondary text-xs"
>
<MessageSquare className="h-3.5 w-3.5" />
{review.companyReply ? t.replied : t.reply}
</button>
<a
href={`/complaints?reservationId=${review.reservation?.id ?? ''}`}
className="btn-secondary text-xs"
>
<AlertTriangle className="h-3.5 w-3.5" />
{t.newComplaint}
</a>
</div>
</div>
{/* Inline reply form */}
{isReplying && (
<div className="mt-4 space-y-3">
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t.replyPlaceholder}
rows={3}
className="input-field resize-none"
/>
<div className="flex gap-2">
<button
onClick={() => handleSaveReply(review.id)}
disabled={replySaving || !replyText.trim()}
className="btn-primary text-sm"
>
{replySaving ? t.saving : t.saveReply}
</button>
<button
onClick={() => { setReplyingId(null); setReplyText('') }}
className="btn-secondary text-sm"
>
{t.cancel}
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{meta.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="btn-secondary text-sm"
>
</button>
<span className="text-sm text-stone-600 dark:text-slate-400">
{page} / {meta.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(meta.totalPages, p + 1))}
disabled={page >= meta.totalPages}
className="btn-secondary text-sm"
>
</button>
</div>
)}
</div>
)
}
@@ -1,7 +1,7 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -35,6 +35,13 @@ interface ProviderAvailability {
paypal: boolean
}
interface PlanFeature {
id: string
plan: Plan
label: string
sortOrder: number
}
interface EmployeeProfile {
role?: string
}
@@ -69,6 +76,8 @@ export default function SubscriptionPage() {
const currency = 'MAD'
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
const [planPrices, setPlanPrices] = useState<Record<string, Record<string, Record<string, number>>>>(PLAN_PRICES)
const [planFeaturesList, setPlanFeaturesList] = useState<PlanFeature[]>([])
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const copy = {
@@ -228,6 +237,15 @@ export default function SubscriptionPage() {
})
}, [router])
const fetchPlanData = useCallback(async () => {
const [prices, features] = await Promise.all([
apiFetch<Record<string, Record<string, Record<string, number>>>>('/subscriptions/plans'),
apiFetch<PlanFeature[]>('/subscriptions/features'),
])
if (prices && Object.keys(prices).length > 0) setPlanPrices(prices)
if (features) setPlanFeaturesList(features)
}, [])
useEffect(() => {
if (canViewPage !== true) return
@@ -235,6 +253,7 @@ export default function SubscriptionPage() {
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
fetchPlanData(),
])
.then(([sub, inv, availability]) => {
setProviderAvailability(availability)
@@ -250,7 +269,21 @@ export default function SubscriptionPage() {
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [canViewPage])
}, [canViewPage, fetchPlanData])
useEffect(() => {
if (canViewPage !== true) return
const handleFocus = () => { fetchPlanData().catch(() => {}) }
const handleVisibility = () => {
if (document.visibilityState === 'visible') fetchPlanData().catch(() => {})
}
window.addEventListener('focus', handleFocus)
document.addEventListener('visibilitychange', handleVisibility)
return () => {
window.removeEventListener('focus', handleFocus)
document.removeEventListener('visibilitychange', handleVisibility)
}
}, [canViewPage, fetchPlanData])
if (canViewPage !== true) {
return null
@@ -309,7 +342,7 @@ export default function SubscriptionPage() {
}
}
const planPrice = PLAN_PRICES[selectedPlan]?.[billingPeriod]?.[currency]
const planPrice = planPrices[selectedPlan]?.[billingPeriod]?.[currency]
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
@@ -402,8 +435,9 @@ export default function SubscriptionPage() {
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
const price = planPrices[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
const features = planFeaturesList.filter((f) => f.plan === plan)
return (
<button
key={plan}
@@ -423,11 +457,17 @@ export default function SubscriptionPage() {
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
))}
{features.length > 0
? features.map((f) => (
<li key={f.id} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f.label}
</li>
))
: copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
))}
</ul>
</button>
)
+4
View File
@@ -91,6 +91,10 @@ html.dark body {
.badge-purple {
@apply inline-flex items-center rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-700 dark:bg-teal-950/40 dark:text-teal-300;
}
.badge-indigo {
@apply inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-950/40 dark:text-indigo-300;
}
}
@media print {