fix notification and add billing page and contract

This commit is contained in:
root
2026-05-13 00:09:39 -04:00
committed by Administrator
parent 1a39aa8433
commit 89621a163b
52 changed files with 5631 additions and 1110 deletions
@@ -1,465 +1,160 @@
'use client'
import { useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type Currency = 'MAD' | 'USD' | 'EUR'
interface Subscription {
id: string
plan: Plan
billingPeriod: BillingPeriod
status: string
currency: Currency
trialEndAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
interface Invoice {
type PaymentRow = {
id: string
amount: number
currency: Currency
status: string
paymentProvider: string
type: 'CHARGE' | 'DEPOSIT'
paymentProvider: 'AMANPAY' | 'PAYPAL'
paidAt: string | null
createdAt: string
reservation: {
id: string
customer: { firstName: string; lastName: string }
vehicle: { make: string; model: string; licensePlate: string }
}
}
const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-amber-100 text-amber-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-amber-100 text-amber-700',
SUCCEEDED: 'bg-green-100 text-green-700',
FAILED: 'bg-red-100 text-red-700',
REFUNDED: 'bg-slate-100 text-slate-600',
PARTIALLY_REFUNDED: 'bg-blue-100 text-blue-700',
}
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
export default function BillingPage() {
export default function RentCarBillingPage() {
const { language } = useDashboardI18n()
const [subscription, setSubscription] = useState<Subscription | null>(null)
const [invoices, setInvoices] = useState<Invoice[]>([])
const [payments, setPayments] = useState<PaymentRow[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const [currency, setCurrency] = useState<Currency>('MAD')
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [paying, setPaying] = useState(false)
const [cancelling, setCancelling] = useState(false)
const copy = {
en: {
title: 'Billing',
subtitle: 'Manage your plan, payment provider, and invoice history.',
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',
title: 'Rent Car Billing',
subtitle: 'Track rental payment transactions across all reservations.',
bookCar: 'Book car',
loading: 'Loading payments…',
empty: 'No rental payments yet.',
failed: 'Failed to load rental payments.',
reservation: 'Reservation',
customer: 'Customer',
vehicle: 'Vehicle',
type: 'Type',
provider: 'Provider',
status: 'Status',
paid: 'Paid',
created: 'Created',
paidAt: 'Paid at',
amount: 'Amount',
loading: 'Loading…',
noInvoices: 'No invoices yet.',
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[]>,
charge: 'Charge',
deposit: 'Deposit',
},
fr: {
title: 'Facturation',
subtitle: 'Gérez votre plan, le prestataire de paiement et lhistorique des factures.',
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',
title: 'Facturation location',
subtitle: 'Suivez les paiements de location sur toutes les réservations.',
bookCar: 'Réserver une voiture',
loading: 'Chargement des paiements…',
empty: 'Aucun paiement de location pour le moment.',
failed: 'Échec du chargement des paiements de location.',
reservation: 'Réservation',
customer: 'Client',
vehicle: 'Véhicule',
type: 'Type',
provider: 'Prestataire',
status: 'Statut',
paid: 'Payé',
created: 'Créé le',
paidAt: 'Payé le',
amount: 'Montant',
loading: 'Chargement',
noInvoices: 'Aucune facture pour le moment.',
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[]>,
charge: 'Paiement',
deposit: 'Dépôt',
},
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: 'التاريخ',
title: 'فوترة تأجير السيارات',
subtitle: 'تتبع معاملات دفع الإيجار عبر جميع الحجوزات.',
bookCar: 'حجز سيارة',
loading: 'جارٍ تحميل المدفوعات…',
empty: 'لا توجد مدفوعات تأجير حتى الآن.',
failed: 'فشل تحميل مدفوعات التأجير.',
reservation: 'الحجز',
customer: 'العميل',
vehicle: 'المركبة',
type: 'النوع',
provider: 'المزوّد',
status: 'الحالة',
paid: 'مدفوع',
created: 'تاريخ الإنشاء',
paidAt: 'تاريخ الدفع',
amount: 'المبلغ',
loading: 'جارٍ التحميل…',
noInvoices: 'لا توجد فواتير حتى الآن.',
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[]>,
charge: 'دفعة',
deposit: 'عربون',
},
}[language]
useEffect(() => {
Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
])
.then(([sub, inv]) => {
if (sub) {
setSubscription(sub)
setSelectedPlan(sub.plan)
setBillingPeriod(sub.billingPeriod)
setCurrency(sub.currency as Currency)
}
setInvoices(inv ?? [])
})
.catch((err) => setError(err.message))
apiFetch<PaymentRow[]>('/payments/company')
.then((rows) => setPayments(rows ?? []))
.catch((err) => setError(err.message ?? copy.failed))
.finally(() => setLoading(false))
}, [])
async function handleCheckout() {
setPaying(true)
setError(null)
try {
const base = window.location.origin
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
plan: selectedPlan,
billingPeriod,
currency,
provider,
successUrl: `${base}/dashboard/billing?payment=success`,
failureUrl: `${base}/dashboard/billing?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 = PLAN_PRICES[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 className="space-y-6">
<div className="flex items-start justify-between gap-3">
<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>
<Link href="/dashboard/reservations/new" className="btn-primary whitespace-nowrap">
{copy.bookCar}
</Link>
</div>
{error && (
<div className="card p-4 border-red-200 bg-red-50 text-sm text-red-700">{error}</div>
)}
{error ? <div className="card p-4 text-sm text-red-700">{error}</div> : null}
{/* 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-amber-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">
<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-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{p === 'MONTHLY' ? copy.monthly : copy.annual}
</button>
))}
</div>
{/* Currency selector */}
<div className="flex items-center gap-2">
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
<button
key={c}
onClick={() => setCurrency(c)}
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{c}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
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, currency) : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
))}
</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">
{(['AMANPAY', 'PAYPAL'] as const).map((p) => (
<button
key={p}
onClick={() => setProvider(p)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl border-2 text-sm font-medium transition-all ${
provider === p ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{p === 'AMANPAY' ? '🏦 AmanPay' : '🔵 PayPal'}
</button>
))}
</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, currency) : '—'}
<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}
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.reservation}</th>
<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.vehicle}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.type}</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-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.created}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.paidAt}</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>
<tr><td colSpan={9} className="px-6 py-10 text-center text-sm text-slate-400">{copy.loading}</td></tr>
) : payments.length === 0 ? (
<tr><td colSpan={9} className="px-6 py-10 text-center text-sm text-slate-400">{copy.empty}</td></tr>
) : payments.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4 text-sm font-medium text-slate-900">#{row.reservation.id.slice(0, 8)}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.reservation.customer.firstName} {row.reservation.customer.lastName}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.reservation.vehicle.make} {row.reservation.vehicle.model} · {row.reservation.vehicle.licensePlate}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.type === 'DEPOSIT' ? copy.deposit : copy.charge}</td>
<td className="px-6 py-4 text-sm text-slate-700">{row.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 className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[row.status] ?? 'bg-slate-100 text-slate-600'}`}>
{row.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, inv.currency)}
</td>
<td className="px-6 py-4 text-sm text-slate-500">{new Date(row.createdAt).toLocaleDateString()}</td>
<td className="px-6 py-4 text-sm text-slate-500">{row.paidAt ? new Date(row.paidAt).toLocaleDateString() : '—'}</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.amount, row.currency)}</td>
</tr>
))}
</tbody>
@@ -0,0 +1,954 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { useParams } from 'next/navigation'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type DamagePoint = {
id?: string
x: number
y: number
damageType: 'SCRATCH' | 'DENT' | 'CRACK' | 'CHIP' | 'MISSING' | 'STAIN' | 'OTHER'
severity: 'MINOR' | 'MODERATE' | 'MAJOR'
description?: string | null
}
type ContractPayload = {
reservationId: string
contractNumber: string | null
invoiceNumber: string | null
generatedAt: string
status: string
paymentStatus: string
paymentMode: string | null
notes: string | null
company: {
name: string
legalName: string
email: string | null
phone: string | null
address: string | Record<string, unknown> | null
city: string | null
country: string | null
registrationNumber: string | null
taxId: string | null
logoUrl: string | null
}
driver: {
firstName: string
lastName: string
email: string
phone: string | null
dateOfBirth: string | null
driverLicense: string | null
licenseCountry: string | null
licenseCategory: string | null
licenseIssuedAt: string | null
licenseExpiry: string | null
nationality: string | null
}
additionalDrivers: Array<{
id: string
firstName: string
lastName: string
email: string | null
phone: string | null
driverLicense: string
licenseIssuedAt: string | null
licenseExpiry: string | null
licenseCountry: string | null
dateOfBirth: string | null
totalCharge: number
}>
vehicle: {
make: string
model: string
year: number
color: string
licensePlate: string
vin: string | null
category: string
}
rentalPeriod: {
startDate: string
endDate: string
totalDays: number
pickupLocation: string | null
returnLocation: string | null
}
insurance: {
total: number
policies: Array<{
id: string
name: string
chargeType: string
unitPrice: number
totalCharge: number
}>
}
inspections: {
checkIn: {
mileage: number | null
fuelLevel: string
generalCondition: string | null
employeeNotes: string | null
damagePoints: DamagePoint[]
} | null
checkOut: {
mileage: number | null
fuelLevel: string
generalCondition: string | null
employeeNotes: string | null
damagePoints: DamagePoint[]
} | null
}
terms: {
terms: string | null
fuelPolicy: string | null
fuelPolicyType: string | null
depositPolicy: string | null
lateFeePolicy: string | null
damagePolicy: string | null
footerNote: string | null
signatureRequired: boolean
}
invoice: {
currency: string
lineItems: Array<{
description: string
qty: number
unitPrice: number
total: number
category: string
}>
subtotal: number
taxes: Array<{ label: string; rate: number; amount: number }>
taxTotal: number
total: number
amountPaid: number
balanceDue: number
payments: Array<{
id: string
amount: number
currency: string
type: string
status: string
provider: string
paymentMethod: string | null
paidAt: string | null
createdAt: string
}>
}
}
const severityColors: Record<DamagePoint['severity'], string> = {
MINOR: '#f59e0b',
MODERATE: '#f97316',
MAJOR: '#dc2626',
}
function VehicleDamageDiagram({ points }: { points: DamagePoint[] }) {
return (
<svg viewBox="0 0 100 180" className="w-full max-w-[120px] rounded-2xl border border-slate-200 bg-white p-2">
<rect x="30" y="12" width="40" height="156" rx="18" fill="#e2e8f0" stroke="#94a3b8" />
<rect x="36" y="22" width="28" height="24" rx="8" fill="#cbd5e1" />
<rect x="36" y="54" width="28" height="70" rx="8" fill="#f8fafc" stroke="#cbd5e1" />
<rect x="36" y="132" width="28" height="20" rx="8" fill="#cbd5e1" />
<rect x="18" y="38" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="70" y="38" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="18" y="114" width="12" height="28" rx="5" fill="#1e293b" />
<rect x="70" y="114" width="12" height="28" rx="5" fill="#1e293b" />
{points.map((point, index) => (
<circle
key={`${point.x}-${point.y}-${index}`}
cx={point.x}
cy={point.y}
r="4.2"
fill={severityColors[point.severity]}
stroke="white"
strokeWidth="1.5"
/>
))}
</svg>
)
}
function formatAddress(address: string | Record<string, unknown> | null, city: string | null, country: string | null) {
const lines: string[] = []
if (typeof address === 'string' && address.trim()) lines.push(address.trim())
if (address && typeof address === 'object' && !Array.isArray(address)) {
for (const value of Object.values(address)) {
if (typeof value === 'string' && value.trim()) lines.push(value.trim())
}
}
if (city) lines.push(city)
if (country) lines.push(country)
return lines.join(', ')
}
function formatDateOnly(value: string | null | undefined, localeCode: string, fallback: string) {
return value
? new Date(value).toLocaleDateString(localeCode, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: fallback
}
function splitTerms(text: string | null | undefined) {
if (!text?.trim()) return []
return text
.split(/\n\s*\n|\n(?=\d+\.)/)
.map((item) => item.replace(/^\d+\.\s*/, '').trim())
.filter(Boolean)
}
function PaperField({
label,
value,
className = '',
}: {
label: string
value: string
className?: string
}) {
return (
<div className={`border border-stone-400/80 ${className}`}>
<div className="border-b border-stone-300 bg-stone-100 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-stone-600">
{label}
</div>
<div className="min-h-[38px] bg-[#fff5ea] px-2 py-2 text-sm text-stone-900">
{value}
</div>
</div>
)
}
function PaperSection({
title,
children,
className = '',
}: {
title: string
children: React.ReactNode
className?: string
}) {
return (
<section className={`border border-stone-500 ${className}`}>
<div className="border-b border-stone-500 bg-stone-100 px-3 py-1.5 text-[11px] font-bold uppercase tracking-[0.12em] text-stone-700">
{title}
</div>
<div>{children}</div>
</section>
)
}
export default function ContractDetailPage() {
const { id } = useParams<{ id: string }>()
const { language } = useDashboardI18n()
const [contract, setContract] = useState<ContractPayload | null>(null)
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
back: 'Back to contracts',
booking: 'Booking',
print: 'Print',
generated: 'Generated',
status: 'Status',
paymentStatus: 'Payment status',
paymentMode: 'Payment mode',
company: 'Rental company',
driver: 'Primary driver',
additionalDrivers: 'Additional drivers',
vehicle: 'Vehicle',
period: 'Rental period',
pickup: 'Pickup',
dropoff: 'Return',
start: 'Start',
end: 'End',
contractTerms: 'Contract terms',
insurance: 'Insurance',
inspections: 'Vehicle condition and damage',
noDamage: 'No damage markers recorded at check-in.',
invoice: 'Invoice',
subtotal: 'Subtotal',
paid: 'Paid',
balance: 'Balance due',
payments: 'Payments',
notes: 'Booking notes',
footer: 'Footer note',
none: 'Not provided',
contractNo: 'Contract #',
invoiceNo: 'Invoice #',
fuel: 'Fuel policy',
deposit: 'Deposit policy',
lateFees: 'Late fee policy',
damage: 'Damage policy',
termsLabel: 'Terms',
signature: 'Signature required',
customerDamage: 'Check-in inspection',
mileage: 'Mileage',
fuelLevel: 'Fuel level',
unitPrice: 'Unit price',
qty: 'Qty',
total: 'Total',
actionOpenBooking: 'Open booking',
noAdditionalDrivers: 'No additional drivers.',
noInsurance: 'No insurance selected.',
noPayments: 'No payments recorded.',
loading: 'Loading contract…',
yes: 'Yes',
no: 'No',
agreementTitle: 'Rental Agreement',
agreementForReservation: 'For reservation',
draftAgreement: 'Draft agreement',
customerInformation: 'Customer information',
rentalPeriodDetails: 'Rental period and driver details',
insuranceCondition: 'Insurance and vehicle condition',
acknowledgementSignature: 'Driver acknowledgement and signature',
vehicleInformation: 'Rental vehicle information',
rentalRate: 'Car rental rate',
billingSummary: 'Payment and billing summary',
damageDiagram: 'Vehicle damage diagram',
customerName: 'Customer name',
homeAddress: 'Home address',
cityCountry: 'City / Country',
telephone: 'Telephone',
emailLabel: 'E-mail',
driverLicenseNumber: "Driver's license no.",
nationalityLabel: 'Nationality',
birthDate: 'Birth date',
issuedExpires: 'Issued / Expires',
dateOut: 'Date out',
dateDueIn: 'Date due in',
mileageOut: 'Mileage out',
mileageIn: 'Mileage in',
pickupLocationLabel: 'Pickup location',
returnLocationLabel: 'Return location',
insuranceSelections: 'Insurance selections',
checkInCondition: 'Check-in condition',
signatureAcknowledgement:
'The renter confirms that the vehicle was received in acceptable condition, agrees to return it according to the rental policies, and accepts responsibility for authorized charges, violations, late fees, fuel adjustments, and damage assessment tied to this reservation.',
customerSignature: 'Customer signature',
companyRepresentative: 'Company representative',
yearMakeModel: 'Year / Make / Model',
licensePlateLabel: 'License plate',
vehicleColor: 'Vehicle color',
categoryLabel: 'Category',
vinLabel: 'VIN',
dailyRate: 'Daily rate',
rentalDays: 'Rental days',
insuranceTotal: 'Insurance total',
additionalDriverFees: 'Additional driver fees',
amountPaidLabel: 'Amount paid',
balanceDueLabel: 'Balance due',
lastPayment: 'Last payment',
roadsideAssistance: 'For after-hours roadside assistance or towing call:',
taxesLabel: 'Taxes',
totalChargeLabel: 'Total charge',
damageMarkersRecorded: (count: number) => `${count} damage marker(s) recorded at check-in.`,
termsAndConditions: 'Rental Agreement Terms and Conditions',
checkInMileageFuel: 'Check-in mileage / fuel',
checkOutMileageFuel: 'Check-out mileage / fuel',
damageEntries: 'Damage entries',
notesAndFooter: 'Notes and footer',
paymentsRecorded: (count: number) => `${count} payment(s) recorded.`,
clauseHeadings: [
'Vehicle and reservation',
'Authorized use',
'Fuel and return condition',
'Damage liability',
'Deposit and security hold',
'Late return and extra charges',
'Payment authorization',
'General provisions',
],
fallbackClauses: {
vehicleReservation: (vehicle: string) => `The renter accepts responsibility for the ${vehicle} during the rental period and agrees to return it in the same general condition, ordinary wear excepted.`,
authorizedUse: 'Only the primary driver and approved additional drivers may operate the vehicle. The renter is responsible for any fines, tolls, traffic violations, or unauthorized use during the reservation.',
fuel: 'The vehicle must be returned with the agreed fuel level. Any shortage may result in refueling charges.',
damage: 'Any loss or damage, including damage caused by negligence or misuse, may be charged to the renter according to company policy and applicable law.',
deposit: 'A security deposit may be held to secure unpaid balances, penalties, damage, or post-rental adjustments until the reservation is reconciled.',
lateFees: 'Late return, excess mileage, cleaning, or administrative fees may be applied based on the reservation terms and final inspection.',
paymentAuthorization: (company: string) => `The renter authorizes ${company} to process charges related to this reservation, including rental charges, taxes, optional coverages, additional drivers, and approved post-rental adjustments.`,
general: "This agreement remains subject to the company's full rental policies, final inspection, and applicable local regulations.",
},
contractStatusLabels: { DRAFT: 'Draft', PENDING: 'Pending', ACTIVE: 'Active', COMPLETED: 'Completed', CANCELLED: 'Cancelled' },
paymentStatusLabels: { UNPAID: 'Unpaid', PAID: 'Paid', PARTIAL: 'Partial', REFUNDED: 'Refunded', OVERDUE: 'Overdue', PENDING: 'Pending', COMPLETED: 'Completed', FAILED: 'Failed' },
chargeTypeLabels: { PER_DAY: 'Per day', FLAT: 'Flat fee', FLAT_RATE: 'Flat fee', PER_RENTAL: 'Per rental' },
damageTypeLabels: { SCRATCH: 'Scratch', DENT: 'Dent', CRACK: 'Crack', CHIP: 'Chip', MISSING: 'Missing', STAIN: 'Stain', OTHER: 'Other' },
severityLabels: { MINOR: 'Minor', MODERATE: 'Moderate', MAJOR: 'Major' },
fuelLevelLabels: { FULL: 'Full', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Empty' },
paymentProviderLabels: { CASH: 'Cash', STRIPE: 'Stripe', BANK_TRANSFER: 'Bank transfer', CHECK: 'Check', PAYPAL: 'PayPal', CREDIT_CARD: 'Credit card', DEBIT_CARD: 'Debit card' },
},
fr: {
back: 'Retour aux contrats',
booking: 'Réservation',
print: 'Imprimer',
generated: 'Généré',
status: 'Statut',
paymentStatus: 'Statut du paiement',
paymentMode: 'Mode de paiement',
company: 'Société de location',
driver: 'Conducteur principal',
additionalDrivers: 'Conducteurs supplémentaires',
vehicle: 'Véhicule',
period: 'Période de location',
pickup: 'Départ',
dropoff: 'Retour',
start: 'Début',
end: 'Fin',
contractTerms: 'Clauses du contrat',
insurance: 'Assurance',
inspections: 'État du véhicule et dommages',
noDamage: 'Aucun dommage enregistré au départ.',
invoice: 'Facture',
subtotal: 'Sous-total',
paid: 'Payé',
balance: 'Solde dû',
payments: 'Paiements',
notes: 'Notes de réservation',
footer: 'Note de bas de page',
none: 'Non renseigné',
contractNo: 'Contrat #',
invoiceNo: 'Facture #',
fuel: 'Politique carburant',
deposit: 'Politique de dépôt',
lateFees: 'Politique de retard',
damage: 'Politique dommages',
termsLabel: 'Conditions',
signature: 'Signature requise',
customerDamage: 'Inspection de départ',
mileage: 'Kilométrage',
fuelLevel: 'Niveau de carburant',
unitPrice: 'Prix unitaire',
qty: 'Qté',
total: 'Total',
actionOpenBooking: 'Ouvrir la réservation',
noAdditionalDrivers: 'Aucun conducteur supplémentaire.',
noInsurance: 'Aucune assurance sélectionnée.',
noPayments: 'Aucun paiement enregistré.',
loading: 'Chargement du contrat…',
yes: 'Oui',
no: 'Non',
agreementTitle: 'Contrat de location',
agreementForReservation: 'Pour la réservation',
draftAgreement: 'Brouillon de contrat',
customerInformation: 'Informations client',
rentalPeriodDetails: 'Période de location et conducteur',
insuranceCondition: 'Assurance et état du véhicule',
acknowledgementSignature: 'Reconnaissance et signature',
vehicleInformation: 'Informations véhicule',
rentalRate: 'Tarification de location',
billingSummary: 'Résumé de facturation',
damageDiagram: 'Schéma des dommages du véhicule',
customerName: 'Nom du client',
homeAddress: 'Adresse',
cityCountry: 'Ville / Pays',
telephone: 'Téléphone',
emailLabel: 'E-mail',
driverLicenseNumber: 'N° de permis',
nationalityLabel: 'Nationalité',
birthDate: 'Date de naissance',
issuedExpires: 'Délivré / Expire',
dateOut: 'Date de sortie',
dateDueIn: 'Date de retour prévue',
mileageOut: 'Kilométrage sortie',
mileageIn: 'Kilométrage retour',
pickupLocationLabel: 'Lieu de départ',
returnLocationLabel: 'Lieu de retour',
insuranceSelections: 'Assurances sélectionnées',
checkInCondition: 'État au départ',
signatureAcknowledgement:
"Le locataire confirme avoir reçu le véhicule dans un état acceptable, s'engage à le restituer selon les politiques de location et accepte la responsabilité des frais autorisés, infractions, pénalités de retard, ajustements carburant et constats de dommages liés à cette réservation.",
customerSignature: 'Signature du client',
companyRepresentative: 'Représentant de la société',
yearMakeModel: 'Année / Marque / Modèle',
licensePlateLabel: "Plaque d'immatriculation",
vehicleColor: 'Couleur du véhicule',
categoryLabel: 'Catégorie',
vinLabel: 'VIN',
dailyRate: 'Tarif journalier',
rentalDays: 'Jours de location',
insuranceTotal: 'Total assurance',
additionalDriverFees: 'Frais conducteur supplémentaire',
amountPaidLabel: 'Montant payé',
balanceDueLabel: 'Solde dû',
lastPayment: 'Dernier paiement',
roadsideAssistance: 'Pour une assistance routière ou un remorquage hors horaires, appelez :',
taxesLabel: 'Taxes',
totalChargeLabel: 'Montant total',
damageMarkersRecorded: (count: number) => `${count} repère(s) de dommage enregistré(s) au départ.`,
termsAndConditions: 'Conditions générales du contrat de location',
checkInMileageFuel: 'Kilométrage / carburant départ',
checkOutMileageFuel: 'Kilométrage / carburant retour',
damageEntries: 'Dommages enregistrés',
notesAndFooter: 'Notes et pied de page',
paymentsRecorded: (count: number) => `${count} paiement(s) enregistré(s).`,
clauseHeadings: [
'Véhicule et réservation',
'Usage autorisé',
'Carburant et restitution',
'Responsabilité dommages',
'Dépôt de garantie',
'Retard et frais supplémentaires',
'Autorisation de paiement',
'Dispositions générales',
],
fallbackClauses: {
vehicleReservation: (vehicle: string) => `Le locataire accepte la responsabilité du ${vehicle} pendant la période de location et s'engage à le restituer dans le même état général, hors usure normale.`,
authorizedUse: 'Seuls le conducteur principal et les conducteurs supplémentaires approuvés peuvent utiliser le véhicule. Le locataire reste responsable des amendes, péages, infractions routières ou usages non autorisés durant la réservation.',
fuel: 'Le véhicule doit être restitué avec le niveau de carburant convenu. Toute insuffisance peut entraîner des frais de ravitaillement.',
damage: 'Toute perte ou tout dommage, y compris causé par négligence ou mauvaise utilisation, peut être facturé au locataire selon la politique de la société et la réglementation applicable.',
deposit: "Un dépôt de garantie peut être retenu pour couvrir les soldes impayés, pénalités, dommages ou ajustements post-location jusqu'à la clôture complète de la réservation.",
lateFees: "Des frais de retard, kilométrage excédentaire, nettoyage ou gestion peuvent être appliqués selon les conditions de réservation et l'inspection finale.",
paymentAuthorization: (company: string) => `Le locataire autorise ${company} à traiter les frais liés à cette réservation, y compris location, taxes, garanties optionnelles, conducteurs supplémentaires et ajustements post-location approuvés.`,
general: "Le présent accord reste soumis aux politiques complètes de la société, à l'inspection finale et à la réglementation locale applicable.",
},
contractStatusLabels: { DRAFT: 'Brouillon', PENDING: 'En attente', ACTIVE: 'Actif', COMPLETED: 'Terminé', CANCELLED: 'Annulé' },
paymentStatusLabels: { UNPAID: 'Non payé', PAID: 'Payé', PARTIAL: 'Partiel', REFUNDED: 'Remboursé', OVERDUE: 'En retard', PENDING: 'En attente', COMPLETED: 'Complété', FAILED: 'Échoué' },
chargeTypeLabels: { PER_DAY: 'Par jour', FLAT: 'Forfait', FLAT_RATE: 'Forfait', PER_RENTAL: 'Par location' },
damageTypeLabels: { SCRATCH: 'Rayure', DENT: 'Bosse', CRACK: 'Fissure', CHIP: 'Éclat', MISSING: 'Manquant', STAIN: 'Tache', OTHER: 'Autre' },
severityLabels: { MINOR: 'Léger', MODERATE: 'Modéré', MAJOR: 'Majeur' },
fuelLevelLabels: { FULL: 'Plein', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'Vide' },
paymentProviderLabels: { CASH: 'Espèces', STRIPE: 'Stripe', BANK_TRANSFER: 'Virement bancaire', CHECK: 'Chèque', PAYPAL: 'PayPal', CREDIT_CARD: 'Carte crédit', DEBIT_CARD: 'Carte débit' },
},
ar: {
back: 'العودة إلى العقود',
booking: 'الحجز',
print: 'طباعة',
generated: 'تم الإنشاء',
status: 'الحالة',
paymentStatus: 'حالة الدفع',
paymentMode: 'طريقة الدفع',
company: 'شركة التأجير',
driver: 'السائق الرئيسي',
additionalDrivers: 'السائقون الإضافيون',
vehicle: 'المركبة',
period: 'مدة الإيجار',
pickup: 'الاستلام',
dropoff: 'التسليم',
start: 'البداية',
end: 'النهاية',
contractTerms: 'شروط العقد',
insurance: 'التأمين',
inspections: 'حالة المركبة والأضرار',
noDamage: 'لا توجد أضرار مسجلة عند الاستلام.',
invoice: 'الفاتورة',
subtotal: 'الإجمالي الفرعي',
paid: 'المدفوع',
balance: 'الرصيد المستحق',
payments: 'المدفوعات',
notes: 'ملاحظات الحجز',
footer: 'ملاحظة التذييل',
none: 'غير متوفر',
contractNo: 'رقم العقد',
invoiceNo: 'رقم الفاتورة',
fuel: 'سياسة الوقود',
deposit: 'سياسة العربون',
lateFees: 'سياسة التأخير',
damage: 'سياسة الأضرار',
termsLabel: 'الشروط',
signature: 'التوقيع مطلوب',
customerDamage: 'فحص الاستلام',
mileage: 'عداد المسافة',
fuelLevel: 'مستوى الوقود',
unitPrice: 'سعر الوحدة',
qty: 'الكمية',
total: 'الإجمالي',
actionOpenBooking: 'فتح الحجز',
noAdditionalDrivers: 'لا يوجد سائقون إضافيون.',
noInsurance: 'لم يتم اختيار تأمين.',
noPayments: 'لا توجد مدفوعات مسجلة.',
loading: 'جار تحميل العقد…',
yes: 'نعم',
no: 'لا',
agreementTitle: 'عقد تأجير',
agreementForReservation: 'للحجز',
draftAgreement: 'مسودة العقد',
customerInformation: 'معلومات العميل',
rentalPeriodDetails: 'مدة التأجير وبيانات السائق',
insuranceCondition: 'التأمين وحالة المركبة',
acknowledgementSignature: 'إقرار السائق والتوقيع',
vehicleInformation: 'معلومات مركبة التأجير',
rentalRate: 'سعر التأجير',
billingSummary: 'ملخص الفوترة',
damageDiagram: 'مخطط أضرار المركبة',
customerName: 'اسم العميل',
homeAddress: 'العنوان',
cityCountry: 'المدينة / الدولة',
telephone: 'الهاتف',
emailLabel: 'البريد الإلكتروني',
driverLicenseNumber: 'رقم رخصة القيادة',
nationalityLabel: 'الجنسية',
birthDate: 'تاريخ الميلاد',
issuedExpires: 'الإصدار / الانتهاء',
dateOut: 'تاريخ الخروج',
dateDueIn: 'تاريخ الإرجاع',
mileageOut: 'عداد الخروج',
mileageIn: 'عداد الإرجاع',
pickupLocationLabel: 'مكان الاستلام',
returnLocationLabel: 'مكان التسليم',
insuranceSelections: 'خيارات التأمين',
checkInCondition: 'الحالة عند الاستلام',
signatureAcknowledgement:
'يقر المستأجر بأنه استلم المركبة بحالة مقبولة، ويلتزم بإعادتها وفق سياسات التأجير، ويقبل المسؤولية عن الرسوم المعتمدة والمخالفات ورسوم التأخير وتسويات الوقود وتقييمات الأضرار المرتبطة بهذا الحجز.',
customerSignature: 'توقيع العميل',
companyRepresentative: 'ممثل الشركة',
yearMakeModel: 'السنة / الصانع / الطراز',
licensePlateLabel: 'لوحة المركبة',
vehicleColor: 'لون المركبة',
categoryLabel: 'الفئة',
vinLabel: 'رقم الهيكل',
dailyRate: 'السعر اليومي',
rentalDays: 'أيام التأجير',
insuranceTotal: 'إجمالي التأمين',
additionalDriverFees: 'رسوم السائق الإضافي',
amountPaidLabel: 'المبلغ المدفوع',
balanceDueLabel: 'الرصيد المستحق',
lastPayment: 'آخر دفعة',
roadsideAssistance: 'للمساعدة على الطريق أو السحب خارج ساعات العمل اتصل بـ:',
taxesLabel: 'الضرائب',
totalChargeLabel: 'إجمالي الرسوم',
damageMarkersRecorded: (count: number) => `تم تسجيل ${count} علامة/علامات ضرر عند الاستلام.`,
termsAndConditions: 'الشروط والأحكام لعقد التأجير',
checkInMileageFuel: 'عداد / وقود الاستلام',
checkOutMileageFuel: 'عداد / وقود الإرجاع',
damageEntries: 'الأضرار المسجلة',
notesAndFooter: 'الملاحظات والتذييل',
paymentsRecorded: (count: number) => `تم تسجيل ${count} دفعة/دفعات.`,
clauseHeadings: [
'المركبة والحجز',
'الاستخدام المصرح',
'الوقود وحالة الإرجاع',
'مسؤولية الأضرار',
'وديعة الضمان',
'التأخير والرسوم الإضافية',
'تفويض الدفع',
'أحكام عامة',
],
fallbackClauses: {
vehicleReservation: (vehicle: string) => `يقبل المستأجر المسؤولية عن ${vehicle} طوال مدة التأجير، ويتعهد بإعادته بنفس الحالة العامة باستثناء الاستهلاك العادي.`,
authorizedUse: 'يُسمح فقط للسائق الرئيسي والسائقين الإضافيين المعتمدين بقيادة المركبة. ويتحمل المستأجر مسؤولية أي غرامات أو رسوم طرق أو مخالفات مرورية أو استخدام غير مصرح به أثناء الحجز.',
fuel: 'يجب إعادة المركبة بمستوى الوقود المتفق عليه. وأي نقص قد يترتب عليه رسوم تعبئة.',
damage: 'أي فقدان أو ضرر، بما في ذلك الناتج عن الإهمال أو سوء الاستخدام، قد يتم تحميله على المستأجر وفق سياسة الشركة والأنظمة المعمول بها.',
deposit: 'قد يتم حجز وديعة ضمان لتغطية الأرصدة غير المسددة أو الغرامات أو الأضرار أو التسويات اللاحقة إلى حين إقفال الحجز بشكل نهائي.',
lateFees: 'قد تُفرض رسوم التأخير أو الكيلومترات الزائدة أو التنظيف أو الرسوم الإدارية وفق شروط الحجز والفحص النهائي.',
paymentAuthorization: (company: string) => `يفوض المستأجر ${company} بتحصيل الرسوم المتعلقة بهذا الحجز، بما في ذلك قيمة التأجير والضرائب والتغطيات الاختيارية والسائقين الإضافيين والتسويات المعتمدة بعد الإرجاع.`,
general: 'يبقى هذا الاتفاق خاضعًا لسياسات الشركة الكاملة والفحص النهائي والأنظمة المحلية المعمول بها.',
},
contractStatusLabels: { DRAFT: 'مسودة', PENDING: 'قيد الانتظار', ACTIVE: 'نشط', COMPLETED: 'مكتمل', CANCELLED: 'ملغى' },
paymentStatusLabels: { UNPAID: 'غير مدفوع', PAID: 'مدفوع', PARTIAL: 'جزئي', REFUNDED: 'مسترد', OVERDUE: 'متأخر', PENDING: 'قيد الانتظار', COMPLETED: 'مكتمل', FAILED: 'فشل' },
chargeTypeLabels: { PER_DAY: 'يومي', FLAT: 'مقطوع', FLAT_RATE: 'مقطوع', PER_RENTAL: 'لكل إيجار' },
damageTypeLabels: { SCRATCH: 'خدش', DENT: 'دهس', CRACK: 'تشقق', CHIP: 'تقشر', MISSING: 'مفقود', STAIN: 'بقعة', OTHER: 'أخرى' },
severityLabels: { MINOR: 'طفيف', MODERATE: 'متوسط', MAJOR: 'خطير' },
fuelLevelLabels: { FULL: 'ممتلئ', SEVEN_EIGHTHS: '7/8', THREE_QUARTERS: '3/4', FIVE_EIGHTHS: '5/8', HALF: '1/2', THREE_EIGHTHS: '3/8', QUARTER: '1/4', ONE_EIGHTH: '1/8', EMPTY: 'فارغ' },
paymentProviderLabels: { CASH: 'نقداً', STRIPE: 'Stripe', BANK_TRANSFER: 'تحويل بنكي', CHECK: 'شيك', PAYPAL: 'PayPal', CREDIT_CARD: 'بطاقة ائتمان', DEBIT_CARD: 'بطاقة خصم' },
},
}[language]
useEffect(() => {
apiFetch<ContractPayload>(`/reservations/${id}/contract`)
.then((data) => {
setContract(data)
setError(null)
})
.catch((err) => setError(err.message ?? 'Failed to load contract'))
}, [id])
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const formatDateTime = (value: string | null | undefined) =>
value
? new Date(value).toLocaleString(localeCode, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: copy.none
const address = useMemo(() => (
contract ? formatAddress(contract.company.address, contract.company.city, contract.company.country) : ''
), [contract])
if (error) return <div className="card p-6 text-sm text-red-600">{error}</div>
if (!contract) return <div className="card p-6 text-sm text-slate-500">{copy.loading}</div>
const tl = (map: Record<string, string>, key: string) => map[key] ?? key
const damagePoints = contract.inspections.checkIn?.damagePoints ?? []
const rentalLine = contract.invoice.lineItems.find((item) => item.category === 'RENTAL') ?? contract.invoice.lineItems[0]
const depositLine = contract.invoice.lineItems.find((item) => item.category === 'DEPOSIT')
const additionalDriverLine = contract.invoice.lineItems.filter((item) => item.category === 'ADDITIONAL_DRIVER')
const clauses = splitTerms(contract.terms.terms)
const vehicleLabel = `${contract.vehicle.year} ${contract.vehicle.make} ${contract.vehicle.model}`
const fallbackClauses = [
copy.fallbackClauses.vehicleReservation(vehicleLabel),
copy.fallbackClauses.authorizedUse,
copy.fallbackClauses.fuel,
copy.fallbackClauses.damage,
copy.fallbackClauses.deposit,
copy.fallbackClauses.lateFees,
copy.fallbackClauses.paymentAuthorization(contract.company.name),
copy.fallbackClauses.general,
]
const agreementClauses = clauses.length > 0 ? clauses : fallbackClauses
const roadsidePhone = contract.company.phone ?? copy.none
const latestPayment = contract.invoice.payments.at(-1)
const driverFullName = `${contract.driver.firstName} ${contract.driver.lastName}`
const additionalDriverNames = contract.additionalDrivers.length > 0
? contract.additionalDrivers.map((driver) => `${driver.firstName} ${driver.lastName}`).join(', ')
: copy.noAdditionalDrivers
return (
<div className="space-y-6 print:space-y-0">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between print:hidden">
<div className="space-y-2">
<Link href="/dashboard/contracts" className="text-sm font-semibold text-blue-700 hover:underline">{copy.back}</Link>
<p className="text-sm font-semibold uppercase tracking-wide text-slate-500">{contract.company.name}</p>
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-xl font-semibold text-slate-900">{contract.contractNumber ?? copy.contractNo}</h2>
<span className="badge-blue">{tl(copy.contractStatusLabels, contract.status)}</span>
<span className="badge-gray">{tl(copy.paymentStatusLabels, contract.paymentStatus)}</span>
</div>
<p className="text-sm text-slate-500">
{copy.invoiceNo}: {contract.invoiceNumber ?? '—'} · {copy.generated}: {formatDateTime(contract.generatedAt)}
</p>
</div>
<div className="flex flex-wrap gap-3 print:hidden">
<Link href={`/dashboard/reservations/${contract.reservationId}`} className="btn-secondary">
{copy.actionOpenBooking}
</Link>
<button type="button" className="btn-primary" onClick={() => window.print()}>
{copy.print}
</button>
</div>
</div>
<div className="space-y-8 print:space-y-0">
<section className="mx-auto max-w-[1060px] overflow-hidden rounded-[28px] border-4 border-stone-900 bg-white text-stone-900 shadow-2xl print:max-w-none print:shadow-none print:break-after-page">
<div className="border-b border-stone-500 px-6 py-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center">
{contract.company.logoUrl && (
<div className="flex shrink-0 items-center justify-center">
<img
src={contract.company.logoUrl}
alt={contract.company.name}
className="h-24 w-24 object-contain"
/>
</div>
)}
<div className="flex-1 text-center md:text-left">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-stone-500">
{contract.company.name}
</p>
<h1 className="mt-2 text-3xl font-black tracking-tight">
{contract.company.name} {copy.agreementTitle}
</h1>
<p className="mt-1 text-sm font-medium text-stone-600">
{copy.agreementForReservation} {contract.contractNumber ?? contract.reservationId.slice(-8).toUpperCase()}
</p>
</div>
<div className="grid min-w-[220px] grid-cols-2 gap-px self-stretch overflow-hidden rounded-2xl border border-stone-400 bg-stone-300 text-sm">
<div className="bg-white px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.status}</p>
<p className="mt-1 font-semibold">{tl(copy.contractStatusLabels, contract.status)}</p>
</div>
<div className="bg-white px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.paymentStatus}</p>
<p className="mt-1 font-semibold">{tl(copy.paymentStatusLabels, contract.paymentStatus)}</p>
</div>
<div className="bg-white px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.contractNo}</p>
<p className="mt-1 font-semibold">{contract.contractNumber ?? '—'}</p>
</div>
<div className="bg-white px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.generated}</p>
<p className="mt-1 font-semibold">{formatDateTime(contract.generatedAt)}</p>
</div>
</div>
</div>
</div>
<div className="grid gap-5 p-6 lg:grid-cols-[1.6fr_1fr] print:grid-cols-[1.6fr_1fr]">
<div className="space-y-5">
<PaperSection title={copy.customerInformation}>
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
<PaperField label={copy.customerName} value={driverFullName} className="bg-white sm:col-span-2" />
<PaperField label={copy.homeAddress} value={address || copy.none} className="bg-white sm:col-span-2" />
<PaperField label={copy.cityCountry} value={[contract.company.city, contract.company.country].filter(Boolean).join(', ') || copy.none} className="bg-white" />
<PaperField label={copy.telephone} value={contract.driver.phone ?? copy.none} className="bg-white" />
<PaperField label={copy.emailLabel} value={contract.driver.email} className="bg-white sm:col-span-2" />
<PaperField label={copy.driverLicenseNumber} value={contract.driver.driverLicense ?? copy.none} className="bg-white" />
<PaperField label={copy.nationalityLabel} value={contract.driver.nationality ?? copy.none} className="bg-white" />
<PaperField label={copy.birthDate} value={formatDateOnly(contract.driver.dateOfBirth, localeCode, copy.none)} className="bg-white" />
<PaperField label={copy.issuedExpires} value={`${formatDateOnly(contract.driver.licenseIssuedAt, localeCode, copy.none)}${formatDateOnly(contract.driver.licenseExpiry, localeCode, copy.none)}`} className="bg-white" />
</div>
</PaperSection>
<PaperSection title={copy.rentalPeriodDetails}>
<div className="grid gap-px bg-stone-300 sm:grid-cols-2">
<PaperField label={copy.dateOut} value={formatDateTime(contract.rentalPeriod.startDate)} className="bg-white" />
<PaperField label={copy.dateDueIn} value={formatDateTime(contract.rentalPeriod.endDate)} className="bg-white" />
<PaperField label={copy.mileageOut} value={String(contract.inspections.checkIn?.mileage ?? copy.none)} className="bg-white" />
<PaperField label={copy.mileageIn} value={String(contract.inspections.checkOut?.mileage ?? copy.none)} className="bg-white" />
<PaperField label={copy.pickupLocationLabel} value={contract.rentalPeriod.pickupLocation ?? copy.none} className="bg-white" />
<PaperField label={copy.returnLocationLabel} value={contract.rentalPeriod.returnLocation ?? copy.none} className="bg-white" />
<PaperField label={copy.additionalDrivers} value={additionalDriverNames} className="bg-white sm:col-span-2" />
</div>
</PaperSection>
<PaperSection title={copy.insuranceCondition}>
<div className="grid gap-px bg-stone-300">
<PaperField
label={copy.insuranceSelections}
value={contract.insurance.policies.length > 0 ? contract.insurance.policies.map((policy) => `${policy.name} (${tl(copy.chargeTypeLabels, policy.chargeType)})`).join(', ') : copy.noInsurance}
className="bg-white"
/>
<PaperField label={copy.fuel} value={contract.terms.fuelPolicy ?? copy.none} className="bg-white" />
<PaperField label={copy.damage} value={contract.terms.damagePolicy ?? copy.none} className="bg-white" />
<PaperField
label={copy.checkInCondition}
value={contract.inspections.checkIn?.generalCondition ?? contract.inspections.checkIn?.employeeNotes ?? copy.none}
className="bg-white"
/>
</div>
</PaperSection>
<PaperSection title={copy.acknowledgementSignature}>
<div className="space-y-4 bg-white p-4 text-sm leading-6 text-stone-700">
<p>{copy.signatureAcknowledgement}</p>
<p>
{copy.signature}: <span className="font-semibold text-stone-900">{contract.terms.signatureRequired ? copy.yes : copy.no}</span>
</p>
<div className="grid gap-4 pt-4 sm:grid-cols-2">
<div className="border-t border-dashed border-stone-500 pt-2 text-sm font-medium text-stone-800">
{copy.customerSignature}
</div>
<div className="border-t border-dashed border-stone-500 pt-2 text-sm font-medium text-stone-800">
{copy.companyRepresentative}
</div>
</div>
</div>
</PaperSection>
</div>
<div className="space-y-5">
<PaperSection title={copy.vehicleInformation}>
<div className="grid gap-px bg-stone-300">
<PaperField label={copy.yearMakeModel} value={`${contract.vehicle.year} ${contract.vehicle.make} ${contract.vehicle.model}`} className="bg-white" />
<PaperField label={copy.licensePlateLabel} value={contract.vehicle.licensePlate} className="bg-white" />
<PaperField label={copy.vehicleColor} value={contract.vehicle.color} className="bg-white" />
<PaperField label={copy.categoryLabel} value={contract.vehicle.category} className="bg-white" />
<PaperField label={copy.vinLabel} value={contract.vehicle.vin ?? copy.none} className="bg-white" />
<div className="border border-stone-400/80 bg-white p-3">
<p className="mb-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-stone-500">{copy.damageDiagram}</p>
<div className="flex justify-center">
<VehicleDamageDiagram points={damagePoints} />
</div>
<p className="mt-3 text-xs text-stone-600">
{damagePoints.length > 0 ? copy.damageMarkersRecorded(damagePoints.length) : copy.noDamage}
</p>
</div>
</div>
</PaperSection>
<PaperSection title={copy.rentalRate}>
<div className="grid gap-px bg-stone-300">
<PaperField label={copy.dailyRate} value={rentalLine ? formatCurrency(rentalLine.unitPrice, contract.invoice.currency) : copy.none} className="bg-white" />
<PaperField label={copy.rentalDays} value={String(contract.rentalPeriod.totalDays)} className="bg-white" />
<PaperField label={copy.insuranceTotal} value={formatCurrency(contract.insurance.total, contract.invoice.currency)} className="bg-white" />
<PaperField label={copy.additionalDriverFees} value={formatCurrency(additionalDriverLine.reduce((sum, item) => sum + item.total, 0), contract.invoice.currency)} className="bg-white" />
<PaperField label={copy.deposit} value={depositLine ? formatCurrency(depositLine.total, contract.invoice.currency) : copy.none} className="bg-white" />
<PaperField label={copy.subtotal} value={formatCurrency(contract.invoice.subtotal, contract.invoice.currency)} className="bg-white" />
<PaperField label={copy.taxesLabel} value={contract.invoice.taxes.length > 0 ? contract.invoice.taxes.map((tax) => `${tax.label} ${tax.rate}%`).join(', ') : copy.none} className="bg-white" />
<PaperField label={copy.totalChargeLabel} value={formatCurrency(contract.invoice.total, contract.invoice.currency)} className="bg-white" />
<PaperField label={copy.amountPaidLabel} value={formatCurrency(contract.invoice.amountPaid, contract.invoice.currency)} className="bg-white" />
<PaperField label={copy.balanceDueLabel} value={formatCurrency(contract.invoice.balanceDue, contract.invoice.currency)} className="bg-white" />
<PaperField label={copy.paymentMode} value={contract.paymentMode ?? latestPayment?.paymentMethod ?? copy.none} className="bg-white" />
</div>
</PaperSection>
<PaperSection title={copy.billingSummary}>
<div className="space-y-3 bg-white p-4 text-sm text-stone-700">
{contract.invoice.lineItems.map((item, index) => (
<div key={`${item.description}-${index}`} className="flex items-start justify-between gap-3 border-b border-stone-200 pb-2 last:border-b-0">
<div>
<p className="font-medium text-stone-900">{item.description}</p>
<p className="text-xs text-stone-500">{copy.qty} {item.qty} · {formatCurrency(item.unitPrice, contract.invoice.currency)}</p>
</div>
<p className="font-semibold text-stone-900">{formatCurrency(item.total, contract.invoice.currency)}</p>
</div>
))}
{latestPayment ? (
<div className="rounded-xl border border-stone-300 bg-stone-50 px-3 py-2 text-xs text-stone-600">
{copy.lastPayment}: {tl(copy.paymentProviderLabels, latestPayment.provider)} · {tl(copy.paymentStatusLabels, latestPayment.status)} · {formatDateTime(latestPayment.paidAt ?? latestPayment.createdAt)}
</div>
) : null}
</div>
</PaperSection>
</div>
</div>
<div className="border-t border-stone-500 px-6 py-4 text-center text-sm text-stone-700">
{copy.roadsideAssistance} <span className="font-semibold text-stone-900">{roadsidePhone}</span>
</div>
</section>
<section className="mx-auto max-w-[1060px] rounded-[28px] border-4 border-stone-900 bg-white px-8 py-7 text-stone-900 shadow-2xl print:max-w-none print:shadow-none">
<div className="border-b border-stone-400 pb-4 text-center">
<h2 className="text-2xl font-black tracking-tight">{copy.termsAndConditions}</h2>
<p className="mt-1 text-sm text-stone-600">
{contract.company.name} · {contract.contractNumber ?? copy.draftAgreement}
</p>
</div>
<div className="mt-6 grid gap-6 md:grid-cols-2 print:grid-cols-2">
{agreementClauses.map((clause, index) => (
<div key={`${index}-${clause.slice(0, 24)}`} className="text-sm leading-6 text-stone-700">
<p className="font-bold text-stone-900">
{index + 1}. {copy.clauseHeadings[index] ?? copy.clauseHeadings[copy.clauseHeadings.length - 1]}
</p>
<p className="mt-2 whitespace-pre-wrap">{clause}</p>
</div>
))}
</div>
<div className="mt-8 grid gap-5 lg:grid-cols-2 print:grid-cols-2">
<PaperSection title={copy.inspections}>
<div className="grid gap-px bg-stone-300">
<PaperField label={copy.checkInMileageFuel} value={`${contract.inspections.checkIn?.mileage ?? copy.none} / ${contract.inspections.checkIn?.fuelLevel ? tl(copy.fuelLevelLabels, contract.inspections.checkIn.fuelLevel) : copy.none}`} className="bg-white" />
<PaperField label={copy.checkOutMileageFuel} value={`${contract.inspections.checkOut?.mileage ?? copy.none} / ${contract.inspections.checkOut?.fuelLevel ? tl(copy.fuelLevelLabels, contract.inspections.checkOut.fuelLevel) : copy.none}`} className="bg-white" />
<PaperField label={copy.damageEntries} value={damagePoints.length > 0 ? damagePoints.map((point) => `${tl(copy.damageTypeLabels, point.damageType)} (${tl(copy.severityLabels, point.severity)})`).join(', ') : copy.noDamage} className="bg-white" />
</div>
</PaperSection>
<PaperSection title={copy.notesAndFooter}>
<div className="space-y-3 bg-white p-4 text-sm text-stone-700">
<div>
<p className="font-semibold text-stone-900">{copy.notes}</p>
<p className="mt-1 whitespace-pre-wrap">{contract.notes || copy.none}</p>
</div>
<div>
<p className="font-semibold text-stone-900">{copy.footer}</p>
<p className="mt-1 whitespace-pre-wrap">{contract.terms.footerNote || copy.none}</p>
</div>
<div>
<p className="font-semibold text-stone-900">{copy.payments}</p>
<p className="mt-1">{contract.invoice.payments.length > 0 ? copy.paymentsRecorded(contract.invoice.payments.length) : copy.noPayments}</p>
</div>
</div>
</PaperSection>
</div>
</section>
</div>
</div>
)
}
@@ -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={`/dashboard/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>
)
}
@@ -6,92 +6,789 @@ import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
interface OfferRow {
const CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
type Category = typeof CATEGORIES[number]
interface Offer {
id: string
title: string
type: 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
promoCode: string | null
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']): string {
if (offer.type === 'PERCENTAGE') return `${offer.discountValue}%`
if (offer.type === 'FIXED_AMOUNT') return formatCurrency(offer.discountValue, 'MAD')
if (offer.type === 'FREE_DAY') return t.freeDay
if (offer.type === 'SPECIAL_RATE') return `${offer.specialRate ?? offer.discountValue} MAD/day`
return String(offer.discountValue)
}
export default function OffersPage() {
const { language } = useDashboardI18n()
const [rows, setRows] = useState<OfferRow[]>([])
const [error, setError] = useState<string | null>(null)
const copy = {
en: {
title: 'Offers',
subtitle: 'Promotions shown on your site and optionally on the marketplace.',
ends: 'ends',
active: 'Active',
inactive: 'Inactive',
marketplace: 'Marketplace',
featured: 'Featured',
empty: 'No offers created yet.',
},
fr: {
title: 'Offres',
subtitle: 'Promotions affichées sur votre site et éventuellement sur la marketplace.',
ends: 'expire le',
active: 'Actif',
inactive: 'Inactif',
marketplace: 'Marketplace',
featured: 'Mise en avant',
empty: 'Aucune offre créée pour le moment.',
},
ar: {
title: 'العروض',
subtitle: 'عروض ترويجية تظهر في موقعك ويمكن عرضها أيضاً في السوق.',
ends: 'تنتهي في',
active: 'نشط',
inactive: 'غير نشط',
marketplace: 'السوق',
featured: 'مميز',
empty: 'لا توجد عروض حتى الآن.',
},
}[language]
const t = copy[language]
useEffect(() => {
apiFetch<OfferRow[]>('/offers')
.then(setRows)
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(data ?? []))
.catch((err) => setError(err.message))
}, [])
}
useEffect(() => { load() }, [])
async function loadFleet() {
setLoadingFleet(true)
try {
const data = await apiFetch<FleetVehicle[]>('/vehicles?pageSize=200')
setFleet(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: 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">
<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>
{/* 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">
{rows.map((offer) => (
<div key={offer.id} className="card p-5">
{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>
<h3 className="text-base font-semibold text-slate-900">{offer.title}</h3>
<p className="text-sm text-slate-500 mt-1">{offer.type} · {copy.ends} {dayjs(offer.validUntil).format('MMM D, YYYY')}</p>
<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={offer.isActive ? 'badge-green' : 'badge-gray'}>{offer.isActive ? copy.active : copy.inactive}</span>
<span className={`shrink-0 ${offer.isActive ? 'badge-green' : 'badge-gray'}`}>
{offer.isActive ? t.active : t.inactive}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{offer.isPublic && <span className="badge-blue">{copy.marketplace}</span>}
{offer.isFeatured && <span className="badge-purple">{copy.featured}</span>}
<p className="text-2xl font-bold text-slate-900">{discountLabel(offer, t)}</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>
<p className="mt-4 text-2xl font-bold text-slate-900">
{offer.type === 'PERCENTAGE' ? `${offer.discountValue}%` : formatCurrency(offer.discountValue, 'MAD')}
</p>
</div>
))}
{rows.length === 0 && !error && (
<div className="card p-8 text-sm text-slate-400">{copy.empty}</div>
{offers.length === 0 && !error && (
<div className="col-span-full card p-10 text-center text-sm text-slate-400">{t.empty}</div>
)}
</div>
{error && <div className="card p-6 text-sm text-red-600">{error}</div>}
{/* Create / Edit Modal */}
{modalOpen && (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/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-black/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>
)
}
@@ -81,7 +81,6 @@ export default function OnlineReservationsPage() {
pendingApproval: 'Pending approval',
loading: 'Loading…',
allCaughtUp: 'All caught up — no pending requests.',
history: 'History',
customer: 'Customer',
vehicle: 'Vehicle',
dates: 'Dates',
@@ -95,7 +94,6 @@ export default function OnlineReservationsPage() {
pendingApproval: 'En attente de validation',
loading: 'Chargement…',
allCaughtUp: 'Tout est à jour, aucune demande en attente.',
history: 'Historique',
customer: 'Client',
vehicle: 'Véhicule',
dates: 'Dates',
@@ -109,7 +107,6 @@ export default function OnlineReservationsPage() {
pendingApproval: 'بانتظار الموافقة',
loading: 'جارٍ التحميل…',
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
history: 'السجل',
customer: 'العميل',
vehicle: 'المركبة',
dates: 'التواريخ',
@@ -127,14 +124,9 @@ export default function OnlineReservationsPage() {
const load = useCallback(async () => {
try {
setLoading(true)
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&pageSize=100')
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&status=DRAFT&pageSize=100')
setRows(
(result ?? []).sort((a, b) => {
// DRAFT first, then by createdAt desc
if (a.status === 'DRAFT' && b.status !== 'DRAFT') return -1
if (a.status !== 'DRAFT' && b.status === 'DRAFT') return 1
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
(result ?? []).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
)
setError(null)
} catch (err: any) {
@@ -173,8 +165,7 @@ export default function OnlineReservationsPage() {
}
}
const pending = rows.filter((r) => r.status === 'DRAFT')
const history = rows.filter((r) => r.status !== 'DRAFT')
const pending = rows
return (
<div className="space-y-8">
@@ -235,58 +226,6 @@ export default function OnlineReservationsPage() {
)}
</section>
{/* History section */}
{!loading && history.length > 0 && (
<section>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
{copy.history}
</h3>
<div className="card overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.customer}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.vehicle}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.dates}</th>
<th className="px-5 py-3 text-left text-xs font-medium uppercase tracking-wide text-slate-500">{copy.status}</th>
<th className="px-5 py-3 text-right text-xs font-medium uppercase tracking-wide text-slate-500">{copy.total}</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{history.map((r) => (
<tr key={r.id} className="hover:bg-slate-50">
<td className="px-5 py-3.5">
<p className="text-sm font-medium text-slate-900">{r.customer.firstName} {r.customer.lastName}</p>
<p className="text-xs text-slate-500">{r.customer.email}</p>
</td>
<td className="px-5 py-3.5 text-sm text-slate-700">{r.vehicle.year} {r.vehicle.make} {r.vehicle.model}</td>
<td className="px-5 py-3.5 text-sm text-slate-500">
{dayjs(r.startDate).format('MMM D')} {dayjs(r.endDate).format('MMM D, YYYY')}
</td>
<td className="px-5 py-3.5">
<span className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLE[r.status] ?? 'bg-slate-100 text-slate-600'}`}>
{STATUS_LABEL[r.status] ?? r.status}
</span>
{r.cancelReason && (
<p className="mt-0.5 text-xs text-slate-400 truncate max-w-[160px]" title={r.cancelReason}>{r.cancelReason}</p>
)}
</td>
<td className="px-5 py-3.5 text-right text-sm font-semibold text-slate-900">
{formatCurrency(r.totalAmount, 'MAD')}
</td>
<td className="px-5 py-3.5">
<Link href={`/dashboard/reservations/${r.id}`} className="text-slate-400 hover:text-slate-700">
<ChevronRight className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
)
}
@@ -93,7 +93,7 @@ function SubscriptionBanner({
<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>
<a href="/dashboard/billing" className="ml-auto text-sm font-semibold underline hover:no-underline">
<a href="/dashboard/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
{manageBillingLabel}
</a>
</div>
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -127,6 +128,9 @@ export default function ReservationDetailPage() {
<p className="mt-1 text-sm text-slate-500">{reservation.source} · {reservation.status} · {reservation.paymentStatus}</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href={`/dashboard/contracts/${reservation.id}`} className="btn-secondary">
{language === 'fr' ? 'Contrat' : language === 'ar' ? 'العقد' : 'Contract'}
</Link>
{reservation.status === 'DRAFT' && (
<button disabled={acting} onClick={() => runAction('confirm')} className="btn-primary">
{acting ? r.working : r.confirmReservation}
@@ -0,0 +1,628 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Customer = {
id: string
firstName: string
lastName: string
email: string
driverLicense?: string | null
licenseExpiry?: string | null
licenseIssuedAt?: string | null
licenseCountry?: string | null
licenseCategory?: string | null
}
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
type CreatedCustomer = { id: string; firstName: string; lastName: string; email: string }
type AdditionalDriverForm = {
firstName: string
lastName: string
email: string
phone: string
driverLicense: string
licenseExpiry: string
licenseIssuedAt: string
dateOfBirth: string
nationality: string
}
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('')
const [newCustomerLastName, setNewCustomerLastName] = useState('')
const [newCustomerEmail, setNewCustomerEmail] = useState('')
const [newCustomerPhone, setNewCustomerPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
const [licenseExpiry, setLicenseExpiry] = useState('')
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: '',
lastName: '',
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: '',
})
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',
driverLicenseInfo: 'Driver license information',
driverLicense: 'Driver license number',
licenseExpiry: 'License expiry',
licenseIssuedAt: 'License issued at',
licenseCountry: 'License country',
licenseCategory: 'License category',
addAdditionalDriver: 'Add additional driver',
additionalDriverInfo: 'Additional driver',
dateOfBirth: 'Date of birth',
nationality: 'License country',
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 (optional)',
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',
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',
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
additionalDriverInfo: 'Conducteur supplémentaire',
dateOfBirth: 'Date de naissance',
nationality: 'Pays du permis',
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 (optionnel)',
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: 'طريقة الدفع',
driverLicenseInfo: 'معلومات رخصة القيادة',
driverLicense: 'رقم رخصة القيادة',
licenseExpiry: 'تاريخ انتهاء الرخصة',
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseCountry: 'بلد الرخصة',
licenseCategory: 'فئة الرخصة',
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
useEffect(() => {
const selected = customers.find((customer) => customer.id === customerId)
if (!selected) return
setDriverLicense(selected.driverLicense ?? '')
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
setLicenseCountry(selected.licenseCountry ?? '')
setLicenseCategory(selected.licenseCategory ?? '')
}, [customerId, customers])
async function addCustomer() {
setError(null)
if (!newCustomerFirstName.trim() || !newCustomerLastName.trim() || !newCustomerEmail.trim()) {
setError(copy.required)
return
}
setSavingCustomer(true)
try {
const created = await apiFetch<CreatedCustomer>('/customers', {
method: 'POST',
body: JSON.stringify({
firstName: newCustomerFirstName.trim(),
lastName: newCustomerLastName.trim(),
email: newCustomerEmail.trim(),
phone: newCustomerPhone.trim() || undefined,
driverLicense: driverLicense.trim() || undefined,
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
licenseCountry: licenseCountry.trim() || undefined,
licenseCategory: licenseCategory.trim() || undefined,
}),
})
setCustomers((prev) => [created, ...prev])
setCustomerId(created.id)
setCustomerSearch(`${created.firstName} ${created.lastName}`)
setShowAddCustomer(false)
setNewCustomerFirstName('')
setNewCustomerLastName('')
setNewCustomerEmail('')
setNewCustomerPhone('')
} 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 (includeAdditionalDriver && (!additionalDriver.firstName.trim() || !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() || undefined,
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
licenseCountry: licenseCountry.trim() || undefined,
licenseCategory: licenseCategory.trim() || undefined,
}),
})
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: additionalDriver.firstName.trim(),
lastName: additionalDriver.lastName.trim(),
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: additionalDriver.nationality.trim() || undefined,
}] : [],
notes: notes || undefined,
}),
})
router.push(`/dashboard/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>
<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>
<input value={newCustomerFirstName} onChange={(e) => setNewCustomerFirstName(e.target.value)} placeholder={copy.firstName} className="input-field" />
<input value={newCustomerLastName} onChange={(e) => setNewCustomerLastName(e.target.value)} placeholder={copy.lastName} className="input-field" />
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} placeholder={copy.email} className="input-field" />
<input value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} placeholder={copy.phone} className="input-field" />
<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>
<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-amber-700">{copy.noVehicles}</span>
) : availableVehicles.length === 0 ? (
<span className="text-xs text-amber-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.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>
<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>
<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>
<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>
<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>
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
</label>
</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>
<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>
<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>
<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.firstName}</span>
<input
value={additionalDriver.firstName}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, firstName: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.lastName}</span>
<input
value={additionalDriver.lastName}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, lastName: 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.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>
<input
value={additionalDriver.driverLicense}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.nationality}</span>
<input
value={additionalDriver.nationality}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, nationality: 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.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('/dashboard/reservations')}>
{copy.cancel}
</button>
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
{saving ? copy.creating : copy.create}
</button>
</div>
</>
)}
</div>
</div>
)
}
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
import { formatCurrency } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -14,6 +14,7 @@ interface ReservationRow {
endDate: string
totalAmount: number
totalDays: number
contractNumber?: string | null
vehicle: { make: string; model: string }
customer: { firstName: string; lastName: string; email: string }
}
@@ -27,8 +28,8 @@ export default function ReservationsPage() {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
apiFetch<ApiPaginated<ReservationRow>>('/reservations?pageSize=100')
.then((result) => setRows(result.data ?? []))
apiFetch<ReservationRow[]>('/reservations?pageSize=100')
.then((result) => setRows(result ?? []))
.catch((err) => setError(err.message))
}, [])
@@ -39,9 +40,14 @@ export default function ReservationsPage() {
return (
<div className="space-y-6">
<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 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="/dashboard/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 ? (
@@ -67,6 +73,11 @@ export default function ReservationsPage() {
{row.customer.firstName} {row.customer.lastName}
</Link>
<p className="text-xs text-slate-500">{row.customer.email}</p>
<Link href={`/dashboard/contracts/${row.id}`} className="mt-1 inline-block text-xs font-semibold text-blue-700 hover:underline">
{row.contractNumber
? (language === 'fr' ? 'Voir le contrat' : language === 'ar' ? 'عرض العقد' : 'View contract')
: (language === 'fr' ? 'Générer le contrat' : language === 'ar' ? 'إنشاء العقد' : 'Generate contract')}
</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>
@@ -0,0 +1,503 @@
'use client'
import { useEffect, useState } from 'react'
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
type BillingPeriod = 'MONTHLY' | 'ANNUAL'
type Currency = 'MAD' | 'USD' | 'EUR'
interface Subscription {
id: string
plan: Plan
billingPeriod: BillingPeriod
status: string
currency: Currency
trialEndAt: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
}
interface Invoice {
id: string
amount: number
currency: Currency
status: string
paymentProvider: string
paidAt: string | null
createdAt: string
}
interface ProviderAvailability {
amanpay: boolean
paypal: boolean
}
const STATUS_BADGE: Record<string, string> = {
TRIALING: 'bg-sky-100 text-sky-700',
ACTIVE: 'bg-green-100 text-green-700',
PAST_DUE: 'bg-amber-100 text-amber-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-amber-100 text-amber-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 { 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 [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
const [currency, setCurrency] = useState<Currency>('MAD')
const [provider, setProvider] = useState<'AMANPAY' | 'PAYPAL'>('AMANPAY')
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability>({ amanpay: false, paypal: false })
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(() => {
Promise.all([
apiFetch<Subscription | null>('/subscriptions/me'),
apiFetch<Invoice[]>('/subscriptions/invoices'),
apiFetch<ProviderAvailability>('/subscriptions/providers'),
])
.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)
setCurrency(sub.currency as Currency)
}
setInvoices(inv ?? [])
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
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 base = window.location.origin
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
plan: selectedPlan,
billingPeriod,
currency,
provider,
successUrl: `${base}/dashboard/subscription?payment=success`,
failureUrl: `${base}/dashboard/subscription?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 = PLAN_PRICES[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-amber-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-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-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-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
{p === 'MONTHLY' ? copy.monthly : copy.annual}
</button>
))}
</div>
{/* Currency selector */}
<div className="flex items-center gap-2">
{(['MAD', 'USD', 'EUR'] as Currency[]).map((c) => (
<button
key={c}
onClick={() => setCurrency(c)}
className={`px-3 py-1 rounded-lg text-sm font-medium border transition-colors ${
currency === c ? 'border-blue-500 bg-blue-50 text-blue-700' : 'border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{c}
</button>
))}
</div>
{/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3">
{PLANS.map((plan) => {
const price = PLAN_PRICES[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
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, currency) : '—'}
<span className="text-sm font-normal text-slate-500">/{billingPeriod === 'MONTHLY' ? copy.perMonthShort : copy.perYearShort}</span>
</p>
<ul className="mt-3 space-y-1">
{copy.planFeatures[plan].map((f) => (
<li key={f} className="text-xs text-slate-600 flex items-center gap-1.5">
<span className="text-green-500"></span> {f}
</li>
))}
</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, currency) : '—'}
<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, inv.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
@@ -13,16 +13,27 @@ function initials(m: TeamMember) {
}
function timeAgo(dateStr: string | null, language: 'en' | 'fr' | 'ar') {
if (!dateStr) return language === 'fr' ? 'Jamais' : language === 'ar' ? 'أبداً' : 'Never'
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' ? 'À linstant' : language === 'ar' ? 'الآن' : 'Just now'
if (mins < 60) return `${mins}m ago`
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 `${hrs}h ago`
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 `${days}d ago`
return new Date(dateStr).toLocaleDateString('en', { month: 'short', day: 'numeric' })
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[] = [
@@ -38,15 +49,22 @@ function avatarColor(id: string) {
return AVATAR_BG[hash % AVATAR_BG.length]
}
function RoleBadge({ role }: { role: string }) {
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',
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',
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}`}>
{role.charAt(0) + role.slice(1).toLowerCase()}
{label}
</span>
)
}
@@ -107,7 +125,7 @@ export default function TeamPage() {
},
fr: {
title: 'Équipe',
subtitle: 'Gérez vos employés et leurs niveaux daccès',
subtitle: "Gérez vos employés et leurs niveaux d'accès",
inviteMember: 'Inviter un membre',
totalMembers: 'Total membres',
active: 'Actif',
@@ -120,7 +138,7 @@ export default function TeamPage() {
agent: 'Agent',
allStatuses: 'Tous les statuts',
pending: 'En attente',
loadingTeam: 'Chargement de léquipe…',
loadingTeam: "Chargement de l'équipe…",
noMembers: 'Aucun membre ne correspond aux filtres',
member: 'Membre',
role: 'Rôle',
@@ -363,7 +381,7 @@ export default function TeamPage() {
</td>
<td className="px-4 py-3">
<RoleBadge role={member.role} />
<RoleBadge role={member.role} language={language} />
</td>
<td className="px-4 py-3">
+10 -6
View File
@@ -4,12 +4,16 @@ import { DashboardLanguageSwitcher, DashboardThemeSwitcher } from '@/components/
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950">
<Sidebar />
<div className="ms-0 flex min-h-screen min-w-0 flex-1 flex-col lg:ms-64">
<TopBar />
<main className="flex-1 overflow-y-auto p-6">{children}</main>
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
<div className="flex min-h-screen bg-slate-50 transition-colors dark:bg-slate-950 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">{children}</main>
<footer className="border-t border-stone-200 bg-white/90 px-6 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90 print:hidden">
<div className="flex flex-col items-center justify-between gap-3 lg:flex-row">
<p className="text-xs font-medium uppercase tracking-[0.16em] text-stone-400 dark:text-slate-500">
Workspace preferences
+11
View File
@@ -79,6 +79,17 @@ html.dark {
}
}
@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(2 6 23);
+15 -3
View File
@@ -1,5 +1,7 @@
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 = {
@@ -7,19 +9,29 @@ export const metadata: Metadata = {
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 function RootLayout({ children }: { children: React.ReactNode }) {
const cookieStore = cookies()
const initialLanguage = resolveInitialLanguage(
cookieStore.get(SHARED_LANGUAGE_COOKIE)?.value ?? cookieStore.get('dashboard-language')?.value,
)
const dir = initialLanguage === 'ar' ? 'rtl' : 'ltr'
return (
<html lang="en" className="light" suppressHydrationWarning>
<html lang={initialLanguage} dir={dir} className="light" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html:
"(function(){try{var theme=localStorage.getItem('dashboard-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme}catch(e){}})();",
"(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!=='medium'&&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>{children}</DashboardI18nProvider>
<DashboardI18nProvider initialLanguage={initialLanguage}>{children}</DashboardI18nProvider>
</body>
</html>
)
@@ -1,22 +1,33 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
}
export default function SignInPage() {
const { language } = useDashboardI18n()
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const portal = searchParams.get('portal') === 'admin' ? 'admin' : 'workspace'
const requestedPortal = searchParams.get('portal') === 'admin' ? 'admin' : 'workspace'
const [portal, setPortal] = useState<'admin' | 'workspace'>(requestedPortal)
const requestedLanguage = searchParams.get('lang')
const requestedTheme = searchParams.get('theme')
const dict = {
en: {
workspace: 'Sign in',
admin: 'Admin sign in',
subtitle: 'Enter your credentials to access your workspace.',
adminSubtitle: 'Use the same sign-in page for platform admin access.',
title: 'Sign in',
workspaceTab: 'Company Workspace',
platformTab: 'Platform',
subtitle: 'Choose where you want to sign in, then enter your credentials.',
email: 'Email',
password: 'Password',
signIn: 'Sign in',
@@ -34,10 +45,10 @@ export default function SignInPage() {
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
workspace: 'Connexion',
admin: 'Connexion admin',
subtitle: 'Saisissez vos identifiants pour accéder à votre espace.',
adminSubtitle: 'Utilisez cette page unique pour accéder à la plateforme admin.',
title: 'Connexion',
workspaceTab: 'Espace',
platformTab: 'Plateforme',
subtitle: 'Choisissez votre accès puis saisissez vos identifiants.',
email: 'Email',
password: 'Mot de passe',
signIn: 'Connexion',
@@ -55,10 +66,10 @@ export default function SignInPage() {
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
workspace: 'تسجيل الدخول',
admin: 'دخول الإدارة',
subtitle: 'أدخل بياناتك للوصول إلى مساحة العمل.',
adminSubtitle: 'استخدم صفحة الدخول نفسها للوصول إلى لوحة الإدارة.',
title: 'تسجيل الدخول',
workspaceTab: 'المساحة',
platformTab: 'المنصة',
subtitle: 'اختر جهة الدخول ثم أدخل بياناتك.',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
@@ -76,33 +87,132 @@ export default function SignInPage() {
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
const themeStyles = {
light: {
main: 'bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]',
logoFrame: 'border-blue-100 bg-white shadow-sm',
brand: 'text-blue-600',
title: 'text-slate-900',
subtitle: 'text-slate-500',
card: 'border-slate-200 bg-white shadow-sm',
toggleWrap: 'border-slate-200 bg-slate-50',
activeToggle: 'bg-slate-900 text-white shadow-sm',
inactiveToggle: 'text-slate-600 hover:bg-white hover:text-slate-900',
},
medium: {
main: 'bg-[radial-gradient(circle_at_top,rgba(148,163,184,0.32),transparent_35%),linear-gradient(180deg,#d7dee8,#f3f4f6)]',
logoFrame: 'border-slate-300 bg-white/90 shadow-[0_12px_30px_rgba(71,85,105,0.18)]',
brand: 'text-slate-700',
title: 'text-slate-900',
subtitle: 'text-slate-600',
card: 'border-slate-300/80 bg-white/88 shadow-[0_18px_50px_rgba(51,65,85,0.14)] backdrop-blur',
toggleWrap: 'border-slate-300 bg-slate-200/80',
activeToggle: 'bg-amber-400 text-slate-950 shadow-sm',
inactiveToggle: 'text-slate-700 hover:bg-white/85 hover:text-slate-950',
},
dark: {
main: 'bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.2),transparent_35%),linear-gradient(180deg,#020617,#0f172a)]',
logoFrame: 'border-slate-700 bg-slate-900 shadow-[0_12px_30px_rgba(2,6,23,0.45)]',
brand: 'text-amber-400',
title: 'text-slate-100',
subtitle: 'text-slate-400',
card: 'border-slate-800 bg-slate-900/92 shadow-[0_18px_50px_rgba(2,6,23,0.45)] backdrop-blur',
toggleWrap: 'border-slate-700 bg-slate-950/80',
activeToggle: 'bg-amber-400 text-slate-950 shadow-sm',
inactiveToggle: 'text-slate-300 hover:bg-slate-800 hover:text-white',
},
}[theme]
useEffect(() => {
setPortal(requestedPortal)
}, [requestedPortal])
useEffect(() => {
if (
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
requestedLanguage !== language
) {
setLanguage(requestedLanguage)
}
}, [language, requestedLanguage, setLanguage])
useEffect(() => {
if (
(requestedTheme === 'light' || requestedTheme === 'medium' || requestedTheme === 'dark') &&
requestedTheme !== theme
) {
setTheme(requestedTheme)
}
}, [requestedTheme, setTheme, theme])
useEffect(() => {
const currentPath = `${pathname}${searchParams.toString() ? `?${searchParams.toString()}` : ''}`
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
}, [pathname, searchParams])
function handlePortalChange(nextPortal: 'admin' | 'workspace') {
setPortal(nextPortal)
const nextParams = new URLSearchParams(searchParams.toString())
if (nextPortal === 'admin') {
nextParams.set('portal', 'admin')
} else {
nextParams.delete('portal')
}
const nextSearch = nextParams.toString()
router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname, { scroll: false })
}
return (
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
<main className={`flex min-h-screen items-center justify-center px-4 py-12 transition-colors ${themeStyles.main}`}>
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<Link href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
<div className="flex justify-center">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={104}
height={104}
className={`h-24 w-24 rounded-[1.75rem] border p-1.5 transition-colors ${themeStyles.logoFrame}`}
/>
</div>
<Link href={marketplaceUrl} className={`text-xs font-semibold uppercase tracking-[0.24em] transition-colors ${themeStyles.brand}`}>
RentalDriveGo
</Link>
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">
{portal === 'admin' ? dict.admin : dict.workspace}
<h1 className={`mt-3 text-3xl font-black tracking-tight transition-colors ${themeStyles.title}`}>
{dict.title}
</h1>
<p className="mt-2 text-sm text-slate-500">
{portal === 'admin' ? dict.adminSubtitle : dict.subtitle}
<p className={`mt-2 text-sm transition-colors ${themeStyles.subtitle}`}>
{dict.subtitle}
</p>
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
<LocalSignInForm dict={dict} portal={portal} />
<section className={`rounded-[2rem] border p-8 transition-colors ${themeStyles.card}`}>
<div className={`mb-6 grid grid-cols-2 gap-2 rounded-full border p-1 transition-colors ${themeStyles.toggleWrap}`}>
{([
{ value: 'workspace', label: dict.workspaceTab },
{ value: 'admin', label: dict.platformTab },
] as const).map((option) => {
const active = option.value === portal
{portal !== 'admin' ? (
<div className="mt-6 border-t border-slate-200 pt-6 text-sm text-slate-500">
{dict.newAccount}
<Link href="/sign-up" className="ml-1 font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
{dict.createWorkspace}
</Link>
</div>
) : null}
return (
<button
key={option.value}
type="button"
onClick={() => handlePortalChange(option.value)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${
active
? themeStyles.activeToggle
: themeStyles.inactiveToggle
}`}
>
{option.label}
</button>
)
})}
</div>
<LocalSignInForm dict={dict} portal={portal} />
</section>
</div>
</main>
@@ -140,6 +250,12 @@ function LocalSignInForm({ dict, portal }: {
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const forgotPasswordHref = portal === 'admin' ? `${adminUrl}/forgot-password` : '/forgot-password'
useEffect(() => {
setStep('credentials')
setTotpCode('')
setError(null)
}, [portal])
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
window.location.href = `${adminUrl}/auth-redirect#${hash}`
@@ -183,7 +299,12 @@ function LocalSignInForm({ dict, portal }: {
if (empRes.ok && empJson?.data?.token) {
const token = empJson.data.token
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
}
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: employeeRedirect })
router.push(employeeRedirect)
return
}