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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -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
}
+93 -31
View File
@@ -1,9 +1,10 @@
'use client'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
import { SHARED_LANGUAGE_COOKIE, SHARED_LANGUAGE_KEY, SHARED_THEME_KEY, readCurrentUserScopedPreference, readScopedPreference, writeScopedPreference } from '@/lib/preferences'
export type DashboardLanguage = 'en' | 'fr' | 'ar'
export type DashboardTheme = 'light' | 'dark'
export type DashboardTheme = 'light' | 'medium' | 'dark'
type FleetDict = {
statusLabels: Record<'AVAILABLE' | 'RENTED' | 'MAINTENANCE' | 'OUT_OF_SERVICE', string>
@@ -294,6 +295,7 @@ type DashboardDictionary = {
language: string
theme: string
light: string
medium: string
dark: string
fleet: FleetDict
vehicleDetail: VehicleDetailDict
@@ -307,26 +309,31 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
nav: {
dashboard: 'Dashboard',
fleet: 'Fleet',
reservations: 'Reservations',
reservations: 'Booking',
onlineReservations: 'Online Reservations',
customers: 'Customers',
offers: 'Offers',
team: 'Team',
reports: 'Reports',
billing: 'Billing',
subscription: 'Subscription',
billing: 'Rent Car Billing',
contracts: 'Contracts',
notifications: 'Notifications',
settings: 'Settings',
},
titles: {
'/dashboard': 'Dashboard',
'/dashboard/fleet': 'Fleet Management',
'/dashboard/reservations': 'Reservations',
'/dashboard/reservations': 'Booking',
'/dashboard/reservations/new': 'Book Car',
'/dashboard/online-reservations': 'Online Reservations',
'/dashboard/customers': 'Customers',
'/dashboard/offers': 'Offers',
'/dashboard/team': 'Team',
'/dashboard/reports': 'Reports',
'/dashboard/billing': 'Billing',
'/dashboard/subscription': 'Subscription',
'/dashboard/billing': 'Rent Car Billing',
'/dashboard/contracts': 'Contracts',
'/dashboard/notifications': 'Notifications',
'/dashboard/settings': 'Settings',
},
@@ -339,6 +346,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
language: 'Language',
theme: 'Theme',
light: 'Light',
medium: 'Medium',
dark: 'Dark',
fleet: {
statusLabels: { AVAILABLE: 'Available', RENTED: 'Rented', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Out of Service' },
@@ -572,11 +580,11 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
trialActive: "You're on a free trial.",
pastDueMsg: 'Your payment is past due. Please update your billing information.',
suspendedMsg: 'Your account has been suspended. Please contact support or renew your subscription.',
manageBilling: 'Manage billing →',
manageBilling: 'Manage subscription →',
statusLabels: { CONFIRMED: 'Confirmed', PENDING: 'Pending', ACTIVE: 'Active', COMPLETED: 'Completed', CANCELLED: 'Cancelled' },
},
reservations: {
heading: 'Reservations',
heading: 'Booking',
subtitle: 'All booking sources, including dashboard, public site, and marketplace.',
colCustomer: 'Customer',
colVehicle: 'Vehicle',
@@ -650,7 +658,9 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
offers: 'Offres',
team: 'Équipe',
reports: 'Rapports',
billing: 'Facturation',
subscription: 'Abonnement',
billing: 'Facturation location',
contracts: 'Contrats',
notifications: 'Notifications',
settings: 'Paramètres',
},
@@ -658,12 +668,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard': 'Tableau de bord',
'/dashboard/fleet': 'Gestion de flotte',
'/dashboard/reservations': 'Réservations',
'/dashboard/reservations/new': 'Réserver une voiture',
'/dashboard/online-reservations': 'Réservations en ligne',
'/dashboard/customers': 'Clients',
'/dashboard/offers': 'Offres',
'/dashboard/team': 'Équipe',
'/dashboard/reports': 'Rapports',
'/dashboard/billing': 'Facturation',
'/dashboard/subscription': 'Abonnement',
'/dashboard/billing': 'Facturation location',
'/dashboard/contracts': 'Contrats',
'/dashboard/notifications': 'Notifications',
'/dashboard/settings': 'Paramètres',
},
@@ -676,6 +689,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
language: 'Langue',
theme: 'Mode',
light: 'Clair',
medium: 'Moyen',
dark: 'Sombre',
fleet: {
statusLabels: { AVAILABLE: 'Disponible', RENTED: 'Loué', MAINTENANCE: 'Maintenance', OUT_OF_SERVICE: 'Hors service' },
@@ -909,7 +923,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
trialActive: "Vous êtes en période d'essai.",
pastDueMsg: 'Votre paiement est en retard. Veuillez mettre à jour vos informations de facturation.',
suspendedMsg: 'Votre compte a été suspendu. Veuillez contacter le support ou renouveler votre abonnement.',
manageBilling: 'Gérer la facturation →',
manageBilling: 'Gérer labonnement →',
statusLabels: { CONFIRMED: 'Confirmé', PENDING: 'En attente', ACTIVE: 'Actif', COMPLETED: 'Terminé', CANCELLED: 'Annulé' },
},
reservations: {
@@ -987,7 +1001,9 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
offers: 'العروض',
team: 'الفريق',
reports: 'التقارير',
billing: 'الفوترة',
subscription: 'الاشتراك',
billing: 'فوترة تأجير السيارات',
contracts: 'العقود',
notifications: 'الإشعارات',
settings: 'الإعدادات',
},
@@ -995,12 +1011,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/dashboard': 'لوحة التحكم',
'/dashboard/fleet': 'إدارة الأسطول',
'/dashboard/reservations': 'الحجوزات',
'/dashboard/reservations/new': 'حجز سيارة',
'/dashboard/online-reservations': 'الحجوزات الإلكترونية',
'/dashboard/customers': 'العملاء',
'/dashboard/offers': 'العروض',
'/dashboard/team': 'الفريق',
'/dashboard/reports': 'التقارير',
'/dashboard/billing': 'الفوترة',
'/dashboard/subscription': 'الاشتراك',
'/dashboard/billing': 'فوترة تأجير السيارات',
'/dashboard/contracts': 'العقود',
'/dashboard/notifications': 'الإشعارات',
'/dashboard/settings': 'الإعدادات',
},
@@ -1013,6 +1032,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
language: 'اللغة',
theme: 'الوضع',
light: 'فاتح',
medium: 'متوسط',
dark: 'داكن',
fleet: {
statusLabels: { AVAILABLE: 'متاح', RENTED: 'مؤجر', MAINTENANCE: 'صيانة', OUT_OF_SERVICE: 'خارج الخدمة' },
@@ -1246,7 +1266,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
trialActive: 'أنت في فترة تجريبية مجانية.',
pastDueMsg: 'دفعتك متأخرة. يرجى تحديث معلومات الفوترة.',
suspendedMsg: 'تم تعليق حسابك. يرجى التواصل مع الدعم أو تجديد اشتراكك.',
manageBilling: 'إدارة الفوترة ←',
manageBilling: 'إدارة الاشتراك ←',
statusLabels: { CONFIRMED: 'مؤكد', PENDING: 'قيد الانتظار', ACTIVE: 'نشط', COMPLETED: 'مكتمل', CANCELLED: 'ملغى' },
},
reservations: {
@@ -1325,25 +1345,38 @@ type I18nContextValue = {
}
const I18nContext = createContext<I18nContextValue | null>(null)
const DASHBOARD_LANGUAGE_KEY = 'dashboard-language'
export function DashboardI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<DashboardLanguage>('en')
function normalizeLanguage(value: string | null | undefined): DashboardLanguage {
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
}
export function DashboardI18nProvider({
children,
initialLanguage = 'en',
}: {
children: React.ReactNode
initialLanguage?: DashboardLanguage
}) {
const [language, setLanguage] = useState<DashboardLanguage>(initialLanguage)
const [theme, setTheme] = useState<DashboardTheme>('light')
const themeInitialized = useRef(false)
useEffect(() => {
const stored = window.localStorage.getItem('dashboard-language')
const storedTheme = window.localStorage.getItem('dashboard-theme')
const stored = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
if (stored === null) return
const next = normalizeLanguage(stored)
if (next !== language) setLanguage(next)
}, [])
if (stored === 'en' || stored === 'fr' || stored === 'ar') {
setLanguage(stored)
}
if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme)
useEffect(() => {
const storedTheme = readScopedPreference(SHARED_THEME_KEY, ['dashboard-theme'])
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
if (storedTheme !== theme) setTheme(storedTheme)
return
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
if (window.matchMedia('(prefers-color-scheme: dark)').matches && theme !== 'dark') {
setTheme('dark')
}
}, [])
@@ -1351,17 +1384,46 @@ export function DashboardI18nProvider({ children }: { children: React.ReactNode
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
window.localStorage.setItem('dashboard-language', language)
writeScopedPreference(SHARED_LANGUAGE_KEY, language, [DASHBOARD_LANGUAGE_KEY])
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; samesite=lax`
document.cookie = `${DASHBOARD_LANGUAGE_KEY}=${language}; path=/; max-age=31536000; samesite=lax`
}, [language])
useEffect(() => {
if (!themeInitialized.current) {
// Skip first run: inline script already applied the correct class; writing 'light' here would flash and corrupt storage.
themeInitialized.current = true
return
}
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(theme)
document.documentElement.style.colorScheme = theme
document.documentElement.classList.add(theme === 'light' ? 'light' : 'dark')
document.documentElement.style.colorScheme = theme === 'light' ? 'light' : 'dark'
document.body.dataset.theme = theme
window.localStorage.setItem('dashboard-theme', theme)
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
}, [theme])
useEffect(() => {
function handleAuthChanged() {
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if (scopedLanguage && normalizeLanguage(scopedLanguage) !== language) {
setLanguage(normalizeLanguage(scopedLanguage))
} else {
writeScopedPreference(SHARED_LANGUAGE_KEY, language, [DASHBOARD_LANGUAGE_KEY])
}
if (scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') {
if (scopedTheme !== theme) setTheme(scopedTheme)
} else {
writeScopedPreference(SHARED_THEME_KEY, theme, ['dashboard-theme'])
}
}
window.addEventListener('rentaldrivego:auth-changed', handleAuthChanged as EventListener)
return () => window.removeEventListener('rentaldrivego:auth-changed', handleAuthChanged as EventListener)
}, [language, theme])
const value = useMemo(
() => ({ language, setLanguage, theme, setTheme, dict: dictionaries[language] }),
[language, theme],
@@ -1426,7 +1488,7 @@ export function DashboardThemeSwitcher() {
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500 dark:text-slate-400">
{dict.theme}
</span>
{(['light', 'dark'] as DashboardTheme[]).map((value) => {
{(['light', 'medium', 'dark'] as DashboardTheme[]).map((value) => {
const active = value === theme
return (
<button
@@ -1439,7 +1501,7 @@ export function DashboardThemeSwitcher() {
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800'
}`}
>
{value === 'light' ? dict.light : dict.dark}
{value === 'light' ? dict.light : value === 'medium' ? dict.medium : dict.dark}
</button>
)
})}
@@ -1,66 +1,280 @@
'use client'
import { ChevronDown, Globe } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import {
DashboardLanguageSwitcher,
DashboardThemeSwitcher,
type DashboardLanguage,
useDashboardI18n,
} from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
type FooterItem = {
label: string
href?: string
}
const localeOptions: Array<{ value: DashboardLanguage; label: string }> = [
{ value: 'en', label: 'Global (English)' },
{ value: 'ar', label: 'NorthAfrica (Arabic)' },
{ value: 'fr', label: 'Europ (French)' },
]
function getFooterContent(language: DashboardLanguage): {
primary: FooterItem[]
secondary: FooterItem[]
localeLabel: string
rightsLabel: string
} {
switch (language) {
case 'fr':
return {
primary: [
{ label: 'À propos', href: marketplaceUrl },
{ label: 'CLUF' },
{ label: "Conditions d'utilisation" },
{ label: 'Sécurité' },
{ label: 'Conformité' },
{ label: 'Politique de confidentialité' },
{ label: 'Politique de cookies' },
{ label: "Programme d'affiliation" },
],
secondary: [
{ label: 'Newsletter' },
{ label: 'Contacter les ventes', href: '/sign-up' },
{ label: 'Nos bureaux', href: marketplaceUrl },
],
localeLabel: 'Europ (French)',
rightsLabel: 'Tous droits réservés.',
}
case 'ar':
return {
primary: [
{ label: 'من نحن', href: marketplaceUrl },
{ label: 'اتفاقية الترخيص' },
{ label: 'شروط الخدمة' },
{ label: 'الأمان' },
{ label: 'الامتثال' },
{ label: 'سياسة الخصوصية' },
{ label: 'سياسة ملفات الارتباط' },
{ label: 'برنامج الشركاء' },
],
secondary: [
{ label: 'النشرة البريدية' },
{ label: 'تواصل مع المبيعات', href: '/sign-up' },
{ label: 'مكاتبنا', href: marketplaceUrl },
],
localeLabel: 'NorthAfrica (Arabic)',
rightsLabel: 'جميع الحقوق محفوظة.',
}
case 'en':
default:
return {
primary: [
{ label: 'About us', href: marketplaceUrl },
{ label: 'EULA' },
{ label: 'Terms of service' },
{ label: 'Security' },
{ label: 'Compliance' },
{ label: 'Privacy policy' },
{ label: 'Cookie policy' },
{ label: 'Affiliate program' },
],
secondary: [
{ label: 'Newsletter' },
{ label: 'Contact sales', href: '/sign-up' },
{ label: 'Our offices', href: marketplaceUrl },
],
localeLabel: 'Global (English)',
rightsLabel: 'All rights reserved.',
}
}
}
export default function PublicShell({ children }: { children: React.ReactNode }) {
const { language } = useDashboardI18n()
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
const localeMenuRef = useRef<HTMLDivElement | null>(null)
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
const dict = {
en: {
workspace: 'Company Workspace',
home: 'Home',
marketplace: 'Marketplace',
signIn: 'Sign in',
createWorkspace: 'Create workspace',
createAccount: 'Create account',
preferences: 'Workspace preferences',
theme: 'Theme',
light: 'Light',
medium: 'Medium',
dark: 'Dark',
},
fr: {
workspace: 'Espace entreprise',
home: 'Accueil',
marketplace: 'Marketplace',
signIn: 'Connexion',
createWorkspace: 'Créer un espace',
createAccount: 'Créer un compte',
preferences: 'Preferences espace',
theme: 'Mode',
light: 'Clair',
medium: 'Moyen',
dark: 'Sombre',
},
ar: {
workspace: 'مساحة الشركة',
home: 'الرئيسية',
marketplace: 'السوق',
signIn: 'تسجيل الدخول',
createWorkspace: 'إنشاء مساحة',
createAccount: 'إنشاء حساب',
preferences: 'تفضيلات المساحة',
theme: 'الوضع',
light: 'فاتح',
medium: 'متوسط',
dark: 'داكن',
},
}[language]
const footerContent = getFooterContent(language)
const availableLocaleOptions = localeOptions.filter((option) => option.value !== language)
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
if (!localeMenuRef.current?.contains(event.target as Node)) {
setLocaleMenuOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [])
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] transition-colors dark:bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.18),transparent_35%),linear-gradient(180deg,#020617,#0f172a)] dark:text-slate-100">
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/85 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/85">
<div className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-4">
<Link href={marketplaceUrl} className="flex items-center gap-3 text-slate-900 dark:text-slate-100">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
<span className="hidden text-sm font-semibold text-slate-500 dark:text-slate-400 sm:inline">{dict.workspace}</span>
<header className="sticky top-0 z-30 border-b border-stone-200 bg-white/85 backdrop-blur-md transition-colors dark:border-stone-800 dark:bg-stone-950/85">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:px-8 lg:py-2">
<Link href={marketplaceUrl} className="flex items-center gap-3 text-sm font-bold uppercase tracking-[0.2em] text-stone-900 dark:text-stone-100">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
width={36}
height={36}
className="h-9 w-9 rounded-md object-contain"
/>
<span>RentalDriveGo</span>
</Link>
<nav className="flex items-center gap-2">
<Link href="/sign-in" className="rounded-full px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-slate-100">
{dict.signIn}
</Link>
<Link href="/sign-up" className="rounded-full bg-slate-900 px-4 py-2 text-sm font-semibold text-white transition hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-950 dark:hover:bg-slate-200">
{dict.createWorkspace}
</Link>
</nav>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<nav className="flex items-center gap-1 overflow-x-auto">
<Link href={marketplaceUrl} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.home}
</Link>
<Link href={`${marketplaceUrl}/explore`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.marketplace}
</Link>
<Link href={`${marketplaceUrl}/sign-in`} className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100">
{dict.signIn}
</Link>
<Link href="/sign-up" className="ml-2 rounded-full bg-stone-900 px-5 py-2 text-sm font-semibold text-white transition hover:bg-stone-700 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300">
{dict.createAccount}
</Link>
</nav>
<div className="shrink-0">
<div className="flex items-center gap-2 rounded-full border border-stone-200/70 bg-white/90 px-2 py-1 shadow-sm dark:border-stone-700 dark:bg-stone-900/80">
<span className="px-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-stone-500 dark:text-stone-400">
{dict.theme}
</span>
{(['light', 'medium', 'dark'] as const).map((value) => {
const active = value === theme
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
active
? 'bg-stone-900 text-white dark:bg-amber-400 dark:text-stone-950'
: 'text-stone-600 hover:bg-stone-100 dark:text-stone-300 dark:hover:bg-stone-800'
}`}
>
{value === 'light' ? dict.light : value === 'medium' ? dict.medium : dict.dark}
</button>
)
})}
</div>
</div>
</div>
</div>
</header>
<div>{children}</div>
<footer className="border-t border-stone-200 bg-white/90 px-4 py-4 backdrop-blur-md transition-colors dark:border-slate-800 dark:bg-slate-950/90">
<div className="mx-auto flex max-w-6xl 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">
{dict.preferences}
</p>
<div className="flex flex-col items-center gap-3 sm:flex-row">
<DashboardLanguageSwitcher />
<DashboardThemeSwitcher />
<footer className="border-t border-stone-200 bg-white/90 px-4 py-8 text-stone-600 transition-colors dark:border-white/5 dark:bg-[#111111] dark:text-stone-300">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-5 text-center px-0 sm:px-2 lg:px-0">
<nav className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.primary.map((item, index) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
{index < footerContent.primary.length - 1 ? (
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
) : null}
</div>
))}
</nav>
<div className="flex flex-wrap items-center justify-center gap-y-3 text-sm">
{footerContent.secondary.map((item) => (
<div key={item.label} className="flex items-center">
<FooterNavItem item={item} />
<span className="px-2 text-stone-400 dark:text-stone-600" aria-hidden="true">|</span>
</div>
))}
<div ref={localeMenuRef} className="relative px-3">
<button
type="button"
onClick={() => setLocaleMenuOpen((open) => !open)}
className="inline-flex items-center gap-2 text-sky-600 transition hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300"
aria-expanded={localeMenuOpen}
aria-haspopup="menu"
>
<Globe className="h-4 w-4" />
<span>{footerContent.localeLabel}</span>
<ChevronDown className={`h-4 w-4 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
</button>
{localeMenuOpen ? (
<div className="absolute left-1/2 top-full z-20 mt-3 w-56 -translate-x-1/2 overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-stone-800 dark:bg-stone-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
{availableLocaleOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => {
setLanguage(option.value)
setLocaleMenuOpen(false)
}}
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-stone-950 dark:text-stone-200 dark:hover:bg-stone-900 dark:hover:text-white"
>
<Globe className="h-4 w-4 text-sky-600 dark:text-sky-400" />
<span>{option.label}</span>
</button>
))}
</div>
) : null}
</div>
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
</p>
</div>
</footer>
</div>
)
}
function FooterNavItem({ item }: { item: FooterItem }) {
const className = 'px-3 text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white'
if (item.href) {
return (
<Link href={item.href} className={className}>
{item.label}
</Link>
)
}
return <span className={className}>{item.label}</span>
}
@@ -16,12 +16,13 @@ import {
Bell,
Settings,
LogOut,
FileText,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
interface BrandSettings {
displayName: string
@@ -33,6 +34,12 @@ interface SidebarUser {
subtitle: string
initials: string
}
interface EmployeeProfile {
email: string
firstName: string
lastName: string
role: string
}
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
@@ -41,6 +48,16 @@ function computeInitials(name: string): string {
return `${parts[0].slice(0, 1)}${parts[1].slice(0, 1)}`.toUpperCase()
}
function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string, fallbackSubtitle: string): SidebarUser {
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
const role = profile.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : fallbackName)
const subtitle = email || role || fallbackSubtitle
const initials = computeInitials(fullName || email || fallbackName)
return { displayName, subtitle, initials }
}
const NAV_ITEMS = [
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
@@ -50,7 +67,9 @@ const NAV_ITEMS = [
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
{ href: '/dashboard/reports', key: 'reports', icon: BarChart2 },
{ href: '/dashboard/subscription', key: 'subscription', icon: CreditCard },
{ href: '/dashboard/billing', key: 'billing', icon: CreditCard },
{ href: '/dashboard/contracts', key: 'contracts', icon: FileText },
{ href: '/dashboard/notifications', key: 'notifications', icon: Bell },
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
] as const
@@ -83,33 +102,25 @@ export default function Sidebar() {
return
}
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as Partial<EmployeeProfile>
setUser(toSidebarUser(profile, dict.workspaceUser, dict.localAuth))
} catch {}
}
let cancelled = false
apiFetch<{
employee: {
email: string
firstName: string
lastName: string
role: string
}
}>('/auth/employee/me')
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
const role = employee.role ?? ''
const displayName = fullName || (email ? email.split('@')[0] : dict.workspaceUser)
const subtitle = email || role || dict.localAuth
const initials = computeInitials(fullName || email || dict.workspaceUser)
setUser({ displayName, subtitle, initials })
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
})
.catch(() => {
if (cancelled) return
setUser({
displayName: dict.workspaceUser,
subtitle: dict.localAuth,
initials: 'W',
})
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
})
return () => { cancelled = true }
@@ -125,6 +136,7 @@ export default function Sidebar() {
function signOut() {
localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
document.cookie = 'employee_token=; path=/; max-age=0; samesite=lax'
router.push('/sign-in')
}
@@ -3,7 +3,7 @@
import { Bell } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
function computeInitials(name: string): string {
@@ -29,7 +29,12 @@ export default function TopBar() {
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const title = dict.titles[pathname] ?? dict.titles['/dashboard']
const title = (() => {
if (dict.titles[pathname]) return dict.titles[pathname]
if (pathname.startsWith('/dashboard/contracts/')) return dict.titles['/dashboard/contracts'] ?? dict.titles['/dashboard']
if (pathname.startsWith('/dashboard/reservations/')) return dict.titles['/dashboard/reservations'] ?? dict.titles['/dashboard']
return dict.titles['/dashboard']
})()
async function refreshUnreadCount() {
try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
@@ -87,6 +92,16 @@ export default function TopBar() {
return
}
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
if (cached) {
try {
const profile = JSON.parse(cached) as { email?: string; firstName?: string; lastName?: string }
const fullName = `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim()
const email = profile.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
} catch {}
}
let cancelled = false
apiFetch<{
@@ -98,6 +113,7 @@ export default function TopBar() {
}>('/auth/employee/me')
.then(({ employee }) => {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const fullName = `${employee.firstName ?? ''} ${employee.lastName ?? ''}`.trim()
const email = employee.email ?? ''
setUserInitials(computeInitials(fullName || email || dict.workspaceUser))
@@ -74,19 +74,27 @@ export default function DamageInspectionCard({
setSaving(true)
setError(null)
try {
const payload = {
...(inspection.mileage !== null ? { mileage: inspection.mileage } : {}),
fuelLevel: inspection.fuelLevel,
...(inspection.fuelCharge !== null ? { fuelCharge: inspection.fuelCharge } : {}),
...(inspection.generalCondition ? { generalCondition: inspection.generalCondition } : {}),
...(inspection.employeeNotes ? { employeeNotes: inspection.employeeNotes } : {}),
customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType,
x,
y,
damageType,
severity,
...(description ? { description } : {}),
isPreExisting,
})),
}
const result = await apiFetch<DamageInspection>(`/reservations/${reservationId}/inspections/${type.toLowerCase()}`, {
method: 'PUT',
body: JSON.stringify({
mileage: inspection.mileage,
fuelLevel: inspection.fuelLevel,
fuelCharge: inspection.fuelCharge,
generalCondition: inspection.generalCondition,
employeeNotes: inspection.employeeNotes,
customerAgreed: inspection.customerAgreed,
damagePoints: inspection.damagePoints.map(({ viewType, x, y, damageType, severity, description, isPreExisting }) => ({
viewType, x, y, damageType, severity, description, isPreExisting,
})),
}),
body: JSON.stringify(payload),
})
setInspection(result)
onSaved(result)
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import type { TeamMember } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
member: TeamMember | null
@@ -13,14 +14,81 @@ interface Props {
onRemove: (memberId: string) => Promise<void>
}
const ROLE_OPTIONS = [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
]
type ActionState = 'idle' | 'saving' | 'deactivating' | 'reactivating' | 'removing'
type ConfirmAction = 'deactivate' | 'reactivate' | 'remove' | null
const copy = {
en: {
pendingInvite: 'Pending invite',
deactivated: 'Deactivated',
role: 'Role',
cancel: 'Cancel',
saveChanges: 'Save changes',
saving: 'Saving…',
working: 'Working…',
deactivate: 'Deactivate',
reactivate: 'Reactivate',
remove: 'Remove',
removePermanently: 'Remove permanently',
confirmDeactivateTitle: 'Deactivate this member?',
confirmRemoveTitle: 'Permanently remove this member?',
confirmReactivateTitle: 'Reactivate this member?',
confirmDeactivateDesc: "They'll immediately lose dashboard access. You can reactivate anytime.",
confirmRemoveDesc: "This will delete the employee record and revoke their dashboard access. This cannot be undone.",
confirmReactivateDesc: "They'll regain dashboard access with their current role.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Full ops — no billing' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Reservations & check-ins' },
],
},
fr: {
pendingInvite: 'Invitation en attente',
deactivated: 'Désactivé',
role: 'Rôle',
cancel: 'Annuler',
saveChanges: 'Enregistrer',
saving: 'Enregistrement…',
working: 'En cours…',
deactivate: 'Désactiver',
reactivate: 'Réactiver',
remove: 'Supprimer',
removePermanently: 'Supprimer définitivement',
confirmDeactivateTitle: 'Désactiver ce membre ?',
confirmRemoveTitle: 'Supprimer définitivement ce membre ?',
confirmReactivateTitle: 'Réactiver ce membre ?',
confirmDeactivateDesc: "Il perdra immédiatement l'accès au tableau de bord. Vous pouvez le réactiver à tout moment.",
confirmRemoveDesc: "Cela supprimera le compte et révoquera son accès au tableau de bord. Cette action est irréversible.",
confirmReactivateDesc: "Il retrouvera l'accès au tableau de bord avec son rôle actuel.",
roleOptions: [
{ value: 'MANAGER' as const, label: 'Manager', description: 'Opérations complètes — sans facturation' },
{ value: 'AGENT' as const, label: 'Agent', description: 'Réservations et enregistrements' },
],
},
ar: {
pendingInvite: 'دعوة قيد الانتظار',
deactivated: 'معطل',
role: 'الدور',
cancel: 'إلغاء',
saveChanges: 'حفظ التغييرات',
saving: 'جارٍ الحفظ…',
working: 'جارٍ التنفيذ…',
deactivate: 'تعطيل',
reactivate: 'إعادة التفعيل',
remove: 'حذف',
removePermanently: 'حذف نهائياً',
confirmDeactivateTitle: 'تعطيل هذا العضو؟',
confirmRemoveTitle: 'حذف هذا العضو نهائياً؟',
confirmReactivateTitle: 'إعادة تفعيل هذا العضو؟',
confirmDeactivateDesc: 'سيفقد الوصول إلى لوحة التحكم فوراً. يمكنك إعادة التفعيل في أي وقت.',
confirmRemoveDesc: 'سيتم حذف سجل الموظف وإلغاء صلاحياته. هذا الإجراء لا يمكن التراجع عنه.',
confirmReactivateDesc: 'سيستعيد الوصول إلى لوحة التحكم بدوره الحالي.',
roleOptions: [
{ value: 'MANAGER' as const, label: 'مدير', description: 'عمليات كاملة — بدون فوترة' },
{ value: 'AGENT' as const, label: 'وكيل', description: 'الحجوزات والاستلام' },
],
},
}
export default function EditMemberModal({
member,
open,
@@ -30,6 +98,10 @@ export default function EditMemberModal({
onReactivate,
onRemove,
}: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [actionState, setActionState] = useState<ActionState>('idle')
const [confirm, setConfirm] = useState<ConfirmAction>(null)
@@ -48,6 +120,8 @@ export default function EditMemberModal({
const isPending = member.invitationStatus === 'pending'
const isActive = member.isActive
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
const handleSave = async () => {
if (role === member.role) { onClose(); return }
@@ -101,15 +175,16 @@ export default function EditMemberModal({
setConfirm(null)
}
const busy = actionState !== 'idle'
const initials = (member.firstName[0] ?? '') + (member.lastName[0] ?? '')
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
{/* Member header */}
<div className="flex items-center gap-3 mb-5 pb-5 border-b border-zinc-100 dark:border-zinc-800">
<div className="w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center text-sm font-medium text-zinc-600 dark:text-zinc-300">
{initials}
@@ -123,35 +198,36 @@ export default function EditMemberModal({
<div className="ml-auto">
{isPending && (
<span className="text-xs px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-950/30 text-amber-700 dark:text-amber-400 border border-amber-100 dark:border-amber-900/50">
Pending invite
{t.pendingInvite}
</span>
)}
{!isPending && !isActive && (
<span className="text-xs px-2 py-1 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400">
Deactivated
{t.deactivated}
</span>
)}
</div>
</div>
{/* Confirm panel */}
{confirm && (
<div className="mb-4 px-4 py-3 rounded-xl border border-red-100 dark:border-red-900/50 bg-red-50 dark:bg-red-950/20">
<p className="text-sm font-medium text-red-700 dark:text-red-400 mb-1">
{confirm === 'deactivate' && 'Deactivate this member?'}
{confirm === 'remove' && 'Permanently remove this member?'}
{confirm === 'reactivate' && 'Reactivate this member?'}
{confirm === 'deactivate' && t.confirmDeactivateTitle}
{confirm === 'remove' && t.confirmRemoveTitle}
{confirm === 'reactivate' && t.confirmReactivateTitle}
</p>
<p className="text-xs text-red-600 dark:text-red-500 mb-3">
{confirm === 'deactivate' && "They'll immediately lose dashboard access. You can reactivate anytime."}
{confirm === 'remove' && "This will delete the employee record and revoke their dashboard access. This cannot be undone."}
{confirm === 'reactivate' && "They'll regain dashboard access with their current role."}
{confirm === 'deactivate' && t.confirmDeactivateDesc}
{confirm === 'remove' && t.confirmRemoveDesc}
{confirm === 'reactivate' && t.confirmReactivateDesc}
</p>
<div className="flex gap-2">
<div className={`flex gap-2 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
onClick={() => setConfirm(null)}
className="flex-1 text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
Cancel
{t.cancel}
</button>
<button
onClick={
@@ -167,23 +243,24 @@ export default function EditMemberModal({
: 'bg-red-600 text-white hover:bg-red-700',
].join(' ')}
>
{busy ? 'Working…' : (
confirm === 'deactivate' ? 'Deactivate' :
confirm === 'reactivate' ? 'Reactivate' :
'Remove permanently'
{busy ? t.working : (
confirm === 'deactivate' ? t.deactivate :
confirm === 'reactivate' ? t.reactivate :
t.removePermanently
)}
</button>
</div>
</div>
)}
{/* Role selector — only for active, non-pending members */}
{!isPending && isActive && (
<div className="mb-4">
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">
Role
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
@@ -209,15 +286,16 @@ export default function EditMemberModal({
</div>
)}
<div className="flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800">
<div className="flex gap-2 mr-auto">
{/* Actions footer */}
<div className={`flex items-center gap-2 pt-4 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<div className={`flex gap-2 ${isRtl ? 'ml-auto' : 'mr-auto'}`}>
{isActive && !isPending && (
<button
onClick={() => setConfirm('deactivate')}
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-red-200 dark:border-red-900/50 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Deactivate
{t.deactivate}
</button>
)}
{!isActive && (
@@ -226,7 +304,7 @@ export default function EditMemberModal({
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
>
Reactivate
{t.reactivate}
</button>
)}
<button
@@ -234,7 +312,7 @@ export default function EditMemberModal({
disabled={busy}
className="text-xs px-3 py-1.5 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/20 disabled:opacity-50 transition-colors"
>
Remove
{t.remove}
</button>
</div>
@@ -243,7 +321,7 @@ export default function EditMemberModal({
disabled={busy}
className="px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors disabled:opacity-50"
>
Cancel
{t.cancel}
</button>
{isActive && !isPending && (
<button
@@ -251,7 +329,7 @@ export default function EditMemberModal({
disabled={busy}
className="px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors disabled:opacity-50"
>
{actionState === 'saving' ? 'Saving…' : 'Save changes'}
{actionState === 'saving' ? t.saving : t.saveChanges}
</button>
)}
</div>
@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'
import type { InvitePayload } from '@/hooks/useTeam'
import { useDashboardI18n } from '@/components/I18nProvider'
interface Props {
open: boolean
@@ -9,22 +10,95 @@ interface Props {
onInvite: (payload: InvitePayload) => Promise<void>
}
const ROLE_OPTIONS = [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
const copy = {
en: {
title: 'Invite a team member',
subtitle: "They'll receive an email invitation to join your dashboard",
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
role: 'Role',
cancel: 'Cancel',
sending: 'Sending…',
sendInvite: 'Send invite →',
failedInvite: 'Failed to send invitation',
more: (n: number) => `+${n} more`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Full fleet ops — no billing or team settings',
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
},
],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Day-to-day operations only',
permissions: ['View fleet', 'Reservations', 'Check-in / Check-out', 'View customers'],
fr: {
title: 'Inviter un membre',
subtitle: "Il recevra un email d'invitation pour rejoindre votre tableau de bord",
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse email',
role: 'Rôle',
cancel: 'Annuler',
sending: 'Envoi…',
sendInvite: "Envoyer l'invitation →",
failedInvite: "Échec de l'envoi de l'invitation",
more: (n: number) => `+${n} de plus`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'Manager',
description: 'Opérations complètes — sans facturation ni paramètres équipe',
permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation permis'],
},
{
value: 'AGENT' as const,
label: 'Agent',
description: 'Opérations quotidiennes uniquement',
permissions: ['Voir la flotte', 'Réservations', 'Départ / Retour', 'Voir les clients'],
},
],
},
]
ar: {
title: 'دعوة عضو في الفريق',
subtitle: 'سيتلقى بريداً إلكترونياً للانضمام إلى لوحة التحكم',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
role: 'الدور',
cancel: 'إلغاء',
sending: 'جارٍ الإرسال…',
sendInvite: '← إرسال الدعوة',
failedInvite: 'فشل إرسال الدعوة',
more: (n: number) => `+${n} أكثر`,
roleOptions: [
{
value: 'MANAGER' as const,
label: 'مدير',
description: 'عمليات أسطول كاملة — بدون الفوترة أو إعدادات الفريق',
permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'],
},
{
value: 'AGENT' as const,
label: 'وكيل',
description: 'العمليات اليومية فقط',
permissions: ['عرض الأسطول', 'الحجوزات', 'الاستلام / الإرجاع', 'عرض العملاء'],
},
],
},
}
export default function InviteModal({ open, onClose, onInvite }: Props) {
const { language } = useDashboardI18n()
const t = copy[language]
const isRtl = language === 'ar'
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
@@ -48,12 +122,11 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? 'Failed to send invitation')
setError(err.message ?? t.failedInvite)
} finally {
setLoading(false)
}
@@ -66,19 +139,20 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6">
<div
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl shadow-xl w-full max-w-md mx-4 p-6"
dir={isRtl ? 'rtl' : 'ltr'}
>
<div className="mb-5">
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">Invite a team member</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
They&apos;ll receive an email invitation to join your dashboard
</p>
<h2 className="text-lg font-medium text-zinc-900 dark:text-white">{t.title}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">{t.subtitle}</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
First name
{t.firstName}
</label>
<input
type="text"
@@ -91,7 +165,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Last name
{t.lastName}
</label>
<input
type="text"
@@ -106,7 +180,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Email address
{t.email}
</label>
<input
type="email"
@@ -120,10 +194,10 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
Role
{t.role}
</label>
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((option) => (
{t.roleOptions.map((option) => (
<button
key={option.value}
type="button"
@@ -161,7 +235,7 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
))}
{option.permissions.length > 3 && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-700 text-zinc-500 dark:text-zinc-400">
+{option.permissions.length - 3} more
{t.more(option.permissions.length - 3)}
</span>
)}
</div>
@@ -176,20 +250,20 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
</div>
)}
<div className="flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800">
<div className={`flex gap-2 pt-2 border-t border-zinc-100 dark:border-zinc-800 ${isRtl ? 'flex-row-reverse' : ''}`}>
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
>
Cancel
{t.cancel}
</button>
<button
type="submit"
disabled={loading}
className="flex-1 px-4 py-2 text-sm bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Sending…' : 'Send invite →'}
{loading ? t.sending : t.sendInvite}
</button>
</div>
</form>
@@ -1,9 +1,10 @@
'use client'
import { useDashboardI18n } from '@/components/I18nProvider'
type Access = 'full' | 'partial' | 'none'
interface Feature {
label: string
manager: Access
managerNote?: string
agent: Access
@@ -11,36 +12,138 @@ interface Feature {
}
const FEATURES: Feature[] = [
{ label: 'View fleet & vehicles', manager: 'full', agent: 'full' },
{ label: 'Add / edit vehicles', manager: 'full', agent: 'none' },
{ label: 'Publish / unpublish vehicle', manager: 'full', agent: 'none' },
{ label: 'Upload vehicle photos', manager: 'full', agent: 'none' },
{ label: 'View reservations', manager: 'full', agent: 'full' },
{ label: 'Create reservations', manager: 'full', agent: 'full' },
{ label: 'Cancel reservations', manager: 'full', agent: 'partial', agentNote: 'Own only' },
{ label: 'Check-in / check-out', manager: 'full', agent: 'full' },
{ label: 'Damage inspection', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — view', manager: 'full', agent: 'full' },
{ label: 'Customer CRM — edit', manager: 'full', agent: 'partial', agentNote: 'Notes only' },
{ label: 'Approve driver licenses', manager: 'full', agent: 'none' },
{ label: 'Flag / blacklist customer', manager: 'full', agent: 'none' },
{ label: 'Offers & promotions', manager: 'full', agent: 'none' },
{ label: 'Analytics & reports', manager: 'full', agent: 'none' },
{ label: 'Contract & invoice PDF', manager: 'full', agent: 'full' },
{ label: 'Billing & subscription', manager: 'none', agent: 'none' },
{ label: 'Invite / remove staff', manager: 'none', agent: 'none' },
{ label: 'Brand & site settings', manager: 'none', agent: 'none' },
{ label: 'Payment settings', manager: 'none', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'full' },
{ manager: 'full', agent: 'partial' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'none' },
{ manager: 'full', agent: 'full' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
{ manager: 'none', agent: 'none' },
]
function AccessCell({ access, note }: { access: Access; note?: string }) {
const copy = {
en: {
heading: 'Role permissions',
subtitle: 'What each role can do across the dashboard. Owners have full access to everything.',
colFeature: 'Feature',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Full',
noAccess: 'No access',
limited: 'Limited',
featureLabels: [
'View fleet & vehicles',
'Add / edit vehicles',
'Publish / unpublish vehicle',
'Upload vehicle photos',
'View reservations',
'Create reservations',
'Cancel reservations',
'Check-in / check-out',
'Damage inspection',
'Customer CRM — view',
'Customer CRM — edit',
'Approve driver licenses',
'Flag / blacklist customer',
'Offers & promotions',
'Analytics & reports',
'Contract & invoice PDF',
'Billing & subscription',
'Invite / remove staff',
'Brand & site settings',
'Payment settings',
],
agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''],
},
fr: {
heading: 'Permissions par rôle',
subtitle: 'Ce que chaque rôle peut faire dans le tableau de bord. Les propriétaires ont un accès complet.',
colFeature: 'Fonctionnalité',
colManager: 'Manager',
colAgent: 'Agent',
full: 'Complet',
noAccess: 'Aucun accès',
limited: 'Limité',
featureLabels: [
'Voir la flotte et les véhicules',
'Ajouter / modifier des véhicules',
'Publier / dépublier un véhicule',
'Télécharger des photos de véhicule',
'Voir les réservations',
'Créer des réservations',
'Annuler des réservations',
'Départ / retour',
'Inspection des dommages',
'CRM clients — consultation',
'CRM clients — modification',
'Approuver les permis de conduire',
'Signaler / blacklister un client',
'Offres et promotions',
'Analyses et rapports',
'Contrat et facture PDF',
'Facturation et abonnement',
'Inviter / supprimer du personnel',
'Paramètres marque et site',
'Paramètres de paiement',
],
agentNotes: ['', '', '', '', '', '', 'Les siennes', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''],
},
ar: {
heading: 'صلاحيات الأدوار',
subtitle: 'ما يمكن لكل دور القيام به في لوحة التحكم. يملك الملاك وصولاً كاملاً لكل شيء.',
colFeature: 'الميزة',
colManager: 'المدير',
colAgent: 'الوكيل',
full: 'كامل',
noAccess: 'لا وصول',
limited: 'محدود',
featureLabels: [
'عرض الأسطول والمركبات',
'إضافة / تعديل المركبات',
'نشر / إلغاء نشر المركبة',
'رفع صور المركبة',
'عرض الحجوزات',
'إنشاء الحجوزات',
'إلغاء الحجوزات',
'الاستلام / الإرجاع',
'فحص الأضرار',
'CRM العملاء — عرض',
'CRM العملاء — تعديل',
'الموافقة على رخص القيادة',
'تعليم / حظر العميل',
'العروض والترويجات',
'التحليلات والتقارير',
'العقد وفاتورة PDF',
'الفوترة والاشتراك',
'دعوة / إزالة الموظفين',
'إعدادات العلامة والموقع',
'إعدادات الدفع',
],
agentNotes: ['', '', '', '', '', '', 'حجوزاته فقط', '', '', '', 'الملاحظات فقط', '', '', '', '', '', '', '', '', ''],
},
}
function AccessCell({ access, label, limitedNote }: { access: Access; label: { full: string; noAccess: string; limited: string }; limitedNote: string }) {
if (access === 'full') {
return (
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-green-600 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 14 14">
<path d="M2.5 7L5.5 10L11.5 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="text-xs text-zinc-700 dark:text-zinc-300">Full</span>
<span className="text-xs text-zinc-700 dark:text-zinc-300">{label.full}</span>
</div>
)
}
@@ -50,7 +153,7 @@ function AccessCell({ access, note }: { access: Access; note?: string }) {
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2.5 h-0.5 rounded bg-amber-500" />
</div>
<span className="text-xs text-amber-700 dark:text-amber-400">{note ?? 'Limited'}</span>
<span className="text-xs text-amber-700 dark:text-amber-400">{limitedNote || label.limited}</span>
</div>
)
}
@@ -59,37 +162,38 @@ function AccessCell({ access, note }: { access: Access; note?: string }) {
<div className="w-3.5 h-3.5 flex-shrink-0 flex items-center justify-center">
<div className="w-2 h-0.5 rounded bg-zinc-300 dark:bg-zinc-600" />
</div>
<span className="text-xs text-zinc-400 dark:text-zinc-600">No access</span>
<span className="text-xs text-zinc-400 dark:text-zinc-600">{label.noAccess}</span>
</div>
)
}
export default function PermissionsMatrix() {
const { language } = useDashboardI18n()
const t = copy[language]
return (
<div>
<div dir={language === 'ar' ? 'rtl' : 'ltr'}>
<div className="mb-4">
<h2 className="text-base font-medium text-zinc-900 dark:text-white">Role permissions</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">
What each role can do across the dashboard. Owners have full access to everything.
</p>
<h2 className="text-base font-medium text-zinc-900 dark:text-white">{t.heading}</h2>
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-0.5">{t.subtitle}</p>
</div>
<div className="border border-zinc-200 dark:border-zinc-700 rounded-xl overflow-hidden">
<div className="grid grid-cols-[1fr_120px_120px] bg-zinc-50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-700">
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
Feature
{t.colFeature}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Manager
{t.colManager}
</div>
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide border-l border-zinc-200 dark:border-zinc-700">
Agent
{t.colAgent}
</div>
</div>
{FEATURES.map((feature, i) => (
<div
key={feature.label}
key={i}
className={[
'grid grid-cols-[1fr_120px_120px]',
i < FEATURES.length - 1 ? 'border-b border-zinc-100 dark:border-zinc-800' : '',
@@ -97,13 +201,21 @@ export default function PermissionsMatrix() {
].join(' ')}
>
<div className="px-4 py-2.5 text-xs text-zinc-600 dark:text-zinc-400">
{feature.label}
{t.featureLabels[i]}
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.manager} note={feature.managerNote} />
<AccessCell
access={feature.manager}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={feature.managerNote ?? ''}
/>
</div>
<div className="px-4 py-2.5 border-l border-zinc-100 dark:border-zinc-800">
<AccessCell access={feature.agent} note={feature.agentNote} />
<AccessCell
access={feature.agent}
label={{ full: t.full, noAccess: t.noAccess, limited: t.limited }}
limitedNote={t.agentNotes[i] ?? ''}
/>
</div>
</div>
))}
+1
View File
@@ -4,6 +4,7 @@ export const API_BASE =
: (process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1')
export const EMPLOYEE_TOKEN_KEY = 'employee_token'
export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
async function getAuthToken(): Promise<string | null> {
if (typeof window === 'undefined') return null
+103
View File
@@ -0,0 +1,103 @@
export const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
export const SHARED_LANGUAGE_KEY = 'rentaldrivego-language'
export const SHARED_THEME_KEY = 'rentaldrivego-theme'
function readEmployeeToken() {
if (typeof window === 'undefined') return null
const localToken = window.localStorage.getItem('employee_token')
if (localToken) return localToken
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith('employee_token='))
?.split('=')[1] ?? null
}
function decodeEmployeeId(token: string | null) {
if (!token) return null
try {
const encoded = token.split('.')[1] ?? ''
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const payload = JSON.parse(atob(padded)) as { sub?: string }
return typeof payload.sub === 'string' && payload.sub ? payload.sub : null
} catch {
return null
}
}
export function getScopedPreferenceKey(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}:${employeeId}` : baseKey
}
export function getScopedPreferenceCookieName(baseKey: string) {
const employeeId = decodeEmployeeId(readEmployeeToken())
return employeeId ? `${baseKey}--${employeeId}` : baseKey
}
function readCookie(name: string) {
if (typeof document === 'undefined') return null
return document.cookie
.split(';')
.map((chunk) => chunk.trim())
.find((chunk) => chunk.startsWith(`${name}=`))
?.slice(name.length + 1) ?? null
}
function writeCookie(name: string, value: string) {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; path=/; max-age=31536000; samesite=lax`
}
export function readCurrentUserScopedPreference(baseKey: string) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
return window.localStorage.getItem(getScopedPreferenceKey(baseKey))
}
export function readScopedPreference(baseKey: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return null
const scopedCookie = readCookie(getScopedPreferenceCookieName(baseKey))
if (scopedCookie) return scopedCookie
const sharedCookie = readCookie(baseKey)
if (sharedCookie) return sharedCookie
const scopedKey = getScopedPreferenceKey(baseKey)
const candidates = [scopedKey, baseKey, ...legacyKeys]
for (const key of candidates) {
const value = window.localStorage.getItem(key)
if (value) return value
}
return null
}
export function writeScopedPreference(baseKey: string, value: string, legacyKeys: string[] = []) {
if (typeof window === 'undefined') return
const scopedKey = getScopedPreferenceKey(baseKey)
const scopedCookie = getScopedPreferenceCookieName(baseKey)
writeCookie(baseKey, value)
if (scopedCookie !== baseKey) {
writeCookie(scopedCookie, value)
}
window.localStorage.setItem(scopedKey, value)
window.localStorage.setItem(baseKey, value)
for (const key of legacyKeys) {
window.localStorage.setItem(key, value)
}
}
+15 -2
View File
@@ -1,4 +1,17 @@
import { resolveBrowserAppUrl } from '@/lib/appUrls'
export const marketplaceUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000')
export const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002')
function toAppOrigin(url: string): string {
try {
return new URL(url).origin
} catch {
return url.replace(/\/$/, '')
}
}
export const marketplaceUrl = toAppOrigin(
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'),
)
export const adminUrl = toAppOrigin(
resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002'),
)