vuln fix
This commit is contained in:
@@ -128,7 +128,7 @@ export default function AdminBillingPage() {
|
|||||||
eyebrow: 'Plateforme',
|
eyebrow: 'Plateforme',
|
||||||
mrr: 'MRR (MAD)',
|
mrr: 'MRR (MAD)',
|
||||||
active: 'Actifs',
|
active: 'Actifs',
|
||||||
trialing: 'Essai',
|
trialing: 'En essai',
|
||||||
pastDue: 'En retard',
|
pastDue: 'En retard',
|
||||||
cancelled: 'Annulés',
|
cancelled: 'Annulés',
|
||||||
company: 'Entreprise',
|
company: 'Entreprise',
|
||||||
@@ -155,9 +155,9 @@ export default function AdminBillingPage() {
|
|||||||
provider: 'Fournisseur',
|
provider: 'Fournisseur',
|
||||||
paidAt: 'Payé le',
|
paidAt: 'Payé le',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
invoiceNo: 'N° Facture',
|
invoiceNo: 'Facture n°',
|
||||||
download: 'PDF',
|
download: 'PDF',
|
||||||
cancelAtEnd: 'Annulé en fin de période',
|
cancelAtEnd: 'Sera annulé en fin de période',
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'الفوترة',
|
title: 'الفوترة',
|
||||||
@@ -193,7 +193,7 @@ export default function AdminBillingPage() {
|
|||||||
date: 'التاريخ',
|
date: 'التاريخ',
|
||||||
invoiceNo: 'رقم الفاتورة',
|
invoiceNo: 'رقم الفاتورة',
|
||||||
download: 'PDF',
|
download: 'PDF',
|
||||||
cancelAtEnd: 'يُلغى في نهاية الفترة',
|
cancelAtEnd: 'سيُلغى في نهاية الفترة',
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function AdminCompaniesPage() {
|
|||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'الشركات',
|
title: 'الشركات',
|
||||||
search: 'ابحث بالاسم أو الـ slug…',
|
search: 'ابحث بالاسم أو بالمُعرّف المختصر…',
|
||||||
loading: 'جارٍ التحميل…',
|
loading: 'جارٍ التحميل…',
|
||||||
empty: 'لم يتم العثور على شركات',
|
empty: 'لم يتم العثور على شركات',
|
||||||
company: 'الشركة',
|
company: 'الشركة',
|
||||||
@@ -66,7 +66,7 @@ export default function AdminCompaniesPage() {
|
|||||||
status: 'الحالة',
|
status: 'الحالة',
|
||||||
plan: 'الخطة',
|
plan: 'الخطة',
|
||||||
fleet: 'الأسطول',
|
fleet: 'الأسطول',
|
||||||
vehicles: 'سيارات',
|
vehicles: 'مركبة',
|
||||||
view: 'عرض',
|
view: 'عرض',
|
||||||
suspend: 'تعليق',
|
suspend: 'تعليق',
|
||||||
reactivate: 'إعادة التفعيل',
|
reactivate: 'إعادة التفعيل',
|
||||||
|
|||||||
@@ -27,20 +27,20 @@ export default function AdminDashboardPage() {
|
|||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||||
platformOverview: 'Vue plateforme',
|
platformOverview: 'Vue d’ensemble de la plateforme',
|
||||||
cards: [
|
cards: [
|
||||||
['Entreprises', 'Lister, rechercher, suspendre, réactiver et revoir l’état des abonnements.', 'Voir tout →'],
|
['Entreprises', 'Lister, rechercher, suspendre, réactiver et consulter l’état des abonnements.', 'Voir tout →'],
|
||||||
['Locataires', 'Flux de support pour bloquer et débloquer les locataires marketplace.', 'Voir tout →'],
|
['Locataires', 'Gérer l’assistance liée au blocage et au déblocage des locataires de la marketplace.', 'Voir tout →'],
|
||||||
['Journaux d’audit', 'Trace complète de chaque action admin sur la plateforme.', 'Voir les journaux →'],
|
['Journaux d’audit', 'Consulter la trace complète de chaque action d’administration sur la plateforme.', 'Voir les journaux →'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||||
platformOverview: 'نظرة عامة على المنصة',
|
platformOverview: 'نظرة عامة على المنصة',
|
||||||
cards: [
|
cards: [
|
||||||
['الشركات', 'اعرض وابحث وعلّق وأعد التفعيل وراجع حالة الاشتراك.', 'عرض الكل ←'],
|
['الشركات', 'اعرض الشركات وابحث عنها وعلّقها وأعد تفعيلها وراجع حالة الاشتراك.', 'عرض الكل ←'],
|
||||||
['المستأجرون', 'مسارات دعم لحظر وفك حظر مستأجري السوق.', 'عرض الكل ←'],
|
['المستأجرون', 'إدارة طلبات الدعم الخاصة بحظر مستأجري السوق وفك الحظر عنهم.', 'عرض الكل ←'],
|
||||||
['سجلات التدقيق', 'تتبع كامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
|
['سجلات التدقيق', 'مراجعة السجل الكامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ export default function AdminRentersPage() {
|
|||||||
title: 'Locataires',
|
title: 'Locataires',
|
||||||
search: 'Rechercher des locataires…',
|
search: 'Rechercher des locataires…',
|
||||||
name: 'Nom',
|
name: 'Nom',
|
||||||
email: 'Email',
|
email: 'E-mail',
|
||||||
phone: 'Téléphone',
|
phone: 'Téléphone',
|
||||||
status: 'Statut',
|
status: 'Statut',
|
||||||
joined: 'Inscrit',
|
joined: 'Date d’inscription',
|
||||||
loading: 'Chargement…',
|
loading: 'Chargement…',
|
||||||
empty: 'Aucun locataire trouvé',
|
empty: 'Aucun locataire trouvé',
|
||||||
blocked: 'Bloqué',
|
blocked: 'Bloqué',
|
||||||
@@ -54,7 +54,7 @@ export default function AdminRentersPage() {
|
|||||||
email: 'البريد الإلكتروني',
|
email: 'البريد الإلكتروني',
|
||||||
phone: 'الهاتف',
|
phone: 'الهاتف',
|
||||||
status: 'الحالة',
|
status: 'الحالة',
|
||||||
joined: 'تاريخ الانضمام',
|
joined: 'تاريخ التسجيل',
|
||||||
loading: 'جارٍ التحميل…',
|
loading: 'جارٍ التحميل…',
|
||||||
empty: 'لم يتم العثور على مستأجرين',
|
empty: 'لم يتم العثور على مستأجرين',
|
||||||
blocked: 'محظور',
|
blocked: 'محظور',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
],
|
],
|
||||||
"heroKicker": "RentalDriveGo",
|
"heroKicker": "RentalDriveGo",
|
||||||
"heroTitle": "Marketplace discovery with a sharper front door.",
|
"heroTitle": "Marketplace discovery with a sharper front door.",
|
||||||
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the company’s own branded checkout.",
|
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the company’s own branded checkout.",
|
||||||
"startTrial": "Start free trial",
|
"startTrial": "Start free trial",
|
||||||
"exploreVehicles": "Explore vehicles",
|
"exploreVehicles": "Explore vehicles",
|
||||||
"surfaceLabel": "Marketplace surface",
|
"surfaceLabel": "Marketplace surface",
|
||||||
@@ -23,11 +23,11 @@
|
|||||||
"brandedFlows": "Branded booking flows",
|
"brandedFlows": "Branded booking flows",
|
||||||
"multiTenant": "Multi-tenant operations",
|
"multiTenant": "Multi-tenant operations",
|
||||||
"companyKicker": "Operator workflow",
|
"companyKicker": "Operator workflow",
|
||||||
"companyTitle": "Control inventory, offers, billing, and staff from one command layer.",
|
"companyTitle": "Manage inventory, offers, billing, and staff from one control layer.",
|
||||||
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
|
||||||
"renterKicker": "Renter experience",
|
"renterKicker": "Renter experience",
|
||||||
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
|
||||||
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.",
|
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
|
||||||
"pillars": [
|
"pillars": [
|
||||||
{
|
{
|
||||||
"title": "Unified publishing",
|
"title": "Unified publishing",
|
||||||
@@ -83,8 +83,8 @@
|
|||||||
],
|
],
|
||||||
"stepLabel": "Step",
|
"stepLabel": "Step",
|
||||||
"readyKicker": "Ready to launch",
|
"readyKicker": "Ready to launch",
|
||||||
"readyTitle": "A marketplace homepage should sell the system in seconds.",
|
"readyTitle": "The marketplace homepage should explain the product in seconds.",
|
||||||
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.",
|
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
|
||||||
"viewPricing": "View pricing",
|
"viewPricing": "View pricing",
|
||||||
"createWorkspace": "Create workspace"
|
"createWorkspace": "Create workspace"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -182,8 +182,8 @@ export default function BillingPage() {
|
|||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
savingPayment: 'Enregistrement…',
|
savingPayment: 'Enregistrement…',
|
||||||
savePayment: 'Enregistrer le paiement',
|
savePayment: 'Enregistrer le paiement',
|
||||||
chargeHelp: 'Paiement enregistre un règlement sur le solde de la réservation.',
|
chargeHelp: 'Le paiement enregistre un règlement sur le solde de la réservation.',
|
||||||
depositHelp: 'Dépôt enregistre l’encaissement du dépôt de garantie remboursable.',
|
depositHelp: 'Le dépôt enregistre l’encaissement du dépôt de garantie remboursable.',
|
||||||
invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.',
|
invalidAmount: 'Saisissez un montant valide dans la limite du solde restant.',
|
||||||
paymentActionDisabled: 'Aucun solde dû',
|
paymentActionDisabled: 'Aucun solde dû',
|
||||||
paymentsRecorded: (count: number) => `${count} paiement(s)`,
|
paymentsRecorded: (count: number) => `${count} paiement(s)`,
|
||||||
@@ -257,7 +257,7 @@ export default function BillingPage() {
|
|||||||
cancel: 'إلغاء',
|
cancel: 'إلغاء',
|
||||||
savingPayment: 'جارٍ الحفظ…',
|
savingPayment: 'جارٍ الحفظ…',
|
||||||
savePayment: 'حفظ الدفعة',
|
savePayment: 'حفظ الدفعة',
|
||||||
chargeHelp: 'الدفعة تسجل مبلغاً مستلماً على رصيد الحجز.',
|
chargeHelp: 'تسجّل الدفعة مبلغاً مستلماً على رصيد الحجز.',
|
||||||
depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.',
|
depositHelp: 'العربون يسجل مبلغ التأمين القابل للاسترداد المستلم.',
|
||||||
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.',
|
invalidAmount: 'أدخل مبلغاً صحيحاً ضمن الرصيد المتبقي.',
|
||||||
paymentActionDisabled: 'لا يوجد رصيد مستحق',
|
paymentActionDisabled: 'لا يوجد رصيد مستحق',
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function CustomersPage() {
|
|||||||
const copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
title: 'Customers',
|
title: 'Customers',
|
||||||
subtitle: 'Company-scoped CRM with license validation and risk flags.',
|
subtitle: 'A company CRM with license validation and risk flags.',
|
||||||
customer: 'Customer',
|
customer: 'Customer',
|
||||||
contact: 'Contact',
|
contact: 'Contact',
|
||||||
license: 'License',
|
license: 'License',
|
||||||
@@ -36,13 +36,13 @@ export default function CustomersPage() {
|
|||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: 'Clients',
|
title: 'Clients',
|
||||||
subtitle: 'CRM de l’entreprise avec validation de permis et indicateurs de risque.',
|
subtitle: 'CRM de l’entreprise avec validation des permis et indicateurs de risque.',
|
||||||
customer: 'Client',
|
customer: 'Client',
|
||||||
contact: 'Contact',
|
contact: 'Contact',
|
||||||
license: 'Permis',
|
license: 'Permis',
|
||||||
flags: 'Signalements',
|
flags: 'Signalements',
|
||||||
imageUploaded: 'Image téléversée',
|
imageUploaded: 'Image téléversée',
|
||||||
imageMissing: 'Pas d’image',
|
imageMissing: 'Aucune image',
|
||||||
noPhone: 'Pas de téléphone',
|
noPhone: 'Pas de téléphone',
|
||||||
flagged: 'Signalé',
|
flagged: 'Signalé',
|
||||||
clear: 'Aucun risque',
|
clear: 'Aucun risque',
|
||||||
@@ -50,16 +50,16 @@ export default function CustomersPage() {
|
|||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'العملاء',
|
title: 'العملاء',
|
||||||
subtitle: 'نظام CRM خاص بالشركة مع التحقق من الرخصة ومؤشرات المخاطر.',
|
subtitle: 'نظام إدارة عملاء خاص بالشركة مع التحقق من الرخص ومؤشرات المخاطر.',
|
||||||
customer: 'العميل',
|
customer: 'العميل',
|
||||||
contact: 'التواصل',
|
contact: 'التواصل',
|
||||||
license: 'الرخصة',
|
license: 'الرخصة',
|
||||||
flags: 'الإشارات',
|
flags: 'العلامات',
|
||||||
imageUploaded: 'تم رفع الصورة',
|
imageUploaded: 'تم رفع الصورة',
|
||||||
imageMissing: 'لا توجد صورة',
|
imageMissing: 'لا توجد صورة',
|
||||||
noPhone: 'لا يوجد هاتف',
|
noPhone: 'لا يوجد هاتف',
|
||||||
flagged: 'مُعلّم',
|
flagged: 'تم تمييزه',
|
||||||
clear: 'سليم',
|
clear: 'لا توجد مخاطر',
|
||||||
empty: 'لا يوجد عملاء حتى الآن.',
|
empty: 'لا يوجد عملاء حتى الآن.',
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function OnlineReservationsPage() {
|
|||||||
refresh: 'Actualiser',
|
refresh: 'Actualiser',
|
||||||
pendingApproval: 'En attente de validation',
|
pendingApproval: 'En attente de validation',
|
||||||
loading: 'Chargement…',
|
loading: 'Chargement…',
|
||||||
allCaughtUp: 'Tout est à jour, aucune demande en attente.',
|
allCaughtUp: 'Tout est à jour. Aucune demande en attente.',
|
||||||
customer: 'Client',
|
customer: 'Client',
|
||||||
vehicle: 'Véhicule',
|
vehicle: 'Véhicule',
|
||||||
dates: 'Dates',
|
dates: 'Dates',
|
||||||
@@ -102,9 +102,9 @@ export default function OnlineReservationsPage() {
|
|||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'الحجوزات الإلكترونية',
|
title: 'الحجوزات الإلكترونية',
|
||||||
subtitle: 'طلبات مقدمة من العملاء عبر السوق. قم بالتأكيد أو الرفض لكل طلب.',
|
subtitle: 'طلبات أرسلها العملاء عبر السوق. أكّد كل طلب أو ارفضه.',
|
||||||
refresh: 'تحديث',
|
refresh: 'تحديث',
|
||||||
pendingApproval: 'بانتظار الموافقة',
|
pendingApproval: 'في انتظار الموافقة',
|
||||||
loading: 'جارٍ التحميل…',
|
loading: 'جارٍ التحميل…',
|
||||||
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
|
allCaughtUp: 'لا توجد طلبات معلقة حالياً.',
|
||||||
customer: 'العميل',
|
customer: 'العميل',
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function ReportsPage() {
|
|||||||
const copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
title: 'Reports',
|
title: 'Reports',
|
||||||
subtitle: 'Accountant-ready exports with insurance, additional-driver, and pricing-rule totals.',
|
subtitle: 'Accountant-ready exports with totals for insurance, additional drivers, and pricing rules.',
|
||||||
exportCsv: 'Export CSV',
|
exportCsv: 'Export CSV',
|
||||||
exporting: 'Exporting…',
|
exporting: 'Exporting…',
|
||||||
bookings: 'Bookings',
|
bookings: 'Bookings',
|
||||||
@@ -60,15 +60,15 @@ export default function ReportsPage() {
|
|||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: 'Rapports',
|
title: 'Rapports',
|
||||||
subtitle: 'Exports prêts pour le comptable avec totaux assurance, conducteurs supplémentaires et règles tarifaires.',
|
subtitle: 'Exports prêts pour la comptabilité avec les totaux d’assurance, de conducteurs supplémentaires et de règles tarifaires.',
|
||||||
exportCsv: 'Exporter CSV',
|
exportCsv: 'Exporter en CSV',
|
||||||
exporting: 'Export en cours…',
|
exporting: 'Export en cours…',
|
||||||
bookings: 'Réservations',
|
bookings: 'Réservations',
|
||||||
rental: 'Location',
|
rental: 'Location',
|
||||||
insurance: 'Assurance',
|
insurance: 'Assurance',
|
||||||
drivers: 'Conducteurs',
|
drivers: 'Conducteurs',
|
||||||
discounts: 'Remises',
|
discounts: 'Remises',
|
||||||
collected: 'Collecté',
|
collected: 'Encaissé',
|
||||||
reservation: 'Réservation',
|
reservation: 'Réservation',
|
||||||
vehicle: 'Véhicule',
|
vehicle: 'Véhicule',
|
||||||
period: 'Période',
|
period: 'Période',
|
||||||
@@ -80,7 +80,7 @@ export default function ReportsPage() {
|
|||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'التقارير',
|
title: 'التقارير',
|
||||||
subtitle: 'تصدير جاهز للمحاسبة مع إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
|
subtitle: 'ملفات تصدير جاهزة للمحاسبة تشمل إجماليات التأمين والسائقين الإضافيين وقواعد التسعير.',
|
||||||
exportCsv: 'تصدير CSV',
|
exportCsv: 'تصدير CSV',
|
||||||
exporting: 'جارٍ التصدير…',
|
exporting: 'جارٍ التصدير…',
|
||||||
bookings: 'الحجوزات',
|
bookings: 'الحجوزات',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface Props {
|
|||||||
const copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
title: 'Invite a team member',
|
title: 'Invite a team member',
|
||||||
subtitle: "They'll receive an email invitation to join your dashboard",
|
subtitle: "They'll receive an email invitation to join your workspace.",
|
||||||
firstName: 'First name',
|
firstName: 'First name',
|
||||||
lastName: 'Last name',
|
lastName: 'Last name',
|
||||||
email: 'Email address',
|
email: 'Email address',
|
||||||
@@ -27,7 +27,7 @@ const copy = {
|
|||||||
{
|
{
|
||||||
value: 'MANAGER' as const,
|
value: 'MANAGER' as const,
|
||||||
label: 'Manager',
|
label: 'Manager',
|
||||||
description: 'Full fleet ops — no billing or team settings',
|
description: 'Full fleet operations, without billing or team settings',
|
||||||
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
|
permissions: ['Vehicles', 'Reservations', 'CRM', 'Offers', 'Reports', 'License approval'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -40,10 +40,10 @@ const copy = {
|
|||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: 'Inviter un membre',
|
title: 'Inviter un membre',
|
||||||
subtitle: "Il recevra un email d'invitation pour rejoindre votre tableau de bord",
|
subtitle: "Cette personne recevra un e-mail d'invitation pour rejoindre votre espace de travail.",
|
||||||
firstName: 'Prénom',
|
firstName: 'Prénom',
|
||||||
lastName: 'Nom',
|
lastName: 'Nom',
|
||||||
email: 'Adresse email',
|
email: 'Adresse e-mail',
|
||||||
role: 'Rôle',
|
role: 'Rôle',
|
||||||
cancel: 'Annuler',
|
cancel: 'Annuler',
|
||||||
sending: 'Envoi…',
|
sending: 'Envoi…',
|
||||||
@@ -54,8 +54,8 @@ const copy = {
|
|||||||
{
|
{
|
||||||
value: 'MANAGER' as const,
|
value: 'MANAGER' as const,
|
||||||
label: 'Manager',
|
label: 'Manager',
|
||||||
description: 'Opérations complètes — sans facturation ni paramètres équipe',
|
description: 'Opérations complètes, sans accès à la facturation ni aux paramètres de l’équipe',
|
||||||
permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation permis'],
|
permissions: ['Véhicules', 'Réservations', 'CRM', 'Offres', 'Rapports', 'Validation des permis'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'AGENT' as const,
|
value: 'AGENT' as const,
|
||||||
@@ -67,7 +67,7 @@ const copy = {
|
|||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'دعوة عضو في الفريق',
|
title: 'دعوة عضو في الفريق',
|
||||||
subtitle: 'سيتلقى بريداً إلكترونياً للانضمام إلى لوحة التحكم',
|
subtitle: 'سيتلقى رسالة بريد إلكتروني للانضمام إلى مساحة العمل.',
|
||||||
firstName: 'الاسم الأول',
|
firstName: 'الاسم الأول',
|
||||||
lastName: 'اسم العائلة',
|
lastName: 'اسم العائلة',
|
||||||
email: 'البريد الإلكتروني',
|
email: 'البريد الإلكتروني',
|
||||||
@@ -81,7 +81,7 @@ const copy = {
|
|||||||
{
|
{
|
||||||
value: 'MANAGER' as const,
|
value: 'MANAGER' as const,
|
||||||
label: 'مدير',
|
label: 'مدير',
|
||||||
description: 'عمليات أسطول كاملة — بدون الفوترة أو إعدادات الفريق',
|
description: 'صلاحيات تشغيل كاملة للأسطول، من دون الفوترة أو إعدادات الفريق',
|
||||||
permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'],
|
permissions: ['المركبات', 'الحجوزات', 'CRM', 'العروض', 'التقارير', 'الموافقة على الرخص'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ const copy = {
|
|||||||
],
|
],
|
||||||
agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''],
|
agentNotes: ['', '', '', '', '', '', 'Own only', '', '', '', 'Notes only', '', '', '', '', '', '', '', '', ''],
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
heading: 'Permissions par rôle',
|
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.',
|
subtitle: 'Ce que chaque rôle peut faire dans l’espace de travail. Les propriétaires ont un accès complet à l’ensemble du système.',
|
||||||
colFeature: 'Fonctionnalité',
|
colFeature: 'Fonctionnalité',
|
||||||
colManager: 'Manager',
|
colManager: 'Manager',
|
||||||
colAgent: 'Agent',
|
colAgent: 'Agent',
|
||||||
@@ -81,7 +81,7 @@ const copy = {
|
|||||||
'Voir la flotte et les véhicules',
|
'Voir la flotte et les véhicules',
|
||||||
'Ajouter / modifier des véhicules',
|
'Ajouter / modifier des véhicules',
|
||||||
'Publier / dépublier un véhicule',
|
'Publier / dépublier un véhicule',
|
||||||
'Télécharger des photos de véhicule',
|
'Téléverser des photos de véhicule',
|
||||||
'Voir les réservations',
|
'Voir les réservations',
|
||||||
'Créer des réservations',
|
'Créer des réservations',
|
||||||
'Annuler des réservations',
|
'Annuler des réservations',
|
||||||
@@ -90,20 +90,20 @@ const copy = {
|
|||||||
'CRM clients — consultation',
|
'CRM clients — consultation',
|
||||||
'CRM clients — modification',
|
'CRM clients — modification',
|
||||||
'Approuver les permis de conduire',
|
'Approuver les permis de conduire',
|
||||||
'Signaler / blacklister un client',
|
'Signaler / bloquer un client',
|
||||||
'Offres et promotions',
|
'Offres et promotions',
|
||||||
'Analyses et rapports',
|
'Analyses et rapports',
|
||||||
'Contrat et facture PDF',
|
'Contrat et facture PDF',
|
||||||
'Facturation et abonnement',
|
'Facturation et abonnement',
|
||||||
'Inviter / supprimer du personnel',
|
'Inviter / supprimer des membres de l’équipe',
|
||||||
'Paramètres marque et site',
|
'Paramètres marque et site',
|
||||||
'Paramètres de paiement',
|
'Paramètres de paiement',
|
||||||
],
|
],
|
||||||
agentNotes: ['', '', '', '', '', '', 'Les siennes', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''],
|
agentNotes: ['', '', '', '', '', '', 'Les siennes uniquement', '', '', '', 'Notes uniquement', '', '', '', '', '', '', '', '', ''],
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
heading: 'صلاحيات الأدوار',
|
heading: 'صلاحيات الأدوار',
|
||||||
subtitle: 'ما يمكن لكل دور القيام به في لوحة التحكم. يملك الملاك وصولاً كاملاً لكل شيء.',
|
subtitle: 'ما يمكن لكل دور القيام به داخل مساحة العمل. يملك المالكون صلاحية كاملة على كل شيء.',
|
||||||
colFeature: 'الميزة',
|
colFeature: 'الميزة',
|
||||||
colManager: 'المدير',
|
colManager: 'المدير',
|
||||||
colAgent: 'الوكيل',
|
colAgent: 'الوكيل',
|
||||||
@@ -120,13 +120,13 @@ const copy = {
|
|||||||
'إلغاء الحجوزات',
|
'إلغاء الحجوزات',
|
||||||
'الاستلام / الإرجاع',
|
'الاستلام / الإرجاع',
|
||||||
'فحص الأضرار',
|
'فحص الأضرار',
|
||||||
'CRM العملاء — عرض',
|
'إدارة العملاء — عرض',
|
||||||
'CRM العملاء — تعديل',
|
'إدارة العملاء — تعديل',
|
||||||
'الموافقة على رخص القيادة',
|
'الموافقة على رخص القيادة',
|
||||||
'تعليم / حظر العميل',
|
'تمييز العميل / حظره',
|
||||||
'العروض والترويجات',
|
'العروض والحملات الترويجية',
|
||||||
'التحليلات والتقارير',
|
'التحليلات والتقارير',
|
||||||
'العقد وفاتورة PDF',
|
'العقد والفاتورة بصيغة PDF',
|
||||||
'الفوترة والاشتراك',
|
'الفوترة والاشتراك',
|
||||||
'دعوة / إزالة الموظفين',
|
'دعوة / إزالة الموظفين',
|
||||||
'إعدادات العلامة والموقع',
|
'إعدادات العلامة والموقع',
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
|||||||
unavailable: 'Marketplace indisponible',
|
unavailable: 'Marketplace indisponible',
|
||||||
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
|
unavailableTitle: "Les détails de l'entreprise sont temporairement indisponibles.",
|
||||||
unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
|
unavailableBody: "L'API marketplace fonctionne, mais la base de données n'est pas accessible dans cet environnement local.",
|
||||||
backToExplore: 'Retour à explorer',
|
backToExplore: 'Retour à l’exploration',
|
||||||
partner: 'Partenaire marketplace',
|
partner: 'Partenaire marketplace',
|
||||||
vehicles: 'véhicules',
|
vehicles: 'véhicules',
|
||||||
offers: 'offres actives',
|
offers: 'offres actives',
|
||||||
@@ -62,10 +62,10 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
|||||||
ar: {
|
ar: {
|
||||||
unavailable: 'السوق غير متاح',
|
unavailable: 'السوق غير متاح',
|
||||||
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
|
unavailableTitle: 'تفاصيل الشركة غير متاحة مؤقتاً.',
|
||||||
unavailableBody: 'واجهة marketplace تعمل، لكن قاعدة البيانات غير متاحة في هذا البيئة المحلية.',
|
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات غير متاحة في هذه البيئة المحلية.',
|
||||||
backToExplore: 'العودة إلى الاستكشاف',
|
backToExplore: 'العودة إلى الاستكشاف',
|
||||||
partner: 'شريك في السوق',
|
partner: 'شريك في السوق',
|
||||||
vehicles: 'سيارات',
|
vehicles: 'مركبات',
|
||||||
offers: 'عروض نشطة',
|
offers: 'عروض نشطة',
|
||||||
rating: 'التقييم',
|
rating: 'التقييم',
|
||||||
new: 'جديد',
|
new: 'جديد',
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
|||||||
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
|
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
|
||||||
unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
|
unavailableBody: "Le frontend marketplace fonctionne, mais la base de données n'est pas accessible pour le moment.",
|
||||||
backToFleet: 'Retour à la flotte',
|
backToFleet: 'Retour à la flotte',
|
||||||
backToExplore: 'Retour à explorer',
|
backToExplore: 'Retour à l’exploration',
|
||||||
noPhotos: 'Aucune photo disponible.',
|
noPhotos: 'Aucune photo disponible.',
|
||||||
rentalCompany: 'Société de location',
|
rentalCompany: 'Société de location',
|
||||||
perDay: '/ jour',
|
perDay: '/ jour',
|
||||||
@@ -81,7 +81,7 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
|||||||
ar: {
|
ar: {
|
||||||
unavailable: 'السوق غير متاح',
|
unavailable: 'السوق غير متاح',
|
||||||
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
|
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
|
||||||
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.',
|
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات في الخلفية غير متاحة حالياً.',
|
||||||
backToFleet: 'العودة إلى الأسطول',
|
backToFleet: 'العودة إلى الأسطول',
|
||||||
backToExplore: 'العودة إلى الاستكشاف',
|
backToExplore: 'العودة إلى الاستكشاف',
|
||||||
noPhotos: 'لا توجد صور متاحة.',
|
noPhotos: 'لا توجد صور متاحة.',
|
||||||
|
|||||||
@@ -87,24 +87,24 @@ const copy = {
|
|||||||
annual: 'Annuel',
|
annual: 'Annuel',
|
||||||
yearly: '/ an',
|
yearly: '/ an',
|
||||||
youSave: 'Vous économisez',
|
youSave: 'Vous économisez',
|
||||||
withAnnual: 'avec la facturation annuelle — prélevée en un seul paiement par an.',
|
withAnnual: 'avec la facturation annuelle, prélevée en un seul paiement par an.',
|
||||||
mostPopular: 'Le plus populaire',
|
mostPopular: 'Le plus populaire',
|
||||||
perMonth: '/mois',
|
perMonth: '/mois',
|
||||||
billedAs: 'Facturé à',
|
billedAs: 'Facturé à',
|
||||||
getStarted: 'Commencer',
|
getStarted: 'Commencer',
|
||||||
footer: 'Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise.',
|
footer: 'Toutes les formules incluent un essai gratuit de 14 jours. Aucune carte bancaire n’est requise.',
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
monthly: 'شهري',
|
monthly: 'شهري',
|
||||||
annual: 'سنوي',
|
annual: 'سنوي',
|
||||||
yearly: '/ سنة',
|
yearly: '/ سنة',
|
||||||
youSave: 'توفر',
|
youSave: 'توفر',
|
||||||
withAnnual: 'مع الفوترة السنوية — دفعة واحدة كل سنة.',
|
withAnnual: 'مع الفوترة السنوية، تُسدد دفعة واحدة كل سنة.',
|
||||||
mostPopular: 'الأكثر شيوعاً',
|
mostPopular: 'الأكثر شيوعاً',
|
||||||
perMonth: '/شهر',
|
perMonth: '/شهر',
|
||||||
billedAs: 'يتم الفوترة بمبلغ',
|
billedAs: 'يتم الفوترة بمبلغ',
|
||||||
getStarted: 'ابدأ الآن',
|
getStarted: 'ابدأ الآن',
|
||||||
footer: 'جميع الخطط تشمل تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
|
footer: 'تشمل جميع الخطط تجربة مجانية لمدة 14 يوماً. لا حاجة إلى بطاقة ائتمان.',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -19,24 +19,24 @@ const copy = {
|
|||||||
fr: {
|
fr: {
|
||||||
kicker: 'Tarifs',
|
kicker: 'Tarifs',
|
||||||
title: 'Une seule plateforme pour toutes les tailles de flotte.',
|
title: 'Une seule plateforme pour toutes les tailles de flotte.',
|
||||||
body: 'Commencez gratuitement pendant 14 jours puis choisissez le plan qui accompagne votre activité. Changez ou annulez à tout moment.',
|
body: 'Commencez gratuitement pendant 14 jours, puis choisissez la formule qui accompagne votre activité. Vous pouvez changer de formule ou annuler à tout moment.',
|
||||||
faq: 'Questions fréquentes',
|
faq: 'Questions fréquentes',
|
||||||
items: [
|
items: [
|
||||||
['Puis-je changer de plan plus tard ?', 'Oui. Vous pouvez monter ou descendre à tout moment — les changements prennent effet au prochain cycle de facturation.'],
|
['Puis-je changer de formule plus tard ?', 'Oui. Vous pouvez passer à une formule supérieure ou inférieure à tout moment. Les changements prennent effet au cycle de facturation suivant.'],
|
||||||
['Qu’est-ce qui compte comme véhicule ?', 'Tout véhicule ajouté à votre tableau de bord flotte, actif ou non.'],
|
['Qu’est-ce qui compte comme véhicule ?', 'Tout véhicule ajouté à votre tableau de bord de flotte, qu’il soit actif ou non.'],
|
||||||
['Y a-t-il des frais d’installation ?', 'Aucun frais d’installation. Vous payez uniquement l’abonnement mensuel ou annuel.'],
|
['Y a-t-il des frais d’installation ?', 'Aucun frais d’installation. Vous payez uniquement l’abonnement mensuel ou annuel.'],
|
||||||
['Comment fonctionne la visibilité marketplace ?', 'Tous les plans payants incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'],
|
['Comment fonctionne la visibilité marketplace ?', 'Toutes les formules payantes incluent une présence publique sur RentalDriveGo. Les clients découvrent votre flotte puis sont redirigés vers votre site de réservation.'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
kicker: 'الأسعار',
|
kicker: 'الأسعار',
|
||||||
title: 'منصة واحدة لكل أحجام الأساطيل.',
|
title: 'منصة واحدة لكل أحجام الأساطيل.',
|
||||||
body: 'ابدأ مجاناً لمدة 14 يوماً ثم اختر الخطة التي تنمو مع نشاطك. يمكنك تغيير الخطة أو الإلغاء في أي وقت.',
|
body: 'ابدأ مجاناً لمدة 14 يوماً، ثم اختر الخطة التي تناسب نمو نشاطك. يمكنك تغيير الخطة أو إلغاءها في أي وقت.',
|
||||||
faq: 'الأسئلة الشائعة',
|
faq: 'الأسئلة الشائعة',
|
||||||
items: [
|
items: [
|
||||||
['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'],
|
['هل يمكنني تغيير الخطة لاحقاً؟', 'نعم. يمكنك الترقية أو التخفيض في أي وقت — وتدخل التغييرات حيز التنفيذ في دورة الفوترة التالية.'],
|
||||||
['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'],
|
['ما الذي يُحتسب كسيارة؟', 'أي سجل سيارة تتم إضافته إلى لوحة الأسطول سواء كان نشطاً أم لا.'],
|
||||||
['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط الاشتراك الشهري أو السنوي.'],
|
['هل توجد رسوم إعداد؟', 'لا توجد أي رسوم إعداد. أنت تدفع فقط قيمة الاشتراك الشهري أو السنوي.'],
|
||||||
['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'],
|
['كيف تعمل قائمة السوق؟', 'كل الخطط المدفوعة تشمل الظهور العام على RentalDriveGo. يكتشف المستأجرون أسطولك ثم يتم تحويلهم إلى موقع الحجز الخاص بك.'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function RenterDashboardPage() {
|
|||||||
fr: {
|
fr: {
|
||||||
loadProfile: 'Impossible de charger le profil.',
|
loadProfile: 'Impossible de charger le profil.',
|
||||||
reachServer: 'Impossible de joindre le serveur.',
|
reachServer: 'Impossible de joindre le serveur.',
|
||||||
backToExplore: 'Retour à explorer',
|
backToExplore: 'Retour à l’exploration',
|
||||||
renterDashboard: 'Espace client',
|
renterDashboard: 'Espace client',
|
||||||
welcome: 'Bon retour,',
|
welcome: 'Bon retour,',
|
||||||
exploreVehicles: 'Explorer les véhicules',
|
exploreVehicles: 'Explorer les véhicules',
|
||||||
@@ -72,7 +72,7 @@ export default function RenterDashboardPage() {
|
|||||||
yourProfile: 'Votre profil',
|
yourProfile: 'Votre profil',
|
||||||
firstName: 'Prénom',
|
firstName: 'Prénom',
|
||||||
lastName: 'Nom',
|
lastName: 'Nom',
|
||||||
email: 'Email',
|
email: 'E-mail',
|
||||||
phone: 'Téléphone',
|
phone: 'Téléphone',
|
||||||
savedCompanies: 'Entreprises sauvegardées',
|
savedCompanies: 'Entreprises sauvegardées',
|
||||||
noneSaved: 'Aucune pour le moment',
|
noneSaved: 'Aucune pour le moment',
|
||||||
@@ -85,7 +85,7 @@ export default function RenterDashboardPage() {
|
|||||||
loadProfile: 'تعذر تحميل الملف الشخصي.',
|
loadProfile: 'تعذر تحميل الملف الشخصي.',
|
||||||
reachServer: 'تعذر الوصول إلى الخادم.',
|
reachServer: 'تعذر الوصول إلى الخادم.',
|
||||||
backToExplore: 'العودة إلى الاستكشاف',
|
backToExplore: 'العودة إلى الاستكشاف',
|
||||||
renterDashboard: 'لوحة المستأجر',
|
renterDashboard: 'بوابة المستأجر',
|
||||||
welcome: 'مرحباً بعودتك،',
|
welcome: 'مرحباً بعودتك،',
|
||||||
exploreVehicles: 'استكشف السيارات',
|
exploreVehicles: 'استكشف السيارات',
|
||||||
logout: 'تسجيل الخروج',
|
logout: 'تسجيل الخروج',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function RenterNotificationsPage() {
|
|||||||
renter: 'Client',
|
renter: 'Client',
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
markAll: 'Tout marquer comme lu',
|
markAll: 'Tout marquer comme lu',
|
||||||
back: 'Retour au dashboard',
|
back: 'Retour au tableau de bord',
|
||||||
loading: 'Chargement des notifications…',
|
loading: 'Chargement des notifications…',
|
||||||
empty: 'Aucune notification client pour le moment.',
|
empty: 'Aucune notification client pour le moment.',
|
||||||
read: 'Lu',
|
read: 'Lu',
|
||||||
@@ -47,12 +47,12 @@ export default function RenterNotificationsPage() {
|
|||||||
updateAll: 'تعذر تحديث الإشعارات.',
|
updateAll: 'تعذر تحديث الإشعارات.',
|
||||||
renter: 'المستأجر',
|
renter: 'المستأجر',
|
||||||
title: 'الإشعارات',
|
title: 'الإشعارات',
|
||||||
markAll: 'تحديد الكل كمقروء',
|
markAll: 'تعيين الكل كمقروء',
|
||||||
back: 'العودة إلى اللوحة',
|
back: 'العودة إلى بوابة المستأجر',
|
||||||
loading: 'جارٍ تحميل الإشعارات…',
|
loading: 'جارٍ تحميل الإشعارات…',
|
||||||
empty: 'لا توجد إشعارات للمستأجر حالياً.',
|
empty: 'لا توجد إشعارات للمستأجر حالياً.',
|
||||||
read: 'مقروء',
|
read: 'مقروء',
|
||||||
markRead: 'تحديد كمقروء',
|
markRead: 'تعيين كمقروء',
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
const [notifications, setNotifications] = useState<RenterNotification[]>([])
|
const [notifications, setNotifications] = useState<RenterNotification[]>([])
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function RenterSavedCompaniesPage() {
|
|||||||
load: 'Impossible de charger les entreprises sauvegardées.',
|
load: 'Impossible de charger les entreprises sauvegardées.',
|
||||||
renter: 'Client',
|
renter: 'Client',
|
||||||
title: 'Entreprises sauvegardées',
|
title: 'Entreprises sauvegardées',
|
||||||
back: 'Retour au dashboard',
|
back: 'Retour au tableau de bord',
|
||||||
loading: 'Chargement des entreprises sauvegardées…',
|
loading: 'Chargement des entreprises sauvegardées…',
|
||||||
empty: 'Vous n’avez encore enregistré aucune entreprise.',
|
empty: 'Vous n’avez encore enregistré aucune entreprise.',
|
||||||
browse: 'Parcourir la marketplace',
|
browse: 'Parcourir la marketplace',
|
||||||
@@ -34,7 +34,7 @@ export default function RenterSavedCompaniesPage() {
|
|||||||
load: 'تعذر تحميل الشركات المحفوظة.',
|
load: 'تعذر تحميل الشركات المحفوظة.',
|
||||||
renter: 'المستأجر',
|
renter: 'المستأجر',
|
||||||
title: 'الشركات المحفوظة',
|
title: 'الشركات المحفوظة',
|
||||||
back: 'العودة إلى اللوحة',
|
back: 'العودة إلى بوابة المستأجر',
|
||||||
loading: 'جارٍ تحميل الشركات المحفوظة…',
|
loading: 'جارٍ تحميل الشركات المحفوظة…',
|
||||||
empty: 'لم تقم بحفظ أي شركة بعد.',
|
empty: 'لم تقم بحفظ أي شركة بعد.',
|
||||||
browse: 'تصفح السوق',
|
browse: 'تصفح السوق',
|
||||||
|
|||||||
@@ -23,18 +23,19 @@ const copy = {
|
|||||||
submit: 'Request reservation',
|
submit: 'Request reservation',
|
||||||
submitting: 'Sending…',
|
submitting: 'Sending…',
|
||||||
successTitle: 'Request received!',
|
successTitle: 'Request received!',
|
||||||
successBody: 'Your reservation request has been sent. The company will review it and get in touch with you shortly.',
|
successBody: 'Your reservation request has been sent. The company will review it and contact you shortly.',
|
||||||
invalidDates: 'Return date must be after pick-up date.',
|
invalidDates: 'Return date must be after pick-up date.',
|
||||||
required: 'Please fill in all required fields.',
|
required: 'Please fill in all required fields.',
|
||||||
perDay: '/ day',
|
perDay: '/ day',
|
||||||
estimatedTotal: 'Estimated total',
|
estimatedTotal: 'Estimated total',
|
||||||
days: 'day(s)',
|
days: 'day(s)',
|
||||||
|
genericError: 'Something went wrong. Please try again.',
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: 'Réserver ce véhicule',
|
title: 'Réserver ce véhicule',
|
||||||
firstName: 'Prénom',
|
firstName: 'Prénom',
|
||||||
lastName: 'Nom',
|
lastName: 'Nom',
|
||||||
email: 'Email',
|
email: 'E-mail',
|
||||||
phone: 'Téléphone',
|
phone: 'Téléphone',
|
||||||
startDate: 'Date de départ',
|
startDate: 'Date de départ',
|
||||||
endDate: 'Date de retour',
|
endDate: 'Date de retour',
|
||||||
@@ -42,12 +43,13 @@ const copy = {
|
|||||||
submit: 'Demander une réservation',
|
submit: 'Demander une réservation',
|
||||||
submitting: 'Envoi…',
|
submitting: 'Envoi…',
|
||||||
successTitle: 'Demande reçue !',
|
successTitle: 'Demande reçue !',
|
||||||
successBody: "Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.",
|
successBody: "Votre demande de réservation a été envoyée. L’entreprise va l’examiner et vous contactera prochainement.",
|
||||||
invalidDates: 'La date de retour doit être après la date de départ.',
|
invalidDates: 'La date de retour doit être après la date de départ.',
|
||||||
required: 'Veuillez remplir tous les champs obligatoires.',
|
required: 'Veuillez remplir tous les champs obligatoires.',
|
||||||
perDay: '/ jour',
|
perDay: '/ jour',
|
||||||
estimatedTotal: 'Total estimé',
|
estimatedTotal: 'Total estimé',
|
||||||
days: 'jour(s)',
|
days: 'jour(s)',
|
||||||
|
genericError: 'Une erreur est survenue. Veuillez réessayer.',
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'احجز هذه السيارة',
|
title: 'احجز هذه السيارة',
|
||||||
@@ -56,7 +58,7 @@ const copy = {
|
|||||||
email: 'البريد الإلكتروني',
|
email: 'البريد الإلكتروني',
|
||||||
phone: 'الهاتف',
|
phone: 'الهاتف',
|
||||||
startDate: 'تاريخ الاستلام',
|
startDate: 'تاريخ الاستلام',
|
||||||
endDate: 'تاريخ الإعادة',
|
endDate: 'تاريخ الإرجاع',
|
||||||
notes: 'ملاحظات (اختيارية)',
|
notes: 'ملاحظات (اختيارية)',
|
||||||
submit: 'طلب حجز',
|
submit: 'طلب حجز',
|
||||||
submitting: 'جارٍ الإرسال…',
|
submitting: 'جارٍ الإرسال…',
|
||||||
@@ -67,6 +69,7 @@ const copy = {
|
|||||||
perDay: '/ يوم',
|
perDay: '/ يوم',
|
||||||
estimatedTotal: 'الإجمالي التقديري',
|
estimatedTotal: 'الإجمالي التقديري',
|
||||||
days: 'يوم',
|
days: 'يوم',
|
||||||
|
genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -123,7 +126,7 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
|
|||||||
})
|
})
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? 'Something went wrong. Please try again.')
|
setError(err.message ?? t.genericError)
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# Security Vulnerability Fix Report
|
||||||
|
|
||||||
|
**Date:** 2026-05-22
|
||||||
|
**Scope:** API (`apps/api`) — automated scan + manual review
|
||||||
|
**Branch:** `develop`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A full security review of the API codebase identified 3 confirmed, high-confidence vulnerabilities. All 3 were fixed in the same session. A separate frontend scan (dashboard, admin, marketplace, public-site) found no confirmed vulnerabilities above the reporting threshold.
|
||||||
|
|
||||||
|
| # | Severity | Category | Status |
|
||||||
|
|---|----------|----------|--------|
|
||||||
|
| [VF-01](#vf-01--payment-credential-exposure) | High | Credential / Data Exposure | Fixed |
|
||||||
|
| [VF-02](#vf-02--webhook-signature-bypass) | High | Authentication Bypass | Fixed |
|
||||||
|
| [VF-03](#vf-03--idor-on-vehicle-maintenance-logs) | Medium-High | IDOR / Tenant Isolation | Fixed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VF-01 — Payment Credential Exposure
|
||||||
|
|
||||||
|
**Severity:** High
|
||||||
|
**Confidence:** 9/10
|
||||||
|
**Category:** Credential / Data Exposure
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
`GET /api/v1/companies/me/brand` returned the full `BrandSettings` database row, including the live payment gateway credentials `amanpaySecretKey`, `amanpayMerchantId`, `paypalEmail`, and `paypalMerchantId` in the JSON response. The endpoint had no role guard, so any authenticated employee — including the lowest-privilege `AGENT` role — could call it and receive the secret key in plaintext.
|
||||||
|
|
||||||
|
### Exploit Scenario
|
||||||
|
|
||||||
|
An `AGENT`-role employee calls `GET /api/v1/companies/me/brand` with a valid session token. The response body includes `amanpaySecretKey`. The attacker uses the merchant ID and secret key to interact directly with the AmanPay payment gateway on behalf of the company, initiating fraudulent charges or refunds outside the application.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
`presentBrand()` in `company.presenter.ts` was a transparent passthrough (`return brand`). The DB query had no `select` clause, so Prisma returned every column. The public-facing site module correctly stripped these fields (evidenced by explicit test assertions), but the authenticated dashboard endpoint did not.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
**File:** `apps/api/src/modules/companies/company.presenter.ts`
|
||||||
|
|
||||||
|
`presentBrand()` now destructures and drops all four credential fields before returning. The caller receives boolean presence indicators (`amanpayConfigured`, `paypalConfigured`) instead of the raw secrets.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
export function presentBrand(brand: any) {
|
||||||
|
- return brand
|
||||||
|
+ if (!brand) return brand
|
||||||
|
+ const { amanpaySecretKey, amanpayMerchantId, paypalEmail, paypalMerchantId, ...safe } = brand
|
||||||
|
+ return {
|
||||||
|
+ ...safe,
|
||||||
|
+ amanpayConfigured: !!(amanpayMerchantId && amanpaySecretKey),
|
||||||
|
+ paypalConfigured: !!(paypalEmail || paypalMerchantId),
|
||||||
|
+ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VF-02 — Webhook Signature Bypass
|
||||||
|
|
||||||
|
**Severity:** High
|
||||||
|
**Confidence:** 8/10
|
||||||
|
**Category:** Authentication Bypass / Payment Fraud
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Both payment and subscription webhook endpoints for AmanPay and PayPal used a short-circuit AND guard for signature verification:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||||
|
return res.status(401).json({ error: 'invalid_signature' })
|
||||||
|
}
|
||||||
|
await service.handleAmanpayWebhook(req.body) // executed unconditionally
|
||||||
|
```
|
||||||
|
|
||||||
|
When `isConfigured()` returns `false` (i.e., credentials are absent or set to placeholder defaults), the entire guard short-circuits and the webhook handler executes on any unauthenticated request. The endpoints had no IP allowlist and were explicitly placed before `requireCompanyAuth`.
|
||||||
|
|
||||||
|
This affected 4 endpoints:
|
||||||
|
- `POST /api/v1/payments/webhooks/amanpay`
|
||||||
|
- `POST /api/v1/payments/webhooks/paypal`
|
||||||
|
- `POST /api/v1/subscriptions/webhooks/amanpay`
|
||||||
|
- `POST /api/v1/subscriptions/webhooks/paypal`
|
||||||
|
|
||||||
|
### Exploit Scenario
|
||||||
|
|
||||||
|
On any staging or misconfigured production environment where AmanPay credentials are not set, an attacker posts `{"transaction_id": "<known_id>", "status": "PAID"}` to `/api/v1/payments/webhooks/amanpay`. The handler finds the pending payment by `transactionId`, marks it succeeded, and unlocks the reservation — with no money changing hands. A valid `amanpayTransactionId` can leak through receipts, logs, or API responses visible to renters.
|
||||||
|
|
||||||
|
The same attack against `/api/v1/subscriptions/webhooks/amanpay` activates a paid subscription tier for free.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
**Files:** `apps/api/src/modules/payments/payment.routes.ts`, `apps/api/src/modules/subscriptions/subscription.routes.ts`
|
||||||
|
|
||||||
|
Inverted the guard logic: an unconfigured provider now immediately returns `401` instead of passing through. Applied to all 4 endpoints.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-if (amanpay.isConfigured() && !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||||
|
+if (!amanpay.isConfigured() || !amanpay.verifyWebhookSignature(rawBody, signature)) {
|
||||||
|
return res.status(401).json({ error: 'invalid_signature' })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
-if (paypal.isConfigured() && !isValid) return res.status(401).json({ error: 'invalid_signature' })
|
||||||
|
+if (!paypal.isConfigured() || !isValid) return res.status(401).json({ error: 'invalid_signature' })
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## VF-03 — IDOR on Vehicle Maintenance Logs
|
||||||
|
|
||||||
|
**Severity:** Medium-High
|
||||||
|
**Confidence:** 9/10
|
||||||
|
**Category:** Insecure Direct Object Reference (IDOR) / Tenant Isolation Bypass
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
`GET /api/v1/vehicles/:id/maintenance` was protected by `requireCompanyAuth` and `requireTenant`, making `req.companyId` available. However, `req.companyId` was never forwarded to the service or repository. The database query used only `vehicleId` with no tenant filter:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// vehicle.repo.ts
|
||||||
|
prisma.maintenanceLog.findMany({ where: { vehicleId } })
|
||||||
|
```
|
||||||
|
|
||||||
|
An authenticated employee from Company A could fetch the full maintenance history of any vehicle belonging to any other company by knowing its UUID. The write path (`POST /:id/maintenance`) already performed the correct ownership check using `repo.findById(id, companyId)` — the read path did not.
|
||||||
|
|
||||||
|
### Exploit Scenario
|
||||||
|
|
||||||
|
An employee of Company A calls `GET /api/v1/vehicles/<vehicleId_from_company_B>/maintenance`. Vehicle UUIDs can leak through marketplace listings, shared bookings, or support interactions. The response contains the complete maintenance history of Company B's vehicle — including service records, mileage data, and operational schedules — across tenant boundaries.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
**Files:** `apps/api/src/modules/vehicles/vehicle.routes.ts`, `apps/api/src/modules/vehicles/vehicle.service.ts`
|
||||||
|
|
||||||
|
`req.companyId` is now threaded through to `getMaintenanceLogs`, which performs the same ownership pre-check already used by the write path. A cross-tenant request returns `404`.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
// vehicle.routes.ts
|
||||||
|
-const logs = await service.getMaintenanceLogs(id)
|
||||||
|
+const logs = await service.getMaintenanceLogs(id, req.companyId)
|
||||||
|
```
|
||||||
|
|
||||||
|
```diff
|
||||||
|
// vehicle.service.ts
|
||||||
|
-export async function getMaintenanceLogs(id: string) {
|
||||||
|
- return repo.findMaintenanceLogs(id)
|
||||||
|
+export async function getMaintenanceLogs(id: string, companyId: string) {
|
||||||
|
+ const vehicle = await repo.findById(id, companyId)
|
||||||
|
+ if (!vehicle) throw new NotFoundError('Vehicle not found')
|
||||||
|
+ return repo.findMaintenanceLogs(id)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Scan Results
|
||||||
|
|
||||||
|
A full scan of all four frontend applications (dashboard, admin, marketplace, public-site) was performed. No vulnerabilities above the reporting threshold (confidence ≥ 8/10) were confirmed.
|
||||||
|
|
||||||
|
### Hardening Recommendations (not vulnerabilities)
|
||||||
|
|
||||||
|
Two items were identified as best-practice improvements:
|
||||||
|
|
||||||
|
1. **Admin auth-redirect `next` param** (`apps/admin/src/app/auth-redirect/page.tsx`) — Validate the `next` query parameter is a relative path (starts with `/`, does not contain `://`) before passing it to `router.replace`. The router itself does not perform cross-origin navigation, but defense-in-depth is recommended.
|
||||||
|
|
||||||
|
2. **`postMessage` wildcard origin + missing `frame-ancestors` CSP** (`apps/dashboard/src/app/sign-in/[[...sign-in]]/SignInPageClient.tsx`, `apps/dashboard/next.config.js`) — Replace `targetOrigin: '*'` with the marketplace origin, and add `Content-Security-Policy: frame-ancestors 'self' <marketplace-origin>` to prevent the sign-in page from being embeddable by arbitrary third-party domains.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `apps/api/src/modules/companies/company.presenter.ts` | Strip payment credentials in `presentBrand()` |
|
||||||
|
| `apps/api/src/modules/payments/payment.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) |
|
||||||
|
| `apps/api/src/modules/subscriptions/subscription.routes.ts` | Fix webhook signature bypass (AmanPay + PayPal) |
|
||||||
|
| `apps/api/src/modules/vehicles/vehicle.routes.ts` | Pass `companyId` to maintenance log service |
|
||||||
|
| `apps/api/src/modules/vehicles/vehicle.service.ts` | Add ownership check in `getMaintenanceLogs` |
|
||||||
@@ -77,7 +77,7 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
|
|||||||
heroKicker: 'RentalDriveGo',
|
heroKicker: 'RentalDriveGo',
|
||||||
heroTitle: 'Marketplace discovery with a sharper front door.',
|
heroTitle: 'Marketplace discovery with a sharper front door.',
|
||||||
heroBody:
|
heroBody:
|
||||||
'Rental companies run private operations, renters browse a shared marketplace, and every booking still lands on the company’s own branded checkout.',
|
'Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the company’s own branded checkout.',
|
||||||
startTrial: 'Start free trial',
|
startTrial: 'Start free trial',
|
||||||
exploreVehicles: 'Explore vehicles',
|
exploreVehicles: 'Explore vehicles',
|
||||||
surfaceLabel: 'Marketplace surface',
|
surfaceLabel: 'Marketplace surface',
|
||||||
@@ -89,13 +89,13 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
|
|||||||
brandedFlows: 'Branded booking flows',
|
brandedFlows: 'Branded booking flows',
|
||||||
multiTenant: 'Multi-tenant operations',
|
multiTenant: 'Multi-tenant operations',
|
||||||
companyKicker: 'Operator workflow',
|
companyKicker: 'Operator workflow',
|
||||||
companyTitle: 'Control inventory, offers, billing, and staff from one command layer.',
|
companyTitle: 'Manage inventory, offers, billing, and staff from one control layer.',
|
||||||
companyBody:
|
companyBody:
|
||||||
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
|
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
|
||||||
renterKicker: 'Renter experience',
|
renterKicker: 'Renter experience',
|
||||||
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
|
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
|
||||||
renterBody:
|
renterBody:
|
||||||
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, pay direct.',
|
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.',
|
||||||
pillars: [
|
pillars: [
|
||||||
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
|
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
|
||||||
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
|
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
|
||||||
@@ -121,9 +121,9 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
|
|||||||
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
|
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
|
||||||
stepLabel: 'Step',
|
stepLabel: 'Step',
|
||||||
readyKicker: 'Ready to launch',
|
readyKicker: 'Ready to launch',
|
||||||
readyTitle: 'A marketplace homepage should sell the system in seconds.',
|
readyTitle: 'The marketplace homepage should explain the product in seconds.',
|
||||||
readyBody:
|
readyBody:
|
||||||
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust attached to your company.',
|
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.',
|
||||||
viewPricing: 'View pricing',
|
viewPricing: 'View pricing',
|
||||||
createWorkspace: 'Create workspace',
|
createWorkspace: 'Create workspace',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user