archetecture security fix

This commit is contained in:
root
2026-06-11 03:22:12 -04:00
parent 6def9993da
commit 9483750161
3126 changed files with 177194 additions and 37211 deletions
@@ -0,0 +1,644 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { formatCurrency, SupportedCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type ReservationRow = {
id: string
invoiceNumber: string | null
contractNumber: string | null
status: string
paymentStatus: string
startDate: string
endDate: string
totalAmount: number
depositAmount: number
paidAmount: number
customer: { firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; licensePlate: string }
}
type PaymentRow = {
id: string
reservationId: string
amount: number
currency: SupportedCurrency
status: string
type: 'CHARGE' | 'DEPOSIT'
paymentProvider: 'AMANPAY' | 'PAYPAL'
paymentMethod: string | null
paidAt: string | null
createdAt: string
}
type BillingRow = ReservationRow & {
balanceDue: number
paymentCount: number
latestPayment: PaymentRow | null
}
type ManualPaymentMethod = 'CASH' | 'CHECK' | 'BANK_TRANSFER' | 'CARD' | 'PAYPAL'
const PAYMENT_STATUS_BADGE: Record<string, string> = {
UNPAID: 'bg-rose-100 text-rose-700',
PAID: 'bg-emerald-100 text-emerald-700',
PARTIAL: 'bg-orange-100 text-orange-700',
PENDING: 'bg-sky-100 text-sky-700',
REFUNDED: 'bg-slate-100 text-slate-700',
FAILED: 'bg-rose-100 text-rose-700',
OVERDUE: 'bg-orange-100 text-orange-700',
}
const PAYMENT_EVENT_BADGE: Record<string, string> = {
PENDING: 'bg-orange-100 text-orange-700',
SUCCEEDED: 'bg-emerald-100 text-emerald-700',
FAILED: 'bg-rose-100 text-rose-700',
REFUNDED: 'bg-slate-100 text-slate-700',
PARTIALLY_REFUNDED: 'bg-sky-100 text-sky-700',
}
export default function BillingPage() {
const { language } = useDashboardI18n()
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [reservations, setReservations] = useState<ReservationRow[]>([])
const [payments, setPayments] = useState<PaymentRow[]>([])
const [search, setSearch] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [paymentModalRow, setPaymentModalRow] = useState<BillingRow | null>(null)
const [paymentMethod, setPaymentMethod] = useState<ManualPaymentMethod>('CASH')
const [paymentType, setPaymentType] = useState<'CHARGE' | 'DEPOSIT'>('CHARGE')
const paymentCurrency = 'MAD'
const [paymentAmount, setPaymentAmount] = useState('')
const [submittingPayment, setSubmittingPayment] = useState(false)
const [paymentError, setPaymentError] = useState<string | null>(null)
const copy = {
en: {
heading: 'Customer Billing',
subtitle: 'Manage customer invoices, payment status, collected amounts, and remaining balances.',
search: 'Search by customer, invoice, contract, vehicle, or plate…',
totalBilled: 'Total billed',
totalCollected: 'Collected',
outstanding: 'Outstanding',
openInvoices: 'Open invoices',
customer: 'Customer',
vehicle: 'Vehicle',
invoice: 'Invoice',
rentalPeriod: 'Rental period',
total: 'Total',
paid: 'Paid',
balance: 'Balance',
status: 'Status',
payment: 'Payment',
paymentMethod: 'Payment method',
actions: 'Actions',
contract: 'Contract',
deposit: 'Deposit',
acceptPayment: 'Accept payment',
paymentModalTitle: 'Accept customer payment',
paymentModalSubtitle: 'Record a payment received for this reservation. No online provider setup is required.',
paymentType: 'Payment type',
paymentCurrency: 'Payment currency',
paymentAmount: 'Payment amount',
cancel: 'Cancel',
savingPayment: 'Saving payment…',
savePayment: 'Save payment',
chargeHelp: 'Charge records a payment against the reservation balance.',
depositHelp: 'Deposit records money received for the refundable security deposit.',
invalidAmount: 'Enter a valid payment amount within the remaining balance.',
paymentActionDisabled: 'No balance due',
paymentsRecorded: (count: number) => `${count} payment(s)`,
none: '—',
loading: 'Loading customer billing…',
noRows: 'No customer bills found.',
failed: 'Failed to load customer billing.',
openContract: 'Open contract',
openBooking: 'Open booking',
charge: 'Charge',
depositType: 'Deposit',
unknownInvoice: 'Draft invoice',
paymentStatusLabels: {
UNPAID: 'Unpaid',
PAID: 'Paid',
PARTIAL: 'Partial',
PENDING: 'Pending',
REFUNDED: 'Refunded',
FAILED: 'Failed',
OVERDUE: 'Overdue',
} as Record<string, string>,
paymentEventLabels: {
PENDING: 'Pending',
SUCCEEDED: 'Succeeded',
FAILED: 'Failed',
REFUNDED: 'Refunded',
PARTIALLY_REFUNDED: 'Partially refunded',
} as Record<string, string>,
paymentProviderLabels: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
paymentMethodLabels: {
CASH: 'Cash',
CHECK: 'Check',
BANK_TRANSFER: 'Bank transfer',
CARD: 'Card',
PAYPAL: 'PayPal',
STRIPE: 'Card',
CREDIT_CARD: 'Card',
DEBIT_CARD: 'Card',
} as Record<string, string>,
},
fr: {
heading: 'Facturation clients',
subtitle: 'Gérez les factures clients, le statut des paiements, les montants encaissés et les soldes restants.',
search: 'Rechercher par client, facture, contrat, véhicule ou plaque…',
totalBilled: 'Total facturé',
totalCollected: 'Encaissé',
outstanding: 'Solde restant',
openInvoices: 'Factures ouvertes',
customer: 'Client',
vehicle: 'Véhicule',
invoice: 'Facture',
rentalPeriod: 'Période',
total: 'Total',
paid: 'Payé',
balance: 'Solde',
status: 'Statut',
payment: 'Paiement',
paymentMethod: 'Mode de paiement',
actions: 'Actions',
contract: 'Contrat',
deposit: 'Dépôt',
acceptPayment: 'Encaisser',
paymentModalTitle: 'Encaisser un paiement client',
paymentModalSubtitle: 'Enregistrez un paiement reçu pour cette réservation. Aucune configuration de fournisseur en ligne nest requise.',
paymentType: 'Type de paiement',
paymentCurrency: 'Devise du paiement',
paymentAmount: 'Montant du paiement',
cancel: 'Annuler',
savingPayment: 'Enregistrement…',
savePayment: 'Enregistrer le paiement',
chargeHelp: 'Le paiement enregistre un règlement sur le solde de la réservation.',
depositHelp: 'Le dépôt enregistre lencaissement du dépôt de garantie remboursable.',
invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.',
paymentActionDisabled: 'Aucun solde dû',
paymentsRecorded: (count: number) => `${count} paiement(s)`,
none: '—',
loading: 'Chargement de la facturation client…',
noRows: 'Aucune facture client trouvée.',
failed: 'Échec du chargement de la facturation client.',
openContract: 'Ouvrir le contrat',
openBooking: 'Ouvrir la réservation',
charge: 'Paiement',
depositType: 'Dépôt',
unknownInvoice: 'Facture brouillon',
paymentStatusLabels: {
UNPAID: 'Non payé',
PAID: 'Payé',
PARTIAL: 'Partiel',
PENDING: 'En attente',
REFUNDED: 'Remboursé',
FAILED: 'Échoué',
OVERDUE: 'En retard',
} as Record<string, string>,
paymentEventLabels: {
PENDING: 'En attente',
SUCCEEDED: 'Réussi',
FAILED: 'Échoué',
REFUNDED: 'Remboursé',
PARTIALLY_REFUNDED: 'Partiellement remboursé',
} as Record<string, string>,
paymentProviderLabels: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
paymentMethodLabels: {
CASH: 'Espèces',
CHECK: 'Chèque',
BANK_TRANSFER: 'Virement bancaire',
CARD: 'Carte',
PAYPAL: 'PayPal',
STRIPE: 'Carte',
CREDIT_CARD: 'Carte',
DEBIT_CARD: 'Carte',
} as Record<string, string>,
},
ar: {
heading: 'فوترة العملاء',
subtitle: 'إدارة فواتير العملاء وحالة الدفع والمبالغ المحصلة والأرصدة المتبقية.',
search: 'ابحث بالعميل أو الفاتورة أو العقد أو السيارة أو اللوحة…',
totalBilled: 'إجمالي الفواتير',
totalCollected: 'المحصّل',
outstanding: 'المتبقي',
openInvoices: 'الفواتير المفتوحة',
customer: 'العميل',
vehicle: 'المركبة',
invoice: 'الفاتورة',
rentalPeriod: 'فترة الإيجار',
total: 'الإجمالي',
paid: 'المدفوع',
balance: 'الرصيد',
status: 'الحالة',
payment: 'الدفعة',
paymentMethod: 'طريقة الدفع',
actions: 'الإجراءات',
contract: 'العقد',
deposit: 'العربون',
acceptPayment: 'تحصيل دفعة',
paymentModalTitle: 'تحصيل دفعة من العميل',
paymentModalSubtitle: 'سجّل دفعة مستلمة لهذا الحجز. لا يتطلب ذلك إعداد مزود دفع عبر الإنترنت.',
paymentType: 'نوع الدفع',
paymentCurrency: 'عملة الدفع',
paymentAmount: 'مبلغ الدفعة',
cancel: 'إلغاء',
savingPayment: 'جارٍ الحفظ…',
savePayment: 'حفظ الدفعة',
chargeHelp: 'تسجّل الدفعة مبلغاً مستلماً على رصيد الحجز.',
depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.',
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.',
paymentActionDisabled: 'لا يوجد رصيد مستحق',
paymentsRecorded: (count: number) => `${count} دفعة`,
none: '—',
loading: 'جارٍ تحميل فوترة العملاء…',
noRows: 'لم يتم العثور على فواتير عملاء.',
failed: 'فشل تحميل فوترة العملاء.',
openContract: 'فتح العقد',
openBooking: 'فتح الحجز',
charge: 'دفعة',
depositType: 'عربون',
unknownInvoice: 'فاتورة مسودة',
paymentStatusLabels: {
UNPAID: 'غير مدفوع',
PAID: 'مدفوع',
PARTIAL: 'جزئي',
PENDING: 'قيد الانتظار',
REFUNDED: 'مسترد',
FAILED: 'فشل',
OVERDUE: 'متأخر',
} as Record<string, string>,
paymentEventLabels: {
PENDING: 'قيد الانتظار',
SUCCEEDED: 'ناجح',
FAILED: 'فشل',
REFUNDED: 'مسترد',
PARTIALLY_REFUNDED: 'مسترد جزئياً',
} as Record<string, string>,
paymentProviderLabels: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
paymentMethodLabels: {
CASH: 'نقداً',
CHECK: 'شيك',
BANK_TRANSFER: 'تحويل بنكي',
CARD: 'بطاقة',
PAYPAL: 'PayPal',
STRIPE: 'بطاقة',
CREDIT_CARD: 'بطاقة',
DEBIT_CARD: 'بطاقة',
} as Record<string, string>,
},
}[language]
async function loadBillingData() {
const [reservationRows, paymentRows] = await Promise.all([
apiFetch<ReservationRow[]>('/reservations?pageSize=100'),
apiFetch<PaymentRow[]>('/payments/company'),
])
setReservations(reservationRows ?? [])
setPayments(paymentRows ?? [])
}
useEffect(() => {
loadBillingData()
.catch((err) => setError(err.message ?? copy.failed))
.finally(() => setLoading(false))
}, [copy.failed])
const billingRows = useMemo<BillingRow[]>(() => {
const paymentsByReservation = new Map<string, PaymentRow[]>()
for (const payment of payments) {
const existing = paymentsByReservation.get(payment.reservationId) ?? []
existing.push(payment)
paymentsByReservation.set(payment.reservationId, existing)
}
return reservations.map((reservation) => {
const reservationPayments = (paymentsByReservation.get(reservation.id) ?? []).sort((a, b) =>
new Date(b.paidAt ?? b.createdAt).getTime() - new Date(a.paidAt ?? a.createdAt).getTime(),
)
return {
...reservation,
balanceDue: Math.max(reservation.totalAmount - reservation.paidAmount, 0),
paymentCount: reservationPayments.length,
latestPayment: reservationPayments[0] ?? null,
}
})
}, [payments, reservations])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return billingRows
return billingRows.filter((row) =>
`${row.customer.firstName} ${row.customer.lastName}`.toLowerCase().includes(q) ||
row.customer.email.toLowerCase().includes(q) ||
`${row.vehicle.make} ${row.vehicle.model}`.toLowerCase().includes(q) ||
row.vehicle.licensePlate.toLowerCase().includes(q) ||
(row.invoiceNumber ?? '').toLowerCase().includes(q) ||
(row.contractNumber ?? '').toLowerCase().includes(q),
)
}, [billingRows, search])
const totals = useMemo(() => {
return filteredRows.reduce(
(acc, row) => {
acc.totalBilled += row.totalAmount
acc.totalCollected += row.paidAmount
acc.outstanding += row.balanceDue
if (row.balanceDue > 0) acc.openInvoices += 1
return acc
},
{ totalBilled: 0, totalCollected: 0, outstanding: 0, openInvoices: 0 },
)
}, [filteredRows])
function formatDate(value: string) {
return new Date(value).toLocaleDateString(localeCode, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function translateStatus(status: string, labels: Record<string, string>) {
return labels[status] ?? status.replace(/_/g, ' ')
}
function translatePaymentMethod(method: string | null) {
if (!method) return copy.none
return copy.paymentMethodLabels[method] ?? method.replace(/_/g, ' ')
}
function canAcceptPayment(row: BillingRow) {
return row.balanceDue > 0
}
function openPaymentModal(row: BillingRow) {
setPaymentModalRow(row)
setPaymentMethod('CASH')
setPaymentType('CHARGE')
setPaymentAmount(String((row.balanceDue / 100).toFixed(2)))
setPaymentError(null)
}
async function handleAcceptPayment() {
if (!paymentModalRow) return
const amount = Math.round(Number(paymentAmount) * 100)
if (!Number.isFinite(amount) || amount <= 0 || amount > paymentModalRow.balanceDue) {
setPaymentError(copy.invalidAmount)
return
}
setSubmittingPayment(true)
setPaymentError(null)
try {
await apiFetch<PaymentRow>(`/payments/reservations/${paymentModalRow.id}/manual`, {
method: 'POST',
body: JSON.stringify({
amount,
type: paymentType,
currency: paymentCurrency,
paymentMethod,
}),
})
await loadBillingData()
setPaymentModalRow(null)
} catch (err: any) {
setPaymentError(err.message ?? copy.failed)
} finally {
setSubmittingPayment(false)
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.heading}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={copy.search}
className="input-field w-full lg:max-w-md"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<MetricCard label={copy.totalBilled} value={formatCurrency(totals.totalBilled, 'MAD')} />
<MetricCard label={copy.totalCollected} value={formatCurrency(totals.totalCollected, 'MAD')} />
<MetricCard label={copy.outstanding} value={formatCurrency(totals.outstanding, 'MAD')} />
<MetricCard label={copy.openInvoices} value={totals.openInvoices.toLocaleString(localeCode)} />
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-6 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.invoice}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.rentalPeriod}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.paid}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.balance}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.paymentMethod}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={11} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td>
</tr>
) : filteredRows.length === 0 ? (
<tr>
<td colSpan={11} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noRows}</td>
</tr>
) : filteredRows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm">
<p className="font-semibold text-slate-900">{row.customer.firstName} {row.customer.lastName}</p>
<p className="text-xs text-slate-500">{row.customer.email}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<p>{row.vehicle.make} {row.vehicle.model}</p>
<p className="text-xs text-slate-500">{row.vehicle.licensePlate}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<p className="font-medium text-slate-900">{row.invoiceNumber ?? copy.unknownInvoice}</p>
<p className="text-xs text-slate-500">{copy.contract}: {row.contractNumber ?? copy.none}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-600">
<p>{formatDate(row.startDate)}</p>
<p>{formatDate(row.endDate)}</p>
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
{formatCurrency(row.totalAmount, 'MAD')}
{row.depositAmount > 0 ? (
<p className="text-xs font-normal text-slate-500">{copy.deposit}: {formatCurrency(row.depositAmount, 'MAD')}</p>
) : null}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-emerald-700">{formatCurrency(row.paidAmount, 'MAD')}</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-orange-700">{formatCurrency(row.balanceDue, 'MAD')}</td>
<td className="px-6 py-4 text-sm">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${PAYMENT_STATUS_BADGE[row.paymentStatus] ?? 'bg-slate-100 text-slate-700'}`}>
{translateStatus(row.paymentStatus, copy.paymentStatusLabels)}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{row.latestPayment ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${PAYMENT_EVENT_BADGE[row.latestPayment.status] ?? 'bg-slate-100 text-slate-700'}`}>
{translateStatus(row.latestPayment.status, copy.paymentEventLabels)}
</span>
<span className="text-xs text-slate-500">{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}</span>
</div>
<p className="text-xs text-slate-500">
{row.latestPayment.type === 'DEPOSIT' ? copy.depositType : copy.charge} · {formatCurrency(row.latestPayment.amount, row.latestPayment.currency)}
</p>
<p className="text-xs text-slate-500">{formatDate(row.latestPayment.paidAt ?? row.latestPayment.createdAt)} · {copy.paymentsRecorded(row.paymentCount)}</p>
</div>
) : (
<span className="text-slate-400">{copy.none}</span>
)}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{row.latestPayment ? (
<div className="space-y-1">
<p className="font-medium text-slate-900">{translatePaymentMethod(row.latestPayment.paymentMethod)}</p>
<p className="text-xs text-slate-500">{copy.paymentProviderLabels[row.latestPayment.paymentProvider] ?? row.latestPayment.paymentProvider}</p>
</div>
) : (
<span className="text-slate-400">{copy.none}</span>
)}
</td>
<td className="px-6 py-4 text-right text-sm">
<div className="flex flex-col items-end gap-2">
{canAcceptPayment(row) ? (
<button type="button" onClick={() => openPaymentModal(row)} className="font-semibold text-emerald-700 hover:underline">
{copy.acceptPayment}
</button>
) : (
<span className="text-xs text-slate-400">{copy.paymentActionDisabled}</span>
)}
<Link href={`/contracts/${row.id}`} className="font-semibold text-blue-700 hover:underline">
{copy.openContract}
</Link>
<Link href={`/reservations/${row.id}`} className="text-slate-600 hover:text-slate-900 hover:underline">
{copy.openBooking}
</Link>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{paymentModalRow ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/30 backdrop-blur-sm" onClick={(event) => {
if (event.target === event.currentTarget && !submittingPayment) setPaymentModalRow(null)
}}>
<div className="w-full max-w-lg rounded-3xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">{copy.paymentModalTitle}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.paymentModalSubtitle}</p>
</div>
<button type="button" onClick={() => setPaymentModalRow(null)} disabled={submittingPayment} className="text-slate-400 transition hover:text-slate-700">
×
</button>
</div>
<div className="mt-5 rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
<p className="font-semibold text-slate-900">{paymentModalRow.customer.firstName} {paymentModalRow.customer.lastName}</p>
<p className="mt-1">{paymentModalRow.vehicle.make} {paymentModalRow.vehicle.model} · {paymentModalRow.vehicle.licensePlate}</p>
<p className="mt-1">{copy.invoice}: {paymentModalRow.invoiceNumber ?? copy.unknownInvoice}</p>
<p className="mt-2 font-semibold text-slate-900">{copy.balance}: {formatCurrency(paymentModalRow.balanceDue, paymentCurrency)}</p>
</div>
{paymentError ? (
<div className="mt-5 rounded-2xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
{paymentError}
</div>
) : null}
<div className="mt-5 space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentMethod}</label>
<select className="input-field" value={paymentMethod} onChange={(event) => setPaymentMethod(event.target.value as ManualPaymentMethod)} disabled={submittingPayment}>
<option value="CASH">{copy.paymentMethodLabels.CASH}</option>
<option value="CHECK">{copy.paymentMethodLabels.CHECK}</option>
<option value="BANK_TRANSFER">{copy.paymentMethodLabels.BANK_TRANSFER}</option>
<option value="CARD">{copy.paymentMethodLabels.CARD}</option>
<option value="PAYPAL">{copy.paymentMethodLabels.PAYPAL}</option>
</select>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentType}</label>
<select className="input-field" value={paymentType} onChange={(event) => setPaymentType(event.target.value as 'CHARGE' | 'DEPOSIT')} disabled={submittingPayment}>
{paymentModalRow.depositAmount > 0 ? <option value="DEPOSIT">{copy.depositType}</option> : null}
<option value="CHARGE">{copy.charge}</option>
</select>
<p className="mt-2 text-xs text-slate-500">{paymentType === 'DEPOSIT' ? copy.depositHelp : copy.chargeHelp}</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">{copy.paymentAmount}</label>
<input type="number" min="0" step="0.01" className="input-field" value={paymentAmount} onChange={(event) => setPaymentAmount(event.target.value)} disabled={submittingPayment} />
</div>
</div>
<div className="mt-6 flex items-center gap-3">
<button type="button" onClick={() => setPaymentModalRow(null)} disabled={submittingPayment} className="flex-1 rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
{copy.cancel}
</button>
<button type="button" onClick={handleAcceptPayment} disabled={submittingPayment} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
{submittingPayment ? copy.savingPayment : copy.savePayment}
</button>
</div>
</div>
</div>
) : null}
</div>
)
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className="card p-5">
<p className="text-sm font-medium text-slate-500">{label}</p>
<p className="mt-2 text-2xl font-bold text-slate-900">{value}</p>
</div>
)
}
@@ -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>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type ReservationRow = {
id: string
contractNumber: string | null
invoiceNumber: string | null
status: string
paymentStatus: string
startDate: string
endDate: string
totalAmount: number
customer: { firstName: string; lastName: string; email: string }
vehicle: { make: string; model: string; licensePlate: string }
}
export default function ContractsPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<ReservationRow[]>([])
const [search, setSearch] = useState('')
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
heading: 'Contracts',
subtitle: 'Generate or reopen rental contracts from any booking.',
search: 'Search customer, vehicle, plate, contract number…',
booking: 'Booking',
customer: 'Customer',
vehicle: 'Vehicle',
dates: 'Dates',
contract: 'Contract',
invoice: 'Invoice',
total: 'Total',
status: 'Status',
action: 'Action',
open: 'Open contract',
generate: 'Generate contract',
empty: 'No bookings found.',
},
fr: {
heading: 'Contrats',
subtitle: 'Générez ou rouvrez un contrat de location depuis nimporte quelle réservation.',
search: 'Rechercher client, véhicule, plaque, numéro de contrat…',
booking: 'Réservation',
customer: 'Client',
vehicle: 'Véhicule',
dates: 'Dates',
contract: 'Contrat',
invoice: 'Facture',
total: 'Total',
status: 'Statut',
action: 'Action',
open: 'Ouvrir le contrat',
generate: 'Générer le contrat',
empty: 'Aucune réservation trouvée.',
},
ar: {
heading: 'العقود',
subtitle: 'أنشئ أو افتح عقد التأجير من أي حجز.',
search: 'ابحث بالعميل أو السيارة أو اللوحة أو رقم العقد…',
booking: 'الحجز',
customer: 'العميل',
vehicle: 'المركبة',
dates: 'التواريخ',
contract: 'العقد',
invoice: 'الفاتورة',
total: 'الإجمالي',
status: 'الحالة',
action: 'الإجراء',
open: 'فتح العقد',
generate: 'إنشاء العقد',
empty: 'لم يتم العثور على حجوزات.',
},
}[language]
useEffect(() => {
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
.then((data) => setRows(data ?? []))
.catch((err) => setError(err.message ?? 'Failed to load contracts'))
}, [])
const filteredRows = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return rows
return rows.filter((row) =>
`${row.customer.firstName} ${row.customer.lastName}`.toLowerCase().includes(q) ||
row.customer.email.toLowerCase().includes(q) ||
`${row.vehicle.make} ${row.vehicle.model}`.toLowerCase().includes(q) ||
row.vehicle.licensePlate.toLowerCase().includes(q) ||
(row.contractNumber ?? '').toLowerCase().includes(q) ||
(row.invoiceNumber ?? '').toLowerCase().includes(q),
)
}, [rows, search])
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const formatDate = (iso: string) => new Date(iso).toLocaleString(localeCode, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.heading}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={copy.search}
className="input-field w-full lg:max-w-md"
/>
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-6 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.dates}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.contract}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.invoice}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.action}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredRows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm">
<p className="font-semibold text-slate-900">{row.customer.firstName} {row.customer.lastName}</p>
<p className="text-xs text-slate-500">{row.customer.email}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<p>{row.vehicle.make} {row.vehicle.model}</p>
<p className="text-xs text-slate-500">{row.vehicle.licensePlate}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-600">
<p>{formatDate(row.startDate)}</p>
<p>{formatDate(row.endDate)}</p>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.contractNumber ?? '—'}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.invoiceNumber ?? '—'}</td>
<td className="px-6 py-4 text-sm">
<div className="flex flex-col gap-2">
<span className="badge-blue">{row.status}</span>
<span className="badge-gray">{row.paymentStatus}</span>
</div>
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
<td className="px-6 py-4 text-right">
<Link href={`/contracts/${row.id}`} className="text-sm font-semibold text-blue-700 hover:underline">
{row.contractNumber ? copy.open : copy.generate}
</Link>
</td>
</tr>
))}
{filteredRows.length === 0 && (
<tr>
<td colSpan={8} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,124 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface CustomerRow {
id: string
firstName: string
lastName: string
email: string
phone: string | null
flagged: boolean
licenseValidationStatus: string
licenseImageUrl: string | null
}
export default function CustomersPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<CustomerRow[]>([])
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Customers',
subtitle: 'A company CRM with license validation and risk flags.',
customer: 'Customer',
contact: 'Contact',
license: 'License',
flags: 'Flags',
imageUploaded: 'Image uploaded',
imageMissing: 'No image',
noPhone: 'No phone',
flagged: 'Flagged',
clear: 'Clear',
empty: 'No customers yet.',
},
fr: {
title: 'Clients',
subtitle: 'CRM de lentreprise avec validation des permis et indicateurs de risque.',
customer: 'Client',
contact: 'Contact',
license: 'Permis',
flags: 'Signalements',
imageUploaded: 'Image téléversée',
imageMissing: 'Aucune image',
noPhone: 'Pas de téléphone',
flagged: 'Signalé',
clear: 'Aucun risque',
empty: 'Aucun client pour le moment.',
},
ar: {
title: 'العملاء',
subtitle: 'نظام إدارة عملاء خاص بالشركة مع التحقق من الرخص ومؤشرات المخاطر.',
customer: 'العميل',
contact: 'التواصل',
license: 'الرخصة',
flags: 'العلامات',
imageUploaded: 'تم رفع الصورة',
imageMissing: 'لا توجد صورة',
noPhone: 'لا يوجد هاتف',
flagged: 'تم تمييزه',
clear: 'لا توجد مخاطر',
empty: 'لا يوجد عملاء حتى الآن.',
},
}[language]
useEffect(() => {
apiFetch<CustomerRow[]>('/customers?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setError(err.message))
}, [])
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-8 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.customer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.contact}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.license}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.flags}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm font-semibold text-slate-900">{row.firstName} {row.lastName}</td>
<td className="px-6 py-4">
<p className="text-sm text-slate-700">{row.email}</p>
<p className="text-xs text-slate-500">{row.phone ?? copy.noPhone}</p>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap items-center gap-2">
<span className="badge-gray">{row.licenseValidationStatus}</span>
<span className={row.licenseImageUrl ? 'badge-green' : 'badge-gray'}>
{row.licenseImageUrl ? copy.imageUploaded : copy.imageMissing}
</span>
</div>
</td>
<td className="px-6 py-4">{row.flagged ? <span className="badge-red">{copy.flagged}</span> : <span className="badge-green">{copy.clear}</span>}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,838 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, DollarSign, Info, Wrench, Plus, ChevronDown, ChevronUp } from 'lucide-react'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import VehicleCalendar from '@/components/VehicleCalendar'
import VehiclePricingManager from '@/components/VehiclePricingManager'
import { useDashboardI18n } from '@/components/I18nProvider'
interface VehicleDetail {
id: string
make: string
model: string
year: number
category: string
dailyRate: number
status: string
color: string
transmission: string
fuelType: string
seats: number
mileage: number | null
licensePlate: string
vin: string | null
photos: string[]
notes: string | null
isPublished: boolean
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function parseLocationInput(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function formatLocationInput(values: string[]) {
return Array.isArray(values) ? values.join(', ') : ''
}
function initMakeSelect(make: string) {
return PRESET_MAKES.includes(make) ? make : (make ? 'custom' : '')
}
function initModelSelect(make: string, model: string) {
const presets = MODELS_BY_MAKE[make]
if (!presets) return model ? 'custom' : ''
return presets.includes(model) ? model : (model ? 'custom' : '')
}
function initColorSelect(color: string) {
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
}
interface MaintenanceLog {
id: string
type: string
description: string | null
cost: number | null
mileage: number | null
performedAt: string
nextDueAt: string | null
nextDueMileage: number | null
}
const ROUTINE_TYPES = [
{ key: 'Oil Change', intervalMonths: 6 },
{ key: 'Technical Inspection', intervalMonths: 12 },
{ key: 'Vehicle Registration', intervalMonths: 12 },
{ key: 'Car Tax', intervalMonths: 12 },
{ key: 'Tire Rotation', intervalMonths: 6 },
{ key: 'Brake Inspection', intervalMonths: 12 },
{ key: 'Air Filter', intervalMonths: 12 },
{ key: 'Cabin Filter', intervalMonths: 12 },
{ key: 'Battery Check', intervalMonths: 12 },
{ key: 'Belt / Chain Service', intervalMonths: 24 },
{ key: 'Coolant Flush', intervalMonths: 24 },
{ key: 'Transmission Service', intervalMonths: 24 },
]
function serviceStatus(log: MaintenanceLog | undefined, currentMileage: number | null): 'none' | 'overdue' | 'due-soon' | 'ok' {
if (!log) return 'none'
const now = new Date()
let worstLevel: 'ok' | 'due-soon' | 'overdue' = 'ok'
if (log.nextDueAt) {
const diffDays = (new Date(log.nextDueAt).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
if (diffDays < 0) return 'overdue'
if (diffDays <= 30) worstLevel = 'due-soon'
}
if (log.nextDueMileage != null && currentMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
if (kmLeft <= 0) return 'overdue'
if (kmLeft <= 500) worstLevel = 'due-soon'
}
return worstLevel
}
function StatusPill({ status }: { status: ReturnType<typeof serviceStatus> }) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
if (status === 'none') return <span className="badge-gray text-xs">{vd.statusNone}</span>
if (status === 'overdue') return <span className="badge-red text-xs">{vd.statusOverdue}</span>
if (status === 'due-soon') return <span className="badge-amber text-xs">{vd.statusDueSoon}</span>
return <span className="badge-green text-xs">{vd.statusOk}</span>
}
function MaintenanceRow({ vehicleId, typeKey, intervalMonths, currentMileage, log, onLogged }: {
vehicleId: string
typeKey: string
intervalMonths: number
currentMileage: number | null
log: MaintenanceLog | undefined
onLogged: () => void
}) {
const { dict } = useDashboardI18n()
const vd = dict.vehicleDetail
const [open, setOpen] = useState(false)
const today = new Date().toISOString().slice(0, 10)
const defaultNextDue = (() => {
const d = new Date()
d.setMonth(d.getMonth() + intervalMonths)
return d.toISOString().slice(0, 10)
})()
const [form, setForm] = useState({
description: '', cost: '', mileage: '', performedAt: today,
nextDueAt: defaultNextDue, nextDueMileage: '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.performedAt) { setError(vd.dateRequired); return }
setSaving(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: typeKey,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
nextDueMileage: form.nextDueMileage ? parseInt(form.nextDueMileage) : undefined,
}),
})
setForm({ description: '', cost: '', mileage: '', performedAt: today, nextDueAt: defaultNextDue, nextDueMileage: '' })
setOpen(false)
onLogged()
} catch (err: any) {
setError(err.message ?? vd.savingLabel)
} finally {
setSaving(false)
}
}
const status = serviceStatus(log, currentMileage)
const displayLabel = vd.routineTypes[typeKey] ?? typeKey
const nextDueText = (() => {
if (!log) return vd.noRecord
const parts: string[] = [`${vd.lastPrefix} ${new Date(log.performedAt).toLocaleDateString()}`]
if (log.mileage) parts.push(vd.atKm(log.mileage.toLocaleString()))
const dueParts: string[] = []
if (log.nextDueAt) dueParts.push(new Date(log.nextDueAt).toLocaleDateString())
if (log.nextDueMileage != null) dueParts.push(`${log.nextDueMileage.toLocaleString()} km`)
if (dueParts.length) parts.push(`${vd.duePrefix} ${dueParts.join(` ${vd.orSep} `)}`)
if (currentMileage != null && log.nextDueMileage != null) {
const kmLeft = log.nextDueMileage - currentMileage
parts.push(`(${kmLeft > 0 ? vd.kmLeft(kmLeft.toLocaleString()) : vd.overdueByKm})`)
}
return parts.join(' ')
})()
return (
<div className="rounded-xl border border-slate-100 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 bg-white hover:bg-slate-50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<Wrench className="w-4 h-4 text-slate-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-sm font-medium text-slate-900">{displayLabel}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{nextDueText}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<StatusPill status={status} />
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-700 px-2 py-1 rounded-lg hover:bg-blue-50 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{vd.logBtn}
{open ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
</div>
</div>
{open && (
<form onSubmit={handleSubmit} className="border-t border-slate-100 bg-slate-50 px-4 py-4 space-y-3">
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.serviceDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} required />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.odometerAtService}</label>
<input type="number" className="input-field text-sm" placeholder={currentMileage ? String(currentMileage) : 'e.g. 52000'} min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueDateLabel}</label>
<input type="date" className="input-field text-sm" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.nextDueKmLabel}</label>
<input type="number" className="input-field text-sm" placeholder="e.g. 60000" min="0" value={form.nextDueMileage} onChange={(e) => setForm({ ...form, nextDueMileage: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.costLabel}</label>
<input type="number" className="input-field text-sm" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">{vd.maintenanceNotesLabel}</label>
<input className="input-field text-sm" placeholder={vd.optional} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setOpen(false)} className="btn-secondary text-xs py-1.5 px-3">{vd.cancelBtn}</button>
<button type="submit" disabled={saving} className="btn-primary text-xs py-1.5 px-3">{saving ? vd.savingLabel : vd.saveLabel}</button>
</div>
</form>
)}
</div>
)
}
type Tab = 'details' | 'maintenance' | 'calendar' | 'pricing'
export default function FleetDetailPage() {
const params = useParams<{ id: string }>()
const router = useRouter()
const searchParams = useSearchParams()
const { dict, language } = useDashboardI18n()
const vd = dict.vehicleDetail
const fl = dict.fleet
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<Tab>('details')
const [editing, setEditing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const [form, setForm] = useState<{
make: string; model: string; year: number; category: string
dailyRate: string; licensePlate: string; vin: string; color: string
seats: number; transmission: string; fuelType: string
status: string; notes: string; mileage: string
pickupLocations: string; allowDifferentDropoff: boolean; dropoffLocations: string
} | null>(null)
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [colorSelect, setColorSelect] = useState('')
const [maintenanceLogs, setMaintenanceLogs] = useState<MaintenanceLog[]>([])
const fetchMaintenance = () => {
apiFetch<MaintenanceLog[]>(`/vehicles/${params.id}/maintenance`)
.then((logs) => setMaintenanceLogs(logs ?? []))
.catch(() => {})
}
useEffect(() => { fetchMaintenance() }, [params.id])
const [deletingPhotoIdx, setDeletingPhotoIdx] = useState<number | null>(null)
const [newPhotoFiles, setNewPhotoFiles] = useState<File[]>([])
const [newPhotoPreviews, setNewPhotoPreviews] = useState<string[]>([])
const [uploadingPhotos, setUploadingPhotos] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
apiFetch<VehicleDetail>(`/vehicles/${params.id}`)
.then(setVehicle)
.catch((err) => setError(err.message))
}, [params.id])
useEffect(() => {
if (searchParams.get('tab') === 'pricing') {
setActiveTab('pricing')
}
}, [searchParams])
const startEdit = () => {
if (!vehicle) return
setForm({
make: vehicle.make, model: vehicle.model, year: vehicle.year,
category: vehicle.category,
dailyRate: (vehicle.dailyRate / 100).toFixed(2),
licensePlate: vehicle.licensePlate,
vin: vehicle.vin ?? '',
color: vehicle.color ?? '', seats: vehicle.seats,
transmission: vehicle.transmission, fuelType: vehicle.fuelType,
status: vehicle.status, notes: vehicle.notes ?? '',
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
pickupLocations: formatLocationInput(vehicle.pickupLocations),
allowDifferentDropoff: vehicle.allowDifferentDropoff,
dropoffLocations: formatLocationInput(vehicle.dropoffLocations),
})
setMakeSelect(initMakeSelect(vehicle.make))
setModelSelect(initModelSelect(vehicle.make, vehicle.model))
setColorSelect(initColorSelect(vehicle.color ?? ''))
setSaveError(null)
setEditing(true)
}
const cancelEdit = () => {
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setEditing(false)
setForm(null)
setSaveError(null)
}
const handleSave = async () => {
if (!form || !vehicle) return
if (!form.make) { setSaveError(vd.makeRequired); return }
if (!form.model) { setSaveError(vd.modelRequired); return }
if (!form.licensePlate) { setSaveError(vd.plateRequired); return }
const rate = parseFloat(form.dailyRate)
if (isNaN(rate) || rate < 0) { setSaveError(vd.rateInvalid); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setSaveError(fl.dropoffLocationsRequired)
return
}
setSaving(true)
setSaveError(null)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}`, {
method: 'PATCH',
body: JSON.stringify({
make: form.make, model: form.model, year: Number(form.year),
category: form.category, dailyRate: Math.round(rate * 100),
licensePlate: form.licensePlate, vin: form.vin.trim() || undefined, color: form.color,
seats: Number(form.seats), transmission: form.transmission,
fuelType: form.fuelType, status: form.status,
notes: form.notes || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
dropoffLocations: form.allowDifferentDropoff ? parseLocationInput(form.dropoffLocations) : [],
}),
})
let finalVehicle = updated
if (newPhotoFiles.length > 0) {
setUploadingPhotos(true)
const fd = new FormData()
newPhotoFiles.forEach((f) => fd.append('photos', f))
finalVehicle = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
newPhotoPreviews.forEach((p) => URL.revokeObjectURL(p))
setNewPhotoFiles([])
setNewPhotoPreviews([])
setUploadingPhotos(false)
}
setVehicle(finalVehicle)
setEditing(false)
setForm(null)
} catch (err: any) {
setSaveError(err.message ?? vd.failedSave)
setUploadingPhotos(false)
} finally {
setSaving(false)
}
}
const handleDeletePhoto = async (idx: number) => {
if (!vehicle) return
setDeletingPhotoIdx(idx)
try {
const updated = await apiFetch<VehicleDetail>(`/vehicles/${vehicle.id}/photos/${idx}`, { method: 'DELETE' })
setVehicle(updated)
} catch (err: any) {
alert(err.message)
} finally {
setDeletingPhotoIdx(null)
}
}
const handleNewPhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const totalExisting = vehicle?.photos.length ?? 0
const combined = [...newPhotoFiles, ...files].slice(0, 10 - totalExisting)
setNewPhotoFiles(combined)
setNewPhotoPreviews(combined.map((f) => URL.createObjectURL(f)))
e.target.value = ''
}
const removeNewPhoto = (i: number) => {
URL.revokeObjectURL(newPhotoPreviews[i])
setNewPhotoFiles(newPhotoFiles.filter((_, idx) => idx !== i))
setNewPhotoPreviews(newPhotoPreviews.filter((_, idx) => idx !== i))
}
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!vehicle) return <div className="card p-6 text-sm text-slate-500">{vd.loadingVehicle}</div>
const presetModels = form && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
const totalPhotos = vehicle.photos.length + newPhotoFiles.length
const categoryLabel = fl.categoryLabels[vehicle.category as keyof typeof fl.categoryLabels] ?? vehicle.category
const statusLabel = fl.statusLabels[vehicle.status as keyof typeof fl.statusLabels] ?? vehicle.status
const fuelLabel = fl.fuelTypeLabels[vehicle.fuelType as keyof typeof fl.fuelTypeLabels] ?? vehicle.fuelType
const transLabel = vehicle.transmission === 'MANUAL' ? fl.manual : fl.automatic
const pricingTabLabel = language === 'fr' ? 'Tarifs' : language === 'ar' ? 'التسعير' : 'Pricing'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={() => router.back()} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<ArrowLeft className="w-4 h-4" />
</button>
<div>
<h2 className="text-xl font-semibold text-slate-900">{vehicle.make} {vehicle.model}</h2>
<p className="text-sm text-slate-500 mt-0.5">
{vehicle.year} · {vehicle.licensePlate}
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
{' · '}
{statusLabel}
</p>
</div>
</div>
{!editing ? (
<button onClick={startEdit} className="btn-secondary flex items-center gap-2">
<Edit className="w-4 h-4" /> {vd.editBtn}
</button>
) : (
<div className="flex gap-2">
<button onClick={cancelEdit} className="btn-secondary flex items-center gap-2">
<X className="w-4 h-4" /> {vd.cancelBtn}
</button>
<button onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
<Check className="w-4 h-4" />
{saving ? (uploadingPhotos ? vd.uploadingPhotosBtn : vd.savingBtn) : vd.saveBtn}
</button>
</div>
)}
</div>
{saveError && (
<div className="px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{saveError}</div>
)}
{/* Tab bar */}
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
{(['details', 'maintenance', 'calendar', 'pricing'] as Tab[]).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={[
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
activeTab === tab
? 'border-blue-500 text-blue-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
].join(' ')}
>
{tab === 'details' && <><Info className="w-4 h-4" /> {vd.tabDetails}</>}
{tab === 'maintenance' && (
<>
<Wrench className="w-4 h-4" /> {vd.tabMaintenance}
{maintenanceLogs.length === 0 && <span className="ml-1 inline-flex h-4 w-4 items-center justify-center rounded-full bg-orange-100 text-orange-700 text-[10px] font-bold">!</span>}
</>
)}
{tab === 'calendar' && <><CalendarDays className="w-4 h-4" /> {vd.tabCalendar}</>}
{tab === 'pricing' && <><DollarSign className="w-4 h-4" /> {pricingTabLabel}</>}
</button>
))}
</div>
{activeTab === 'details' && (
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
{/* Photos */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.photosHeading}</h3>
<div className="grid gap-4 sm:grid-cols-2">
{vehicle.photos.map((photo, index) => (
<div key={`${photo}-${index}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group">
<Image src={photo} alt={`${vehicle.make} ${vehicle.model} photo ${index + 1}`} fill className="object-cover" unoptimized={!photo.includes('res.cloudinary.com')} priority={index === 0} />
{editing && (
<button
onClick={() => handleDeletePhoto(index)}
disabled={deletingPhotoIdx === index}
className="absolute top-2 right-2 p-1.5 bg-red-600 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity disabled:opacity-60"
>
{deletingPhotoIdx === index
? <div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Trash2 className="w-3.5 h-3.5" />}
</button>
)}
</div>
))}
{editing && newPhotoPreviews.map((src, i) => (
<div key={`new-${i}`} className="relative h-48 rounded-xl overflow-hidden bg-slate-100 group ring-2 ring-blue-400">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={`new photo ${i + 1}`} className="object-cover w-full h-full" />
<button
onClick={() => removeNewPhoto(i)}
className="absolute top-2 right-2 p-1.5 bg-blue-950/60 text-white rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3.5 h-3.5" />
</button>
<span className="absolute bottom-2 left-2 text-xs bg-blue-600 text-white px-2 py-0.5 rounded-full">{vd.newPhotoBadge}</span>
</div>
))}
{vehicle.photos.length === 0 && !editing && (
<div className="text-sm text-slate-400">{vd.noPhotosYet}</div>
)}
</div>
{editing && totalPhotos < 10 && (
<div className="mt-4">
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleNewPhotoChange} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{totalPhotos === 0 ? vd.clickToAddPhotos : vd.addMorePhotos(totalPhotos)}
</button>
</div>
)}
{editing && totalPhotos >= 10 && (
<p className="mt-3 text-xs text-slate-400 text-center">{vd.maxPhotosReached}</p>
)}
</div>
{/* Details / Edit form */}
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900 mb-4">{vd.vehicleDetailsHeading}</h3>
{!editing || !form ? (
<dl className="space-y-3 text-sm">
<div><dt className="text-slate-500">{vd.labelCategory}</dt><dd className="text-slate-900">{categoryLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelDailyRate}</dt><dd className="text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{vd.labelStatus}</dt><dd className="text-slate-900">{statusLabel}</dd></div>
<div><dt className="text-slate-500">{vd.vinLabel}</dt><dd className="text-slate-900">{vehicle.vin || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelSeats}</dt><dd className="text-slate-900">{vehicle.seats}</dd></div>
<div><dt className="text-slate-500">{vd.labelTransmission}</dt><dd className="text-slate-900">{transLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelColor}</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelOdometer}</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
<div><dt className="text-slate-500">{fl.pickupLocations}</dt><dd className="text-slate-900">{vehicle.pickupLocations.length > 0 ? vehicle.pickupLocations.join(', ') : '—'}</dd></div>
<div><dt className="text-slate-500">{fl.allowDifferentDropoff}</dt><dd className="text-slate-900">{vehicle.allowDifferentDropoff ? fl.differentDropoffAvailable : fl.sameDropoffOnly}</dd></div>
{vehicle.allowDifferentDropoff && (
<div><dt className="text-slate-500">{fl.dropoffLocations}</dt><dd className="text-slate-900">{vehicle.dropoffLocations.length > 0 ? vehicle.dropoffLocations.join(', ') : '—'}</dd></div>
)}
<div><dt className="text-slate-500">{vd.labelNotes}</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
</dl>
) : (
<div className="space-y-4 text-sm">
{/* Make */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.makeLabel}</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">{vd.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.other}</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeMake} value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} />
)}
</div>
{/* Model */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.modelLabel}</label>
{!presetModels ? (
<input className="input-field" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">{vd.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{vd.other}</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeModel} value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
)}
</>
)}
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.yearLabel}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel.replace('Type', '').trim() !== '' ? vd.fuelTypeLabel : fl.category}</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{fl.categoryLabels[c]}</option>
))}
</select>
</div>
</div>
{/* Daily Rate, License Plate & VIN */}
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.dailyRateLabel}</label>
<input type="number" className="input-field" min="0" step="0.01" value={form.dailyRate} onChange={(e) => setForm({ ...form, dailyRate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.licensePlateLabel}</label>
<input className="input-field" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.vinLabel}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
</div>
{/* Status */}
<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', '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>
</div>
{/* Color & Seats */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.colorLabel}</label>
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">{vd.selectColor}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">{vd.other}</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder={vd.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.seatsLabel}</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
</div>
{/* Transmission & Fuel */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.transmissionLabel}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">{fl.manual}</option>
<option value="AUTOMATIC">{fl.automatic}</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.fuelTypeLabel}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((f) => (
<option key={f} value={f}>{fl.fuelTypeLabels[f]}</option>
))}
</select>
</div>
</div>
{/* Odometer */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.odometerLabel}</label>
<input type="number" className="input-field" placeholder="e.g. 45000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={fl.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={form.allowDifferentDropoff}
onChange={(e) => setForm({
...form,
allowDifferentDropoff: e.target.checked,
dropoffLocations: e.target.checked ? form.dropoffLocations : '',
})}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{fl.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={fl.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.notesLabel}</label>
<textarea className="input-field resize-none" rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} placeholder={vd.optionalNotes} />
</div>
</div>
)}
</div>
</div>
)}
{activeTab === 'maintenance' && (
<div className="space-y-3">
<p className="text-sm text-slate-500">{vd.maintenanceIntro}</p>
{ROUTINE_TYPES.map(({ key, intervalMonths }) => {
const latest = maintenanceLogs
.filter((l) => l.type === key)
.sort((a, b) => new Date(b.performedAt).getTime() - new Date(a.performedAt).getTime())[0]
return (
<MaintenanceRow
key={key}
vehicleId={params.id}
typeKey={key}
intervalMonths={intervalMonths}
currentMileage={vehicle.mileage}
log={latest}
onLogged={fetchMaintenance}
/>
)
})}
</div>
)}
{activeTab === 'calendar' && (
<VehicleCalendar vehicleId={params.id} />
)}
{activeTab === 'pricing' && (
<VehiclePricingManager vehicleId={params.id} />
)}
</div>
)
}
@@ -0,0 +1,805 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Plus, Edit, Eye, Search, Upload, X, ChevronDown, DollarSign } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { apiFetch } from '@/lib/api'
import { formatCurrency } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Vehicle {
id: string
make: string
model: string
year: number
category: string
status: 'AVAILABLE' | 'RESERVED' | 'READY' | 'RENTED' | 'RETURNED' | 'NEEDS_CLEANING' | 'MAINTENANCE' | 'DAMAGE_REVIEW' | 'OUT_OF_SERVICE'
dailyRate: number
isPublished: boolean
primaryPhoto: string | null
licensePlate: string
vin?: string | null
pickupLocations?: string[]
allowDifferentDropoff?: boolean
dropoffLocations?: string[]
}
interface AddVehicleModalProps {
open: boolean
onClose: () => void
onSaved: () => void
}
const STATUS_BADGE: Record<Vehicle['status'], string> = {
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', '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
vehicleName: string
open: boolean
onClose: () => void
onSaved: () => void
}
function MaintenanceModal({ vehicleId, vehicleName, open, onClose, onSaved }: MaintenanceModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const today = new Date().toISOString().slice(0, 10)
const [form, setForm] = useState({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const reset = () => {
setForm({ type: '', description: '', cost: '', mileage: '', performedAt: today, nextDueAt: '' })
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.type) { setError(f.maintenanceTypeRequired); return }
setLoading(true)
setError(null)
try {
await apiFetch(`/vehicles/${vehicleId}/maintenance`, {
method: 'POST',
body: JSON.stringify({
type: form.type,
description: form.description || undefined,
cost: form.cost ? Math.round(parseFloat(form.cost) * 100) : undefined,
mileage: form.mileage ? parseInt(form.mileage) : undefined,
performedAt: new Date(form.performedAt).toISOString(),
nextDueAt: form.nextDueAt ? new Date(form.nextDueAt).toISOString() : undefined,
}),
})
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? f.failedToSave)
} finally {
setLoading(false)
}
}
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-1">{f.logMaintenance}</h2>
<p className="text-sm text-slate-500 mb-5">{f.maintenanceSubtitle(vehicleName)}</p>
{error && <div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.typeLabel}</label>
<input className="input-field" placeholder={f.typePlaceholder} value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value })} autoFocus />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.description}</label>
<textarea className="input-field resize-none" rows={2} placeholder={f.descriptionPlaceholder} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.date}</label>
<input type="date" className="input-field" value={form.performedAt} onChange={(e) => setForm({ ...form, performedAt: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.nextDueDate}</label>
<input type="date" className="input-field" value={form.nextDueAt} onChange={(e) => setForm({ ...form, nextDueAt: e.target.value })} />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.cost}</label>
<input type="number" className="input-field" placeholder="0.00" min="0" step="0.01" value={form.cost} onChange={(e) => setForm({ ...form, cost: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input type="number" className="input-field" placeholder="e.g. 52000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
</div>
<div className="flex gap-3 pt-1">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.skip}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">{loading ? f.saving : f.saveLog}</button>
</div>
</form>
</div>
</div>
)
}
function StatusDropdown({ vehicleId, status, onStatusChange }: {
vehicleId: string
status: Vehicle['status']
onStatusChange: (id: string, status: Vehicle['status']) => void
}) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const openDropdown = () => {
if (triggerRef.current) {
const r = triggerRef.current.getBoundingClientRect()
setPos({ top: r.bottom + 4, left: r.left })
}
setOpen(true)
}
useEffect(() => {
if (!open) return
const close = (e: MouseEvent) => {
if (
!triggerRef.current?.contains(e.target as Node) &&
!panelRef.current?.contains(e.target as Node)
) setOpen(false)
}
const closeOnScroll = () => setOpen(false)
document.addEventListener('mousedown', close)
window.addEventListener('scroll', closeOnScroll, true)
return () => {
document.removeEventListener('mousedown', close)
window.removeEventListener('scroll', closeOnScroll, true)
}
}, [open])
const select = async (next: Vehicle['status']) => {
if (next === status) { setOpen(false); return }
setOpen(false)
setSaving(true)
try {
await apiFetch(`/vehicles/${vehicleId}/status`, { method: 'PATCH', body: JSON.stringify({ status: next }) })
onStatusChange(vehicleId, next)
} catch (err: any) {
alert(err.message)
} finally {
setSaving(false)
}
}
return (
<>
<button
ref={triggerRef}
onClick={openDropdown}
disabled={saving}
className={`inline-flex items-center gap-1 ${STATUS_BADGE[status]} cursor-pointer select-none`}
>
{saving ? '…' : f.statusLabels[status]}
<ChevronDown className="w-3 h-3 opacity-60" />
</button>
{open && (
<div
ref={panelRef}
style={{ top: pos.top, left: pos.left }}
className="fixed z-50 min-w-[160px] rounded-xl border border-slate-100 bg-white shadow-lg py-1"
>
{ALL_STATUSES.map((s) => (
<button
key={s}
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 ${STATUS_DOT[s]}`} />
{f.statusLabels[s]}
</button>
))}
</div>
)}
</>
)
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
const PRESET_MAKES = ['Audi', 'BMW', 'Chevrolet', 'Citroën', 'Dacia', 'Fiat', 'Ford', 'Honda', 'Hyundai', 'Kia', 'Mazda', 'Mercedes-Benz', 'Nissan', 'Opel', 'Peugeot', 'Renault', 'Seat', 'Skoda', 'Suzuki', 'Toyota', 'Volkswagen', 'Volvo']
const MODELS_BY_MAKE: Record<string, string[]> = {
'Audi': ['A1', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8', 'Q2', 'Q3', 'Q5', 'Q7', 'Q8', 'TT'],
'BMW': ['116i', '118i', '120i', '218i', '316i', '318i', '320i', '325i', 'X1', 'X2', 'X3', 'X4', 'X5', 'X6', 'X7'],
'Chevrolet': ['Aveo', 'Captiva', 'Cruze', 'Malibu', 'Spark', 'Trax'],
'Citroën': ['Berlingo', 'C1', 'C3', 'C3 Aircross', 'C4', 'C4 Cactus', 'C5', 'C5 Aircross', 'Jumpy', 'SpaceTourer'],
'Dacia': ['Dokker', 'Duster', 'Jogger', 'Lodgy', 'Logan', 'Sandero', 'Spring'],
'Fiat': ['500', '500X', 'Bravo', 'Doblo', 'Ducato', 'Panda', 'Punto', 'Tipo'],
'Ford': ['B-Max', 'C-Max', 'EcoSport', 'Edge', 'Explorer', 'Fiesta', 'Focus', 'Fusion', 'Kuga', 'Mondeo', 'Mustang', 'Puma', 'Ranger', 'S-Max', 'Transit'],
'Honda': ['Accord', 'Civic', 'CR-V', 'HR-V', 'Jazz', 'Pilot'],
'Hyundai': ['Accent', 'Creta', 'Elantra', 'i10', 'i20', 'i30', 'ix35', 'Kona', 'Santa Fe', 'Sonata', 'Tucson'],
'Kia': ['Carnival', 'Ceed', 'Niro', 'Optima', 'Picanto', 'Rio', 'Sorento', 'Soul', 'Sportage', 'Stinger'],
'Mazda': ['2', '3', '6', 'CX-3', 'CX-5', 'CX-9', 'MX-5'],
'Mercedes-Benz': ['A-Class', 'B-Class', 'C-Class', 'CLA', 'E-Class', 'GLA', 'GLB', 'GLC', 'GLE', 'GLS', 'S-Class', 'Sprinter', 'Vito'],
'Nissan': ['Juke', 'Leaf', 'Micra', 'Murano', 'Navara', 'Note', 'Pathfinder', 'Patrol', 'Pulsar', 'Qashqai', 'X-Trail'],
'Opel': ['Astra', 'Corsa', 'Crossland', 'Grandland', 'Insignia', 'Meriva', 'Mokka', 'Zafira'],
'Peugeot': ['108', '208', '2008', '3008', '308', '4008', '408', '5008', '508', 'Boxer', 'Expert', 'Partner', 'Traveller'],
'Renault': ['Arkana', 'Captur', 'Clio', 'Duster', 'Espace', 'Grand Scenic', 'Kadjar', 'Kangoo', 'Koleos', 'Laguna', 'Master', 'Megane', 'Scenic', 'Talisman', 'Trafic', 'Twingo', 'Zoe'],
'Seat': ['Arona', 'Arosa', 'Ateca', 'Ibiza', 'Leon', 'Tarraco', 'Toledo'],
'Skoda': ['Fabia', 'Karoq', 'Kodiaq', 'Octavia', 'Rapid', 'Scala', 'Superb', 'Yeti'],
'Suzuki': ['Alto', 'Baleno', 'Celerio', 'Ignis', 'Jimny', 'S-Cross', 'Swift', 'Vitara'],
'Toyota': ['Auris', 'Avensis', 'Aygo', 'Camry', 'Corolla', 'C-HR', 'Fortuner', 'Hilux', 'Land Cruiser', 'Prius', 'RAV4', 'Rush', 'Urban Cruiser', 'Yaris'],
'Volkswagen': ['Amarok', 'Arteon', 'Caddy', 'Golf', 'ID.3', 'ID.4', 'Passat', 'Polo', 'Sharan', 'T-Cross', 'T-Roc', 'Tiguan', 'Touareg', 'Touran', 'Transporter', 'Up'],
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function parseLocationInput(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [form, setForm] = useState({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
})
const [colorSelect, setColorSelect] = useState('')
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
const [photoFiles, setPhotoFiles] = useState<File[]>([])
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? [])
if (!files.length) return
const combined = [...photoFiles, ...files].slice(0, 10)
setPhotoFiles(combined)
setPhotoPreviews(combined.map((file) => URL.createObjectURL(file)))
e.target.value = ''
}
const removePhoto = (index: number) => {
URL.revokeObjectURL(photoPreviews[index])
const files = photoFiles.filter((_, i) => i !== index)
const previews = photoPreviews.filter((_, i) => i !== index)
setPhotoFiles(files)
setPhotoPreviews(previews)
}
const reset = () => {
setForm({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
licensePlate: '', vin: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
})
setColorSelect('')
setMakeSelect('')
setModelSelect('')
photoPreviews.forEach((p) => URL.revokeObjectURL(p))
setPhotoFiles([])
setPhotoPreviews([])
setError(null)
}
const handleClose = () => { reset(); onClose() }
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.make) { setError(f.makeMissing); return }
if (!form.model) { setError(f.modelMissing); return }
if (!form.licensePlate) { setError(f.plateMissing); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setError(f.dropoffLocationsRequired)
return
}
setLoading(true)
setError(null)
try {
const vehicle = await apiFetch<{ id: string }>('/vehicles', {
method: 'POST',
body: JSON.stringify({
...form,
year: Number(form.year),
seats: Number(form.seats),
vin: form.vin.trim() || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
dropoffLocations: form.allowDifferentDropoff ? parseLocationInput(form.dropoffLocations) : [],
}),
})
if (photoFiles.length > 0) {
const fd = new FormData()
photoFiles.forEach((file) => fd.append('photos', file))
await apiFetch(`/vehicles/${vehicle.id}/photos`, { method: 'POST', body: fd })
}
onSaved()
handleClose()
} catch (err: any) {
setError(err.message ?? f.failedToAdd)
} finally {
setLoading(false)
}
}
if (!open) return null
const presetModels = form.make && MODELS_BY_MAKE[form.make] ? MODELS_BY_MAKE[form.make] : null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/40" onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}>
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6 max-h-[90vh] overflow-y-auto">
<h2 className="text-lg font-semibold text-slate-900 mb-5">{f.addNewVehicle}</h2>
{error && (
<div className="px-3 py-2 mb-4 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Make & Model */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.make}</label>
<select
className="input-field"
value={makeSelect}
onChange={(e) => {
setMakeSelect(e.target.value)
setModelSelect('')
if (e.target.value !== 'custom') setForm({ ...form, make: e.target.value, model: '' })
else setForm({ ...form, make: '', model: '' })
}}
>
<option value="">{f.selectMake}</option>
{PRESET_MAKES.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{f.other}</option>
</select>
{makeSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeMake} required value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.model}</label>
{!presetModels ? (
<input className="input-field" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} />
) : (
<>
<select
className="input-field"
value={modelSelect}
onChange={(e) => {
setModelSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, model: e.target.value })
else setForm({ ...form, model: '' })
}}
>
<option value="">{f.selectModel}</option>
{presetModels.map((m) => <option key={m} value={m}>{m}</option>)}
<option value="custom">{f.other}</option>
</select>
{modelSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeModel} required value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} autoFocus />
)}
</>
)}
</div>
</div>
{/* Year & Category */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.year}</label>
<input type="number" className="input-field" min="1990" max={new Date().getFullYear() + 1} required value={form.year} onChange={(e) => setForm({ ...form, year: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.category}</label>
<select className="input-field" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })}>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{f.categoryLabels[c]}</option>
))}
</select>
</div>
</div>
{/* License Plate & VIN */}
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.licensePlate}</label>
<input className="input-field" placeholder="AB-1234-CD" required value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.vin}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
</div>
</div>
{/* Color, Seats, Transmission */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.color}</label>
<select
className="input-field"
value={colorSelect}
onChange={(e) => {
setColorSelect(e.target.value)
if (e.target.value !== 'custom') setForm({ ...form, color: e.target.value })
else setForm({ ...form, color: '' })
}}
>
<option value="">{f.select}</option>
{PRESET_COLORS.map((c) => <option key={c} value={c}>{c}</option>)}
<option value="custom">{f.other}</option>
</select>
{colorSelect === 'custom' && (
<input className="input-field mt-2" placeholder={f.typeColor} value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} autoFocus />
)}
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.seats}</label>
<input type="number" className="input-field" min="2" max="15" value={form.seats} onChange={(e) => setForm({ ...form, seats: Number(e.target.value) })} />
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.transmission}</label>
<select className="input-field" value={form.transmission} onChange={(e) => setForm({ ...form, transmission: e.target.value })}>
<option value="MANUAL">{f.manual}</option>
<option value="AUTOMATIC">{f.automatic}</option>
</select>
</div>
</div>
{/* Fuel Type & Odometer */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.fuelType}</label>
<select className="input-field" value={form.fuelType} onChange={(e) => setForm({ ...form, fuelType: e.target.value })}>
{(['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const).map((fuel) => (
<option key={fuel} value={fuel}>{f.fuelTypeLabels[fuel]}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.odometer}</label>
<input
type="number"
className="input-field"
placeholder="e.g. 45000"
min="0"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
/>
</div>
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={f.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={form.allowDifferentDropoff}
onChange={(e) => setForm({
...form,
allowDifferentDropoff: e.target.checked,
dropoffLocations: e.target.checked ? form.dropoffLocations : '',
})}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{f.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={f.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
)}
</div>
{/* Photos */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.photosLabel}</label>
{photoPreviews.length > 0 && (
<div className="grid grid-cols-3 gap-2 mb-3">
{photoPreviews.map((src, i) => (
<div key={i} className="relative h-24 rounded-lg overflow-hidden bg-slate-100 group">
<Image src={src} alt={`preview ${i + 1}`} fill className="object-cover" unoptimized />
<button
type="button"
onClick={() => removePhoto(i)}
className="absolute top-1 right-1 p-0.5 bg-blue-950/60 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{photoFiles.length < 10 && (
<>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handlePhotoChange}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-200 rounded-xl text-sm text-slate-500 hover:border-slate-300 hover:text-slate-700 transition-colors"
>
<Upload className="w-4 h-4" />
{photoFiles.length === 0 ? f.clickToAddPhotos : f.addMorePhotos}
</button>
</>
)}
</div>
<div className="flex gap-3 pt-2">
<button type="button" onClick={handleClose} className="btn-secondary flex-1">{f.cancel}</button>
<button type="submit" disabled={loading} className="btn-primary flex-1">
{loading ? f.adding : f.addVehicle}
</button>
</div>
</form>
</div>
</div>
)
}
export default function FleetPage() {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showAddModal, setShowAddModal] = useState(false)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [publishedFilter, setPublishedFilter] = useState('')
const [maintenanceModal, setMaintenanceModal] = useState<{ vehicleId: string; vehicleName: string } | null>(null)
const fetchVehicles = () => {
setLoading(true)
apiFetch<Vehicle[]>('/vehicles?pageSize=100')
.then((result) => setVehicles(result ?? []))
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
useEffect(() => { fetchVehicles() }, [])
const togglePublished = async (id: string, current: boolean) => {
try {
await apiFetch(`/vehicles/${id}`, { method: 'PATCH', body: JSON.stringify({ isPublished: !current }) })
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, isPublished: !current } : v))
} catch (err: any) {
alert(err.message)
}
}
const PUBLISHED = new Set<Vehicle['status']>(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
const changeStatus = (id: string, status: Vehicle['status']) => {
const isPublished = PUBLISHED.has(status)
setVehicles((prev) => prev.map((v) => v.id === id ? { ...v, status, isPublished } : v))
setStatusFilter('')
if (status === 'MAINTENANCE') {
const vehicle = vehicles.find((v) => v.id === id)
if (vehicle) setMaintenanceModal({ vehicleId: id, vehicleName: `${vehicle.make} ${vehicle.model}` })
}
}
const filtered = vehicles.filter((v) => {
if (search && !`${v.make} ${v.model} ${v.licensePlate} ${v.vin ?? ''}`.toLowerCase().includes(search.toLowerCase())) return false
if (statusFilter && v.status !== statusFilter) return false
if (categoryFilter && v.category !== categoryFilter) return false
if (publishedFilter === 'published' && !v.isPublished) return false
if (publishedFilter === 'unpublished' && v.isPublished) return false
return true
})
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{f.heading}</h2>
<p className="text-sm text-slate-500 mt-0.5">{f.vehicleCount(vehicles.length)}</p>
</div>
<button onClick={() => setShowAddModal(true)} className="btn-primary">
<Plus className="w-4 h-4" /> {f.addVehicle}
</button>
</div>
{/* Filters */}
<div className="card p-4 flex flex-wrap gap-3">
<div className="flex items-center gap-2 flex-1 min-w-48">
<Search className="w-4 h-4 text-slate-400" />
<input
className="flex-1 text-sm outline-none placeholder:text-slate-400"
placeholder={f.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<select className="input-field w-36" value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}>
<option value="">{f.allStatuses}</option>
{ALL_STATUSES.map((s) => (
<option key={s} value={s}>{f.statusLabels[s]}</option>
))}
</select>
<select className="input-field w-36" value={categoryFilter} onChange={(e) => setCategoryFilter(e.target.value)}>
<option value="">{f.allCategories}</option>
{(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const).map((c) => (
<option key={c} value={c}>{f.categoryLabels[c]}</option>
))}
</select>
<select className="input-field w-36" value={publishedFilter} onChange={(e) => setPublishedFilter(e.target.value)}>
<option value="">{f.allVisibility}</option>
<option value="published">{f.published}</option>
<option value="unpublished">{f.unpublished}</option>
</select>
</div>
{/* Table */}
<div className="card overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
) : error ? (
<div className="p-8 text-center">
<p className="text-red-600 font-medium">{error}</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colVehicle}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colCategory}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colStatus}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colDailyRate}</th>
<th className="text-start px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colPublished}</th>
<th className="text-end px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{f.colActions}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((vehicle) => (
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div>
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
<p className="text-xs text-slate-400">
{vehicle.year} · {vehicle.licensePlate}
{vehicle.vin ? ` · ${vehicle.vin}` : ''}
</p>
</div>
</td>
<td className="px-6 py-4">
<span className="badge-gray">{f.categoryLabels[vehicle.category as keyof typeof f.categoryLabels] ?? vehicle.category}</span>
</td>
<td className="px-6 py-4">
<StatusDropdown vehicleId={vehicle.id} status={vehicle.status} onStatusChange={changeStatus} />
</td>
<td className="px-6 py-4 text-sm font-medium text-slate-900">
{formatCurrency(vehicle.dailyRate, 'MAD')}{f.perDay}
</td>
<td className="px-6 py-4">
<div className="flex items-center">
<button
onClick={() => togglePublished(vehicle.id, vehicle.isPublished)}
className={[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none',
vehicle.isPublished ? 'bg-blue-600' : 'bg-slate-200',
].join(' ')}
>
<span
className={[
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition duration-200',
vehicle.isPublished ? 'ltr:translate-x-5 rtl:-translate-x-5' : 'translate-x-0',
].join(' ')}
/>
</button>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2 rtl:justify-start">
<Link href={`/fleet/${vehicle.id}?tab=pricing`} title="Manage pricing" aria-label="Manage pricing" className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<DollarSign className="w-4 h-4" />
</Link>
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Eye className="w-4 h-4" />
</Link>
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Edit className="w-4 h-4" />
</Link>
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-sm text-slate-400">
{vehicles.length === 0 ? f.noVehicles : f.noMatch}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSaved={fetchVehicles} />
{maintenanceModal && (
<MaintenanceModal
vehicleId={maintenanceModal.vehicleId}
vehicleName={maintenanceModal.vehicleName}
open={true}
onClose={() => setMaintenanceModal(null)}
onSaved={() => setMaintenanceModal(null)}
/>
)}
</div>
)
}
@@ -0,0 +1,21 @@
import DashboardAccessGuard from '@/components/layout/DashboardAccessGuard'
import Sidebar from '@/components/layout/Sidebar'
import TopBar from '@/components/layout/TopBar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<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,#0f1f4a_0%,#112d6e_35%,#0d1f4f_100%)] print:block print:min-h-0 print:bg-white">
<div className="print:hidden">
<Sidebar />
</div>
<div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64 print:ms-0 print:min-h-0 print:block">
<div className="print:hidden">
<TopBar />
</div>
<main className="flex-1 overflow-y-auto p-6 print:p-0 print:overflow-visible">
<DashboardAccessGuard>{children}</DashboardAccessGuard>
</main>
</div>
</div>
)
}
@@ -0,0 +1,469 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type NotificationItem = {
id: string
type: string
title: string
body: string
channel: string
status: string
sentAt: string | null
readAt: string | null
createdAt: string
providerMessageId: string | null
locale: string
}
type PreferenceItem = {
notificationType: string
channel: string
enabled: boolean
}
const COMPANY_EVENTS = [
'NEW_RESERVATION',
'RESERVATION_CANCELLED',
'PAYMENT_RECEIVED',
'SUBSCRIPTION_TRIAL_ENDING',
'MAINTENANCE_DUE',
'OFFER_EXPIRING',
]
const COMPANY_CHANNELS = ['EMAIL', 'SMS', 'WHATSAPP', 'IN_APP', 'PUSH']
const CHANNEL_BADGE: Record<string, string> = {
EMAIL: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
SMS: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
WHATSAPP: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300',
IN_APP: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
PUSH: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
}
const STATUS_BADGE: Record<string, string> = {
PENDING: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
SENT: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
DELIVERED: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
FAILED: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
READ: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400',
}
export default function DashboardNotificationsPage() {
const { language } = useDashboardI18n()
const [notifications, setNotifications] = useState<NotificationItem[]>([])
const [history, setHistory] = useState<NotificationItem[]>([])
const [preferences, setPreferences] = useState<Record<string, boolean>>({})
const [activeTab, setActiveTab] = useState<'inbox' | 'history' | 'preferences'>('inbox')
const [loading, setLoading] = useState(true)
const [historyLoading, setHistoryLoading] = useState(false)
const [historyLoaded, setHistoryLoaded] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterChannel, setFilterChannel] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const copy = {
en: {
title: 'Notifications',
subtitle: 'Track in-app alerts, audit delivery history, and control preferences for your team account.',
inbox: 'Inbox',
history: 'History',
preferences: 'Preferences',
markAllRead: 'Mark all as read',
loading: 'Loading notifications…',
empty: 'No notifications yet.',
read: 'Read',
markRead: 'Mark as read',
event: 'Event',
channel: 'Channel',
status: 'Status',
sentAt: 'Sent at',
date: 'Date',
allChannels: 'All channels',
allStatuses: 'All statuses',
savePreferences: 'Save preferences',
saving: 'Saving…',
failedLoad: 'Failed to load notifications',
failedSave: 'Failed to save preferences',
noHistory: 'No notification history found.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'In-app', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'Pending', SENT: 'Sent', DELIVERED: 'Delivered', FAILED: 'Failed', READ: 'Read' } as Record<string, string>,
events: {
NEW_RESERVATION: 'New reservation',
RESERVATION_CANCELLED: 'Reservation cancelled',
PAYMENT_RECEIVED: 'Payment received',
SUBSCRIPTION_TRIAL_ENDING: 'Trial ending',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offer expiring',
BOOKING_CONFIRMED: 'Booking confirmed',
VEHICLE_MAINTENANCE_DUE: 'Vehicle maintenance due',
} as Record<string, string>,
},
fr: {
title: 'Notifications',
subtitle: 'Suivez les alertes, auditez l\'historique de diffusion et gérez les préférences de votre équipe.',
inbox: 'Boîte de réception',
history: 'Historique',
preferences: 'Préférences',
markAllRead: 'Tout marquer comme lu',
loading: 'Chargement des notifications…',
empty: 'Aucune notification pour le moment.',
read: 'Lu',
markRead: 'Marquer comme lu',
event: 'Événement',
channel: 'Canal',
status: 'Statut',
sentAt: 'Envoyé le',
date: 'Date',
allChannels: 'Tous les canaux',
allStatuses: 'Tous les statuts',
savePreferences: 'Enregistrer les préférences',
saving: 'Enregistrement…',
failedLoad: 'Échec du chargement des notifications',
failedSave: 'Échec de l\'enregistrement des préférences',
noHistory: 'Aucun historique de notification trouvé.',
channels: { EMAIL: 'Email', SMS: 'SMS', WHATSAPP: 'WhatsApp', IN_APP: 'Dans l\'application', PUSH: 'Push' } as Record<string, string>,
statuses: { PENDING: 'En attente', SENT: 'Envoyé', DELIVERED: 'Délivré', FAILED: 'Échoué', READ: 'Lu' } as Record<string, string>,
events: {
NEW_RESERVATION: 'Nouvelle réservation',
RESERVATION_CANCELLED: 'Réservation annulée',
PAYMENT_RECEIVED: 'Paiement reçu',
SUBSCRIPTION_TRIAL_ENDING: 'Fin d\'essai proche',
MAINTENANCE_DUE: 'Maintenance due',
OFFER_EXPIRING: 'Offre expirante',
BOOKING_CONFIRMED: 'Réservation confirmée',
VEHICLE_MAINTENANCE_DUE: 'Maintenance véhicule due',
} as Record<string, string>,
},
ar: {
title: 'الإشعارات',
subtitle: 'تابع التنبيهات وراجع سجل الإرسال وتحكم في التفضيلات لحساب فريقك.',
inbox: 'صندوق الوارد',
history: 'السجل',
preferences: 'التفضيلات',
markAllRead: 'تحديد الكل كمقروء',
loading: 'جارٍ تحميل الإشعارات…',
empty: 'لا توجد إشعارات حالياً.',
read: 'مقروء',
markRead: 'تحديد كمقروء',
event: 'الحدث',
channel: 'القناة',
status: 'الحالة',
sentAt: 'وقت الإرسال',
date: 'التاريخ',
allChannels: 'جميع القنوات',
allStatuses: 'جميع الحالات',
savePreferences: 'حفظ التفضيلات',
saving: 'جارٍ الحفظ…',
failedLoad: 'فشل تحميل الإشعارات',
failedSave: 'فشل حفظ التفضيلات',
noHistory: 'لا يوجد سجل إشعارات.',
channels: { EMAIL: 'البريد', SMS: 'رسائل', WHATSAPP: 'واتساب', IN_APP: 'داخل التطبيق', PUSH: 'إشعار فوري' } as Record<string, string>,
statuses: { PENDING: 'قيد الانتظار', SENT: 'مرسل', DELIVERED: 'تم التسليم', FAILED: 'فشل', READ: 'مقروء' } as Record<string, string>,
events: {
NEW_RESERVATION: 'حجز جديد',
RESERVATION_CANCELLED: 'إلغاء حجز',
PAYMENT_RECEIVED: 'تم استلام الدفع',
SUBSCRIPTION_TRIAL_ENDING: 'اقتراب نهاية التجربة',
MAINTENANCE_DUE: 'صيانة مستحقة',
OFFER_EXPIRING: 'عرض على وشك الانتهاء',
BOOKING_CONFIRMED: 'تأكيد الحجز',
VEHICLE_MAINTENANCE_DUE: 'صيانة المركبة مستحقة',
} as Record<string, string>,
},
}[language]
async function load() {
setLoading(true)
setError(null)
try {
const [notificationData, preferenceData] = await Promise.all([
apiFetch<NotificationItem[]>('/notifications/company'),
apiFetch<PreferenceItem[]>('/notifications/company/preferences'),
])
setNotifications(notificationData)
const mapped = Object.fromEntries(
preferenceData.map((item) => [`${item.notificationType}:${item.channel}`, item.enabled]),
)
setPreferences(mapped)
} catch (err: any) {
setError(err.message ?? copy.failedLoad)
} finally {
setLoading(false)
}
}
async function loadHistory() {
setHistoryLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (filterChannel) params.set('channel', filterChannel)
if (filterStatus) params.set('status', filterStatus)
const qs = params.toString()
const data = await apiFetch<NotificationItem[]>(`/notifications/history${qs ? `?${qs}` : ''}`)
setHistory(data)
setHistoryLoaded(true)
} catch (err: any) {
setError(err.message ?? copy.failedLoad)
} finally {
setHistoryLoading(false)
}
}
useEffect(() => {
load()
}, [])
useEffect(() => {
if (activeTab === 'history') {
loadHistory()
}
}, [activeTab, filterChannel, filterStatus])
async function markRead(id: string) {
await apiFetch(`/notifications/company/${id}/read`, { method: 'POST' })
setNotifications((current) =>
current.map((item) => (item.id === id ? { ...item, readAt: new Date().toISOString() } : item)),
)
window.dispatchEvent(new Event('notifications:updated'))
}
async function markAllRead() {
await apiFetch('/notifications/company/read-all', { method: 'POST' })
setNotifications((current) => current.map((item) => ({ ...item, readAt: item.readAt ?? new Date().toISOString() })))
window.dispatchEvent(new Event('notifications:updated'))
}
async function savePreferences() {
setSaving(true)
setError(null)
try {
const body = Object.entries(preferences).map(([key, enabled]) => {
const [notificationType, channel] = key.split(':')
return { notificationType, channel, enabled }
})
await apiFetch('/notifications/company/preferences', {
method: 'PATCH',
body: JSON.stringify(body),
})
} catch (err: any) {
setError(err.message ?? copy.failedSave)
} finally {
setSaving(false)
}
}
function preferenceValue(notificationType: string, channel: string) {
return preferences[`${notificationType}:${channel}`] ?? true
}
function setPreference(notificationType: string, channel: string, enabled: boolean) {
setPreferences((current) => ({
...current,
[`${notificationType}:${channel}`]: enabled,
}))
}
function labelEvent(type: string) {
return copy.events[type] ?? type.replaceAll('_', ' ')
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{copy.title}</h1>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{copy.subtitle}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('inbox')}
className={activeTab === 'inbox' ? 'btn-primary' : 'btn-secondary'}
>
{copy.inbox}
</button>
<button
type="button"
onClick={() => setActiveTab('history')}
className={activeTab === 'history' ? 'btn-primary' : 'btn-secondary'}
>
{copy.history}
</button>
<button
type="button"
onClick={() => setActiveTab('preferences')}
className={activeTab === 'preferences' ? 'btn-primary' : 'btn-secondary'}
>
{copy.preferences}
</button>
</div>
</div>
{error ? <div className="card p-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
{/* ── Inbox ── */}
{activeTab === 'inbox' ? (
<div className="space-y-4">
<div className="flex justify-end">
<button type="button" onClick={markAllRead} className="btn-secondary">
{copy.markAllRead}
</button>
</div>
{loading ? <div className="card p-6 text-sm text-slate-500">{copy.loading}</div> : null}
{!loading && notifications.length === 0 ? <div className="card p-6 text-sm text-slate-500">{copy.empty}</div> : null}
{!loading
? notifications.map((notification) => (
<article key={notification.id} className="card p-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{labelEvent(notification.type)}
</p>
<h2 className="mt-2 text-lg font-semibold text-slate-900 dark:text-slate-100">{notification.title}</h2>
</div>
{notification.readAt ? (
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-400">{copy.read}</span>
) : (
<button type="button" onClick={() => markRead(notification.id)} className="btn-secondary">
{copy.markRead}
</button>
)}
</div>
<p className="mt-3 text-sm leading-7 text-slate-600 dark:text-slate-300">{notification.body}</p>
<p className="mt-4 text-xs text-slate-400">{new Date(notification.createdAt).toLocaleString()}</p>
</article>
))
: null}
</div>
) : null}
{/* ── History ── */}
{activeTab === 'history' ? (
<div className="space-y-4">
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
value={filterChannel}
onChange={(e) => setFilterChannel(e.target.value)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-blue-950 dark:text-slate-200"
>
<option value="">{copy.allChannels}</option>
{COMPANY_CHANNELS.map((ch) => (
<option key={ch} value={ch}>{copy.channels[ch] ?? ch}</option>
))}
</select>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-blue-900 dark:bg-blue-950 dark:text-slate-200"
>
<option value="">{copy.allStatuses}</option>
{Object.keys(copy.statuses).map((s) => (
<option key={s} value={s}>{copy.statuses[s]}</option>
))}
</select>
</div>
{historyLoading ? (
<div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
) : historyLoaded && history.length === 0 ? (
<div className="card p-6 text-sm text-slate-500">{copy.noHistory}</div>
) : (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 dark:bg-blue-950">
<tr>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.date}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.event}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.channel}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.title ?? 'Title'}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.status}</th>
<th className="whitespace-nowrap px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">{copy.sentAt}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-blue-900/50">
{history.map((item) => (
<tr key={item.id} className="hover:bg-slate-50/60 dark:hover:bg-blue-900/20">
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="whitespace-nowrap px-4 py-3 text-xs font-medium text-slate-700 dark:text-slate-300">
{labelEvent(item.type)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${CHANNEL_BADGE[item.channel] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.channels[item.channel] ?? item.channel}
</span>
</td>
<td className="max-w-[260px] px-4 py-3">
<p className="truncate text-sm font-medium text-slate-800 dark:text-slate-200">{item.title}</p>
<p className="truncate text-xs text-slate-400 dark:text-slate-500">{item.body}</p>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${STATUS_BADGE[item.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statuses[item.status] ?? item.status}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-slate-500 dark:text-slate-400">
{item.sentAt ? new Date(item.sentAt).toLocaleString() : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
) : null}
{/* ── Preferences ── */}
{activeTab === 'preferences' ? (
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="bg-slate-50 dark:bg-blue-950">
<tr>
<th className="px-4 py-3 text-left font-semibold text-slate-600 dark:text-slate-300">{copy.event}</th>
{COMPANY_CHANNELS.map((channel) => (
<th key={channel} className="px-4 py-3 text-center font-semibold text-slate-600 dark:text-slate-300">
{copy.channels[channel] ?? channel.replace('_', ' ')}
</th>
))}
</tr>
</thead>
<tbody>
{COMPANY_EVENTS.map((eventName) => (
<tr key={eventName} className="border-t border-slate-100 dark:border-blue-900/50">
<td className="px-4 py-3 font-medium text-slate-900 dark:text-slate-200">{copy.events[eventName] ?? eventName.replaceAll('_', ' ')}</td>
{COMPANY_CHANNELS.map((channel) => (
<td key={`${eventName}-${channel}`} className="px-4 py-3 text-center">
<input
type="checkbox"
checked={preferenceValue(eventName, channel)}
onChange={(event) => setPreference(eventName, channel, event.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600"
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex justify-end border-t border-slate-100 dark:border-blue-900/50 p-4">
<button type="button" onClick={savePreferences} disabled={saving} className="btn-primary">
{saving ? copy.saving : copy.savePreferences}
</button>
</div>
</div>
) : null}
</div>
)
}
@@ -0,0 +1,797 @@
'use client'
import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import { formatCurrency, getCurrencyLabel } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
const CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
type Category = typeof CATEGORIES[number]
interface Offer {
id: string
title: string
description?: string | null
termsAndConds?: string | null
type: 'PERCENTAGE' | 'FIXED_AMOUNT' | 'FREE_DAY' | 'SPECIAL_RATE'
discountValue: number
specialRate?: number | null
appliesToAll: boolean
categories: Category[]
minRentalDays?: number | null
maxRentalDays?: number | null
promoCode?: string | null
maxRedemptions?: number | null
validFrom: string
validUntil: string
isActive: boolean
isPublic: boolean
isFeatured: boolean
redemptionCount?: number
vehicles?: Array<{ vehicleId: string }>
}
interface FleetVehicle {
id: string
make: string
model: string
year: number
category: string
licensePlate: string
}
interface FormState {
title: string
description: string
termsAndConds: string
type: Offer['type']
discountValue: string
specialRate: string
appliesToAll: boolean
categories: Category[]
vehicleIds: string[]
minRentalDays: string
maxRentalDays: string
promoCode: string
maxRedemptions: string
validFrom: string
validUntil: string
isActive: boolean
isPublic: boolean
isFeatured: boolean
}
function emptyForm(): FormState {
const today = dayjs().format('YYYY-MM-DD')
const nextMonth = dayjs().add(30, 'day').format('YYYY-MM-DD')
return {
title: '',
description: '',
termsAndConds: '',
type: 'PERCENTAGE',
discountValue: '',
specialRate: '',
appliesToAll: true,
categories: [],
vehicleIds: [],
minRentalDays: '',
maxRentalDays: '',
promoCode: '',
maxRedemptions: '',
validFrom: today,
validUntil: nextMonth,
isActive: true,
isPublic: true,
isFeatured: false,
}
}
function offerToForm(o: Offer): FormState {
return {
title: o.title,
description: o.description ?? '',
termsAndConds: o.termsAndConds ?? '',
type: o.type,
discountValue: String(o.discountValue),
specialRate: o.specialRate != null ? String(o.specialRate) : '',
appliesToAll: o.appliesToAll,
categories: o.categories ?? [],
vehicleIds: o.vehicles?.map((v) => v.vehicleId) ?? [],
minRentalDays: o.minRentalDays != null ? String(o.minRentalDays) : '',
maxRentalDays: o.maxRentalDays != null ? String(o.maxRentalDays) : '',
promoCode: o.promoCode ?? '',
maxRedemptions: o.maxRedemptions != null ? String(o.maxRedemptions) : '',
validFrom: dayjs(o.validFrom).format('YYYY-MM-DD'),
validUntil: dayjs(o.validUntil).format('YYYY-MM-DD'),
isActive: o.isActive,
isPublic: o.isPublic,
isFeatured: o.isFeatured,
}
}
const copy = {
en: {
title: 'Offers',
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
createOffer: 'Create Offer',
editOffer: 'Edit Offer',
deleteOffer: 'Delete Offer',
confirmDelete: 'Are you sure you want to delete this offer? This action cannot be undone.',
cancel: 'Cancel',
save: 'Save',
saving: 'Saving…',
delete: 'Delete',
deleting: 'Deleting…',
ends: 'ends',
active: 'Active',
inactive: 'Inactive',
marketplace: 'Marketplace',
featured: 'Featured',
empty: 'No offers yet. Create your first offer to attract customers.',
labelTitle: 'Title *',
labelDescription: 'Description',
labelTerms: 'Terms & Conditions',
labelType: 'Discount Type *',
labelDiscount: 'Discount Value *',
labelSpecialRate: 'Special Rate (MAD/day) *',
labelAppliesToAll: 'Applies to all vehicles',
labelCategories: 'Vehicle Categories',
labelVehicles: 'Specific Vehicles',
labelVehicleSearch: 'Search vehicles…',
labelMinDays: 'Min Rental Days',
labelMaxDays: 'Max Rental Days',
labelPromoCode: 'Promo Code',
labelMaxRedemptions: 'Max Redemptions',
labelValidFrom: 'Valid From *',
labelValidUntil: 'Valid Until *',
labelIsActive: 'Active',
labelIsPublic: 'Show on marketplace',
labelIsFeatured: 'Featured',
typePERCENTAGE: 'Percentage (%)',
typeFIXED_AMOUNT: 'Fixed Amount (MAD)',
typeFREE_DAY: 'Free Day',
typeSPECIAL_RATE: 'Special Daily Rate',
placeholderTitle: 'e.g. Summer Promotion',
placeholderDescription: 'Optional description…',
placeholderTerms: 'Terms and conditions…',
placeholderPromoCode: 'e.g. SUMMER20',
errorTitle: 'Title is required.',
errorDiscount: 'Please enter a valid discount value.',
errorDates: 'Valid From and Valid Until are required.',
errorDateOrder: 'Valid Until must be after Valid From.',
failedSave: 'Failed to save offer.',
failedDelete: 'Failed to delete offer.',
redemptions: 'redemptions',
freeDay: 'Free day',
loadingVehicles: 'Loading fleet…',
noVehiclesFound: 'No vehicles found.',
selected: (n: number) => `${n} selected`,
categoryLabels: {
ECONOMY: 'Economy', COMPACT: 'Compact', MIDSIZE: 'Midsize', FULLSIZE: 'Full-size',
SUV: 'SUV', LUXURY: 'Luxury', VAN: 'Van', TRUCK: 'Truck',
},
},
fr: {
title: 'Offres',
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
createOffer: 'Créer une offre',
editOffer: "Modifier l'offre",
deleteOffer: "Supprimer l'offre",
confirmDelete: 'Êtes-vous sûr de vouloir supprimer cette offre ? Cette action est irréversible.',
cancel: 'Annuler',
save: 'Enregistrer',
saving: 'Enregistrement…',
delete: 'Supprimer',
deleting: 'Suppression…',
ends: 'expire le',
active: 'Actif',
inactive: 'Inactif',
marketplace: 'Marketplace',
featured: 'Mise en avant',
empty: 'Aucune offre. Créez votre première offre pour attirer des clients.',
labelTitle: 'Titre *',
labelDescription: 'Description',
labelTerms: 'Conditions générales',
labelType: 'Type de remise *',
labelDiscount: 'Valeur de la remise *',
labelSpecialRate: 'Tarif spécial (MAD/jour) *',
labelAppliesToAll: 'Applicable à tous les véhicules',
labelCategories: 'Catégories de véhicules',
labelVehicles: 'Véhicules spécifiques',
labelVehicleSearch: 'Rechercher des véhicules…',
labelMinDays: 'Jours de location minimum',
labelMaxDays: 'Jours de location maximum',
labelPromoCode: 'Code promo',
labelMaxRedemptions: 'Nombre max de rédemptions',
labelValidFrom: 'Valable du *',
labelValidUntil: "Valable jusqu'au *",
labelIsActive: 'Actif',
labelIsPublic: 'Afficher sur la marketplace',
labelIsFeatured: 'Mise en avant',
typePERCENTAGE: 'Pourcentage (%)',
typeFIXED_AMOUNT: 'Montant fixe (MAD)',
typeFREE_DAY: 'Jour gratuit',
typeSPECIAL_RATE: 'Tarif journalier spécial',
placeholderTitle: 'ex. Promotion estivale',
placeholderDescription: 'Description optionnelle…',
placeholderTerms: 'Conditions générales…',
placeholderPromoCode: 'ex. ETE20',
errorTitle: 'Le titre est requis.',
errorDiscount: 'Veuillez saisir une valeur de remise valide.',
errorDates: 'Les dates de début et de fin sont requises.',
errorDateOrder: 'La date de fin doit être après la date de début.',
failedSave: "Échec de l'enregistrement de l'offre.",
failedDelete: "Échec de la suppression de l'offre.",
redemptions: 'rédemptions',
freeDay: 'Jour gratuit',
loadingVehicles: 'Chargement de la flotte…',
noVehiclesFound: 'Aucun véhicule trouvé.',
selected: (n: number) => `${n} sélectionné${n > 1 ? 's' : ''}`,
categoryLabels: {
ECONOMY: 'Économique', COMPACT: 'Compacte', MIDSIZE: 'Intermédiaire', FULLSIZE: 'Grande berline',
SUV: 'SUV', LUXURY: 'Luxe', VAN: 'Van', TRUCK: 'Camionnette',
},
},
ar: {
title: 'العروض',
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
createOffer: 'إنشاء عرض',
editOffer: 'تعديل العرض',
deleteOffer: 'حذف العرض',
confirmDelete: 'هل أنت متأكد من حذف هذا العرض؟ لا يمكن التراجع عن هذا الإجراء.',
cancel: 'إلغاء',
save: 'حفظ',
saving: 'جارٍ الحفظ…',
delete: 'حذف',
deleting: 'جارٍ الحذف…',
ends: 'تنتهي في',
active: 'نشط',
inactive: 'غير نشط',
marketplace: 'السوق',
featured: 'مميز',
empty: 'لا توجد عروض بعد. أنشئ أول عرض لجذب العملاء.',
labelTitle: 'العنوان *',
labelDescription: 'الوصف',
labelTerms: 'الشروط والأحكام',
labelType: 'نوع الخصم *',
labelDiscount: 'قيمة الخصم *',
labelSpecialRate: 'السعر الخاص (درهم/يوم) *',
labelAppliesToAll: 'ينطبق على جميع المركبات',
labelCategories: 'فئات المركبات',
labelVehicles: 'مركبات محددة',
labelVehicleSearch: 'ابحث عن مركبة…',
labelMinDays: 'الحد الأدنى لأيام الإيجار',
labelMaxDays: 'الحد الأقصى لأيام الإيجار',
labelPromoCode: 'رمز الترويج',
labelMaxRedemptions: 'الحد الأقصى للاسترداد',
labelValidFrom: 'صالح من *',
labelValidUntil: 'صالح حتى *',
labelIsActive: 'نشط',
labelIsPublic: 'عرض في السوق',
labelIsFeatured: 'مميز',
typePERCENTAGE: 'نسبة مئوية (%)',
typeFIXED_AMOUNT: 'مبلغ ثابت (درهم)',
typeFREE_DAY: 'يوم مجاني',
typeSPECIAL_RATE: 'سعر يومي خاص',
placeholderTitle: 'مثال: عرض الصيف',
placeholderDescription: 'وصف اختياري…',
placeholderTerms: 'الشروط والأحكام…',
placeholderPromoCode: 'مثال: SUMMER20',
errorTitle: 'العنوان مطلوب.',
errorDiscount: 'يرجى إدخال قيمة خصم صحيحة.',
errorDates: 'تاريخ البداية والنهاية مطلوبان.',
errorDateOrder: 'يجب أن يكون تاريخ الانتهاء بعد تاريخ البداية.',
failedSave: 'فشل حفظ العرض.',
failedDelete: 'فشل حذف العرض.',
redemptions: 'استرداد',
freeDay: 'يوم مجاني',
loadingVehicles: 'جارٍ تحميل الأسطول…',
noVehiclesFound: 'لم يتم العثور على مركبات.',
selected: (n: number) => `${n} محدد`,
categoryLabels: {
ECONOMY: 'اقتصادية', COMPACT: 'مدمجة', MIDSIZE: 'متوسطة', FULLSIZE: 'كبيرة',
SUV: 'دفع رباعي', LUXURY: 'فاخرة', VAN: 'فان', TRUCK: 'شاحنة',
},
},
}
function discountLabel(offer: Offer, t: typeof copy['en'], language: 'en' | 'fr' | 'ar'): string {
if (offer.type === 'PERCENTAGE') return `${offer.discountValue}%`
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD', language)
if (offer.type === 'FREE_DAY') return t.freeDay
if (offer.type === 'SPECIAL_RATE') return `${offer.specialRate ?? offer.discountValue} ${getCurrencyLabel(language)}${language === 'fr' ? '/jour' : language === 'ar' ? '/يوم' : '/day'}`
return String(offer.discountValue)
}
export default function OffersPage() {
const { language } = useDashboardI18n()
const t = copy[language]
const [offers, setOffers] = useState<Offer[]>([])
const [error, setError] = useState<string | null>(null)
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Offer | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [formError, setFormError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<Offer | null>(null)
const [deleting, setDeleting] = useState(false)
const [fleet, setFleet] = useState<FleetVehicle[]>([])
const [loadingFleet, setLoadingFleet] = useState(false)
const [vehicleSearch, setVehicleSearch] = useState('')
function load() {
apiFetch<Offer[]>('/offers')
.then((data) => {
setOffers(Array.isArray(data) ? data : [])
setError(null)
})
.catch((err) => setError(err.message))
}
useEffect(() => { load() }, [])
async function loadFleet() {
setLoadingFleet(true)
try {
const data = await apiFetch<FleetVehicle[]>('/vehicles?pageSize=200')
setFleet(Array.isArray(data) ? data : [])
} catch {
// non-fatal; vehicle picker just stays empty
} finally {
setLoadingFleet(false)
}
}
function openCreate() {
setEditing(null)
setForm(emptyForm())
setFormError(null)
setVehicleSearch('')
setModalOpen(true)
loadFleet()
}
async function openEdit(offer: Offer) {
setEditing(offer)
setForm(offerToForm(offer))
setFormError(null)
setVehicleSearch('')
setModalOpen(true)
// Load fleet and the offer's current vehicle IDs in parallel
loadFleet()
try {
const detail = await apiFetch<Offer>(`/offers/${offer.id}`)
setForm((prev) => ({
...prev,
vehicleIds: Array.isArray(detail?.vehicles) ? detail.vehicles.map((v) => v.vehicleId) : [],
}))
} catch {
// leave vehicleIds as empty if fetch fails
}
}
function closeModal() {
setModalOpen(false)
setEditing(null)
setFormError(null)
}
function toggleCategory(cat: Category) {
setForm((f) => ({
...f,
categories: f.categories.includes(cat)
? f.categories.filter((c) => c !== cat)
: [...f.categories, cat],
}))
}
function toggleVehicle(id: string) {
setForm((f) => ({
...f,
vehicleIds: f.vehicleIds.includes(id)
? f.vehicleIds.filter((v) => v !== id)
: [...f.vehicleIds, id],
}))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setFormError(null)
if (!form.title.trim()) return setFormError(t.errorTitle)
if (!form.validFrom || !form.validUntil) return setFormError(t.errorDates)
if (form.validUntil <= form.validFrom) return setFormError(t.errorDateOrder)
if (form.type !== 'FREE_DAY') {
const dv = Number(form.discountValue)
if (isNaN(dv) || dv < 0) return setFormError(t.errorDiscount)
}
setSaving(true)
try {
const payload: Record<string, unknown> = {
title: form.title.trim(),
description: form.description.trim() || undefined,
termsAndConds: form.termsAndConds.trim() || undefined,
type: form.type,
discountValue: form.type === 'FREE_DAY' ? 0 : Number(form.discountValue),
specialRate: form.specialRate ? Number(form.specialRate) : undefined,
appliesToAll: form.appliesToAll,
categories: form.appliesToAll ? [] : form.categories,
minRentalDays: form.minRentalDays ? Number(form.minRentalDays) : undefined,
maxRentalDays: form.maxRentalDays ? Number(form.maxRentalDays) : undefined,
promoCode: form.promoCode.trim() || undefined,
maxRedemptions: form.maxRedemptions ? Number(form.maxRedemptions) : undefined,
validFrom: new Date(form.validFrom).toISOString(),
validUntil: new Date(form.validUntil).toISOString(),
isActive: form.isActive,
isPublic: form.isPublic,
isFeatured: form.isFeatured,
vehicleIds: form.appliesToAll ? [] : form.vehicleIds,
}
if (editing) {
await apiFetch(`/offers/${editing.id}`, { method: 'PATCH', body: JSON.stringify(payload) })
} else {
await apiFetch('/offers', { method: 'POST', body: JSON.stringify(payload) })
}
closeModal()
load()
} catch (err: any) {
setFormError(err.message ?? t.failedSave)
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
await apiFetch(`/offers/${deleteTarget.id}`, { method: 'DELETE' })
setDeleteTarget(null)
load()
} catch (err: any) {
setFormError(err.message ?? t.failedDelete)
setDeleteTarget(null)
} finally {
setDeleting(false)
}
}
const isRtl = language === 'ar'
const filteredFleet = fleet.filter((v) => {
const q = vehicleSearch.toLowerCase()
return (
!q ||
v.make.toLowerCase().includes(q) ||
v.model.toLowerCase().includes(q) ||
v.licensePlate.toLowerCase().includes(q)
)
})
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-slate-900">{t.title}</h2>
<p className="text-sm text-slate-500 mt-1">{t.subtitle}</p>
</div>
<button type="button" onClick={openCreate} className="btn-primary shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
{t.createOffer}
</button>
</div>
{error && <div className="card p-4 text-sm text-red-600">{error}</div>}
{/* Offer cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{offers.map((offer) => (
<div key={offer.id} className="card p-5 flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-base font-semibold text-slate-900 truncate">{offer.title}</h3>
<p className="text-sm text-slate-500 mt-0.5">
{t[`type${offer.type}` as keyof typeof t] as string} · {t.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}
</p>
</div>
<span className={`shrink-0 ${offer.isActive ? 'badge-green' : 'badge-gray'}`}>
{offer.isActive ? t.active : t.inactive}
</span>
</div>
<p className="text-2xl font-bold text-slate-900">{discountLabel(offer, t, language)}</p>
{offer.description && (
<p className="text-sm text-slate-500 line-clamp-2">{offer.description}</p>
)}
<div className="flex flex-wrap gap-2">
{offer.isPublic && <span className="badge-blue">{t.marketplace}</span>}
{offer.isFeatured && <span className="badge-purple">{t.featured}</span>}
{offer.promoCode && <span className="badge-amber">{offer.promoCode}</span>}
{typeof offer.redemptionCount === 'number' && (
<span className="badge-gray">{offer.redemptionCount} {t.redemptions}</span>
)}
</div>
<div className={`flex gap-2 mt-auto pt-2 border-t border-slate-100 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button type="button" onClick={() => openEdit(offer)} className="btn-secondary text-xs px-3 py-1.5">
{t.editOffer}
</button>
<button type="button" onClick={() => setDeleteTarget(offer)} className="btn-danger text-xs px-3 py-1.5">
{t.deleteOffer}
</button>
</div>
</div>
))}
{offers.length === 0 && !error && (
<div className="col-span-full card p-10 text-center text-sm text-slate-400">{t.empty}</div>
)}
</div>
{/* Create / Edit Modal */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-blue-950/50 p-4">
<div className="relative w-full max-w-2xl my-8 card p-6" dir={isRtl ? 'rtl' : 'ltr'}>
<h3 className="text-lg font-semibold text-slate-900 mb-5">
{editing ? t.editOffer : t.createOffer}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Title */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelTitle}</label>
<input
className="input-field"
value={form.title}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder={t.placeholderTitle}
/>
</div>
{/* Description */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelDescription}</label>
<textarea
className="input-field resize-none"
rows={2}
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder={t.placeholderDescription}
/>
</div>
{/* Type + Discount/Rate */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelType}</label>
<select
className="input-field"
value={form.type}
onChange={(e) => setForm((f) => ({ ...f, type: e.target.value as Offer['type'] }))}
>
<option value="PERCENTAGE">{t.typePERCENTAGE}</option>
<option value="FIXED_AMOUNT">{t.typeFIXED_AMOUNT}</option>
<option value="FREE_DAY">{t.typeFREE_DAY}</option>
<option value="SPECIAL_RATE">{t.typeSPECIAL_RATE}</option>
</select>
</div>
<div>
{form.type === 'SPECIAL_RATE' ? (
<>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelSpecialRate}</label>
<input type="number" min={0} className="input-field" value={form.specialRate} onChange={(e) => setForm((f) => ({ ...f, specialRate: e.target.value }))} />
</>
) : form.type !== 'FREE_DAY' ? (
<>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelDiscount}</label>
<input type="number" min={0} className="input-field" value={form.discountValue} onChange={(e) => setForm((f) => ({ ...f, discountValue: e.target.value }))} />
</>
) : null}
</div>
</div>
{/* Dates */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelValidFrom}</label>
<input type="date" className="input-field" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelValidUntil}</label>
<input type="date" className="input-field" value={form.validUntil} onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
</div>
</div>
{/* Promo code + Max Redemptions */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelPromoCode}</label>
<input className="input-field" value={form.promoCode} onChange={(e) => setForm((f) => ({ ...f, promoCode: e.target.value }))} placeholder={t.placeholderPromoCode} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxRedemptions}</label>
<input type="number" min={1} className="input-field" value={form.maxRedemptions} onChange={(e) => setForm((f) => ({ ...f, maxRedemptions: e.target.value }))} />
</div>
</div>
{/* Min / Max Rental Days */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMinDays}</label>
<input type="number" min={1} className="input-field" value={form.minRentalDays} onChange={(e) => setForm((f) => ({ ...f, minRentalDays: e.target.value }))} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxDays}</label>
<input type="number" min={1} className="input-field" value={form.maxRentalDays} onChange={(e) => setForm((f) => ({ ...f, maxRentalDays: e.target.value }))} />
</div>
</div>
{/* Applies-to-all toggle */}
<div>
<label className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 accent-blue-600"
checked={form.appliesToAll}
onChange={(e) => setForm((f) => ({ ...f, appliesToAll: e.target.checked }))}
/>
{t.labelAppliesToAll}
</label>
</div>
{/* Scoped selection — only shown when not appliesToAll */}
{!form.appliesToAll && (
<div className="space-y-4 rounded-xl border border-slate-200 p-4">
{/* Category chips */}
<div>
<p className="text-xs font-medium text-slate-600 mb-2">{t.labelCategories}</p>
<div className="flex flex-wrap gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => toggleCategory(cat)}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
form.categories.includes(cat)
? 'bg-blue-600 text-white border-blue-600'
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
}`}
>
{t.categoryLabels[cat]}
</button>
))}
</div>
</div>
{/* Vehicle picker */}
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-slate-600">{t.labelVehicles}</p>
{form.vehicleIds.length > 0 && (
<span className="badge-blue">{t.selected(form.vehicleIds.length)}</span>
)}
</div>
{/* Search input */}
<div className="relative mb-2">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-4.35-4.35M17 11A6 6 0 111 11a6 6 0 0116 0z" />
</svg>
<input
className="input-field pl-8 text-xs py-1.5"
value={vehicleSearch}
onChange={(e) => setVehicleSearch(e.target.value)}
placeholder={t.labelVehicleSearch}
/>
</div>
{/* Vehicle list */}
<div className="max-h-52 overflow-y-auto rounded-lg border border-slate-200 divide-y divide-slate-100">
{loadingFleet ? (
<p className="px-4 py-6 text-center text-xs text-slate-400">{t.loadingVehicles}</p>
) : filteredFleet.length === 0 ? (
<p className="px-4 py-6 text-center text-xs text-slate-400">{t.noVehiclesFound}</p>
) : (
filteredFleet.map((v) => {
const checked = form.vehicleIds.includes(v.id)
return (
<label
key={v.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${
checked ? 'bg-blue-50' : 'hover:bg-slate-50'
}`}
>
<input
type="checkbox"
className="h-4 w-4 shrink-0 rounded border-slate-300 accent-blue-600"
checked={checked}
onChange={() => toggleVehicle(v.id)}
/>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-slate-800 truncate">
{v.year} {v.make} {v.model}
</p>
<p className="text-xs text-slate-400">{v.licensePlate} · {t.categoryLabels[v.category as Category] ?? v.category}</p>
</div>
</label>
)
})
)}
</div>
</div>
</div>
)}
{/* Terms */}
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelTerms}</label>
<textarea
className="input-field resize-none"
rows={2}
value={form.termsAndConds}
onChange={(e) => setForm((f) => ({ ...f, termsAndConds: e.target.value }))}
placeholder={t.placeholderTerms}
/>
</div>
{/* Toggles */}
<div className="flex flex-wrap gap-x-6 gap-y-2">
{([
{ key: 'isActive', label: t.labelIsActive },
{ key: 'isPublic', label: t.labelIsPublic },
{ key: 'isFeatured', label: t.labelIsFeatured },
] as const).map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 text-sm text-slate-700 cursor-pointer select-none">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 accent-blue-600"
checked={form[key] as boolean}
onChange={(e) => setForm((f) => ({ ...f, [key]: e.target.checked }))}
/>
{label}
</label>
))}
</div>
{formError && <p className="text-sm text-red-600">{formError}</p>}
<div className={`flex gap-3 pt-2 ${isRtl ? 'flex-row-reverse' : 'justify-end'}`}>
<button type="button" onClick={closeModal} className="btn-secondary">{t.cancel}</button>
<button type="submit" disabled={saving} className="btn-primary">
{saving ? t.saving : t.save}
</button>
</div>
</form>
</div>
</div>
)}
{/* Delete confirmation */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-blue-950/50 p-4">
<div className="card p-6 w-full max-w-sm" dir={isRtl ? 'rtl' : 'ltr'}>
<h3 className="text-base font-semibold text-slate-900 mb-2">{t.deleteOffer}</h3>
<p className="text-sm text-slate-600 mb-5">{t.confirmDelete}</p>
{formError && <p className="text-sm text-red-600 mb-3">{formError}</p>}
<div className={`flex gap-3 ${isRtl ? 'flex-row-reverse' : 'justify-end'}`}>
<button type="button" onClick={() => setDeleteTarget(null)} className="btn-secondary">{t.cancel}</button>
<button type="button" onClick={handleDelete} disabled={deleting} className="btn-danger">
{deleting ? t.deleting : t.delete}
</button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,310 @@
'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 }
}
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',
}
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="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
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>
{/* 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>
)
}
+316
View File
@@ -0,0 +1,316 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { formatCurrency } from '@rentaldrivego/types'
interface DashboardData {
kpis: {
totalBookings: number
activeVehicles: number
monthlyRevenue: number
totalCustomers: number
bookingsChange: number
vehiclesChange: number
revenueChange: number
customersChange: number
}
recentReservations: {
id: string
bookingRef: string
customerName: string
vehicleName: string
startDate: string
endDate: string
status: string
totalAmount: number
}[]
sourceBreakdown: { source: string; count: number; revenue: number }[]
subscription: {
status: string
planName: string
trialEndsAt: string | null
}
}
interface EmployeeProfile {
role?: string
}
const STATUS_COLORS: Record<string, string> = {
CONFIRMED: 'badge-blue',
PENDING: 'badge-amber',
ACTIVE: 'badge-green',
COMPLETED: 'badge-gray',
CANCELLED: 'badge-red',
}
function SubscriptionBanner({
status,
trialEndsAt,
trialEndsMsg,
trialActiveMsg,
pastDueMsg,
suspendedMsg,
manageBillingLabel,
}: {
status: string
trialEndsAt: string | null
trialEndsMsg: (date: string) => string
trialActiveMsg: string
pastDueMsg: string
suspendedMsg: string
manageBillingLabel: string
}) {
if (status === 'ACTIVE') return null
const localeDate = trialEndsAt
? new Date(trialEndsAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
: ''
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
TRIALING: {
bg: 'bg-blue-50 border-blue-200 text-blue-800',
icon: <Clock className="w-4 h-4" />,
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
},
PAST_DUE: {
bg: 'bg-orange-50 border-orange-200 text-orange-800',
icon: <AlertTriangle className="w-4 h-4" />,
message: pastDueMsg,
},
SUSPENDED: {
bg: 'bg-red-50 border-red-200 text-red-800',
icon: <XCircle className="w-4 h-4" />,
message: suspendedMsg,
},
}
const config = configs[status]
if (!config) return null
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl border mb-6 ${config.bg}`}>
{config.icon}
<p className="text-sm font-medium">{config.message}</p>
<Link href="/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
{manageBillingLabel}
</Link>
</div>
)
}
export default function DashboardPage() {
const { dict, language } = useDashboardI18n()
const d = dict.dashboardPage
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<{ code: string; message: string } | null>(null)
const [employeeRole, setEmployeeRole] = useState<string | null>(null)
useEffect(() => {
try {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (!cached) return
const profile = JSON.parse(cached) as EmployeeProfile
setEmployeeRole(profile.role ?? null)
} catch {}
}, [])
useEffect(() => {
apiFetch<DashboardData>('/analytics/dashboard')
.then(setData)
.catch((err) => setError({ code: err.code ?? '', message: err.message }))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
return (
<div className="card p-6 text-center">
<p className="text-slate-500 text-sm">{errorMsg}</p>
</div>
)
}
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
const canViewRevenue = employeeRole !== 'AGENT'
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
return (
<div className="space-y-6">
{data?.subscription && (
<SubscriptionBanner
status={data.subscription.status}
trialEndsAt={data.subscription.trialEndsAt}
trialEndsMsg={d.trialEnds}
trialActiveMsg={d.trialActive}
pastDueMsg={d.pastDueMsg}
suspendedMsg={d.suspendedMsg}
manageBillingLabel={d.manageBilling}
/>
)}
{/* KPI Cards */}
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 ${canViewRevenue ? 'lg:grid-cols-4' : 'lg:grid-cols-3'}`}>
<StatCard
title={d.kpiTotalBookings}
value={kpis.totalBookings.toLocaleString()}
change={kpis.bookingsChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Calendar}
iconColor="text-blue-600"
iconBg="bg-blue-50"
/>
<StatCard
title={d.kpiActiveVehicles}
value={kpis.activeVehicles.toLocaleString()}
change={kpis.vehiclesChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Car}
iconColor="text-green-600"
iconBg="bg-green-50"
/>
{canViewRevenue ? (
<StatCard
title={d.kpiMonthlyRevenue}
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
change={kpis.revenueChange}
vsLastMonthLabel={d.vsLastMonth}
icon={DollarSign}
iconColor="text-orange-600"
iconBg="bg-orange-50"
/>
) : null}
<StatCard
title={d.kpiTotalCustomers}
value={kpis.totalCustomers.toLocaleString()}
change={kpis.customersChange}
vsLastMonthLabel={d.vsLastMonth}
icon={Users}
iconColor="text-purple-600"
iconBg="bg-purple-50"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Booking Sources Chart */}
<div className="card p-6 lg:col-span-2">
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data.sourceBreakdown}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="source" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
/>
<Legend wrapperStyle={{ fontSize: '12px' }} />
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
<Bar dataKey="revenue" fill="#10b981" name={d.barRevenue} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-48 flex items-center justify-center text-slate-400 text-sm">
{d.noBookingData}
</div>
)}
</div>
{/* Quick stats */}
<div className="card p-6">
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.quickStats}</h2>
<div className="space-y-4">
{(data?.sourceBreakdown ?? []).map((item) => (
<div key={item.source} className="flex items-center justify-between">
<span className="text-sm text-slate-600 capitalize">{item.source.toLowerCase()}</span>
<div className="text-right">
<span className="text-sm font-semibold text-slate-900">{item.count}</span>
<p className="text-xs text-slate-400">{formatCurrency(item.revenue, 'MAD')}</p>
</div>
</div>
))}
{(!data?.sourceBreakdown || data.sourceBreakdown.length === 0) && (
<p className="text-sm text-slate-400">{d.noData}</p>
)}
</div>
</div>
</div>
{/* Recent Reservations */}
<div className="card">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2>
<Link href="/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
{d.viewAll}
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(data?.recentReservations ?? []).map((res) => (
<tr key={res.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-3">
<Link href={`/reservations/${res.id}`} className="text-sm font-medium text-blue-600 hover:text-blue-700">
#{res.bookingRef}
</Link>
</td>
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
<td className="px-6 py-3 text-sm text-slate-500">
{formatDate(res.startDate)} {formatDateYear(res.endDate)}
</td>
<td className="px-6 py-3">
<span className={STATUS_COLORS[res.status] ?? 'badge-gray'}>
{d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
</span>
</td>
<td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right">
{formatCurrency(res.totalAmount, 'MAD')}
</td>
</tr>
))}
{(!data?.recentReservations || data.recentReservations.length === 0) && (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-sm text-slate-400">
{d.noReservations}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,206 @@
'use client'
import { useEffect, useState } from 'react'
import { formatCurrency } from '@rentaldrivego/types'
import { API_BASE, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReportRow {
reservationId: string
customerName: string
vehicle: string
startDate: string
endDate: string
totalAmount: number
source: string
paymentStatus: string
}
interface ReportData {
summary: {
totalReservations: number
totalRentalRevenue: number
totalDiscounts: number
totalInsurance: number
totalAdditionalDrivers: number
totalCollected: number
totalPricingRulesAdj?: number
}
rows: ReportRow[]
}
type ReportPeriod = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'ANNUAL'
export default function ReportsPage() {
const { language } = useDashboardI18n()
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
const [report, setReport] = useState<ReportData | null>(null)
const [error, setError] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
const copy = {
en: {
title: 'Reports',
subtitle: 'Accountant-ready exports with totals for insurance, additional drivers, and pricing rules.',
exportCsv: 'Export CSV',
exporting: 'Exporting…',
bookings: 'Bookings',
rental: 'Rental',
insurance: 'Insurance',
drivers: 'Drivers',
discounts: 'Discounts',
collected: 'Collected',
reservation: 'Reservation',
vehicle: 'Vehicle',
period: 'Period',
source: 'Source',
payment: 'Payment',
total: 'Total',
emptyRows: 'No report rows available for this period.',
periodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<ReportPeriod, string>,
},
fr: {
title: 'Rapports',
subtitle: 'Exports prêts pour la comptabilité avec les totaux dassurance, de conducteurs supplémentaires et de règles tarifaires.',
exportCsv: 'Exporter en CSV',
exporting: 'Export en cours…',
bookings: 'Réservations',
rental: 'Location',
insurance: 'Assurance',
drivers: 'Conducteurs',
discounts: 'Remises',
collected: 'Encaissé',
reservation: 'Réservation',
vehicle: 'Véhicule',
period: 'Période',
source: 'Source',
payment: 'Paiement',
total: 'Total',
emptyRows: 'Aucune ligne de rapport pour cette période.',
periodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<ReportPeriod, string>,
},
ar: {
title: 'التقارير',
subtitle: 'ملفات تصدير جاهزة للمحاسبة تشمل إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
exportCsv: 'تصدير CSV',
exporting: 'جارٍ التصدير…',
bookings: 'الحجوزات',
rental: 'الإيجار',
insurance: 'التأمين',
drivers: 'السائقون',
discounts: 'الخصومات',
collected: 'المحصّل',
reservation: 'الحجز',
vehicle: 'المركبة',
period: 'الفترة',
source: 'المصدر',
payment: 'الدفع',
total: 'الإجمالي',
emptyRows: 'لا توجد بيانات تقرير لهذه الفترة.',
periodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<ReportPeriod, string>,
},
}[language]
useEffect(() => {
apiFetch<ReportData>(`/analytics/report?period=${period}`)
.then(setReport)
.catch((err) => setError(err.message))
}, [period])
async function exportCsv() {
setExporting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/analytics/report?period=${period}&format=CSV`, {
credentials: 'include',
})
if (!res.ok) {
const json = await res.json().catch(() => null)
throw new Error(json?.message ?? copy.exportCsv)
}
const csv = await res.text()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = window.URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `rentaldrivego-${period.toLowerCase()}-report.csv`
anchor.click()
window.URL.revokeObjectURL(url)
} catch (err: any) {
setError(err.message ?? copy.exportCsv)
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<div className="flex flex-wrap gap-2">
{(Object.keys(copy.periodLabels) as ReportPeriod[]).map((option) => (
<button
key={option}
onClick={() => setPeriod(option)}
className={`rounded-full px-4 py-2 text-sm font-semibold ${period === option ? 'bg-blue-900 text-white' : 'border border-slate-300 text-slate-700'}`}
>
{copy.periodLabels[option]}
</button>
))}
<button onClick={exportCsv} disabled={exporting} className="rounded-full border border-slate-900 px-4 py-2 text-sm font-semibold text-slate-900">
{exporting ? copy.exporting : copy.exportCsv}
</button>
</div>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{report && (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-6">
<div className="card p-5"><p className="text-sm text-slate-500">{copy.bookings}</p><p className="mt-1 text-2xl font-bold text-slate-900">{report.summary.totalReservations}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.rental}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalRentalRevenue, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.insurance}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalInsurance, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.drivers}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalAdditionalDrivers, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.discounts}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalDiscounts, 'MAD')}</p></div>
<div className="card p-5"><p className="text-sm text-slate-500">{copy.collected}</p><p className="mt-1 text-2xl font-bold text-slate-900">{formatCurrency(report.summary.totalCollected, 'MAD')}</p></div>
</div>
)}
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.reservation}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.period}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.source}</th>
<th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.payment}</th>
<th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{(report?.rows ?? []).map((row) => (
<tr key={row.reservationId}>
<td className="px-6 py-4 text-sm text-slate-700">{row.customerName}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle}</td>
<td className="px-6 py-4 text-sm text-slate-500">{row.startDate} - {row.endDate}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-gray">{row.paymentStatus}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{(report?.rows.length ?? 0) === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{copy.emptyRows}</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,824 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
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
status: string
source: string
startDate: string
endDate: string
totalAmount: number
discountAmount: number
insuranceTotal: number
additionalDriverTotal: number
pricingRulesTotal: number
paymentStatus: string
depositAmount: number
pickupLocation: string | null
returnLocation: string | null
paymentMode: string | null
notes: string | null
contractNumber: string | null
invoiceNumber: string | null
damageChargeAmount: number | null
damageChargeNote: string | null
pricingRulesApplied: { name: string; amount: number; type: string }[] | null
customer: {
firstName: string
lastName: string
email: string
phone: string | null
driverLicense: string | null
licenseImageUrl: string | null
licenseValidationStatus: string
flagged: boolean
}
vehicle: {
make: string
model: string
licensePlate: string
}
insurances: { id: string; policyName: string; totalCharge: number }[]
additionalDrivers: {
id: string
firstName: string
lastName: string
driverLicense: string
totalCharge: number
requiresApproval: boolean
approvedAt: string | null
approvalNote: string | null
}[]
workflow: {
contractGenerated: boolean
closed: boolean
closedAt: string | null
closedBy: string | null
coreEditable: boolean
returnEditable: boolean
checkInInspectionEditable: boolean
checkOutInspectionEditable: boolean
}
}
type EditMode = 'booking' | 'return' | null
type ReservationFormState = {
startDate: string
endDate: string
pickupLocation: string
returnLocation: string
depositAmount: string
paymentMode: string
notes: string
damageChargeAmount: string
damageChargeNote: string
}
const detailCopy = {
en: {
editBooking: 'Edit booking',
saveBooking: 'Save booking',
editReturn: 'Edit return',
saveReturn: 'Save return',
cancelEdit: 'Cancel',
closeReservation: 'Close reservation',
bookingDetails: 'Booking details',
returnDetails: 'Return closing',
startLabel: 'Start date & time',
endLabel: 'End date & time',
pickupLabel: 'Pickup location',
returnLabel: 'Return location',
depositLabel: 'Deposit',
paymentModeLabel: 'Payment mode',
bookingNotesLabel: 'Booking notes',
returnChargeLabel: 'Damage charge',
returnChargeNoteLabel: 'Return note',
paymentModeEmpty: 'Not set',
noLocation: 'Not provided',
failedSave: 'Failed to save reservation',
failedClose: 'Failed to close reservation',
contractLockMessage: 'Contract generated. Booking details are locked and remain view-only until the vehicle is returned.',
activeLockMessage: 'Rental is in progress. Booking details are view-only until the vehicle is returned.',
returnLockMessage: 'Vehicle returned. Only the return section and the check-out inspection can still be edited before closing the reservation.',
closedLockMessage: 'Reservation closed. No further editing is allowed.',
closedMeta: 'Closed',
checkoutSummary: 'Check-out status',
checkoutPending: 'Waiting for return edits',
checkoutReady: 'Return section open',
checkoutClosed: 'Reservation closed',
checkInReadOnly: 'Check-in inspection is only editable after the reservation is confirmed and before return.',
checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.',
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',
saveBooking: 'Enregistrer la réservation',
editReturn: 'Modifier le retour',
saveReturn: 'Enregistrer le retour',
cancelEdit: 'Annuler',
closeReservation: 'Clôturer la réservation',
bookingDetails: 'Détails de la réservation',
returnDetails: 'Clôture du retour',
startLabel: 'Date et heure de départ',
endLabel: 'Date et heure de retour',
pickupLabel: 'Lieu de départ',
returnLabel: 'Lieu de retour',
depositLabel: 'Dépôt',
paymentModeLabel: 'Mode de paiement',
bookingNotesLabel: 'Notes de réservation',
returnChargeLabel: 'Frais de dommage',
returnChargeNoteLabel: 'Note de retour',
paymentModeEmpty: 'Non défini',
noLocation: 'Non renseigné',
failedSave: 'Échec de la sauvegarde de la réservation',
failedClose: 'Échec de la clôture de la réservation',
contractLockMessage: 'Le contrat a été généré. Les détails de la réservation sont verrouillés jusquau retour du véhicule.',
activeLockMessage: 'La location est en cours. Les détails de la réservation sont en lecture seule jusquau retour du véhicule.',
returnLockMessage: 'Le véhicule est de retour. Seule la section de retour et linspection de retour peuvent encore être modifiées avant la clôture.',
closedLockMessage: 'La réservation est clôturée. Aucune autre modification nest autorisée.',
closedMeta: 'Clôturée',
checkoutSummary: 'Statut du retour',
checkoutPending: 'En attente des modifications de retour',
checkoutReady: 'Section retour ouverte',
checkoutClosed: 'Réservation clôturée',
checkInReadOnly: 'Linspection de départ est modifiable après confirmation et avant le retour.',
checkOutReadOnly: 'Linspection de retour devient modifiable une fois le véhicule retourné.',
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: 'تعديل الحجز',
saveBooking: 'حفظ الحجز',
editReturn: 'تعديل الإرجاع',
saveReturn: 'حفظ الإرجاع',
cancelEdit: 'إلغاء',
closeReservation: 'إغلاق الحجز',
bookingDetails: 'تفاصيل الحجز',
returnDetails: 'إقفال الإرجاع',
startLabel: 'تاريخ ووقت البداية',
endLabel: 'تاريخ ووقت النهاية',
pickupLabel: 'موقع الاستلام',
returnLabel: 'موقع الإرجاع',
depositLabel: 'العربون',
paymentModeLabel: 'طريقة الدفع',
bookingNotesLabel: 'ملاحظات الحجز',
returnChargeLabel: 'رسوم الأضرار',
returnChargeNoteLabel: 'ملاحظة الإرجاع',
paymentModeEmpty: 'غير محدد',
noLocation: 'غير متوفر',
failedSave: 'فشل حفظ الحجز',
failedClose: 'فشل إغلاق الحجز',
contractLockMessage: 'تم إنشاء العقد. تفاصيل الحجز مقفلة وتبقى للعرض فقط حتى عودة السيارة.',
activeLockMessage: 'التأجير قيد التنفيذ. تفاصيل الحجز للعرض فقط حتى عودة السيارة.',
returnLockMessage: 'تمت إعادة السيارة. يمكن تعديل قسم الإرجاع وفحص الإرجاع فقط قبل إغلاق الحجز.',
closedLockMessage: 'تم إغلاق الحجز. لا يُسمح بأي تعديل إضافي.',
closedMeta: 'مغلق',
checkoutSummary: 'حالة الإرجاع',
checkoutPending: 'بانتظار تعديلات الإرجاع',
checkoutReady: 'قسم الإرجاع مفتوح',
checkoutClosed: 'تم إغلاق الحجز',
checkInReadOnly: 'يمكن تعديل فحص الاستلام بعد تأكيد الحجز وقبل الإرجاع فقط.',
checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.',
inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.',
licenseImageLabel: 'صورة الرخصة',
noLicenseImage: 'لم يتم رفع صورة الرخصة.',
pickupPhotosTitle: 'صور الاستلام',
dropoffPhotosTitle: 'صور الإرجاع',
pickupPhotosReadOnly: 'تتوفر صور الاستلام بعد تأكيد الحجز.',
dropoffPhotosReadOnly: 'تتوفر صور الإرجاع بعد إعادة السيارة.',
extendBtn: 'تمديد التأجير',
extendTitle: 'تمديد التأجير',
extendNewEnd: 'تاريخ الإرجاع الجديد',
extendReason: 'سبب التمديد',
extendReasonPlaceholder: 'مثال: طلب العميل يومين إضافيين',
extendSubmit: 'تأكيد التمديد',
extendCancel: 'إلغاء',
extendFailed: 'فشل تمديد الحجز',
},
} as const
function toDateTimeInputValue(iso: string) {
const date = new Date(iso)
const offset = date.getTimezoneOffset()
return new Date(date.getTime() - offset * 60_000).toISOString().slice(0, 16)
}
function toFormState(reservation: ReservationDetail): ReservationFormState {
return {
startDate: toDateTimeInputValue(reservation.startDate),
endDate: toDateTimeInputValue(reservation.endDate),
pickupLocation: reservation.pickupLocation ?? '',
returnLocation: reservation.returnLocation ?? '',
depositAmount: String(reservation.depositAmount ?? 0),
paymentMode: reservation.paymentMode ?? '',
notes: reservation.notes ?? '',
damageChargeAmount: reservation.damageChargeAmount !== null && reservation.damageChargeAmount !== undefined ? String(reservation.damageChargeAmount) : '',
damageChargeNote: reservation.damageChargeNote ?? '',
}
}
function toIsoString(value: string) {
return new Date(value).toISOString()
}
export default function ReservationDetailPage() {
const params = useParams<{ id: string }>()
const { dict, language } = useDashboardI18n()
const r = dict.reservations
const copy = detailCopy[language]
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [reservation, setReservation] = useState<ReservationDetail | null>(null)
const [inspections, setInspections] = useState<DamageInspection[]>([])
const [form, setForm] = useState<ReservationFormState | null>(null)
const [editMode, setEditMode] = useState<EditMode>(null)
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 {
const [reservationData, inspectionData] = await Promise.all([
apiFetch<ReservationDetail>(`/reservations/${params.id}`),
apiFetch<DamageInspection[]>(`/reservations/${params.id}/inspections`),
])
setReservation(reservationData)
setInspections(inspectionData)
setForm(toFormState(reservationData))
setEditMode(null)
setError(null)
} catch (err: any) {
setError(err.message ?? r.failedToLoad)
}
}
useEffect(() => {
loadReservation()
}, [params.id])
async function runAction(action: 'confirm' | 'checkin' | 'checkout') {
setActing(true)
setActionError(null)
const errorMsg = action === 'confirm' ? r.failedConfirm : action === 'checkin' ? r.failedCheckIn : r.failedCheckOut
try {
await apiFetch(`/reservations/${params.id}/${action}`, {
method: 'POST',
body: JSON.stringify({}),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? errorMsg)
} finally {
setActing(false)
}
}
async function saveReservation(mode: Exclude<EditMode, null>) {
if (!form) return
setActing(true)
setActionError(null)
try {
const payload = mode === 'booking'
? {
startDate: toIsoString(form.startDate),
endDate: toIsoString(form.endDate),
pickupLocation: form.pickupLocation || null,
returnLocation: form.returnLocation || null,
depositAmount: Number(form.depositAmount || 0),
paymentMode: form.paymentMode || null,
notes: form.notes || null,
}
: {
returnLocation: form.returnLocation || null,
damageChargeAmount: form.damageChargeAmount ? Number(form.damageChargeAmount) : 0,
damageChargeNote: form.damageChargeNote || null,
}
await apiFetch(`/reservations/${params.id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? copy.failedSave)
} finally {
setActing(false)
}
}
async function closeReservation() {
setActing(true)
setActionError(null)
try {
await apiFetch(`/reservations/${params.id}/close`, {
method: 'POST',
body: JSON.stringify({}),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? copy.failedClose)
} finally {
setActing(false)
}
}
async function approveDriver(driverId: string) {
setActing(true)
setActionError(null)
try {
await apiFetch(`/reservations/${params.id}/additional-drivers/${driverId}/approval`, {
method: 'PATCH',
body: JSON.stringify({ approved: true }),
})
await loadReservation()
} catch (err: any) {
setActionError(err.message ?? r.failedApproveDriver)
} finally {
setActing(false)
}
}
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' })
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!reservation || !form) return <div className="card p-6 text-sm text-slate-500">{r.loadingReservation}</div>
const checkinInspection = inspections.find((inspection) => inspection.type === 'CHECKIN')
const checkoutInspection = inspections.find((inspection) => inspection.type === 'CHECKOUT')
const bookingInputsDisabled = editMode !== 'booking'
const returnInputsDisabled = editMode !== 'return'
let workflowMessage = ''
if (reservation.workflow.closed) workflowMessage = copy.closedLockMessage
else if (reservation.workflow.returnEditable) workflowMessage = copy.returnLockMessage
else if (reservation.workflow.contractGenerated) workflowMessage = copy.contractLockMessage
else if (reservation.status === 'ACTIVE') workflowMessage = copy.activeLockMessage
const checkInReadOnlyMessage = reservation.workflow.closed
? copy.inspectionClosed
: copy.checkInReadOnly
const checkOutReadOnlyMessage = reservation.workflow.closed
? copy.inspectionClosed
: copy.checkOutReadOnly
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-xl font-semibold text-slate-900">Reservation {reservation.id.slice(-8).toUpperCase()}</h2>
<p className="mt-1 text-sm text-slate-500">{reservation.source} · {reservation.status} · {reservation.paymentStatus}</p>
{reservation.workflow.closedAt && (
<p className="mt-2 text-xs text-slate-500">
{copy.closedMeta} {formatDate(reservation.workflow.closedAt)}{reservation.workflow.closedBy ? ` · ${reservation.workflow.closedBy}` : ''}
</p>
)}
</div>
<div className="flex flex-wrap gap-3">
{editMode === null && reservation.workflow.coreEditable && (
<button disabled={acting} onClick={() => setEditMode('booking')} className="btn-secondary">
{copy.editBooking}
</button>
)}
{editMode === 'booking' && (
<>
<button disabled={acting} onClick={() => saveReservation('booking')} className="btn-primary">
{acting ? r.working : copy.saveBooking}
</button>
<button disabled={acting} onClick={() => { setForm(toFormState(reservation)); setEditMode(null) }} className="btn-secondary">
{copy.cancelEdit}
</button>
</>
)}
{editMode === null && reservation.workflow.returnEditable && (
<>
<button disabled={acting} onClick={() => setEditMode('return')} className="btn-secondary">
{copy.editReturn}
</button>
<button disabled={acting} onClick={closeReservation} className="btn-primary">
{acting ? r.working : copy.closeReservation}
</button>
</>
)}
{editMode === 'return' && (
<>
<button disabled={acting} onClick={() => saveReservation('return')} className="btn-primary">
{acting ? r.working : copy.saveReturn}
</button>
<button disabled={acting} onClick={() => { setForm(toFormState(reservation)); setEditMode(null) }} className="btn-secondary">
{copy.cancelEdit}
</button>
</>
)}
{reservation.status === 'DRAFT' && (
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
{acting ? r.working : r.confirmReservation}
</button>
)}
{reservation.status === 'CONFIRMED' && (
<button disabled={acting} onClick={() => runAction('checkin')} className="btn-primary">
{acting ? r.working : r.checkInVehicle}
</button>
)}
{reservation.status === 'ACTIVE' && (
<button disabled={acting} onClick={() => runAction('checkout')} className="btn-primary">
{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}
</div>
)}
{actionError && <div className="card p-4 text-sm text-red-600">{actionError}</div>}
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionCustomer}</h3>
<div className="space-y-2 text-sm">
<p className="font-medium text-slate-900">{reservation.customer.firstName} {reservation.customer.lastName}</p>
<p className="text-slate-600">{reservation.customer.email}</p>
<p className="text-slate-600">{reservation.customer.phone ?? r.noPhone}</p>
<p className="text-slate-600">{r.licenseLabel} {reservation.customer.driverLicense ?? r.notCaptured}</p>
<div className="pt-2">
<p className="text-slate-600">{copy.licenseImageLabel}</p>
{reservation.customer.licenseImageUrl ? (
<a href={reservation.customer.licenseImageUrl} target="_blank" rel="noreferrer" className="mt-2 block max-w-sm">
<img
src={reservation.customer.licenseImageUrl}
alt="Driver license"
className="h-40 w-full rounded-xl border border-slate-200 object-cover"
/>
</a>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
</div>
<div className="flex flex-wrap gap-2 pt-2">
<span className="badge-gray">{reservation.customer.licenseValidationStatus}</span>
{reservation.customer.flagged && <span className="badge-red">{r.flaggedBadge}</span>}
</div>
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionVehicle}</h3>
<div className="space-y-2 text-sm text-slate-600">
<p className="font-medium text-slate-900">{reservation.vehicle.make} {reservation.vehicle.model}</p>
<p>{reservation.vehicle.licensePlate}</p>
<p>{formatDate(reservation.startDate)} - {formatDate(reservation.endDate)}</p>
<p>{copy.paymentModeLabel}: <span className="font-medium text-slate-900">{reservation.paymentMode || copy.paymentModeEmpty}</span></p>
</div>
</div>
</div>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{copy.bookingDetails}</h3>
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.startLabel}</label>
<input
type="datetime-local"
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.startDate}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, startDate: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.endLabel}</label>
<input
type="datetime-local"
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.endDate}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, endDate: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.pickupLabel}</label>
<input
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.pickupLocation}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, pickupLocation: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnLabel}</label>
<input
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.returnLocation}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, returnLocation: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.depositLabel}</label>
<input
type="number"
min="0"
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.depositAmount}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, depositAmount: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paymentModeLabel}</label>
<input
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.paymentMode}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, paymentMode: e.target.value } : current)}
/>
</div>
</div>
<div className="mt-4">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.bookingNotesLabel}</label>
<textarea
className="input-field min-h-24 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.notes}
disabled={bookingInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, notes: e.target.value } : current)}
/>
</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">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnLabel}</label>
<input
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.returnLocation}
disabled={returnInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, returnLocation: e.target.value } : current)}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnChargeLabel}</label>
<input
type="number"
min="0"
className="input-field disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.damageChargeAmount}
disabled={returnInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, damageChargeAmount: e.target.value } : current)}
/>
</div>
</div>
<div className="mt-4">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.returnChargeNoteLabel}</label>
<textarea
className="input-field min-h-24 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
value={form.damageChargeNote}
disabled={returnInputsDisabled}
onChange={(e) => setForm((current) => current ? { ...current, damageChargeNote: e.target.value } : current)}
/>
</div>
<div className="mt-4 rounded-2xl border border-slate-200 px-4 py-3 text-sm text-slate-600">
<span className="font-medium text-slate-900">{copy.checkoutSummary}:</span>{' '}
{reservation.workflow.closed
? copy.checkoutClosed
: reservation.workflow.returnEditable
? copy.checkoutReady
: copy.checkoutPending}
</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">
<div><dt className="text-slate-500">{r.chargeDiscount}</dt><dd className="text-slate-900">{formatCurrency(reservation.discountAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeInsurance}</dt><dd className="text-slate-900">{formatCurrency(reservation.insuranceTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeAdditionalDrivers}</dt><dd className="text-slate-900">{formatCurrency(reservation.additionalDriverTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargePricingAdjustments}</dt><dd className="text-slate-900">{formatCurrency(reservation.pricingRulesTotal, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{r.chargeGrandTotal}</dt><dd className="font-semibold text-slate-900">{formatCurrency(reservation.totalAmount, 'MAD')}</dd></div>
<div><dt className="text-slate-500">{copy.returnChargeLabel}</dt><dd className="text-slate-900">{formatCurrency(reservation.damageChargeAmount ?? 0, 'MAD')}</dd></div>
</dl>
{reservation.insurances.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">{r.appliedInsurance}</p>
<div className="space-y-2">
{reservation.insurances.map((insurance) => (
<div key={insurance.id} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
<span className="text-slate-700">{insurance.policyName}</span>
<span className="font-semibold text-slate-900">{formatCurrency(insurance.totalCharge, 'MAD')}</span>
</div>
))}
</div>
</div>
)}
{reservation.pricingRulesApplied && reservation.pricingRulesApplied.length > 0 && (
<div className="mt-5">
<p className="mb-2 text-sm font-semibold text-slate-900">{r.pricingRulesApplied}</p>
<div className="space-y-2">
{reservation.pricingRulesApplied.map((rule) => (
<div key={`${rule.name}-${rule.amount}`} className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3 text-sm">
<span className="text-slate-700">{rule.name}</span>
<span className={rule.amount < 0 ? 'font-semibold text-emerald-700' : 'font-semibold text-orange-700'}>
{rule.amount < 0 ? '-' : '+'}{formatCurrency(Math.abs(rule.amount), 'MAD')}
</span>
</div>
))}
</div>
</div>
)}
</div>
<DamageInspectionCard
reservationId={reservation.id}
type="CHECKIN"
initialInspection={checkinInspection}
editable={reservation.workflow.checkInInspectionEditable}
readOnlyMessage={checkInReadOnlyMessage}
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
/>
<DamageInspectionCard
reservationId={reservation.id}
type="CHECKOUT"
initialInspection={checkoutInspection}
comparisonPoints={checkinInspection?.damagePoints ?? []}
editable={reservation.workflow.checkOutInspectionEditable}
readOnlyMessage={checkOutReadOnlyMessage}
onSaved={(inspection) => setInspections((current) => [...current.filter((item) => item.type !== inspection.type), inspection])}
/>
</div>
<div className="space-y-6">
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionAdditionalDrivers}</h3>
<div className="space-y-3">
{reservation.additionalDrivers.map((driver) => (
<div key={driver.id} className="rounded-2xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-slate-900">{driver.firstName} {driver.lastName}</p>
<p className="text-sm text-slate-500">{r.driverLicenseLabel} {driver.driverLicense}</p>
<p className="mt-1 text-sm text-slate-600">{r.driverChargeLabel} {formatCurrency(driver.totalCharge, 'MAD')}</p>
</div>
{driver.requiresApproval && !driver.approvedAt ? (
<button onClick={() => approveDriver(driver.id)} disabled={acting} className="rounded-full bg-orange-600 px-4 py-2 text-xs font-semibold text-white">
{r.approveBtn}
</button>
) : (
<span className={driver.approvedAt ? 'badge-green' : 'badge-gray'}>
{driver.approvedAt ? r.approvedBadge : r.noApprovalNeeded}
</span>
)}
</div>
{driver.approvalNote && <p className="mt-2 text-xs text-orange-700">{driver.approvalNote}</p>}
</div>
))}
{reservation.additionalDrivers.length === 0 && (
<div className="text-sm text-slate-400">{r.noAdditionalDrivers}</div>
)}
</div>
</div>
<div className="card p-6">
<h3 className="mb-4 text-base font-semibold text-slate-900">{r.sectionInspectionSummary}</h3>
<div className="space-y-3 text-sm">
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{r.checkInInspectionLabel}</span>
<span className={checkinInspection ? 'badge-green' : 'badge-gray'}>{checkinInspection ? r.savedBadge : r.pendingBadge}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{r.checkOutInspectionLabel}</span>
<span className={checkoutInspection ? 'badge-green' : 'badge-gray'}>{checkoutInspection ? r.savedBadge : r.pendingBadge}</span>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 px-4 py-3">
<span className="text-slate-600">{copy.returnLabel}</span>
<span className="font-medium text-slate-900">{reservation.returnLocation ?? copy.noLocation}</span>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,808 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
type Customer = {
id: string
firstName: string
lastName: string
email: string
phone?: string | null
driverLicense?: string | null
dateOfBirth?: string | null
nationality?: string | null
address?: Record<string, unknown> | null
licenseExpiry?: string | null
licenseIssuedAt?: string | null
licenseCountry?: string | null
licenseNumber?: string | null
licenseCategory?: string | null
licenseImageUrl?: string | null
}
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
type AdditionalDriverForm = {
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
driverLicense: string
licenseExpiry: string
licenseIssuedAt: string
dateOfBirth: string
nationality: BilingualField
}
type CreatedReservation = { id: string }
export default function NewReservationPage() {
const { language } = useDashboardI18n()
const router = useRouter()
const [customers, setCustomers] = useState<Customer[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [customerId, setCustomerId] = useState('')
const [customerSearch, setCustomerSearch] = useState('')
const [vehicleId, setVehicleId] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [pickupLocation, setPickupLocation] = useState('')
const [returnLocation, setReturnLocation] = useState('')
const [depositAmount, setDepositAmount] = useState('0')
const [paymentMode, setPaymentMode] = useState('CASH')
const [notes, setNotes] = useState('')
const [showAddCustomer, setShowAddCustomer] = useState(false)
const [savingCustomer, setSavingCustomer] = useState(false)
const [includeAdditionalDriver, setIncludeAdditionalDriver] = useState(false)
const [newCustomerFirstName, setNewCustomerFirstName] = useState<BilingualField>(emptyBilingual())
const [newCustomerLastName, setNewCustomerLastName] = useState<BilingualField>(emptyBilingual())
const [newCustomerEmail, setNewCustomerEmail] = useState('')
const [newCustomerPhone, setNewCustomerPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
const [customerDateOfBirth, setCustomerDateOfBirth] = useState('')
const [customerNationality, setCustomerNationality] = useState('')
const [customerFullAddress, setCustomerFullAddress] = useState('')
const [customerIdentityDocumentNumber, setCustomerIdentityDocumentNumber] = useState('')
const [customerInternationalLicenseNumber, setCustomerInternationalLicenseNumber] = useState('')
const [licenseExpiry, setLicenseExpiry] = useState('')
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [licenseImageUrl, setLicenseImageUrl] = useState<string | null>(null)
const [licenseImageFile, setLicenseImageFile] = useState<File | null>(null)
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: emptyBilingual(),
})
const copy = {
en: {
title: 'Local Booking',
subtitle: 'Create a reservation directly from workspace without a previous online reservation.',
customer: 'Customer',
searchCustomer: 'Search previous customer',
vehicle: 'Vehicle',
startDate: 'Start date & time',
endDate: 'End date & time',
pickup: 'Pickup location',
return: 'Return location',
deposit: 'Deposit amount (MAD)',
paymentMode: 'Payment mode',
renterIdentity: 'Renter identity',
driverLicenseInfo: 'Driver license information',
driverLicense: 'Driver license number',
licenseExpiry: 'License expiry',
licenseIssuedAt: 'License issued at',
licenseCountry: 'License country',
licenseCategory: 'License category',
fullAddress: 'Full address',
identityDocumentNumber: 'CIN / Passport number',
internationalLicenseNumber: 'International permit number',
licenseImage: 'Driver license image',
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
licenseImageSelected: 'Selected file',
licenseImageCurrent: 'Current image',
noLicenseImage: 'No license image uploaded yet.',
addAdditionalDriver: 'Add additional driver',
additionalDriverInfo: 'Additional driver',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
notes: 'Notes',
notesPlaceholder: 'Optional notes…',
create: 'Create booking',
creating: 'Creating…',
cancel: 'Cancel',
loadFailed: 'Failed to load customers/vehicles.',
invalidDates: 'End date must be after start date.',
required: 'Please fill all required fields.',
selectCustomer: 'Select customer…',
addCustomer: 'Add customer',
addCustomerTitle: 'Add new customer',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
saveCustomer: 'Save customer',
savingCustomer: 'Saving customer…',
selectVehicle: 'Select vehicle…',
noVehicles: 'No available vehicles.',
paymentModes: {
CASH: 'Cash',
CARD: 'Card',
BANK_TRANSFER: 'Bank transfer',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
fr: {
title: 'Réservation locale',
subtitle: 'Créez une réservation directement depuis lespace, sans réservation en ligne préalable.',
customer: 'Client',
searchCustomer: 'Rechercher un client existant',
vehicle: 'Véhicule',
startDate: 'Date et heure de départ',
endDate: 'Date et heure de retour',
pickup: 'Lieu de départ',
return: 'Lieu de retour',
deposit: 'Montant du dépôt (MAD)',
paymentMode: 'Mode de paiement',
renterIdentity: 'Identité du locataire',
driverLicenseInfo: 'Informations du permis',
driverLicense: 'Numéro du permis',
licenseExpiry: 'Expiration du permis',
licenseIssuedAt: 'Permis délivré le',
licenseCountry: 'Pays du permis',
licenseCategory: 'Catégorie du permis',
fullAddress: 'Adresse complète',
identityDocumentNumber: 'N° CIN / passeport',
internationalLicenseNumber: 'N° de permis international',
licenseImage: 'Image du permis',
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
licenseImageSelected: 'Fichier sélectionné',
licenseImageCurrent: 'Image actuelle',
noLicenseImage: 'Aucune image de permis téléversée.',
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
additionalDriverInfo: 'Conducteur supplémentaire',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
notes: 'Notes',
notesPlaceholder: 'Notes optionnelles…',
create: 'Créer la réservation',
creating: 'Création…',
cancel: 'Annuler',
loadFailed: 'Échec du chargement des clients/véhicules.',
invalidDates: 'La date de fin doit être après la date de début.',
required: 'Veuillez remplir tous les champs requis.',
selectCustomer: 'Sélectionner un client…',
addCustomer: 'Ajouter un client',
addCustomerTitle: 'Ajouter un nouveau client',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone',
saveCustomer: 'Enregistrer le client',
savingCustomer: 'Enregistrement du client…',
selectVehicle: 'Sélectionner un véhicule…',
noVehicles: 'Aucun véhicule disponible.',
paymentModes: {
CASH: 'Espèces',
CARD: 'Carte',
BANK_TRANSFER: 'Virement',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
ar: {
title: 'حجز محلي',
subtitle: 'أنشئ حجزًا مباشرة من مساحة العمل بدون حجز إلكتروني مسبق.',
customer: 'العميل',
searchCustomer: 'ابحث عن عميل سابق',
vehicle: 'المركبة',
startDate: 'تاريخ ووقت البداية',
endDate: 'تاريخ ووقت النهاية',
pickup: 'موقع الاستلام',
return: 'موقع التسليم',
deposit: 'مبلغ العربون (MAD)',
paymentMode: 'طريقة الدفع',
renterIdentity: 'بيانات المستأجر',
driverLicenseInfo: 'معلومات رخصة القيادة',
driverLicense: 'رقم رخصة القيادة',
licenseExpiry: 'تاريخ انتهاء الرخصة',
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseCountry: 'بلد الرخصة',
licenseCategory: 'فئة الرخصة',
fullAddress: 'العنوان الكامل',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
internationalLicenseNumber: 'رقم الرخصة الدولية',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
licenseImageCurrent: 'الصورة الحالية',
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
addAdditionalDriver: 'إضافة سائق إضافي',
additionalDriverInfo: 'السائق الإضافي',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
notes: 'ملاحظات',
notesPlaceholder: 'ملاحظات اختيارية…',
create: 'إنشاء الحجز',
creating: 'جارٍ الإنشاء…',
cancel: 'إلغاء',
loadFailed: 'فشل تحميل العملاء/المركبات.',
invalidDates: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
required: 'يرجى تعبئة جميع الحقول المطلوبة.',
selectCustomer: 'اختر عميلًا…',
addCustomer: 'إضافة عميل',
addCustomerTitle: 'إضافة عميل جديد',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
saveCustomer: 'حفظ العميل',
savingCustomer: 'جارٍ حفظ العميل…',
selectVehicle: 'اختر مركبة…',
noVehicles: 'لا توجد مركبات متاحة.',
paymentModes: {
CASH: 'نقدا',
CARD: 'بطاقة',
BANK_TRANSFER: 'تحويل بنكي',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
}[language]
useEffect(() => {
Promise.all([
apiFetch<Customer[]>('/customers?pageSize=100'),
apiFetch<Vehicle[]>('/vehicles?pageSize=100'),
])
.then(([c, v]) => {
setCustomers(c ?? [])
setVehicles(v ?? [])
})
.catch((err) => setError(err.message ?? copy.loadFailed))
.finally(() => setLoading(false))
}, [])
const filteredCustomers = (() => {
const q = customerSearch.trim().toLowerCase()
if (!q) return customers
return customers.filter((c) =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q),
)
})()
const availableVehicles = vehicles.filter((v) => v.status === 'AVAILABLE')
const canSubmit = !!customerId && !!vehicleId && !!startDate && !!endDate
function readCustomerAddressValue(customer: Customer | undefined, key: string) {
const address = customer?.address
if (!address || typeof address !== 'object' || Array.isArray(address)) return ''
const value = address[key]
return typeof value === 'string' ? value : ''
}
useEffect(() => {
const selected = customers.find((customer) => customer.id === customerId)
if (!selected) return
setDriverLicense(selected.driverLicense ?? selected.licenseNumber ?? '')
setCustomerDateOfBirth(selected.dateOfBirth ? selected.dateOfBirth.slice(0, 10) : '')
setCustomerNationality(selected.nationality ?? '')
setCustomerFullAddress(readCustomerAddressValue(selected, 'fullAddress'))
setCustomerIdentityDocumentNumber(readCustomerAddressValue(selected, 'identityDocumentNumber'))
setCustomerInternationalLicenseNumber(readCustomerAddressValue(selected, 'internationalLicenseNumber'))
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
setLicenseCountry(selected.licenseCountry ?? '')
setLicenseCategory(selected.licenseCategory ?? '')
setLicenseImageUrl(selected.licenseImageUrl ?? null)
setLicenseImageFile(null)
}, [customerId, customers])
useEffect(() => {
if (!licenseImageFile) {
setLicenseImagePreviewUrl(null)
return
}
const objectUrl = URL.createObjectURL(licenseImageFile)
setLicenseImagePreviewUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [licenseImageFile])
async function uploadLicenseImage(customerIdToUpdate: string) {
if (!licenseImageFile) return null
const formData = new FormData()
formData.append('file', licenseImageFile)
const updated = await apiFetch<Customer>(`/customers/${customerIdToUpdate}/license-image`, {
method: 'POST',
body: formData,
})
setCustomers((prev) => prev.map((customer) => customer.id === updated.id ? { ...customer, ...updated } : customer))
setLicenseImageUrl(updated.licenseImageUrl ?? null)
setLicenseImageFile(null)
return updated.licenseImageUrl ?? null
}
async function addCustomer() {
setError(null)
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
setError(copy.required)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
setSavingCustomer(true)
try {
const created = await apiFetch<Customer>('/customers', {
method: 'POST',
body: JSON.stringify({
firstName: bilingualPrimary(newCustomerFirstName).trim(),
firstNameAr: newCustomerFirstName.ar.trim() || undefined,
lastName: bilingualPrimary(newCustomerLastName).trim(),
lastNameAr: newCustomerLastName.ar.trim() || undefined,
email: newCustomerEmail.trim(),
phone: newCustomerPhone.trim(),
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
setCustomers((prev) => [created, ...prev])
setCustomerId(created.id)
setCustomerSearch(`${bilingualPrimary(newCustomerFirstName)} ${bilingualPrimary(newCustomerLastName)}`)
setShowAddCustomer(false)
setNewCustomerFirstName(emptyBilingual())
setNewCustomerLastName(emptyBilingual())
setNewCustomerEmail('')
setNewCustomerPhone('')
setLicenseImageUrl(created.licenseImageUrl ?? null)
if (licenseImageFile) {
await uploadLicenseImage(created.id)
}
} catch (err: any) {
setError(err.message)
} finally {
setSavingCustomer(false)
}
}
async function submit() {
setError(null)
if (!canSubmit) {
setError(copy.required)
return
}
const start = new Date(startDate)
const end = new Date(endDate)
if (end <= start) {
setError(copy.invalidDates)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
if (includeAdditionalDriver && (!bilingualPrimary(additionalDriver.firstName).trim() || !bilingualPrimary(additionalDriver.lastName).trim() || !additionalDriver.driverLicense.trim())) {
setError(copy.required)
return
}
setSaving(true)
try {
await apiFetch(`/customers/${customerId}`, {
method: 'PATCH',
body: JSON.stringify({
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
if (licenseImageFile) {
await uploadLicenseImage(customerId)
}
const created = await apiFetch<CreatedReservation>('/reservations', {
method: 'POST',
body: JSON.stringify({
customerId,
vehicleId,
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: pickupLocation || undefined,
returnLocation: returnLocation || undefined,
depositAmount: Number.isFinite(Number(depositAmount)) ? Math.max(0, Math.round(Number(depositAmount))) : 0,
paymentMode,
additionalDrivers: includeAdditionalDriver ? [{
firstName: bilingualPrimary(additionalDriver.firstName).trim(),
firstNameAr: additionalDriver.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(additionalDriver.lastName).trim(),
lastNameAr: additionalDriver.lastName.ar.trim() || undefined,
email: additionalDriver.email.trim() || undefined,
phone: additionalDriver.phone.trim() || undefined,
driverLicense: additionalDriver.driverLicense.trim(),
licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined,
licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined,
dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined,
nationality: bilingualPrimary(additionalDriver.nationality).trim() || undefined,
nationalityAr: additionalDriver.nationality.ar.trim() || undefined,
}] : [],
notes: notes || undefined,
}),
})
router.push(`/reservations/${created.id}`)
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
</div>
{error ? <div className="card p-4 text-sm text-red-700">{error}</div> : null}
<div className="card p-6 space-y-4">
{loading ? (
<p className="text-sm text-slate-500">Loading</p>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.customer} <span className="text-red-600">*</span></span>
<input
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
placeholder={copy.searchCustomer}
className="input-field mb-2"
/>
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)} className="input-field">
<option value="">{copy.selectCustomer}</option>
{filteredCustomers.map((c) => (
<option key={c.id} value={c.id}>{c.firstName} {c.lastName} · {c.email}</option>
))}
</select>
<button
type="button"
className="text-xs font-semibold text-blue-700 hover:underline mt-1"
onClick={() => setShowAddCustomer((v) => !v)}
>
{copy.addCustomer}
</button>
{showAddCustomer ? (
<div className="mt-2 rounded-lg border border-slate-200 p-3 space-y-2 bg-slate-50">
<p className="text-xs font-semibold text-slate-700">{copy.addCustomerTitle}</p>
<BilingualInput label={copy.firstName} required value={newCustomerFirstName} onChange={setNewCustomerFirstName} />
<BilingualInput label={copy.lastName} required value={newCustomerLastName} onChange={setNewCustomerLastName} />
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email} <span className="text-red-600">*</span></span>
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} className="input-field" />
</div>
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone} <span className="text-red-600">*</span></span>
<input required value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} className="input-field" />
</div>
<div className="flex justify-end">
<button type="button" className="btn-secondary" onClick={addCustomer} disabled={savingCustomer}>
{savingCustomer ? copy.savingCustomer : copy.saveCustomer}
</button>
</div>
</div>
) : null}
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.vehicle} <span className="text-red-600">*</span></span>
<select value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} className="input-field">
<option value="">{copy.selectVehicle}</option>
{availableVehicles.map((v) => (
<option key={v.id} value={v.id}>{v.make} {v.model} · {v.licensePlate}</option>
))}
</select>
{vehicles.length === 0 ? (
<span className="text-xs text-orange-700">{copy.noVehicles}</span>
) : availableVehicles.length === 0 ? (
<span className="text-xs text-orange-700">
{language === 'fr'
? 'Des véhicules existent dans la flotte, mais aucun nest marqué Disponible.'
: language === 'ar'
? 'توجد مركبات في الأسطول، لكن لا توجد مركبة بحالة متاحة.'
: 'Vehicles exist in fleet, but none are marked Available.'}
</span>
) : null}
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.renterIdentity}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth} <span className="text-red-600">*</span></span>
<input type="date" value={customerDateOfBirth} onChange={(e) => setCustomerDateOfBirth(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.nationality} <span className="text-red-600">*</span></span>
<input value={customerNationality} onChange={(e) => setCustomerNationality(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.identityDocumentNumber} <span className="text-red-600">*</span></span>
<input value={customerIdentityDocumentNumber} onChange={(e) => setCustomerIdentityDocumentNumber(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.internationalLicenseNumber}</span>
<input value={customerInternationalLicenseNumber} onChange={(e) => setCustomerInternationalLicenseNumber(e.target.value)} className="input-field" />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.fullAddress} <span className="text-red-600">*</span></span>
<textarea value={customerFullAddress} onChange={(e) => setCustomerFullAddress(e.target.value)} className="input-field min-h-[88px]" />
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.driverLicenseInfo}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input value={driverLicense} onChange={(e) => setDriverLicense(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCountry} <span className="text-red-600">*</span></span>
<input value={licenseCountry} onChange={(e) => setLicenseCountry(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt} <span className="text-red-600">*</span></span>
<input type="date" value={licenseIssuedAt} onChange={(e) => setLicenseIssuedAt(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry} <span className="text-red-600">*</span></span>
<input type="date" value={licenseExpiry} onChange={(e) => setLicenseExpiry(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCategory} <span className="text-red-600">*</span></span>
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
</label>
</div>
<div className="space-y-2">
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
<input
type="file"
accept="image/*"
onChange={(e) => setLicenseImageFile(e.target.files?.[0] ?? null)}
className="input-field"
/>
</label>
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
{licenseImageFile ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {licenseImageFile.name}</p>
) : licenseImageUrl ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
{licenseImagePreviewUrl || licenseImageUrl ? (
<img
src={licenseImagePreviewUrl ?? licenseImageUrl ?? ''}
alt="Driver license preview"
className="h-40 w-full max-w-sm rounded-xl border border-slate-200 object-cover"
/>
) : null}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.startDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.endDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.pickup}</span>
<input value={pickupLocation} onChange={(e) => setPickupLocation(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.return}</span>
<input value={returnLocation} onChange={(e) => setReturnLocation(e.target.value)} className="input-field" />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.deposit}</span>
<input type="number" min={0} value={depositAmount} onChange={(e) => setDepositAmount(e.target.value)} className="input-field" />
</label>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.paymentMode}</span>
<select value={paymentMode} onChange={(e) => setPaymentMode(e.target.value)} className="input-field">
{(['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const).map((mode) => (
<option key={mode} value={mode}>{copy.paymentModes[mode]}</option>
))}
</select>
</label>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={includeAdditionalDriver}
onChange={(e) => setIncludeAdditionalDriver(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600"
/>
<span className="text-sm font-medium text-slate-900">{copy.addAdditionalDriver}</span>
</label>
{includeAdditionalDriver ? (
<div className="space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
<BilingualInput label={copy.firstName} required value={additionalDriver.firstName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, firstName: v }))} />
<BilingualInput label={copy.lastName} required value={additionalDriver.lastName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, lastName: v }))} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email}</span>
<input
value={additionalDriver.email}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, email: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone}</span>
<input
value={additionalDriver.phone}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))}
className="input-field"
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input
value={additionalDriver.driverLicense}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
className="input-field"
/>
</label>
<div className="space-y-1">
<BilingualInput label={copy.nationality} value={additionalDriver.nationality} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, nationality: v }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth}</span>
<input
type="date"
value={additionalDriver.dateOfBirth}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt}</span>
<input
type="date"
value={additionalDriver.licenseIssuedAt}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry}</span>
<input
type="date"
value={additionalDriver.licenseExpiry}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))}
className="input-field"
/>
</label>
</div>
</div>
) : null}
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
</label>
<div className="pt-2 flex items-center justify-end gap-3">
<button type="button" className="btn-secondary" onClick={() => router.push('/reservations')}>
{copy.cancel}
</button>
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
{saving ? copy.creating : copy.create}
</button>
</div>
</>
)}
</div>
</div>
)
}
@@ -0,0 +1,114 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface ReservationRow {
id: string
status: string
source: string
startDate: string
endDate: string
totalAmount: number
totalDays: number
contractNumber?: string | null
workflow?: {
contractGenerated: boolean
closed: boolean
coreEditable: boolean
returnEditable: boolean
}
vehicle: { make: string; model: string }
customer: { firstName: string; lastName: string; email: string }
}
export default function ReservationsPage() {
const { dict, language } = useDashboardI18n()
const r = dict.reservations
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [rows, setRows] = useState<ReservationRow[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setError(err.message))
}, [])
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
const formatDateYear = (iso: string) =>
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric', year: 'numeric' })
const reservationActionLabel = (row: ReservationRow) => {
if (row.workflow?.returnEditable) {
return language === 'fr' ? 'Retour / clôture' : language === 'ar' ? 'الإرجاع / الإغلاق' : 'Return / close'
}
if (row.workflow?.coreEditable) {
return language === 'fr' ? 'Modifier la réservation' : language === 'ar' ? 'تعديل الحجز' : 'Edit reservation'
}
return language === 'fr' ? 'Voir la réservation' : language === 'ar' ? 'عرض الحجز' : 'View reservation'
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-slate-900">{r.heading}</h2>
<p className="text-sm text-slate-500 mt-1">{r.subtitle}</p>
</div>
<Link href="/reservations/new" className="btn-primary whitespace-nowrap">
{language === 'fr' ? 'Réserver une voiture' : language === 'ar' ? 'حجز سيارة' : 'Book car'}
</Link>
</div>
<div className="card overflow-hidden">
{error ? (
<div className="p-8 text-sm text-red-600">{error}</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colVehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colDates}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colSource}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colStatus}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{r.colTotal}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4">
<Link href={`/reservations/${row.id}`} className="text-sm font-semibold text-slate-900 hover:text-blue-700">
{row.customer.firstName} {row.customer.lastName}
</Link>
<p className="text-xs text-slate-500">{row.customer.email}</p>
<Link href={`/reservations/${row.id}`} className="mt-1 inline-block text-xs font-semibold text-blue-700 hover:underline">
{reservationActionLabel(row)}
</Link>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.vehicle.make} {row.vehicle.model}</td>
<td className="px-6 py-4 text-sm text-slate-500">{formatDate(row.startDate)} - {formatDateYear(row.endDate)}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.source}</td>
<td className="px-6 py-4"><span className="badge-blue">{row.status}</span></td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-sm text-slate-400">{r.noReservations}</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}
@@ -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>
)
}
@@ -0,0 +1,830 @@
'use client'
import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface BrandSettings {
displayName: string
tagline: string | null
primaryColor: string
accentColor?: string | null
publicEmail: string | null
publicPhone: string | null
publicAddress?: string | null
publicCity: string | null
publicCountry?: string | null
subdomain: string
logoUrl?: string | null
heroImageUrl?: string | null
websiteUrl?: string | null
whatsappNumber?: string | null
paypalEmail: string | null
amanpayMerchantId: string | null
amanpaySecretKey?: string | null
paypalMerchantId?: string | null
customDomain?: string | null
customDomainVerified?: boolean
defaultLocale?: string
defaultCurrency?: string
isListedOnMarketplace: boolean
}
interface ContractSettings {
fuelPolicyType: string
fuelPolicyNote: string | null
additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT'
additionalDriverDailyRate: number
additionalDriverFlatRate: number
damagePolicy: string
}
interface InsurancePolicy {
id: string
name: string
type: string
chargeType: string
chargeValue: number
isRequired: boolean
isActive: boolean
}
interface PricingRule {
id: string
name: string
type: string
condition: string
conditionValue: number
adjustmentType: string
adjustmentValue: number
isActive: boolean
}
interface AccountingSettings {
reportingPeriod: string
accountantEmail: string | null
accountantName: string | null
autoSendReport: boolean
reportFormat: string
}
const emptyInsurance = { name: '', type: 'BASIC', chargeType: 'PER_DAY', chargeValue: 0, isRequired: false, isActive: true }
const emptyRule = { name: '', type: 'SURCHARGE', condition: 'AGE_LESS_THAN', conditionValue: 25, adjustmentType: 'PERCENTAGE', adjustmentValue: 0, isActive: true }
export default function SettingsPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Advanced settings',
subtitle: 'Manage insurance, additional-driver rules, pricing adjustments, and reporting defaults.',
save: 'Saving…',
saveBrand: 'Save brand',
brandProfile: 'Brand and public profile',
brandProfileDesc: 'Control how your company appears on the marketplace and public booking site.',
payments: 'Rental payment methods',
paymentsDesc: 'Configure how renters pay your company on the public booking site.',
savePayments: 'Save payments',
customDomain: 'Custom domain',
customDomainDesc: 'Point your own domain to the branded booking site.',
saveDomain: 'Save domain',
removeDomain: 'Remove custom domain',
contractPolicies: 'Contract and driver policies',
savePolicies: 'Save policies',
insurancePolicies: 'Insurance policies',
pricingRules: 'Pricing rules',
accounting: 'Accounting and exports',
saveAccounting: 'Save accounting',
displayName: 'Display name',
tagline: 'Tagline',
publicEmail: 'Public email',
publicPhone: 'Public phone',
city: 'City',
country: 'Country',
websiteUrl: 'Website URL',
contractLanguage: 'Contract language',
whatsapp: 'WhatsApp number',
brandColor: 'Brand color',
listedMarketplace: 'Listed on marketplace',
logoUpload: 'Logo upload',
heroUpload: 'Hero image upload',
uploading: 'Uploading…',
logoUploaded: 'Logo uploaded',
noLogo: 'No logo uploaded yet',
heroUploaded: 'Hero image uploaded',
noHero: 'No hero image uploaded yet',
subdomain: 'Subdomain',
customDomainLabel: 'Custom domain',
domainPlaceholder: 'cars.example.com',
status: 'Status',
verified: 'Verified',
pendingDns: 'Pending DNS verification',
notConfigured: 'Not configured',
fuelPolicyType: 'Fuel policy type',
additionalDriverCharge: 'Additional driver charge',
dailyDriverRate: 'Daily driver rate',
flatDriverRate: 'Flat driver rate',
fuelPolicyNote: 'Fuel policy note',
damagePolicy: 'Damage policy',
active: 'Active',
inactive: 'Inactive',
noInsurance: 'No insurance policies configured yet.',
policyName: 'Policy name',
chargeValue: 'Charge value',
required: 'Required',
addPolicy: 'Add policy',
noRules: 'No pricing rules configured yet.',
ruleName: 'Rule name',
addRule: 'Add rule',
reportingPeriod: 'Reporting period',
reportFormat: 'Report format',
accountantName: 'Accountant name',
accountantEmail: 'Accountant email',
autoSend: 'Auto-send reports to the accountant',
amanpayMerchantId: 'AmanPay merchant ID',
amanpaySecretKey: 'AmanPay secret key',
paypalEmail: 'PayPal business email',
noPaymentConfigured: 'If no payment method is configured, renters can still submit reservation requests and pay on pickup.',
fuelPolicyLabels: { FULL_TO_FULL: 'Full to full', FULL_TO_EMPTY: 'Full to empty', SAME_TO_SAME: 'Same to same', PREPAID: 'Prepaid', FREE: 'Included' } as Record<string, string>,
driverChargeLabels: { FREE: 'Free', PER_DAY: 'Per day', FLAT: 'Flat' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Weekly', MONTHLY: 'Monthly', QUARTERLY: 'Quarterly', ANNUAL: 'Annual' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Both' } as Record<string, string>,
},
fr: {
title: 'Paramètres avancés',
subtitle: 'Gérez les assurances, règles conducteurs, ajustements tarifaires et paramètres de reporting.',
save: 'Enregistrement…',
saveBrand: 'Enregistrer la marque',
brandProfile: 'Marque et profil public',
brandProfileDesc: 'Contrôlez laffichage de votre entreprise sur la marketplace et le site de réservation.',
payments: 'Méthodes de paiement location',
paymentsDesc: 'Configurez comment les clients paient votre entreprise.',
savePayments: 'Enregistrer les paiements',
customDomain: 'Domaine personnalisé',
customDomainDesc: 'Pointez votre propre domaine vers le site de réservation.',
saveDomain: 'Enregistrer le domaine',
removeDomain: 'Supprimer le domaine',
contractPolicies: 'Contrat et politiques conducteur',
savePolicies: 'Enregistrer les politiques',
insurancePolicies: 'Polices dassurance',
pricingRules: 'Règles tarifaires',
accounting: 'Comptabilité et exports',
saveAccounting: 'Enregistrer la comptabilité',
displayName: 'Nom affiché',
tagline: 'Slogan',
publicEmail: 'E-mail public',
publicPhone: 'Téléphone public',
city: 'Ville',
country: 'Pays',
websiteUrl: 'URL du site',
contractLanguage: 'Langue du contrat',
whatsapp: 'Numéro WhatsApp',
brandColor: 'Couleur de marque',
listedMarketplace: 'Publié sur la marketplace',
logoUpload: 'Logo',
heroUpload: 'Image principale',
uploading: 'Téléversement…',
logoUploaded: 'Logo téléversé',
noLogo: 'Aucun logo téléversé',
heroUploaded: 'Image principale téléversée',
noHero: 'Aucune image principale téléversée',
subdomain: 'Sous-domaine',
customDomainLabel: 'Domaine personnalisé',
domainPlaceholder: 'voitures.exemple.com',
status: 'Statut',
verified: 'Vérifié',
pendingDns: 'Vérification DNS en attente',
notConfigured: 'Non configuré',
fuelPolicyType: 'Type de politique carburant',
additionalDriverCharge: 'Supplément conducteur additionnel',
dailyDriverRate: 'Tarif conducteur/jour',
flatDriverRate: 'Tarif conducteur fixe',
fuelPolicyNote: 'Note sur la politique carburant',
damagePolicy: 'Politique dommages',
active: 'Actif',
inactive: 'Inactif',
noInsurance: 'Aucune police dassurance configurée.',
policyName: 'Nom de la police',
chargeValue: 'Valeur de facturation',
required: 'Obligatoire',
addPolicy: 'Ajouter la police',
noRules: 'Aucune règle tarifaire configurée.',
ruleName: 'Nom de la règle',
addRule: 'Ajouter la règle',
reportingPeriod: 'Période de reporting',
reportFormat: 'Format de rapport',
accountantName: 'Nom du comptable',
accountantEmail: 'E-mail du comptable',
autoSend: 'Envoyer automatiquement les rapports au comptable',
amanpayMerchantId: 'ID marchand AmanPay',
amanpaySecretKey: 'Clé secrète AmanPay',
paypalEmail: 'Email business PayPal',
noPaymentConfigured: 'Si aucun moyen de paiement nest configuré, les clients peuvent envoyer une demande puis payer à la prise du véhicule.',
fuelPolicyLabels: { FULL_TO_FULL: 'Plein à plein', FULL_TO_EMPTY: 'Plein à vide', SAME_TO_SAME: 'Même niveau', PREPAID: 'Prépayé', FREE: 'Inclus' } as Record<string, string>,
driverChargeLabels: { FREE: 'Gratuit', PER_DAY: 'Par jour', FLAT: 'Forfait' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'Hebdomadaire', MONTHLY: 'Mensuel', QUARTERLY: 'Trimestriel', ANNUAL: 'Annuel' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'Les deux' } as Record<string, string>,
},
ar: {
title: 'الإعدادات المتقدمة',
subtitle: 'إدارة التأمين وقواعد السائق الإضافي وتعديلات التسعير وإعدادات التقارير.',
save: 'جارٍ الحفظ…',
saveBrand: 'حفظ العلامة',
brandProfile: 'العلامة والملف العام',
brandProfileDesc: 'تحكم في ظهور شركتك في السوق وموقع الحجز العام.',
payments: 'طرق دفع الإيجار',
paymentsDesc: 'تهيئة طريقة دفع العملاء لشركتك في موقع الحجز.',
savePayments: 'حفظ الدفع',
customDomain: 'نطاق مخصص',
customDomainDesc: 'ربط نطاقك الخاص بموقع الحجز.',
saveDomain: 'حفظ النطاق',
removeDomain: 'إزالة النطاق المخصص',
contractPolicies: 'العقد وسياسات السائق',
savePolicies: 'حفظ السياسات',
insurancePolicies: 'سياسات التأمين',
pricingRules: 'قواعد التسعير',
accounting: 'المحاسبة والتصدير',
saveAccounting: 'حفظ المحاسبة',
displayName: 'اسم العرض',
tagline: 'الشعار',
publicEmail: 'البريد الإلكتروني العام',
publicPhone: 'الهاتف العام',
city: 'المدينة',
country: 'الدولة',
websiteUrl: 'رابط الموقع',
contractLanguage: 'لغة العقد',
whatsapp: 'رقم واتساب',
brandColor: 'لون العلامة',
listedMarketplace: 'مدرج في السوق',
logoUpload: 'رفع الشعار',
heroUpload: 'رفع صورة الواجهة',
uploading: 'جارٍ الرفع…',
logoUploaded: 'تم رفع الشعار',
noLogo: 'لم يتم رفع شعار بعد',
heroUploaded: 'تم رفع صورة الواجهة',
noHero: 'لم يتم رفع صورة واجهة بعد',
subdomain: 'النطاق الفرعي',
customDomainLabel: 'النطاق المخصص',
domainPlaceholder: 'cars.example.com',
status: 'الحالة',
verified: 'موثق',
pendingDns: 'بانتظار التحقق من DNS',
notConfigured: 'غير مُعدّ',
fuelPolicyType: 'نوع سياسة الوقود',
additionalDriverCharge: 'رسوم السائق الإضافي',
dailyDriverRate: 'تعرفة السائق اليومية',
flatDriverRate: 'تعرفة السائق الثابتة',
fuelPolicyNote: 'ملاحظة سياسة الوقود',
damagePolicy: 'سياسة الأضرار',
active: 'نشط',
inactive: 'غير نشط',
noInsurance: 'لا توجد سياسات تأمين مهيأة.',
policyName: 'اسم السياسة',
chargeValue: 'قيمة الرسوم',
required: 'إلزامي',
addPolicy: 'إضافة سياسة',
noRules: 'لا توجد قواعد تسعير مهيأة.',
ruleName: 'اسم القاعدة',
addRule: 'إضافة قاعدة',
reportingPeriod: 'فترة التقرير',
reportFormat: 'تنسيق التقرير',
accountantName: 'اسم المحاسب',
accountantEmail: 'بريد المحاسب',
autoSend: 'إرسال التقارير تلقائياً إلى المحاسب',
amanpayMerchantId: 'معرّف التاجر AmanPay',
amanpaySecretKey: 'المفتاح السري AmanPay',
paypalEmail: 'بريد أعمال PayPal',
noPaymentConfigured: 'إذا لم يتم إعداد وسيلة دفع، لا يزال بإمكان العملاء إرسال طلب حجز والدفع عند الاستلام.',
fuelPolicyLabels: { FULL_TO_FULL: 'ممتلئ إلى ممتلئ', FULL_TO_EMPTY: 'ممتلئ إلى فارغ', SAME_TO_SAME: 'نفس المستوى', PREPAID: 'مدفوع مسبقاً', FREE: 'مشمول' } as Record<string, string>,
driverChargeLabels: { FREE: 'مجاني', PER_DAY: 'يومي', FLAT: 'ثابت' } as Record<string, string>,
reportingPeriodLabels: { WEEKLY: 'أسبوعي', MONTHLY: 'شهري', QUARTERLY: 'ربع سنوي', ANNUAL: 'سنوي' } as Record<string, string>,
reportFormatLabels: { CSV: 'CSV', PDF: 'PDF', BOTH: 'كلاهما' } as Record<string, string>,
},
}[language]
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [contractSettings, setContractSettings] = useState<ContractSettings | null>(null)
const [insurancePolicies, setInsurancePolicies] = useState<InsurancePolicy[]>([])
const [pricingRules, setPricingRules] = useState<PricingRule[]>([])
const [accountingSettings, setAccountingSettings] = useState<AccountingSettings | null>(null)
const [newInsurance, setNewInsurance] = useState(emptyInsurance)
const [newRule, setNewRule] = useState(emptyRule)
const [customDomain, setCustomDomain] = useState('')
const [uploadingAsset, setUploadingAsset] = useState<'logo' | 'hero' | null>(null)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
async function load() {
try {
const [brandData, contractData, insuranceData, ruleData, accountingData] = await Promise.all([
apiFetch<BrandSettings | null>('/companies/me/brand'),
apiFetch<ContractSettings | null>('/companies/me/contract-settings'),
apiFetch<InsurancePolicy[]>('/companies/me/insurance-policies'),
apiFetch<PricingRule[]>('/companies/me/pricing-rules'),
apiFetch<AccountingSettings | null>('/companies/me/accounting-settings'),
])
setBrand(brandData)
setCustomDomain(brandData?.customDomain ?? '')
setContractSettings(contractData ?? {
fuelPolicyType: 'FULL_TO_FULL',
fuelPolicyNote: '',
additionalDriverCharge: 'FREE',
additionalDriverDailyRate: 0,
additionalDriverFlatRate: 0,
damagePolicy: '',
})
setInsurancePolicies(insuranceData)
setPricingRules(ruleData)
setAccountingSettings(accountingData ?? {
reportingPeriod: 'MONTHLY',
accountantEmail: '',
accountantName: '',
autoSendReport: false,
reportFormat: 'CSV',
})
setError(null)
} catch (err: any) {
setError(err.message ?? 'Failed to load settings')
}
}
async function saveBrandSettings() {
if (!brand) return
setSaving(true)
setError(null)
try {
const updated = await apiFetch<BrandSettings>('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
displayName: brand.displayName,
tagline: brand.tagline || undefined,
primaryColor: brand.primaryColor,
accentColor: brand.accentColor || undefined,
publicEmail: brand.publicEmail || undefined,
publicPhone: brand.publicPhone || undefined,
publicAddress: brand.publicAddress || undefined,
publicCity: brand.publicCity || undefined,
publicCountry: brand.publicCountry || undefined,
websiteUrl: brand.websiteUrl || undefined,
defaultLocale: brand.defaultLocale || undefined,
whatsappNumber: brand.whatsappNumber || undefined,
paypalEmail: brand.paypalEmail || undefined,
amanpayMerchantId: brand.amanpayMerchantId || undefined,
amanpaySecretKey: brand.amanpaySecretKey || undefined,
paypalMerchantId: brand.paypalMerchantId || undefined,
defaultCurrency: brand.defaultCurrency || undefined,
isListedOnMarketplace: brand.isListedOnMarketplace,
}),
})
setBrand(updated)
} catch (err: any) {
setError(err.message ?? 'Failed to save brand settings')
} finally {
setSaving(false)
}
}
async function uploadBrandAsset(kind: 'logo' | 'hero', file: File | null) {
if (!file) return
setUploadingAsset(kind)
setError(null)
try {
const formData = new FormData()
formData.append('file', file)
const updated = await apiFetch<BrandSettings>(`/companies/me/brand/${kind}`, {
method: 'POST',
body: formData,
})
setBrand(updated)
} catch (err: any) {
setError(err.message ?? `Failed to upload ${kind}`)
} finally {
setUploadingAsset(null)
}
}
async function saveCustomDomain() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/brand/custom-domain', {
method: 'POST',
body: JSON.stringify({ customDomain }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to save custom domain')
} finally {
setSaving(false)
}
}
async function removeCustomDomain() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/brand/custom-domain', {
method: 'DELETE',
})
setCustomDomain('')
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to remove custom domain')
} finally {
setSaving(false)
}
}
useEffect(() => {
load()
}, [])
async function saveContractSettings() {
if (!contractSettings) return
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/contract-settings', {
method: 'PATCH',
body: JSON.stringify(contractSettings),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save contract settings')
} finally {
setSaving(false)
}
}
async function saveAccountingSettings() {
if (!accountingSettings) return
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/accounting-settings', {
method: 'PATCH',
body: JSON.stringify(accountingSettings),
})
} catch (err: any) {
setError(err.message ?? 'Failed to save accounting settings')
} finally {
setSaving(false)
}
}
async function createInsurance() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/insurance-policies', {
method: 'POST',
body: JSON.stringify(newInsurance),
})
setNewInsurance(emptyInsurance)
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to create insurance policy')
} finally {
setSaving(false)
}
}
async function toggleInsurance(policy: InsurancePolicy) {
try {
await apiFetch(`/companies/me/insurance-policies/${policy.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !policy.isActive }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to update insurance policy')
}
}
async function createRule() {
setSaving(true)
setError(null)
try {
await apiFetch('/companies/me/pricing-rules', {
method: 'POST',
body: JSON.stringify(newRule),
})
setNewRule(emptyRule)
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to create pricing rule')
} finally {
setSaving(false)
}
}
async function toggleRule(rule: PricingRule) {
try {
await apiFetch(`/companies/me/pricing-rules/${rule.id}`, {
method: 'PATCH',
body: JSON.stringify({ isActive: !rule.isActive }),
})
await load()
} catch (err: any) {
setError(err.message ?? 'Failed to update pricing rule')
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{brand && (
<div className="grid gap-6">
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.brandProfile}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.brandProfileDesc}</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.saveBrand}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.displayName}</label>
<input className="input-field" value={brand.displayName} onChange={(event) => setBrand((current) => current ? { ...current, displayName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.tagline}</label>
<input className="input-field" value={brand.tagline ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, tagline: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicEmail}</label>
<input className="input-field" type="email" value={brand.publicEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicEmail: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.publicPhone}</label>
<input className="input-field" value={brand.publicPhone ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicPhone: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.city}</label>
<input className="input-field" value={brand.publicCity ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCity: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.country}</label>
<input className="input-field" value={brand.publicCountry ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, publicCountry: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.websiteUrl}</label>
<input className="input-field" value={brand.websiteUrl ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, websiteUrl: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.contractLanguage}</label>
<select className="input-field" value={brand.defaultLocale ?? 'en'} onChange={(event) => setBrand((current) => current ? { ...current, defaultLocale: event.target.value } : current)}>
<option value="en">EN</option>
<option value="fr">FR</option>
<option value="ar">AR</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.whatsapp}</label>
<input className="input-field" value={brand.whatsappNumber ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, whatsappNumber: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.brandColor}</label>
<input className="input-field" value={brand.primaryColor} onChange={(event) => setBrand((current) => current ? { ...current, primaryColor: event.target.value } : current)} />
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={brand.isListedOnMarketplace} onChange={(event) => setBrand((current) => current ? { ...current, isListedOnMarketplace: event.target.checked } : current)} />
{copy.listedMarketplace}
</label>
</div>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.logoUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('logo', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'logo' ? copy.uploading : brand.logoUrl ? copy.logoUploaded : copy.noLogo}</p>
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">{copy.heroUpload}</span>
<input type="file" accept="image/*" onChange={(event) => uploadBrandAsset('hero', event.target.files?.[0] ?? null)} className="input-field" />
<p className="mt-2 text-xs text-slate-500">{uploadingAsset === 'hero' ? copy.uploading : brand.heroImageUrl ? copy.heroUploaded : copy.noHero}</p>
</label>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.payments}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.paymentsDesc}</p>
</div>
<button onClick={saveBrandSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.savePayments}
</button>
</div>
<div className="mt-5 grid gap-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpayMerchantId}</label>
<input className="input-field" value={brand.amanpayMerchantId ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpayMerchantId: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.amanpaySecretKey}</label>
<input className="input-field" type="password" value={brand.amanpaySecretKey ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, amanpaySecretKey: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.paypalEmail}</label>
<input className="input-field" type="email" value={brand.paypalEmail ?? ''} onChange={(event) => setBrand((current) => current ? { ...current, paypalEmail: event.target.value } : current)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
{copy.noPaymentConfigured}
</div>
</div>
</div>
<div className="card p-6">
<div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-base font-semibold text-slate-900">{copy.customDomain}</h3>
<p className="mt-1 text-sm text-slate-500">{copy.customDomainDesc}</p>
</div>
<button onClick={saveCustomDomain} disabled={saving || !customDomain.trim()} className="btn-primary">
{saving ? copy.save : copy.saveDomain}
</button>
</div>
<div className="mt-5 space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
<input className="input-field" placeholder={copy.domainPlaceholder} value={customDomain} onChange={(event) => setCustomDomain(event.target.value)} />
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-600">
{copy.status}: {brand.customDomain ? (brand.customDomainVerified ? copy.verified : copy.pendingDns) : copy.notConfigured}
</div>
{brand.customDomain ? (
<button onClick={removeCustomDomain} disabled={saving} className="btn-secondary">
{copy.removeDomain}
</button>
) : null}
</div>
</div>
</div>
</div>
)}
{contractSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{copy.contractPolicies}</h3>
<button onClick={saveContractSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.savePolicies}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyType}</label>
<select className="input-field" value={contractSettings.fuelPolicyType} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyType: event.target.value } : current)}>
{['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE'].map((option) => <option key={option} value={option}>{copy.fuelPolicyLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.additionalDriverCharge}</label>
<select className="input-field" value={contractSettings.additionalDriverCharge} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverCharge: event.target.value as ContractSettings['additionalDriverCharge'] } : current)}>
{['FREE', 'PER_DAY', 'FLAT'].map((option) => <option key={option} value={option}>{copy.driverChargeLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.dailyDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverDailyRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverDailyRate: Number(event.target.value) } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.flatDriverRate}</label>
<input type="number" className="input-field" value={contractSettings.additionalDriverFlatRate} onChange={(event) => setContractSettings((current) => current ? { ...current, additionalDriverFlatRate: Number(event.target.value) } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.fuelPolicyNote}</label>
<textarea className="input-field min-h-24" value={contractSettings.fuelPolicyNote ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, fuelPolicyNote: event.target.value } : current)} />
</div>
<div className="lg:col-span-2">
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.damagePolicy}</label>
<textarea className="input-field min-h-24" value={contractSettings.damagePolicy ?? ''} onChange={(event) => setContractSettings((current) => current ? { ...current, damagePolicy: event.target.value } : current)} />
</div>
</div>
</div>
)}
<div className="grid gap-6 xl:grid-cols-2">
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.insurancePolicies}</h3>
<div className="mt-5 space-y-3">
{insurancePolicies.map((policy) => (
<div key={policy.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{policy.name}</p>
<p className="text-slate-500">{policy.type} · {policy.chargeType} · {policy.chargeValue}</p>
</div>
<button onClick={() => toggleInsurance(policy)} className={policy.isActive ? 'badge-green' : 'badge-gray'}>
{policy.isActive ? copy.active : copy.inactive}
</button>
</div>
))}
{insurancePolicies.length === 0 && <div className="text-sm text-slate-400">{copy.noInsurance}</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder={copy.policyName} value={newInsurance.name} onChange={(event) => setNewInsurance((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newInsurance.type} onChange={(event) => setNewInsurance((current) => ({ ...current, type: event.target.value }))}>
{['BASIC', 'FULL', 'CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'ROADSIDE', 'PERSONAL', 'CUSTOM'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newInsurance.chargeType} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeType: event.target.value }))}>
{['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" placeholder={copy.chargeValue} value={newInsurance.chargeValue} onChange={(event) => setNewInsurance((current) => ({ ...current, chargeValue: Number(event.target.value) }))} />
</div>
<div className="mt-3 flex items-center gap-4 text-sm">
<label className="flex items-center gap-2"><input type="checkbox" checked={newInsurance.isRequired} onChange={(event) => setNewInsurance((current) => ({ ...current, isRequired: event.target.checked }))} /> {copy.required}</label>
<button onClick={createInsurance} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addPolicy}</button>
</div>
</div>
<div className="card p-6">
<h3 className="text-base font-semibold text-slate-900">{copy.pricingRules}</h3>
<div className="mt-5 space-y-3">
{pricingRules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between rounded-2xl border border-slate-200 px-4 py-3 text-sm">
<div>
<p className="font-medium text-slate-900">{rule.name}</p>
<p className="text-slate-500">{rule.type} · {rule.condition} {rule.conditionValue} · {rule.adjustmentType} {rule.adjustmentValue}</p>
</div>
<button onClick={() => toggleRule(rule)} className={rule.isActive ? 'badge-green' : 'badge-gray'}>
{rule.isActive ? copy.active : copy.inactive}
</button>
</div>
))}
{pricingRules.length === 0 && <div className="text-sm text-slate-400">{copy.noRules}</div>}
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<input className="input-field" placeholder={copy.ruleName} value={newRule.name} onChange={(event) => setNewRule((current) => ({ ...current, name: event.target.value }))} />
<select className="input-field" value={newRule.type} onChange={(event) => setNewRule((current) => ({ ...current, type: event.target.value }))}>
{['SURCHARGE', 'DISCOUNT'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<select className="input-field" value={newRule.condition} onChange={(event) => setNewRule((current) => ({ ...current, condition: event.target.value }))}>
{['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" value={newRule.conditionValue} onChange={(event) => setNewRule((current) => ({ ...current, conditionValue: Number(event.target.value) }))} />
<select className="input-field" value={newRule.adjustmentType} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentType: event.target.value }))}>
{['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL'].map((option) => <option key={option} value={option}>{option}</option>)}
</select>
<input type="number" className="input-field" value={newRule.adjustmentValue} onChange={(event) => setNewRule((current) => ({ ...current, adjustmentValue: Number(event.target.value) }))} />
</div>
<div className="mt-3">
<button onClick={createRule} disabled={saving} className="btn-primary">{saving ? copy.save : copy.addRule}</button>
</div>
</div>
</div>
{accountingSettings && (
<div className="card p-6">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-slate-900">{copy.accounting}</h3>
<button onClick={saveAccountingSettings} disabled={saving} className="btn-primary">
{saving ? copy.save : copy.saveAccounting}
</button>
</div>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportingPeriod}</label>
<select className="input-field" value={accountingSettings.reportingPeriod} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportingPeriod: event.target.value } : current)}>
{['WEEKLY', 'MONTHLY', 'QUARTERLY', 'ANNUAL'].map((option) => <option key={option} value={option}>{copy.reportingPeriodLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.reportFormat}</label>
<select className="input-field" value={accountingSettings.reportFormat} onChange={(event) => setAccountingSettings((current) => current ? { ...current, reportFormat: event.target.value } : current)}>
{['CSV', 'PDF', 'BOTH'].map((option) => <option key={option} value={option}>{copy.reportFormatLabels[option] ?? option}</option>)}
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantName}</label>
<input className="input-field" value={accountingSettings.accountantName ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantName: event.target.value } : current)} />
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.accountantEmail}</label>
<input className="input-field" value={accountingSettings.accountantEmail ?? ''} onChange={(event) => setAccountingSettings((current) => current ? { ...current, accountantEmail: event.target.value } : current)} />
</div>
</div>
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" checked={accountingSettings.autoSendReport} onChange={(event) => setAccountingSettings((current) => current ? { ...current, autoSendReport: event.target.checked } : current)} />
{copy.autoSend}
</label>
</div>
)}
</div>
)
}
@@ -0,0 +1,567 @@
'use client'
import { useRouter } from 'next/navigation'
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'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
interface Subscription {
id: string
plan: Plan
billingPeriod: BillingPeriod
status: string
currency: string
trialEndAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
interface Invoice {
id: string
amount: number
currency: string
status: string
paymentProvider: string
paidAt: string | null
createdAt: string
}
interface ProviderAvailability {
amanpay: boolean
paypal: boolean
}
interface PlanFeature {
id: string
plan: Plan
label: string
sortOrder: number
}
interface EmployeeProfile {
role?: string
}
const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-orange-100 text-orange-700',
CANCELLED: 'bg-slate-100 text-slate-600',
UNPAID: 'bg-red-100 text-red-700',
}
const INVOICE_STATUS: Record<string, string> = {
PAID: 'bg-green-100 text-green-700',
PENDING: 'bg-orange-100 text-orange-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
}
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
export default function SubscriptionPage() {
const router = useRouter()
const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
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 = {
en: {
title: 'Subscription',
subtitle: 'Manage your plan, payment provider, and subscription invoices.',
trial: 'Free trial',
remaining: 'remaining. Subscribe before it ends to keep access.',
currentPlan: 'Current plan',
renews: 'renews',
cancelScheduled: 'Cancellation scheduled at end of billing period.',
undo: 'Undo',
cancelling: 'Cancelling…',
cancelPlan: 'Cancel plan',
changePlan: 'Change plan',
subscribe: 'Subscribe',
selectPlan: 'Select a plan and payment provider to proceed.',
monthly: 'Monthly',
annual: 'Annual (save ~17%)',
active: 'Active',
perMonthShort: 'mo',
perYearShort: 'yr',
paymentProvider: 'Payment provider',
total: 'Total',
perMonth: 'month',
perYear: 'year',
redirecting: 'Redirecting…',
subscribeNow: 'Subscribe now',
invoiceHistory: 'Invoice history',
date: 'Date',
provider: 'Provider',
status: 'Status',
paid: 'Paid',
amount: 'Amount',
loading: 'Loading…',
noInvoices: 'No invoices yet.',
noProviderConfigured: 'No payment provider is configured. Contact support to enable AmanPay or PayPal.',
providerUnavailable: 'This payment provider is not configured.',
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', UNPAID: 'Unpaid' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Marketplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
} as Record<Plan, string[]>,
},
fr: {
title: 'Abonnement',
subtitle: 'Gérez votre plan, le prestataire de paiement et les factures dabonnement.',
trial: 'Essai gratuit',
remaining: 'restants. Abonnez-vous avant la fin pour garder laccès.',
currentPlan: 'Plan actuel',
renews: 'renouvelle le',
cancelScheduled: 'Annulation programmée à la fin de la période.',
undo: 'Annuler',
cancelling: 'Annulation…',
cancelPlan: 'Annuler le plan',
changePlan: 'Changer de plan',
subscribe: 'Sabonner',
selectPlan: 'Sélectionnez un plan et un prestataire de paiement.',
monthly: 'Mensuel',
annual: 'Annuel (économie ~17%)',
active: 'Actif',
perMonthShort: 'mois',
perYearShort: 'an',
paymentProvider: 'Prestataire de paiement',
total: 'Total',
perMonth: 'mois',
perYear: 'an',
redirecting: 'Redirection…',
subscribeNow: 'Sabonner maintenant',
invoiceHistory: 'Historique des factures',
date: 'Date',
provider: 'Prestataire',
status: 'Statut',
paid: 'Payé',
amount: 'Montant',
loading: 'Chargement…',
noInvoices: 'Aucune facture pour le moment.',
noProviderConfigured: 'Aucun prestataire de paiement nest configuré. Contactez le support pour activer AmanPay ou PayPal.',
providerUnavailable: 'Ce prestataire de paiement nest pas configuré.',
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', UNPAID: 'Impayé' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence marketplace'],
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
} as Record<Plan, string[]>,
},
ar: {
title: 'الاشتراك',
subtitle: 'إدارة الخطة ومزوّد الدفع وفواتير الاشتراك.',
trial: 'تجربة مجانية',
remaining: 'متبقية. اشترك قبل انتهائها للحفاظ على الوصول.',
currentPlan: 'الخطة الحالية',
renews: 'يتجدد في',
cancelScheduled: 'تمت جدولة الإلغاء عند نهاية فترة الفوترة.',
undo: 'تراجع',
cancelling: 'جارٍ الإلغاء…',
cancelPlan: 'إلغاء الخطة',
changePlan: 'تغيير الخطة',
subscribe: 'اشتراك',
selectPlan: 'اختر خطة ومزوّد دفع للمتابعة.',
monthly: 'شهري',
annual: 'سنوي (توفير ~17%)',
active: 'نشط',
perMonthShort: 'شهر',
perYearShort: 'سنة',
paymentProvider: 'مزوّد الدفع',
total: 'الإجمالي',
perMonth: 'شهر',
perYear: 'سنة',
redirecting: 'جارٍ التحويل…',
subscribeNow: 'اشترك الآن',
invoiceHistory: 'سجل الفواتير',
date: 'التاريخ',
provider: 'المزوّد',
status: 'الحالة',
paid: 'مدفوع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
noInvoices: 'لا توجد فواتير حتى الآن.',
noProviderConfigured: 'لا يوجد مزوّد دفع مهيأ. تواصل مع الدعم لتفعيل AmanPay أو PayPal.',
providerUnavailable: 'مزوّد الدفع هذا غير مهيأ.',
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', UNPAID: 'غير مدفوع' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
} as Record<Plan, string[]>,
},
}[language]
useEffect(() => {
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
return
} catch {}
}
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/')
})
.catch(() => {
setCanViewPage(false)
router.replace('/')
})
}, [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
Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
fetchPlanData(),
])
.then(([sub, inv, availability]) => {
setProviderAvailability(availability)
if (availability.amanpay) setProvider('AMANPAY')
else if (availability.paypal) setProvider('PAYPAL')
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
setBillingPeriod(sub.billingPeriod)
// currency is always MAD
}
setInvoices(inv ?? [])
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [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
}
async function handleCheckout() {
setPaying(true)
setError(null)
try {
if (provider === 'AMANPAY' && !providerAvailability.amanpay) throw new Error(copy.providerUnavailable)
if (provider === 'PAYPAL' && !providerAvailability.paypal) throw new Error(copy.providerUnavailable)
const currentUrl = new URL(window.location.href)
currentUrl.search = ''
currentUrl.hash = ''
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
plan: selectedPlan,
billingPeriod,
currency,
provider,
successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`,
}),
})
window.location.href = result.checkoutUrl
} catch (err: any) {
setError(err.message)
setPaying(false)
}
}
async function handleCancel() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/cancel', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
async function handleResume() {
setCancelling(true)
setError(null)
try {
const sub = await apiFetch<Subscription>('/subscriptions/resume', { method: 'POST' })
setSubscription(sub)
} catch (err: any) {
setError(err.message)
} finally {
setCancelling(false)
}
}
const planPrice = planPrices[selectedPlan]?.[billingPeriod]?.[currency]
const daysLeft = subscription?.trialEndAt
? Math.ceil((new Date(subscription.trialEndAt).getTime() - Date.now()) / 86400000)
: null
return (
<div className="space-y-8">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
{error && (
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
)}
{/* Trial banner */}
{subscription?.status === 'TRIALING' && daysLeft !== null && daysLeft > 0 && (
<div className="card p-4 border-sky-200 bg-sky-50 flex items-center justify-between">
<p className="text-sm font-medium text-sky-800">
{copy.trial} <strong>{daysLeft} days</strong> {copy.remaining}
</p>
</div>
)}
{/* Current plan */}
{subscription && (
<div className="card p-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
<div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statusLabels[subscription.status] ?? subscription.status}
</span>
</div>
<p className="mt-1 text-sm text-slate-500">
{subscription.billingPeriod} · {subscription.currency}
{subscription.currentPeriodEnd && ` · ${copy.renews} ${new Date(subscription.currentPeriodEnd).toLocaleDateString()}`}
</p>
{subscription.cancelAtPeriodEnd && (
<p className="mt-2 text-sm font-medium text-orange-700">
{copy.cancelScheduled}{' '}
<button onClick={handleResume} disabled={cancelling} className="underline">{copy.undo}</button>
</p>
)}
</div>
{!subscription.cancelAtPeriodEnd && (
<button
onClick={handleCancel}
disabled={cancelling}
className="btn-secondary text-red-600 border-red-200 hover:bg-red-50 text-sm"
>
{cancelling ? copy.cancelling : copy.cancelPlan}
</button>
)}
</div>
</div>
)}
{/* Plan selector + checkout */}
<div className="card p-6 space-y-6">
{!providerAvailability.amanpay && !providerAvailability.paypal ? (
<div className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-700">
{copy.noProviderConfigured}
</div>
) : null}
<div>
<h3 className="text-base font-semibold text-slate-900">
{subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribe}
</h3>
<p className="mt-1 text-sm text-slate-500">{copy.selectPlan}</p>
</div>
{/* Billing period toggle */}
<div className="flex items-center gap-2">
{(['MONTHLY', 'ANNUAL'] as BillingPeriod[]).map((p) => (
<button
key={p}
onClick={() => setBillingPeriod(p)}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
billingPeriod === p ? 'bg-blue-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{p === 'MONTHLY' ? copy.monthly : copy.annual}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
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}
onClick={() => setSelectedPlan(plan)}
className={`text-left p-5 rounded-xl border-2 transition-all ${
selectedPlan === plan
? 'border-blue-500 bg-blue-50/50'
: 'border-slate-200 hover:border-slate-300 bg-white'
} ${isActive ? 'ring-2 ring-green-200' : ''}`}
>
<div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{plan}</p>
{isActive && <span className="badge-green">{copy.active}</span>}
</div>
<p className="mt-2 text-2xl font-black text-slate-900">
{price ? formatCurrency(price, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{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>
)
})}
</div>
{/* Provider selector */}
<div>
<p className="text-sm font-medium text-slate-700 mb-2">{copy.paymentProvider}</p>
<div className="flex gap-3">
{providerAvailability.amanpay ? (
<button
onClick={() => setProvider('AMANPAY')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === 'AMANPAY' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
🏦 AmanPay
</button>
) : null}
{providerAvailability.paypal ? (
<button
onClick={() => setProvider('PAYPAL')}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === 'PAYPAL' ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
🔵 PayPal
</button>
) : null}
</div>
</div>
{/* Checkout CTA */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm text-slate-500">{copy.total}</p>
<p className="text-xl font-black text-slate-900">
{planPrice ? formatCurrency(planPrice, 'MAD') : '—'}
<span className="text-sm font-normal text-slate-500 ml-1">/{billingPeriod === 'MONTHLY' ? copy.perMonth : copy.perYear}</span>
</p>
</div>
<button
onClick={handleCheckout}
disabled={paying || loading || (!providerAvailability.amanpay && !providerAvailability.paypal)}
className="btn-primary px-8 py-3"
>
{paying ? copy.redirecting : subscription?.status === 'ACTIVE' ? copy.changePlan : copy.subscribeNow}
</button>
</div>
</div>
{/* Invoice history */}
<div className="card overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-base font-semibold text-slate-900">{copy.invoiceHistory}</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-slate-50 border-b border-slate-200">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.date}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.provider}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paid}</th>
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.amount}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : invoices.length === 0 ? (
<tr><td colSpan={5} className="px-6 py-10 text-center text-sm text-slate-400">{copy.noInvoices}</td></tr>
) : invoices.map((inv) => (
<tr key={inv.id}>
<td className="px-6 py-4 text-sm text-slate-700">{new Date(inv.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-700">{inv.paymentProvider}</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${INVOICE_STATUS[inv.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.invoiceStatusLabels[inv.status] ?? inv.status}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">
{inv.paidAt ? new Date(inv.paidAt).toLocaleDateString() : '—'}
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">
{formatCurrency(inv.amount, 'MAD')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -0,0 +1,440 @@
'use client'
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 { apiFetch } from '@/lib/api'
import { useTeam, TeamMember, InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
function initials(m: TeamMember) {
return (m.firstName[0] ?? '') + (m.lastName[0] ?? '')
}
function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
if (!dateStr) {
return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
}
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 2) {
return language === 'fr' ? "À l'instant" : language === 'ar' ? 'الآن' : 'Just now'
}
if (mins < 60) {
return language === 'fr' ? `il y a ${mins} min` : language === 'ar' ? `منذ ${mins} دقيقة` : `${mins}m ago`
}
const hrs = Math.floor(mins / 60)
if (hrs < 24) {
return language === 'fr' ? `il y a ${hrs}h` : language === 'ar' ? `منذ ${hrs} ساعة` : `${hrs}h ago`
}
const days = Math.floor(hrs / 24)
if (days < 30) {
return language === 'fr' ? `il y a ${days}j` : language === 'ar' ? `منذ ${days} يوم` : `${days}d ago`
}
const locale = language === 'fr' ? 'fr' : language === 'ar' ? 'ar' : 'en'
return new Date(dateStr).toLocaleDateString(locale, { month: 'short', day: 'numeric' })
}
const AVATAR_BG: string[] = [
'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300',
'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300',
'bg-teal-100 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300',
'bg-orange-100 dark:bg-orange-900/40 text-orange-700 dark:text-orange-300',
'bg-pink-100 dark:bg-pink-900/40 text-pink-700 dark:text-pink-300',
]
function avatarColor(id: string) {
const hash = id.split('').reduce((acc, c) => acc + c.charCodeAt(0), 0)
return AVATAR_BG[hash % AVATAR_BG.length]
}
const roleLabels: Record<string, Record<'en' | 'fr' | 'ar', string>> = {
OWNER: { en: 'Owner', fr: 'Propriétaire', ar: 'مالك' },
MANAGER: { en: 'Manager', fr: 'Manager', ar: 'مدير' },
AGENT: { en: 'Agent', fr: 'Agent', ar: 'وكيل' },
}
function RoleBadge({ role, language }: { role: string; language: 'en' | 'fr' | 'ar' }) {
const styles: Record<string, string> = {
OWNER: 'bg-violet-50 dark:bg-violet-950/40 text-violet-700 dark:text-violet-300 border-violet-100 dark:border-violet-900/50',
MANAGER: 'bg-blue-50 dark:bg-blue-950/40 text-blue-700 dark:text-blue-300 border-blue-100 dark:border-blue-900/50',
AGENT: 'bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 border-zinc-200 dark:border-zinc-700',
}
const label = roleLabels[role]?.[language] ?? (role.charAt(0) + role.slice(1).toLowerCase())
return (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${styles[role] ?? styles.AGENT}`}>
{label}
</span>
)
}
function StatusBadge({ member, language }: { member: TeamMember; language: 'en' | 'fr' | 'ar' }) {
if (member.invitationStatus === 'pending') {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-orange-50 dark:bg-orange-950/30 text-orange-700 dark:text-orange-400 border-orange-100 dark:border-orange-900/50">
{language === 'fr' ? 'Invitation en attente' : language === 'ar' ? 'دعوة قيد الانتظار' : 'Pending invite'}
</span>
)
}
if (!member.isActive) {
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-500 border-zinc-200 dark:border-zinc-700">
{language === 'fr' ? 'Désactivé' : language === 'ar' ? 'معطل' : 'Deactivated'}
</span>
)
}
return (
<span className="text-xs px-2 py-0.5 rounded-full border bg-green-50 dark:bg-green-950/30 text-green-700 dark:text-green-400 border-green-100 dark:border-green-900/50">
{language === 'fr' ? 'Actif' : language === 'ar' ? 'نشط' : 'Active'}
</span>
)
}
export default function TeamPage() {
const { language } = useDashboardI18n()
const copy = {
en: {
title: 'Team',
subtitle: 'Manage your employees and their access levels',
inviteMember: 'Invite member',
totalMembers: 'Total members',
active: 'Active',
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
search: 'Search by name or email…',
allRoles: 'All roles',
owner: 'Owner',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'All statuses',
pending: 'Pending',
loadingTeam: 'Loading team…',
noMembers: 'No members match your filters',
member: 'Member',
role: 'Role',
status: 'Status',
lastActive: 'Last active',
manage: 'Manage',
inviteSent: 'Invite sent to',
roleUpdated: 'Role updated',
memberDeactivated: 'Member deactivated',
memberReactivated: 'Member reactivated',
memberRemoved: 'Member removed',
you: '(you)',
},
fr: {
title: 'Équipe',
subtitle: "Gérez vos employés et leurs niveaux d'accès",
inviteMember: 'Inviter un membre',
totalMembers: 'Total membres',
active: 'Actif',
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
search: 'Rechercher par nom ou email…',
allRoles: 'Tous les rôles',
owner: 'Propriétaire',
manager: 'Manager',
agent: 'Agent',
allStatuses: 'Tous les statuts',
pending: 'En attente',
loadingTeam: "Chargement de l'équipe…",
noMembers: 'Aucun membre ne correspond aux filtres',
member: 'Membre',
role: 'Rôle',
status: 'Statut',
lastActive: 'Dernière activité',
manage: 'Gérer',
inviteSent: 'Invitation envoyée à',
roleUpdated: 'Rôle mis à jour',
memberDeactivated: 'Membre désactivé',
memberReactivated: 'Membre réactivé',
memberRemoved: 'Membre supprimé',
you: '(vous)',
},
ar: {
title: 'الفريق',
subtitle: 'إدارة الموظفين ومستويات الوصول الخاصة بهم',
inviteMember: 'دعوة عضو',
totalMembers: 'إجمالي الأعضاء',
active: 'نشط',
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
search: 'ابحث بالاسم أو البريد الإلكتروني…',
allRoles: 'كل الأدوار',
owner: 'مالك',
manager: 'مدير',
agent: 'وكيل',
allStatuses: 'كل الحالات',
pending: 'قيد الانتظار',
loadingTeam: 'جارٍ تحميل الفريق…',
noMembers: 'لا يوجد أعضاء مطابقون للفلاتر',
member: 'العضو',
role: 'الدور',
status: 'الحالة',
lastActive: 'آخر نشاط',
manage: 'إدارة',
inviteSent: 'تم إرسال الدعوة إلى',
roleUpdated: 'تم تحديث الدور',
memberDeactivated: 'تم تعطيل العضو',
memberReactivated: 'تمت إعادة تفعيل العضو',
memberRemoved: 'تم حذف العضو',
you: '(أنت)',
},
}[language]
const { members, stats, loading, error, invite, updateRole, deactivate, reactivate, remove } = useTeam()
const [currentEmployeeId, setCurrentEmployeeId] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<TeamMember | null>(null)
const [toast, setToast] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
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)
const isOwner = currentEmployee?.role === 'OWNER'
function showToast(msg: string) {
setToast(msg)
setTimeout(() => setToast(null), 3000)
}
const filtered = useMemo(() => {
return members.filter((m) => {
const q = search.toLowerCase()
const matchQ = !q ||
`${m.firstName} ${m.lastName}`.toLowerCase().includes(q) ||
m.email.toLowerCase().includes(q)
const matchRole = !roleFilter || m.role === roleFilter
const matchStatus =
!statusFilter ||
(statusFilter === 'active' && m.isActive && m.invitationStatus === 'accepted') ||
(statusFilter === 'pending' && m.invitationStatus === 'pending') ||
(statusFilter === 'inactive' && !m.isActive && m.invitationStatus === 'accepted')
return matchQ && matchRole && matchStatus
})
}, [members, search, roleFilter, statusFilter])
const handleInvite = async (payload: InvitePayload) => {
await invite(payload)
showToast(`${copy.inviteSent} ${payload.email}`)
}
const handleUpdateRole = async (id: string, role: 'MANAGER' | 'AGENT') => {
await updateRole(id, role)
showToast(copy.roleUpdated)
}
const handleDeactivate = async (id: string) => {
await deactivate(id)
showToast(copy.memberDeactivated)
}
const handleReactivate = async (id: string) => {
await reactivate(id)
showToast(copy.memberReactivated)
}
const handleRemove = async (id: string) => {
await remove(id)
showToast(copy.memberRemoved)
}
return (
<div className="max-w-5xl mx-auto py-8 px-4 sm:px-6">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-2xl font-medium text-zinc-900 dark:text-white">{copy.title}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
{copy.subtitle}
</p>
</div>
{isOwner && (
<button
onClick={() => setInviteOpen(true)}
className="flex items-center gap-1.5 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 16 16">
<path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
{copy.inviteMember}
</button>
)}
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ label: copy.totalMembers, value: stats.total },
{ label: copy.active, value: stats.active },
{ label: copy.pendingInvite, value: stats.pending },
{ label: copy.deactivated, value: stats.inactive },
].map((s) => (
<div
key={s.label}
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 flex flex-col gap-1"
>
<span className="text-xs text-zinc-500 dark:text-zinc-400">{s.label}</span>
<span className="text-2xl font-medium text-zinc-900 dark:text-white">{s.value}</span>
</div>
))}
</div>
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative flex-1 min-w-48">
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-400" fill="none" viewBox="0 0 14 14">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.2" />
<path d="M10 10L13 13" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
<input
type="text"
placeholder={copy.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">{copy.allRoles}</option>
<option value="OWNER">{copy.owner}</option>
<option value="MANAGER">{copy.manager}</option>
<option value="AGENT">{copy.agent}</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 text-zinc-700 dark:text-zinc-300 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white"
>
<option value="">{copy.allStatuses}</option>
<option value="active">{copy.active}</option>
<option value="pending">{copy.pending}</option>
<option value="inactive">{copy.deactivated}</option>
</select>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden mb-10">
{loading ? (
<div className="py-16 flex flex-col items-center gap-2 text-zinc-400">
<div className="w-5 h-5 border-2 border-zinc-200 dark:border-zinc-700 border-t-zinc-500 rounded-full animate-spin" />
<span className="text-sm">{copy.loadingTeam}</span>
</div>
) : error ? (
<div className="py-12 text-center text-sm text-red-500">{error}</div>
) : filtered.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-zinc-500 dark:text-zinc-400">{copy.noMembers}</p>
</div>
) : (
<table className="w-full">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.member}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.role}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">{copy.status}</th>
<th className="text-left px-4 py-3 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide hidden sm:table-cell">{copy.lastActive}</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filtered.map((member) => (
<tr
key={member.id}
className={[
'border-b border-zinc-100 dark:border-zinc-800 last:border-0',
'hover:bg-zinc-50 dark:hover:bg-zinc-800/30 transition-colors',
].join(' ')}
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${avatarColor(member.id)}`}>
{initials(member)}
</div>
<div>
<div className="text-sm font-medium text-zinc-900 dark:text-white">
{member.firstName} {member.lastName}
{member.id === currentEmployeeId && (
<span className="ml-1.5 text-[10px] text-zinc-400">{copy.you}</span>
)}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">{member.email}</div>
</div>
</div>
</td>
<td className="px-4 py-3">
<RoleBadge role={member.role} language={language} />
</td>
<td className="px-4 py-3">
<StatusBadge member={member} language={language} />
</td>
<td className="px-4 py-3 hidden sm:table-cell">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{member.invitationStatus === 'pending' ? '—' : timeAgo(member.lastActiveAt, language)}
</span>
</td>
<td className="px-4 py-3 text-right">
{isOwner && member.role !== 'OWNER' && (
<button
onClick={() => setEditTarget(member)}
className="text-xs px-3 py-1.5 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
{copy.manage}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<PermissionsMatrix />
<InviteModal
open={inviteOpen}
onClose={() => setInviteOpen(false)}
onInvite={handleInvite}
/>
<EditMemberModal
member={editTarget}
open={!!editTarget}
onClose={() => setEditTarget(null)}
onUpdateRole={handleUpdateRole}
onDeactivate={handleDeactivate}
onReactivate={handleReactivate}
onRemove={handleRemove}
/>
{toast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl shadow-lg text-sm font-medium animate-in slide-in-from-bottom-2 duration-200">
<svg className="w-4 h-4 text-green-400 dark:text-green-600" fill="none" viewBox="0 0 16 16">
<path d="M3 8L6.5 11.5L13 5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{toast}
</div>
)}
</div>
)
}
@@ -0,0 +1,3 @@
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return children
}
@@ -0,0 +1,17 @@
import { Suspense } from 'react'
import SignInPageClient from '@/app/sign-in/[[...sign-in]]/SignInPageClient'
export default async function SignInPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const params = await searchParams
const embedded = params.embedded === '1'
return (
<Suspense fallback={null}>
<SignInPageClient embedded={embedded} />
</Suspense>
)
}
@@ -0,0 +1,151 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { API_BASE } from '@/lib/api'
import { marketplaceUrl } from '@/lib/urls'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
export default function ForgotPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
const { language } = useDashboardI18n()
const dict = {
en: {
title: 'Forgot your password?',
subtitle: "Enter your work email and we'll send you a reset link.",
email: 'Email',
submit: 'Send reset link',
submitting: 'Sending…',
backToLogin: 'Back to sign in',
successTitle: 'Check your email',
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
error: 'Something went wrong. Please try again.',
},
fr: {
title: 'Mot de passe oublié ?',
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
email: 'Email',
submit: 'Envoyer le lien',
submitting: 'Envoi…',
backToLogin: 'Retour à la connexion',
successTitle: 'Vérifiez votre email',
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
error: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'نسيت كلمة المرور؟',
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
email: 'البريد الإلكتروني',
submit: 'إرسال رابط إعادة التعيين',
submitting: 'جارٍ الإرسال…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تحقق من بريدك الإلكتروني',
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
// Try employee first, then admin — mirrors the sign-in page which handles both
const [empRes, adminRes] = await Promise.all([
fetch(`${API_BASE}/auth/employee/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}),
fetch(`${API_BASE}/admin/auth/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
}),
])
if (!empRes.ok && !adminRes.ok) throw new Error()
setSent(true)
} catch {
setError(dict.error)
} finally {
setLoading(false)
}
}
return (
<PublicShell embedded={embedded}>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-stone-200 bg-white p-1.5 shadow-sm transition-colors dark:border-blue-800 dark:bg-blue-950"
/>
</a>
</div>
<h1 className="mt-4 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{dict.title}</h1>
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.subtitle}</p>
</div>
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm transition-colors dark:border-blue-900 dark:bg-blue-950">
{sent ? (
<div className="space-y-4 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
<svg className="h-7 w-7 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-stone-900 dark:text-stone-100">{dict.successTitle}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.successBody}</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400">{error}</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.submitting : dict.submit}
</button>
</form>
)}
<div className="mt-6 border-t border-stone-200 pt-6 text-center text-sm text-stone-500 dark:border-blue-900 dark:text-stone-400">
<Link href="/sign-in" className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700">
{dict.backToLogin}
</Link>
</div>
</section>
</div>
</main>
</PublicShell>
)
}
@@ -0,0 +1,5 @@
import ForgotPasswordPageClient from './ForgotPasswordPageClient'
export default function ForgotPasswordPage() {
return <ForgotPasswordPageClient embedded={true} />
}
+155
View File
@@ -0,0 +1,155 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html.light {
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
:root {
--primary: #2563eb;
--primary-hover: #1d4ed8;
--accent: #f97316;
--sidebar-bg: #0a1128;
--sidebar-text: #94a3b8;
--sidebar-active: #ffffff;
}
@layer base {
* {
box-sizing: border-box;
}
body {
@apply text-blue-950 antialiased transition-colors dark:text-slate-100;
background-image:
linear-gradient(180deg, #ffffff 0%, #f5f8ff 28%, #eef4ff 58%, #ffffff 100%);
}
h1, h2, h3, h4, h5, h6 {
@apply font-semibold;
}
}
html.dark body {
background-image:
linear-gradient(180deg, #0a1128 0%, #0d1b38 35%, #07101e 100%);
}
@layer components {
.btn-primary {
@apply inline-flex items-center gap-2 rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-orange-500 dark:hover:bg-orange-400;
}
.btn-secondary {
@apply inline-flex items-center gap-2 rounded-full border border-stone-300 bg-white/85 px-5 py-2.5 text-sm font-semibold text-stone-700 transition hover:border-blue-900 hover:text-blue-900 dark:border-blue-800 dark:bg-blue-950/20 dark:text-blue-100 dark:hover:border-blue-400 dark:hover:text-white;
}
.btn-danger {
@apply inline-flex items-center gap-2 rounded-full bg-red-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50;
}
.input-field {
@apply w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm 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-900 dark:bg-blue-950/50 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:ring-orange-400;
}
.card {
@apply rounded-[1.75rem] border shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:shadow-[0_30px_80px_rgba(0,0,0,0.36)];
border-color: rgb(231 229 228 / 0.8);
background-color: rgb(255 255 255 / 0.82);
}
html.dark .card {
border-color: rgb(30 60 140 / 0.40);
background-color: rgb(11 25 55 / 0.72);
}
.badge-green {
@apply inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300;
}
.badge-blue {
@apply inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-950/40 dark:text-blue-300;
}
.badge-amber {
@apply inline-flex items-center rounded-full bg-orange-100 px-2.5 py-0.5 text-xs font-medium text-orange-700 dark:bg-orange-950/40 dark:text-orange-300;
}
.badge-red {
@apply inline-flex items-center rounded-full bg-red-100 px-2.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-950/40 dark:text-red-300;
}
.badge-gray {
@apply inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-600 dark:bg-blue-950/40 dark:text-slate-300;
}
.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 {
@page {
size: A4 portrait;
margin: 10mm 12mm;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
}
@layer utilities {
.dark .bg-slate-50 {
background-color: rgb(7 16 46);
}
.dark .bg-white {
background-color: rgb(11 25 55);
}
.dark .bg-white\/90 {
background-color: rgb(11 25 55 / 0.9);
}
.dark .border-slate-200,
.dark .border-stone-200 {
border-color: rgb(30 60 140 / 0.50);
}
.dark .text-slate-900 {
color: rgb(241 245 249);
}
.dark .text-slate-700 {
color: rgb(203 213 225);
}
.dark .text-slate-600,
.dark .text-stone-500 {
color: rgb(148 163 184);
}
.dark .text-slate-500,
.dark .text-slate-400,
.dark .text-stone-400 {
color: rgb(100 116 139);
}
.dark .hover\:bg-slate-100:hover {
background-color: rgb(20 42 90);
}
.dark .hover\:text-slate-900:hover {
color: rgb(241 245 249);
}
}
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0f172a" />
<text
x="16"
y="21"
text-anchor="middle"
font-family="Inter, Arial, sans-serif"
font-size="18"
font-weight="700"
fill="#ffffff"
>
R
</text>
</svg>

After

Width:  |  Height:  |  Size: 302 B

+38
View File
@@ -0,0 +1,38 @@
import type { Metadata } from 'next'
import { cookies } from 'next/headers'
import { DashboardI18nProvider } from '@/components/I18nProvider'
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
import './globals.css'
export const metadata: Metadata = {
title: 'RentalDriveGo Dashboard',
description: 'Manage your rental car business',
}
function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
}
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,
)
const dir = initialLanguage === 'ar' ? 'rtl' : 'ltr'
return (
<html lang={initialLanguage} dir={dir} className="light" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('dashboard-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}var rootTheme=theme==='light'?'light':'dark';document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(rootTheme);document.documentElement.style.colorScheme=rootTheme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}}
/>
</head>
<body className="font-sans">
<DashboardI18nProvider initialLanguage={initialLanguage}>{children}</DashboardI18nProvider>
</body>
</html>
)
}
@@ -0,0 +1,25 @@
import Link from 'next/link'
import PublicShell from '@/components/layout/PublicShell'
export default function AcceptInvitePage() {
return (
<PublicShell>
<main className="flex flex-col items-center justify-center px-4 py-16">
<div className="w-full max-w-md card p-10 text-center">
<div className="mx-auto mb-6 flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-slate-900">Invitation accepted</h1>
<p className="mt-3 text-sm text-slate-500">
Your invitation has been processed. You can now sign in to access the team dashboard.
</p>
<Link href="/sign-in" className="mt-8 btn-primary justify-center w-full">
Sign in to dashboard
</Link>
</div>
</main>
</PublicShell>
)
}
+269
View File
@@ -0,0 +1,269 @@
'use client'
import Link from 'next/link'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
type Step = 1 | 2 | 3
export default function OnboardingPage() {
const router = useRouter()
const [step, setStep] = useState<Step>(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [company, setCompany] = useState({ name: '', slug: '' })
const [brand, setBrand] = useState({
displayName: '',
tagline: '',
primaryColor: '#2563eb',
publicCity: '',
publicCountry: '',
})
const [payments, setPayments] = useState({
amanpayMerchantId: '',
amanpaySecretKey: '',
paypalEmail: '',
isListedOnMarketplace: true,
})
async function handleStep1() {
if (!company.name.trim()) return setError('Company name is required')
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me', {
method: 'PATCH',
body: JSON.stringify({ name: company.name }),
})
setStep(2)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleStep2() {
if (!brand.displayName.trim()) return setError('Display name is required')
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
displayName: brand.displayName,
tagline: brand.tagline || undefined,
primaryColor: brand.primaryColor,
publicCity: brand.publicCity || undefined,
publicCountry: brand.publicCountry || undefined,
}),
})
setStep(3)
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
async function handleStep3() {
setLoading(true)
setError(null)
try {
await apiFetch('/companies/me/brand', {
method: 'PATCH',
body: JSON.stringify({
amanpayMerchantId: payments.amanpayMerchantId || undefined,
amanpaySecretKey: payments.amanpaySecretKey || undefined,
paypalEmail: payments.paypalEmail || undefined,
isListedOnMarketplace: payments.isListedOnMarketplace,
}),
})
router.push('/')
} catch (err: any) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<PublicShell>
<main className="flex items-center justify-center px-4 py-12">
<div className="w-full max-w-lg">
<div className="mb-8 text-center">
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</a>
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
</div>
<div className="flex gap-2 mb-8">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`flex-1 h-1.5 rounded-full transition-colors ${
s <= step ? 'bg-blue-600' : 'bg-slate-200'
}`}
/>
))}
</div>
<div className="card p-8">
{error && (
<div className="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
{error}
</div>
)}
{step === 1 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Your company</h2>
<p className="mt-1 text-sm text-slate-500">Tell us the basics about your rental company.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Company name</label>
<input
className="input-field"
placeholder="Casablanca Car Rentals"
value={company.name}
onChange={(e) => setCompany({ ...company, name: e.target.value })}
/>
</div>
<button onClick={handleStep1} disabled={loading} className="btn-primary w-full justify-center">
{loading ? 'Saving…' : 'Continue'}
</button>
</div>
)}
{step === 2 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Brand &amp; identity</h2>
<p className="mt-1 text-sm text-slate-500">Customize how your company appears to renters.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Display name</label>
<input
className="input-field"
placeholder="Casa Rentals"
value={brand.displayName}
onChange={(e) => setBrand({ ...brand, displayName: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tagline <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="The best fleet in the city"
value={brand.tagline}
onChange={(e) => setBrand({ ...brand, tagline: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">City</label>
<input
className="input-field"
placeholder="Casablanca"
value={brand.publicCity}
onChange={(e) => setBrand({ ...brand, publicCity: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Country</label>
<input
className="input-field"
placeholder="Morocco"
value={brand.publicCountry}
onChange={(e) => setBrand({ ...brand, publicCountry: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Brand color</label>
<div className="flex items-center gap-3">
<input
type="color"
className="h-10 w-14 rounded border border-slate-200 cursor-pointer"
value={brand.primaryColor}
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
/>
<input
className="input-field"
value={brand.primaryColor}
onChange={(e) => setBrand({ ...brand, primaryColor: e.target.value })}
/>
</div>
</div>
<div className="flex gap-3">
<button onClick={() => setStep(1)} className="btn-secondary flex-1 justify-center">Back</button>
<button onClick={handleStep2} disabled={loading} className="btn-primary flex-1 justify-center">
{loading ? 'Saving…' : 'Continue'}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="space-y-5">
<div>
<h2 className="text-lg font-semibold text-slate-900">Payment setup</h2>
<p className="mt-1 text-sm text-slate-500">Connect payment providers so renters can book online. You can skip this for now.</p>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Merchant ID <span className="text-slate-400">(optional)</span></label>
<input
className="input-field"
placeholder="your-merchant-id"
value={payments.amanpayMerchantId}
onChange={(e) => setPayments({ ...payments, amanpayMerchantId: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">AmanPay Secret Key <span className="text-slate-400">(optional)</span></label>
<input
type="password"
className="input-field"
placeholder="your-secret-key"
value={payments.amanpaySecretKey}
onChange={(e) => setPayments({ ...payments, amanpaySecretKey: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">PayPal email <span className="text-slate-400">(optional)</span></label>
<input
type="email"
className="input-field"
placeholder="payments@yourcompany.com"
value={payments.paypalEmail}
onChange={(e) => setPayments({ ...payments, paypalEmail: e.target.value })}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
checked={payments.isListedOnMarketplace}
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
/>
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span>
</label>
<div className="flex gap-3">
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
<button onClick={handleStep3} disabled={loading} className="btn-primary flex-1 justify-center">
{loading ? 'Finishing…' : 'Go to dashboard'}
</button>
</div>
</div>
)}
</div>
</div>
</main>
</PublicShell>
)
}
@@ -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()
})
})
@@ -0,0 +1,214 @@
'use client'
import Link from 'next/link'
import { Suspense, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { API_BASE } from '@/lib/api'
export default function ResetPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
return (
<Suspense>
<ResetPasswordContent embedded={embedded} />
</Suspense>
)
}
function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
const { language } = useDashboardI18n()
const dict = {
en: {
title: 'Set new password',
subtitle: 'Choose a strong password for your workspace account.',
newPassword: 'New password',
confirmPassword: 'Confirm password',
submit: 'Reset password',
submitting: 'Resetting…',
backToLogin: 'Back to sign in',
successTitle: 'Password updated',
successBody: 'Your password has been reset. You can now sign in with your new password.',
signIn: 'Sign in',
errorMismatch: 'Passwords do not match.',
errorShort: 'Password must be at least 8 characters.',
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
errorGeneric: 'Something went wrong. Please try again.',
noToken: 'No reset token found. Please request a new password reset.',
requestNew: 'Request new reset link',
},
fr: {
title: 'Nouveau mot de passe',
subtitle: "Choisissez un mot de passe fort pour votre compte.",
newPassword: 'Nouveau mot de passe',
confirmPassword: 'Confirmer le mot de passe',
submit: 'Réinitialiser',
submitting: 'Traitement…',
backToLogin: 'Retour à la connexion',
successTitle: 'Mot de passe mis à jour',
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
signIn: 'Se connecter',
errorMismatch: 'Les mots de passe ne correspondent pas.',
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
noToken: 'Aucun jeton de réinitialisation trouvé. Veuillez demander un nouveau lien.',
requestNew: 'Demander un nouveau lien',
},
ar: {
title: 'تعيين كلمة مرور جديدة',
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
newPassword: 'كلمة المرور الجديدة',
confirmPassword: 'تأكيد كلمة المرور',
submit: 'إعادة تعيين',
submitting: 'جارٍ المعالجة…',
backToLogin: 'العودة إلى تسجيل الدخول',
successTitle: 'تم تحديث كلمة المرور',
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
signIn: 'تسجيل الدخول',
errorMismatch: 'كلمتا المرور غير متطابقتين.',
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
noToken: 'لم يتم العثور على رمز إعادة التعيين. يرجى طلب رابط جديد.',
requestNew: 'طلب رابط جديد',
},
}[language]
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('')
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (password.length < 8) { setError(dict.errorShort); return }
if (password !== confirm) { setError(dict.errorMismatch); return }
setLoading(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
})
const json = await res.json()
if (!res.ok) {
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
throw new Error(dict.errorGeneric)
}
setDone(true)
} catch (err: any) {
setError(err.message ?? dict.errorGeneric)
} finally {
setLoading(false)
}
}
if (!token) {
return (
<ResetShell embedded={embedded} title={dict.title}>
<p className="text-center text-sm text-stone-500 dark:text-stone-400">{dict.noToken}</p>
<div className="mt-4 text-center">
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
</div>
</ResetShell>
)
}
if (done) {
return (
<ResetShell embedded={embedded} title={dict.title}>
<div className="space-y-4 text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
<svg className="h-7 w-7 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-stone-900 dark:text-stone-100">{dict.successTitle}</h2>
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.successBody}</p>
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
</div>
</ResetShell>
)
}
return (
<ResetShell embedded={embedded} title={dict.title} subtitle={dict.subtitle}>
<form onSubmit={handleSubmit} className="space-y-5">
{error && (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400">{error}</div>
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.newPassword}</label>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.confirmPassword}</label>
<input
type="password"
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
className="input-field"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.submitting : dict.submit}
</button>
</form>
<div className="mt-6 border-t border-stone-200 pt-6 text-center text-sm text-stone-500 dark:border-blue-900 dark:text-stone-400">
<Link href="/sign-in" className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700">
{dict.backToLogin}
</Link>
</div>
</ResetShell>
)
}
function ResetShell({
embedded = false,
title,
subtitle,
children,
}: {
embedded?: boolean
title: string
subtitle?: string
children: React.ReactNode
}) {
return (
<PublicShell embedded={embedded}>
<main className="flex min-h-[calc(100vh-80px)] items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-400">RentalDriveGo</span>
<h1 className="mt-3 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{title}</h1>
{subtitle && <p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{subtitle}</p>}
</div>
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm transition-colors dark:border-blue-900 dark:bg-blue-950">
{children}
</section>
</div>
</main>
</PublicShell>
)
}
@@ -0,0 +1,5 @@
import ResetPasswordPageClient from './ResetPasswordPageClient'
export default function ResetPasswordPage() {
return <ResetPasswordPageClient embedded={true} />
}
@@ -0,0 +1,435 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
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 } from '@/lib/api'
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
}
export default function SignInPageClient({ embedded = false }: { embedded?: boolean }) {
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const requestedLanguage = searchParams.get('lang')
const requestedTheme = searchParams.get('theme')
const initializedFromQuery = useRef(false)
const dict = {
en: {
brandName: 'Space Agency',
title: 'Sign in',
subtitle: 'Enter your credentials to access your account.',
email: 'Email',
password: 'Password',
signIn: 'Sign in',
signingIn: 'Signing in…',
verify: 'Verify code',
verifying: 'Verifying…',
authCode: 'Authentication code',
enterCode: 'Enter the 6-digit code from your authenticator app.',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
invalidCredentials: 'Invalid email or password.',
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
brandName: 'Espace agence',
title: 'Connexion',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
verify: 'Vérifier le code',
verifying: 'Vérification…',
authCode: "Code d'authentification",
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
invalidCredentials: 'Adresse e-mail ou mot de passe invalide.',
passwordNotSet: `Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »`,
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
brandName: 'مساحة الوكالة',
title: 'تسجيل الدخول',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
useEffect(() => {
if (initializedFromQuery.current) return
if (
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
requestedLanguage !== language
) {
setLanguage(requestedLanguage)
}
if (
(requestedTheme === 'light' || requestedTheme === 'dark') &&
requestedTheme !== theme
) {
setTheme(requestedTheme)
}
initializedFromQuery.current = true
}, [language, requestedLanguage, requestedTheme, setLanguage, setTheme, theme])
useEffect(() => {
if (!initializedFromQuery.current) return
const params = new URLSearchParams(searchParams.toString())
let changed = false
if (params.get('lang') !== language) {
params.set('lang', language)
changed = true
}
if (params.get('theme') !== theme) {
params.set('theme', theme)
changed = true
}
// useSearchParams() can return empty params during hydration without a Suspense boundary.
// Always preserve embedded=1 when the component was server-rendered as embedded so
// the router.replace doesn't strip it and trigger an unintended redirect.
if (embedded && params.get('embedded') !== '1') {
params.set('embedded', '1')
changed = true
}
if (!changed) return
const nextQuery = params.toString()
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false })
}, [embedded, language, pathname, router, searchParams, theme])
useEffect(() => {
const currentPath = window.location.pathname + (window.location.search || '')
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
}, [pathname, searchParams])
return (
<PublicShell embedded={embedded}>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm transition-colors dark:border-blue-800 dark:bg-blue-950/80"
/>
</a>
</div>
<p className="mt-5 text-xs font-bold uppercase tracking-[0.28em] text-orange-700 dark:text-orange-300">
RentalDriveGo
</p>
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.brandName}
</h1>
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
{dict.subtitle}
</p>
<div className="mt-4 flex items-center justify-center gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => setLanguage(lang)}
className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider transition-colors text-orange-700 dark:text-orange-300 ${
language === lang
? 'ring-1 ring-current opacity-100'
: 'opacity-45 hover:opacity-80'
}`}
>
{lang}
</button>
))}
</div>
</div>
<section className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/78 dark:shadow-[0_30px_80px_rgba(0,0,0,0.26)]">
<LocalSignInForm dict={dict} />
</section>
</div>
</main>
</PublicShell>
)
}
function LocalSignInForm({
dict,
}: {
dict: {
email: string
password: string
signIn: string
signingIn: string
verify: string
verifying: string
authCode: string
enterCode: string
back: string
forgotPassword: string
invalidCredentials: string
passwordNotSet: string
unexpectedError: string
}
}) {
const searchParams = useSearchParams()
const { setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
window.location.href = `${adminUrl}/auth-redirect#${hash}`
}
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
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?.employee) {
const targetPath = toPublicDashboardPath(employeeRedirect)
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
const prefLang = empJson.data.employee?.preferredLanguage
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
setLanguage(prefLang)
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; 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
// from a fresh request after the token cookie and localStorage are set.
window.location.replace(targetPath)
return
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet)
return
}
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?.admin) {
window.location.href = `${adminUrl}/dashboard`
return
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
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' },
credentials: 'include',
body: JSON.stringify({ email, password, ...secondFactor }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.admin) {
window.location.href = `${adminUrl}/dashboard`
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
return (
<>
{error ? (
<div className="mb-5 rounded-[1.5rem] border border-red-200/80 bg-red-50/90 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm 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>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.password}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm 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"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
>
{loading ? dict.signingIn : dict.signIn}
</button>
<div className="text-center">
<Link href="/forgot-password" className="text-sm text-stone-500 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 dark:text-stone-400 dark:decoration-stone-600 dark:hover:text-stone-100">
{dict.forgotPassword}
</Link>
</div>
</form>
) : (
<form onSubmit={handleTotp} className="space-y-5">
<div className="rounded-[1.5rem] border border-stone-200/80 bg-stone-50/90 px-4 py-4 text-center text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300">
{dict.enterCode}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.authCode}</label>
<input
type="text"
inputMode="text"
pattern="[0-9A-Za-z-]{6,14}"
maxLength={14}
required
value={totpCode}
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>
<button
type="submit"
disabled={loading}
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
>
{loading ? dict.verifying : dict.verify}
</button>
<button
type="button"
onClick={() => {
setStep('credentials')
setTotpCode('')
setError(null)
}}
className="w-full text-sm text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
>
{dict.back}
</button>
</form>
)}
</>
)
}
@@ -0,0 +1,936 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { getCurrencyLabel } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
import { BilingualField, BilingualInput, emptyBilingual } from '@/components/ui/BilingualInput'
type SignupForm = {
firstName: BilingualField
lastName: BilingualField
email: string
password: string
confirmPassword: string
preferredLanguage: 'en' | 'fr' | 'ar' | ''
companyName: BilingualField
legalName: string
legalForm: string
registrationNumber: string
iceNumber: string
taxId: string
operatingLicenseNumber: string
operatingLicenseIssuedAt: string
operatingLicenseIssuedBy: string
streetAddress: BilingualField
city: BilingualField
country: BilingualField
zipCode: string
companyPhone: string
companyEmail: string
fax: string
yearsActive: string
representativeName: string
representativeTitle: string
responsibleName: string
responsibleRole: string
responsibleIdentityNumber: string
responsibleQualification: string
responsiblePhone: string
responsibleEmail: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'
paymentProvider: 'AMANPAY' | 'PAYPAL'
}
type CompletedSignup = {
companyName: string
email: string
emailWarning?: string | null
}
type SignupResponse = {
companyId: string
companyName: string
slug: string
trialEndsAt: string | null
nextStep: string
emailDelivery?: {
attempted: boolean
success: boolean
error: string | null
} | null
}
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
export default function SignUpPage() {
const { language, setLanguage } = useDashboardI18n()
const searchParams = useSearchParams()
const initialPlan = normalizePlan(searchParams.get('plan'))
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
const [form, setForm] = useState<SignupForm>({
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
password: '',
confirmPassword: '',
preferredLanguage: '',
companyName: emptyBilingual(),
legalName: '',
legalForm: '',
registrationNumber: '',
iceNumber: '',
taxId: '',
operatingLicenseNumber: '',
operatingLicenseIssuedAt: '',
operatingLicenseIssuedBy: '',
streetAddress: emptyBilingual(),
city: emptyBilingual(),
country: emptyBilingual(),
zipCode: '',
companyPhone: '',
companyEmail: '',
fax: '',
yearsActive: '',
representativeName: '',
representativeTitle: '',
responsibleName: '',
responsibleRole: '',
responsibleIdentityNumber: '',
responsibleQualification: '',
responsiblePhone: '',
responsibleEmail: '',
plan: initialPlan,
billingPeriod: initialBillingPeriod,
currency: initialCurrency,
paymentProvider: 'AMANPAY',
})
const isArabic = language === 'ar'
const dict = {
en: {
brand: 'RentalDriveGo',
pageTitle: 'Launch your rental workspace',
pageSubtitle: 'Create the user account, complete the company information, and launch your workspace.',
steps: ['Account User', 'Company info', 'Plan', 'Verify'],
ownerTitle: 'Account User',
ownerBody: 'Create the primary user account that will manage this company workspace.',
partnerTitle: 'Company info',
partnerBody: 'Complete the company information required to create the workspace.',
planTitle: 'Choose your plan',
planBody: 'Your workspace starts immediately on a 90-day free trial in your preferred billing currency.',
reviewTitle: 'Review and launch',
reviewBody: 'We will create the company workspace immediately and start the 90-day trial with the plan you selected.',
firstName: 'Manager/Owner first name',
lastName: 'Manager/Owner last name',
ownerEmail: 'Manager/Owner email',
emailReuseHint: 'You can reuse the same email for the owner, company, and responsible person.',
password: 'Password',
confirmPassword: 'Confirm password',
passwordHint: 'Use at least 8 characters for your company owner account.',
companyName: 'Commercial name',
legalName: 'Legal company name',
legalForm: 'Legal form',
registrationNumber: 'Commercial Registry (RC)',
iceNumber: 'ICE number',
taxId: 'Fiscal ID (IF)',
operatingLicenseNumber: 'Operating license number',
operatingLicenseIssuedAt: 'License issue date',
operatingLicenseIssuedBy: 'Issuing authority',
streetAddress: 'Street Address',
city: 'City',
country: 'Country',
zipCode: 'Zip code',
companyPhone: 'Phone',
companyEmail: 'Company contact email',
fax: 'Fax (optional)',
yearsActive: 'Years active',
representativeName: 'Legal representative name',
representativeTitle: 'Legal representative title',
responsibleName: 'Responsible person name',
responsibleRole: 'Responsible person role',
responsibleIdentityNumber: 'Responsible person ID number',
responsibleQualification: 'Qualification / diploma / experience',
responsiblePhone: 'Responsible person phone',
responsibleEmail: 'Responsible person email',
identitySection: 'Legal identity',
contactSection: 'Company contact',
representativeSection: 'Legal representative',
responsibleSection: 'Responsible person',
continue: 'Continue',
back: 'Back',
working: 'Working…',
createWorkspace: 'Create workspace',
workspaceReady: 'Workspace ready',
workspaceReadyBody: 'is now active and the owner email',
enterDashboard: 'Enter dashboard',
signInAnotherDevice: 'Sign in on another device',
reviewWorkspace: 'Workspace',
reviewPlan: 'Plan',
reviewOwnerEmail: 'Owner email',
reviewCompanyEmail: 'Company email',
reviewPhone: 'Phone',
reviewLegalName: 'Legal name',
reviewRegistration: 'RC / ICE / IF',
invalidPassword: 'Choose a password with at least 8 characters.',
passwordMismatch: 'Passwords do not match.',
missingRequired: 'Fill in all required fields.',
missingLanguage: 'Please select your preferred language.',
preferredLanguageLabel: 'Preferred language',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'Your company',
couldNotCreate: 'Could not create workspace',
legalFormOptions: {
'': 'Select legal form',
SARL: 'SARL',
'SARL AU': 'SARL AU',
SA: 'SA',
SAS: 'SAS',
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
EI: 'Sole proprietorship',
OTHER: 'Other',
} as Record<string, string>,
yearsActiveOptions: {
'': 'Select years active',
LESS_THAN_1: 'Less than 1 year',
ONE_TO_FIVE: '1 to 5 years',
MORE_THAN_5: 'More than 5 years',
} as Record<string, string>,
planDescriptions: {
STARTER: 'Core fleet, offers, and bookings.',
GROWTH: 'Featured marketplace placement and more room to scale.',
PRO: 'Full white-label and premium controls.',
} as Record<SignupForm['plan'], string>,
billingPeriodOptions: {
MONTHLY: 'Monthly',
ANNUAL: 'Annual',
} as Record<SignupForm['billingPeriod'], string>,
paymentProviderOptions: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<SignupForm['paymentProvider'], string>,
},
fr: {
brand: 'RentalDriveGo',
pageTitle: 'Lancez votre espace de location',
pageSubtitle: 'Créez le compte utilisateur, complétez les informations de lentreprise et lancez votre espace.',
steps: ['Compte utilisateur', 'Infos entreprise', 'Plan', 'Vérification'],
ownerTitle: 'Compte utilisateur',
ownerBody: 'Créez le compte utilisateur principal qui gérera cet espace entreprise.',
partnerTitle: 'Infos entreprise',
partnerBody: 'Complétez les informations de lentreprise requises pour créer lespace.',
planTitle: 'Choisissez votre formule',
planBody: 'Votre espace démarre immédiatement avec un essai gratuit de 90 jours dans la devise choisie.',
reviewTitle: 'Vérification et lancement',
reviewBody: 'Nous allons créer immédiatement lespace entreprise et démarrer lessai de 90 jours avec la formule choisie.',
firstName: 'Prénom du gérant/propriétaire',
lastName: 'Nom du gérant/propriétaire',
ownerEmail: 'E-mail du gérant/propriétaire',
emailReuseHint: 'Vous pouvez utiliser le même e-mail pour le propriétaire, lentreprise et le responsable.',
password: 'Mot de passe',
confirmPassword: 'Confirmer le mot de passe',
passwordHint: 'Utilisez au moins 8 caractères pour le compte propriétaire.',
companyName: 'Nom commercial',
legalName: 'Raison sociale',
legalForm: 'Forme juridique',
registrationNumber: 'Registre du commerce (RC)',
iceNumber: 'Numéro ICE',
taxId: 'Identifiant fiscal (IF)',
operatingLicenseNumber: 'Numéro dagrément',
operatingLicenseIssuedAt: 'Date de délivrance',
operatingLicenseIssuedBy: 'Autorité délivrante',
streetAddress: 'Adresse',
city: 'Ville',
country: 'Pays',
zipCode: 'Code postal',
companyPhone: 'Téléphone',
companyEmail: 'E-mail de contact de lentreprise',
fax: 'Fax (optionnel)',
yearsActive: 'Années dactivité',
representativeName: 'Nom et prénom du représentant',
representativeTitle: 'Fonction du représentant',
responsibleName: 'Nom et prénom du responsable',
responsibleRole: 'Qualité du responsable',
responsibleIdentityNumber: 'N° de pièce didentité',
responsibleQualification: 'Qualification / diplôme / expérience',
responsiblePhone: 'Téléphone du responsable',
responsibleEmail: 'E-mail du responsable',
identitySection: 'Identité juridique',
contactSection: 'Coordonnées de lentreprise',
representativeSection: 'Représentant légal',
responsibleSection: 'Responsable désigné',
continue: 'Continuer',
back: 'Retour',
working: 'Traitement…',
createWorkspace: 'Créer lespace',
workspaceReady: 'Espace prêt',
workspaceReadyBody: 'est maintenant actif et le-mail propriétaire',
enterDashboard: 'Accéder au tableau de bord',
signInAnotherDevice: 'Se connecter sur un autre appareil',
reviewWorkspace: 'Espace',
reviewPlan: 'Formule',
reviewOwnerEmail: 'E-mail propriétaire',
reviewCompanyEmail: 'E-mail entreprise',
reviewPhone: 'Téléphone',
reviewLegalName: 'Raison sociale',
reviewRegistration: 'RC / ICE / IF',
invalidPassword: 'Choisissez un mot de passe dau moins 8 caractères.',
passwordMismatch: 'Les mots de passe ne correspondent pas.',
missingRequired: 'Renseignez tous les champs obligatoires.',
missingLanguage: 'Veuillez sélectionner votre langue préférée.',
preferredLanguageLabel: 'Langue préférée',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'Votre entreprise',
couldNotCreate: 'Impossible de créer lespace',
legalFormOptions: {
'': 'Sélectionner la forme juridique',
SARL: 'SARL',
'SARL AU': 'SARL AU',
SA: 'SA',
SAS: 'SAS',
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
EI: 'Entreprise individuelle',
OTHER: 'Autre',
} as Record<string, string>,
yearsActiveOptions: {
'': 'Sélectionner les années dactivité',
LESS_THAN_1: 'Moins de 1 an',
ONE_TO_FIVE: '1 an à 5 ans',
MORE_THAN_5: 'Plus de 5 ans',
} as Record<string, string>,
planDescriptions: {
STARTER: 'Flotte, offres et réservations essentielles.',
GROWTH: 'Mise en avant marketplace et plus de marge pour évoluer.',
PRO: 'White-label complet et contrôles premium.',
} as Record<SignupForm['plan'], string>,
billingPeriodOptions: {
MONTHLY: 'Mensuel',
ANNUAL: 'Annuel',
} as Record<SignupForm['billingPeriod'], string>,
paymentProviderOptions: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<SignupForm['paymentProvider'], string>,
},
ar: {
brand: 'RentalDriveGo',
pageTitle: 'أطلق مساحة التأجير الخاصة بك',
pageSubtitle: 'أنشئ حساب المستخدم، وأكمل معلومات الشركة، ثم أطلق مساحة العمل.',
steps: ['حساب المستخدم', 'معلومات الشركة', 'الخطة', 'المراجعة'],
ownerTitle: 'حساب المستخدم',
ownerBody: 'أنشئ حساب المستخدم الرئيسي الذي سيدير مساحة عمل الشركة.',
partnerTitle: 'معلومات الشركة',
partnerBody: 'أكمل معلومات الشركة المطلوبة لإنشاء مساحة العمل.',
planTitle: 'اختر خطتك',
planBody: 'تبدأ المساحة فوراً بفترة تجريبية مجانية لمدة 14 يوماً بالعملة التي تفضلها.',
reviewTitle: 'راجع وأطلق',
reviewBody: 'سننشئ مساحة الشركة فوراً ونبدأ الفترة التجريبية لمدة 14 يوماً بالخطة التي اخترتها.',
firstName: 'الاسم الأول للمدير/المالك',
lastName: 'اسم العائلة للمدير/المالك',
ownerEmail: 'بريد المدير/المالك الإلكتروني',
emailReuseHint: 'يمكنك استخدام نفس البريد الإلكتروني للمالك والشركة والمسؤول.',
password: 'كلمة المرور',
confirmPassword: 'تأكيد كلمة المرور',
passwordHint: 'استخدم 8 أحرف على الأقل لحساب مالك الشركة.',
companyName: 'الاسم التجاري',
legalName: 'الاسم القانوني للشركة',
legalForm: 'الشكل القانوني',
registrationNumber: 'السجل التجاري (RC)',
iceNumber: 'رقم ICE',
taxId: 'المعرف الضريبي (IF)',
operatingLicenseNumber: 'رقم الترخيص',
operatingLicenseIssuedAt: 'تاريخ إصدار الترخيص',
operatingLicenseIssuedBy: 'الجهة المانحة',
streetAddress: 'عنوان الشارع',
city: 'المدينة',
country: 'الدولة',
zipCode: 'الرمز البريدي',
companyPhone: 'الهاتف',
companyEmail: 'بريد الشركة للتواصل',
fax: 'الفاكس (اختياري)',
yearsActive: 'سنوات النشاط',
representativeName: 'اسم الممثل القانوني',
representativeTitle: 'صفة الممثل القانوني',
responsibleName: 'اسم المسؤول',
responsibleRole: 'صفة المسؤول',
responsibleIdentityNumber: 'رقم وثيقة الهوية',
responsibleQualification: 'المؤهل / الشهادة / الخبرة',
responsiblePhone: 'هاتف المسؤول',
responsibleEmail: 'بريد المسؤول الإلكتروني',
identitySection: 'الهوية القانونية',
contactSection: 'بيانات الشركة',
representativeSection: 'الممثل القانوني',
responsibleSection: 'الشخص المسؤول',
continue: 'متابعة',
back: 'رجوع',
working: 'جارٍ التنفيذ…',
createWorkspace: 'إنشاء المساحة',
workspaceReady: 'المساحة جاهزة',
workspaceReadyBody: 'أصبحت الآن نشطة وتم تسجيل بريد المالك',
enterDashboard: 'الدخول إلى لوحة التحكم',
signInAnotherDevice: 'تسجيل الدخول على جهاز آخر',
reviewWorkspace: 'المساحة',
reviewPlan: 'الخطة',
reviewOwnerEmail: 'بريد المالك',
reviewCompanyEmail: 'بريد الشركة',
reviewPhone: 'الهاتف',
reviewLegalName: 'الاسم القانوني',
reviewRegistration: 'RC / ICE / IF',
invalidPassword: 'اختر كلمة مرور من 8 أحرف على الأقل.',
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
missingRequired: 'أكمل جميع الحقول الإلزامية.',
missingLanguage: 'يرجى اختيار اللغة المفضلة.',
preferredLanguageLabel: 'اللغة المفضلة',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'شركتك',
couldNotCreate: 'تعذر إنشاء المساحة',
legalFormOptions: {
'': 'اختر الشكل القانوني',
SARL: 'شركة ذات مسؤولية محدودة',
'SARL AU': 'شركة ذات مسؤولية محدودة لشخص واحد',
SA: 'شركة مساهمة',
SAS: 'شركة مساهمة مبسطة',
AUTO_ENTREPRENEUR: 'مقاول ذاتي',
EI: 'مؤسسة فردية',
OTHER: 'أخرى',
} as Record<string, string>,
yearsActiveOptions: {
'': 'اختر سنوات النشاط',
LESS_THAN_1: 'أقل من سنة',
ONE_TO_FIVE: 'من سنة إلى 5 سنوات',
MORE_THAN_5: 'أكثر من 5 سنوات',
} as Record<string, string>,
planDescriptions: {
STARTER: 'الأساسيات للأسطول والعروض والحجوزات.',
GROWTH: 'ظهور مميز في السوق ومساحة أكبر للنمو.',
PRO: 'تحكم كامل وواجهة بيضاء متقدمة.',
} as Record<SignupForm['plan'], string>,
billingPeriodOptions: {
MONTHLY: 'شهري',
ANNUAL: 'سنوي',
} as Record<SignupForm['billingPeriod'], string>,
paymentProviderOptions: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<SignupForm['paymentProvider'], string>,
},
}[language]
function handleStep1Continue() {
if (!form.preferredLanguage) {
setError(dict.missingLanguage)
return
}
const ownerEmail = normalizeEmail(form.email)
setForm((current) => ({
...current,
companyEmail: current.companyEmail || ownerEmail,
responsibleEmail: current.responsibleEmail || ownerEmail,
}))
setError(null)
setStep(2)
}
function handleStep2Continue() {
if (!hasRequiredPartnerFields(form)) {
setError(dict.missingRequired)
return
}
setError(null)
setStep(3)
}
async function submitSignup() {
if (form.password.length < 8) {
setError(dict.invalidPassword)
return
}
if (form.password !== form.confirmPassword) {
setError(dict.passwordMismatch)
return
}
if (!hasRequiredPartnerFields(form)) {
setError(dict.missingRequired)
return
}
setLoading(true)
setError(null)
try {
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
method: 'POST',
body: JSON.stringify({
firstName: form.firstName.fr || form.firstName.ar,
firstNameAr: form.firstName.ar || undefined,
lastName: form.lastName.fr || form.lastName.ar,
lastNameAr: form.lastName.ar || undefined,
email: form.email,
password: form.password,
preferredLanguage: form.preferredLanguage || 'en',
companyName: form.companyName.fr || form.companyName.ar,
legalName: form.legalName,
companyNameAr: form.companyName.ar || undefined,
legalForm: form.legalForm,
registrationNumber: form.registrationNumber,
iceNumber: form.iceNumber,
taxId: form.taxId,
operatingLicenseNumber: form.operatingLicenseNumber,
operatingLicenseIssuedAt: form.operatingLicenseIssuedAt,
operatingLicenseIssuedBy: form.operatingLicenseIssuedBy,
streetAddress: form.streetAddress.fr || form.streetAddress.ar,
streetAddressAr: form.streetAddress.ar || undefined,
city: form.city.fr || form.city.ar,
cityAr: form.city.ar || undefined,
country: form.country.fr || form.country.ar,
countryAr: form.country.ar || undefined,
zipCode: form.zipCode,
companyPhone: form.companyPhone,
companyEmail: form.companyEmail,
fax: form.fax || undefined,
yearsActive: form.yearsActive,
representativeName: form.representativeName || undefined,
representativeTitle: form.representativeTitle || undefined,
responsibleName: form.responsibleName,
responsibleRole: form.responsibleRole,
responsibleIdentityNumber: form.responsibleIdentityNumber,
responsibleQualification: form.responsibleQualification || undefined,
responsiblePhone: form.responsiblePhone,
responsibleEmail: form.responsibleEmail,
plan: form.plan,
billingPeriod: form.billingPeriod,
currency: form.currency,
paymentProvider: form.paymentProvider,
}),
})
setCompletedSignup({
companyName: form.companyName.fr || form.companyName.ar,
email: form.email,
emailWarning: getEmailWarning(response.emailDelivery, language),
})
} catch (err) {
setError(getErrorMessage(err, dict.couldNotCreate))
} finally {
setLoading(false)
}
}
if (completedSignup) {
return (
<PublicShell>
<main className="flex-1 px-4 py-16">
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-blue-950"
/>
</a>
</div>
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">{dict.workspaceReady}</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody}
<span className="font-semibold text-slate-900"> {completedSignup.email}</span>.
</p>
{completedSignup.emailWarning ? (
<div className="mt-6 rounded-2xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
{completedSignup.emailWarning}
</div>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link href="/" className="btn-primary justify-center">
{dict.enterDashboard}
</Link>
<a href="/sign-in" className="btn-secondary justify-center">
{dict.signInAnotherDevice}
</a>
</div>
</div>
</main>
</PublicShell>
)
}
return (
<PublicShell>
<main className="px-4 py-12">
<div className="mx-auto max-w-4xl space-y-8">
<div className={`text-center ${isArabic ? 'rtl' : ''}`}>
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-blue-950"
/>
</a>
</div>
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">{dict.pageTitle}</h1>
<p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
</div>
<div className="grid gap-3 sm:grid-cols-4">
{dict.steps.map((label, index) => (
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-blue-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
{label}
</div>
))}
</div>
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
{step === 1 ? (
<div className="space-y-5">
<SectionIntro title={dict.ownerTitle} body={dict.ownerBody} />
<BilingualInput label={dict.firstName} required maxLength={80} value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
<BilingualInput label={dict.lastName} required maxLength={80} value={form.lastName} onChange={(current) => setForm((f) => ({ ...f, lastName: current }))} />
<LabeledInput label={dict.ownerEmail} required type="email" maxLength={254} value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: normalizeEmail(value) }))} />
<p className="text-xs text-slate-500">{dict.emailReuseHint}</p>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.password} required type="password" maxLength={128} value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
<LabeledInput label={dict.confirmPassword} required type="password" maxLength={128} value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
</div>
<p className="text-xs text-slate-500">{dict.passwordHint}</p>
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{dict.preferredLanguageLabel} <span className="text-red-600">*</span>
</span>
<div className="grid grid-cols-3 gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => {
setForm((current) => ({ ...current, preferredLanguage: lang }))
setLanguage(lang)
}}
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
form.preferredLanguage === lang
? 'border-blue-900 bg-blue-900 text-white'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
}`}
>
{dict.languageOptions[lang]}
</button>
))}
</div>
</div>
<div className="flex justify-end">
<button type="button" onClick={handleStep1Continue} className="btn-primary">{dict.continue}</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="space-y-5">
<SectionIntro title={dict.partnerTitle} body={dict.partnerBody} />
<div className="space-y-4">
<FormSubsection title={dict.identitySection} />
<BilingualInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
<LabeledInput label={dict.legalName} required maxLength={160} value={form.legalName} onChange={(value) => setForm((current) => ({ ...current, legalName: normalizeTrimmed(value) }))} />
<LabeledSelect label={dict.legalForm} required value={form.legalForm} options={['', ...LEGAL_FORM_OPTIONS]} labels={dict.legalFormOptions} onChange={(value) => setForm((current) => ({ ...current, legalForm: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeUpper(value) }))} />
<LabeledInput label={dict.iceNumber} required maxLength={120} value={form.iceNumber} onChange={(value) => setForm((current) => ({ ...current, iceNumber: normalizeUpper(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.taxId} required maxLength={120} value={form.taxId} onChange={(value) => setForm((current) => ({ ...current, taxId: normalizeUpper(value) }))} />
<LabeledInput label={dict.operatingLicenseNumber} required maxLength={120} value={form.operatingLicenseNumber} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseNumber: normalizeUpper(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.operatingLicenseIssuedAt} required type="date" value={form.operatingLicenseIssuedAt} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedAt: value }))} />
<LabeledInput label={dict.operatingLicenseIssuedBy} required maxLength={160} value={form.operatingLicenseIssuedBy} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedBy: normalizeTrimmed(value) }))} />
</div>
<FormSubsection title={dict.contactSection} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.companyPhone} required maxLength={80} value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
<LabeledInput label={dict.companyEmail} required type="email" maxLength={254} value={form.companyEmail} onChange={(value) => setForm((current) => ({ ...current, companyEmail: normalizeEmail(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.fax} maxLength={80} value={form.fax} onChange={(value) => setForm((current) => ({ ...current, fax: value }))} />
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: value }))} />
</div>
<BilingualInput label={dict.streetAddress} required maxLength={200} value={form.streetAddress} onChange={(value) => setForm((current) => ({ ...current, streetAddress: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<BilingualInput label={dict.city} required maxLength={120} value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
<BilingualInput label={dict.country} required maxLength={120} value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
</div>
<LabeledInput label={dict.zipCode} required maxLength={40} value={form.zipCode} onChange={(value) => setForm((current) => ({ ...current, zipCode: value.toUpperCase() }))} />
<FormSubsection title={dict.representativeSection} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.representativeName} maxLength={160} value={form.representativeName} onChange={(value) => setForm((current) => ({ ...current, representativeName: normalizeTrimmed(value) }))} />
<LabeledInput label={dict.representativeTitle} maxLength={120} value={form.representativeTitle} onChange={(value) => setForm((current) => ({ ...current, representativeTitle: normalizeTrimmed(value) }))} />
</div>
<FormSubsection title={dict.responsibleSection} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.responsibleName} required maxLength={160} value={form.responsibleName} onChange={(value) => setForm((current) => ({ ...current, responsibleName: normalizeTrimmed(value) }))} />
<LabeledInput label={dict.responsibleRole} required maxLength={120} value={form.responsibleRole} onChange={(value) => setForm((current) => ({ ...current, responsibleRole: normalizeTrimmed(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.responsibleIdentityNumber} required maxLength={120} value={form.responsibleIdentityNumber} onChange={(value) => setForm((current) => ({ ...current, responsibleIdentityNumber: normalizeUpper(value) }))} />
<LabeledInput label={dict.responsibleQualification} maxLength={200} value={form.responsibleQualification} onChange={(value) => setForm((current) => ({ ...current, responsibleQualification: normalizeTrimmed(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.responsiblePhone} required maxLength={80} value={form.responsiblePhone} onChange={(value) => setForm((current) => ({ ...current, responsiblePhone: value }))} />
<LabeledInput label={dict.responsibleEmail} required type="email" maxLength={254} value={form.responsibleEmail} onChange={(value) => setForm((current) => ({ ...current, responsibleEmail: normalizeEmail(value) }))} />
</div>
</div>
<NavActions onBack={() => setStep(1)} onNext={handleStep2Continue} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
</div>
) : null}
{step === 3 ? (
<div className="space-y-5">
<SectionIntro title={dict.planTitle} body={dict.planBody} />
<div className="grid gap-4 sm:grid-cols-3">
{(['STARTER', 'GROWTH', 'PRO'] as const).map((plan) => (
<button
key={plan}
type="button"
onClick={() => setForm((current) => ({ ...current, plan }))}
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-blue-900 bg-blue-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
>
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
<p className="mt-3 text-sm opacity-80">{dict.planDescriptions[plan]}</p>
</button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledSelect label={language === 'fr' ? 'Période de facturation' : language === 'ar' ? 'فترة الفوترة' : 'Billing period'} value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} labels={dict.billingPeriodOptions} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
<LabeledSelect label={language === 'fr' ? 'Prestataire principal' : language === 'ar' ? 'مزود الدفع الرئيسي' : 'Primary provider'} value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} labels={dict.paymentProviderOptions} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
</div>
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
</div>
) : null}
{step === 4 ? (
<div className="space-y-5">
<SectionIntro title={dict.reviewTitle} body={dict.reviewBody} />
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
<p><span className="font-semibold text-slate-900">{dict.reviewWorkspace}:</span> {form.companyName.fr || form.companyName.ar || dict.companyFallback}{form.companyName.fr && form.companyName.ar ? <span className="ml-2 text-slate-400">/ {form.companyName.ar}</span> : null}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewLegalName}:</span> {form.legalName || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · {getCurrencyLabel(language)}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewOwnerEmail}:</span> {form.email || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewCompanyEmail}:</span> {form.companyEmail || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewRegistration}:</span> {form.registrationNumber || '—'} / {form.iceNumber || '—'} / {form.taxId || '—'}</p>
</div>
<NavActions
onBack={() => setStep(3)}
onNext={submitSignup}
loading={loading}
nextLabel={dict.createWorkspace}
backLabel={dict.back}
loadingLabel={dict.working}
/>
</div>
) : null}
</div>
</div>
</main>
</PublicShell>
)
}
function hasRequiredPartnerFields(form: SignupForm) {
const bilingualFilled = (f: BilingualField) => f.fr.trim().length > 0 || f.ar.trim().length > 0
return (
bilingualFilled(form.companyName) &&
bilingualFilled(form.streetAddress) &&
bilingualFilled(form.city) &&
bilingualFilled(form.country) &&
[
form.legalName,
form.legalForm,
form.registrationNumber,
form.iceNumber,
form.taxId,
form.operatingLicenseNumber,
form.operatingLicenseIssuedAt,
form.operatingLicenseIssuedBy,
form.zipCode,
form.companyPhone,
form.companyEmail,
form.yearsActive,
form.responsibleName,
form.responsibleRole,
form.responsibleIdentityNumber,
form.responsiblePhone,
form.responsibleEmail,
]
.every((v) => v.trim().length > 0)
)
}
function SectionIntro({ title, body }: { title: string; body: string }) {
return (
<div>
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
<p className="mt-2 text-sm leading-7 text-slate-500">{body}</p>
</div>
)
}
function FormSubsection({ title }: { title: string }) {
return <h3 className="pt-2 text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">{title}</h3>
}
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery'], language: 'en' | 'fr' | 'ar') {
if (!emailDelivery) return null
if (emailDelivery.success) return null
if (emailDelivery.attempted) {
if (language === 'fr') return `Le compte a été créé, mais le-mail de confirmation na pas pu être envoyé${emailDelivery.error ? ` : ${emailDelivery.error}` : '.'}`
if (language === 'ar') return `تم إنشاء الحساب، لكن تعذر إرسال رسالة التأكيد${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
return `The account was created, but the confirmation email could not be delivered${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
}
if (language === 'fr') return 'Le compte a été créé, mais lenvoi de-mails nest pas encore configuré sur cet environnement.'
if (language === 'ar') return 'تم إنشاء الحساب، لكن إرسال البريد الإلكتروني غير مهيأ بعد في هذه البيئة.'
return 'The account was created, but email sending is not configured on this environment yet.'
}
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
return fallback
}
function normalizeEmail(value: string) {
return value.replace(/\s+/g, '').toLowerCase()
}
function normalizeTrimmed(value: string) {
return value.replace(/^\s+/, '')
}
function normalizeUpper(value: string) {
return value.replace(/^\s+/, '').toUpperCase()
}
function LabeledInput({
label,
value,
onChange,
type = 'text',
required = false,
maxLength,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
required?: boolean
maxLength?: number
}) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{label}
{required ? <span className="ml-1 text-red-600">*</span> : null}
</span>
<input type={type} maxLength={maxLength} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
</label>
)
}
function LabeledSelect({
label,
value,
options,
onChange,
labels,
required = false,
}: {
label: string
value: string
options: readonly string[]
onChange: (value: string) => void
labels?: Record<string, string>
required?: boolean
}) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{label}
{required ? <span className="ml-1 text-red-600">*</span> : null}
</span>
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option || 'empty'} value={option}>{labels?.[option] ?? option}</option>
))}
</select>
</label>
)
}
function NavActions({
onBack,
onNext,
loading = false,
nextLabel = 'Continue',
backLabel = 'Back',
loadingLabel = 'Working…',
}: {
onBack?: () => void
onNext: () => void
loading?: boolean
nextLabel?: string
backLabel?: string
loadingLabel?: string
}) {
return (
<div className="flex gap-3">
{onBack ? (
<button type="button" onClick={onBack} className="btn-secondary flex-1 justify-center">
{backLabel}
</button>
) : null}
<button type="button" onClick={onNext} disabled={loading} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
{loading ? loadingLabel : nextLabel}
</button>
</div>
)
}
function normalizePlan(value: string | null): SignupForm['plan'] {
if (value === 'GROWTH' || value === 'PRO' || value === 'STARTER') return value
return 'STARTER'
}
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
}
function normalizeCurrency(_value: string | null): SignupForm['currency'] {
return 'MAD'
}