From 81ee2acefd6f53e0a068ea8d96c89ad8b8bc6de6 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Sat, 25 Jul 2026 14:53:26 +0300 Subject: [PATCH] Add removal/blacklist tools, Arabic UI, and a persistent sign-in error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorisation: driver/deleteCaptain.php permanently deletes a captain and had no role check at all — any valid token, including a driver's or passenger's, could delete captains. It now requires super_admin. The two unblacklist endpoints had no role check either and now require an admin role. Console: - Blacklist & removal screen (super admin only): current blocks, permanent removal, and lifting a block. Deletion additionally requires the phone number to be retyped, because the row is deleted outright. - Arabic interface with full RTL layout and a language toggle. Sidebar, content offset, input affordances, toasts and directional icons all mirror; numbers, identifiers and the diagnostics output stay LTR. - Sign-in failures now render in the form and stay there. They were only shown as a toast, which disappears before it can be read — a locked account or a rejected device looked like the page simply returning to the login screen with no explanation. Co-Authored-By: Claude Opus 5 --- dashboard/siro-admin/css/main.css | 124 +++++++++ dashboard/siro-admin/index.html | 139 +++++----- dashboard/siro-admin/js/app.js | 443 +++++++++++++++++++++++++++--- 3 files changed, 608 insertions(+), 98 deletions(-) diff --git a/dashboard/siro-admin/css/main.css b/dashboard/siro-admin/css/main.css index d37c409c..6d156e3a 100644 --- a/dashboard/siro-admin/css/main.css +++ b/dashboard/siro-admin/css/main.css @@ -1384,3 +1384,127 @@ textarea.form-input { text-align: start; } } .card-header { gap: 1rem; flex-wrap: wrap; } + +/* Destructive action button */ +.btn-danger { + background: var(--danger-bg); + border-color: rgba(244, 63, 94, 0.45); + color: var(--danger); + font-weight: 600; +} + +.btn-danger:hover { background: rgba(244, 63, 94, 0.28); } + +/* ========================================================================== + Right-to-left layout (Arabic) + The base layout is written LTR with physical properties, so RTL flips the + fixed sidebar, the content offset and anything anchored to a screen edge. + ========================================================================== */ +.lang-toggle { + font-weight: 700; + font-size: 0.85rem; + font-family: 'Outfit', sans-serif; +} + +[dir="rtl"] .sidebar { + left: auto; + right: 0; + border-right: none; + border-left: 1px solid var(--border-color); +} + +[dir="rtl"] .main-wrapper { + margin-left: 0; + margin-right: var(--sidebar-width); +} + +[dir="rtl"] .nav-item.active::before, +[dir="rtl"] .nav-item::before { + left: auto; + right: 0; +} + +[dir="rtl"] .toast { + right: auto; + left: 24px; +} + +/* The icon sits at the inline start; in RTL that is the right edge, and the + input's reserved padding must move with it. */ +[dir="rtl"] .form-input-group i { + left: auto; + right: 1rem; +} + +[dir="rtl"] .form-input-group .form-input { + padding-left: 1rem; + padding-right: 2.8rem; +} + +[dir="rtl"] .search-box i { + left: auto; + right: 0.9rem; +} + +[dir="rtl"] .search-box input { + padding-left: 1rem; + padding-right: 2.5rem; +} + +[dir="rtl"] .nav-count { margin-left: 0; margin-right: auto; } +[dir="rtl"] .card-sub { margin-left: 0; margin-right: 0.4rem; } +[dir="rtl"] .kv-row strong { text-align: left; } +[dir="rtl"] .stop-list { padding-left: 0; padding-right: 1.2rem; } + +/* Directional icons must mirror, plain symbols must not */ +[dir="rtl"] .route-cell .ph-arrow-right, +[dir="rtl"] .btn .ph-caret-left, +[dir="rtl"] .btn .ph-caret-right { + transform: scaleX(-1); +} + +/* Numbers, code and diagnostics stay LTR even inside an RTL page */ +[dir="rtl"] .diagnostics, +[dir="rtl"] .device-note code, +[dir="rtl"] .stat-value, +[dir="rtl"] .kpi-tile-value { + direction: ltr; + text-align: start; +} + +@media (max-width: 992px) { + [dir="rtl"] .sidebar { transform: translateX(100%); } + [dir="rtl"] .sidebar.open { transform: translateX(0); } + [dir="rtl"] .main-wrapper { margin-right: 0; } +} + +/* Persistent sign-in error */ +.login-error { + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin-top: 1.25rem; + padding: 0.8rem 0.95rem; + border-radius: var(--radius-md); + background: var(--danger-bg); + border: 1px solid rgba(244, 63, 94, 0.4); + color: var(--danger); + font-size: 0.82rem; + line-height: 1.55; +} + +/* display:flex would otherwise override the [hidden] attribute */ +.login-error[hidden] { display: none; } + +.login-error i { font-size: 1.1rem; flex-shrink: 0; } +.login-error div { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; } +.login-error strong { color: var(--text-main); font-weight: 600; } + +.login-error span { + color: var(--text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.72rem; + word-break: break-all; + direction: ltr; + text-align: start; +} diff --git a/dashboard/siro-admin/index.html b/dashboard/siro-admin/index.html index df13f403..c577ca52 100644 --- a/dashboard/siro-admin/index.html +++ b/dashboard/siro-admin/index.html @@ -15,7 +15,7 @@ - + @@ -27,17 +27,17 @@ SIRO ADMIN -

Operations & Control Console

+

Operations & Control Console

- Secure session · device-bound token · WhatsApp OTP + Secure session · device-bound token · WhatsApp OTP
- +
@@ -45,15 +45,19 @@
- +
+ + +
@@ -80,41 +84,41 @@
@@ -126,7 +130,7 @@
—
- @@ -143,15 +147,16 @@
- Connecting… + Connecting…
- +
@@ -163,8 +168,8 @@
@@ -205,13 +210,13 @@
—
-
Registered Captains
+
Registered Captains
@@ -219,13 +224,13 @@
—
-
Registered Passengers
+
Registered Passengers
@@ -234,7 +239,7 @@
-

Rides per Day last 21 days

+

Rides per Day last 21 days

@@ -243,7 +248,7 @@
-

Ride Outcomes

+

Ride Outcomes

@@ -255,7 +260,7 @@
-

Demand by Time of Day

+

Demand by Time of Day

@@ -264,7 +269,7 @@
-

Service Mix

+

Service Mix

@@ -276,7 +281,7 @@ @@ -302,13 +307,13 @@ - - - - - + + + + + - + @@ -328,7 +333,7 @@
TripPassengerCaptainRouteFareTripPassengerCaptainRouteFare StatusCreatedCreated
- + @@ -371,7 +376,7 @@ @@ -388,7 +393,7 @@
IDID Name Phone Email
- + @@ -409,27 +414,27 @@
—
-
Captain earnings (completed trips)
+
Captain earnings (completed trips)
—
-
Average passenger fare
+
Average passenger fare
—
-
Total distance covered
+
Total distance covered
-

Trip Performance

+

Trip Performance

@@ -438,8 +443,8 @@
@@ -448,12 +453,12 @@
IDID Name Contact Trips
- + - - - + + + @@ -468,22 +473,22 @@
—
-
Filed today
+
Filed today
—
-
This week
+
This week
—
-
This month
+
This month
@@ -493,12 +498,12 @@
-

Current session

+

Current session

@@ -545,7 +550,7 @@
-

API endpoint

+

API endpoint

The mobile admin app points at a different host per country. If every panel is empty, @@ -560,7 +565,7 @@

-

Server diagnostics

+

Server diagnostics

@@ -585,7 +590,7 @@
- + diff --git a/dashboard/siro-admin/js/app.js b/dashboard/siro-admin/js/app.js index 1a57a4d7..dc75191f 100644 --- a/dashboard/siro-admin/js/app.js +++ b/dashboard/siro-admin/js/app.js @@ -11,7 +11,199 @@ // Bump together with the ?v= query in index.html. Shown in the UI and in the // diagnostics report so "the deploy did nothing" can be answered with a fact // rather than a guess about caching. - const BUILD = '2026-07-25-2'; + const BUILD = '2026-07-25-6'; + + // ── Localisation ───────────────────────────────────────────────────────── + // Arabic is the operators' language; English is kept because several screens + // mirror field names that only exist in English in the database. + const LANG_KEY = 'siro_admin_lang'; + let lang = localStorage.getItem(LANG_KEY) || 'ar'; + + const AR = { + // Auth + 'Operations & Control Console': 'منصة العمليات والتحكم', + 'Secure session · device-bound token · WhatsApp OTP': 'جلسة آمنة · توكن مرتبط بالجهاز · رمز واتساب', + 'Admin Email / Phone': 'البريد الإلكتروني أو رقم الهاتف', + 'Password': 'كلمة المرور', + 'Sign In': 'تسجيل الدخول', + 'Verifying…': 'جارٍ التحقق…', + 'WhatsApp Verification': 'التحقق عبر واتساب', + 'Verify & Sign In': 'تحقّق وسجّل الدخول', + 'Cancel': 'إلغاء', + 'Enter the 3-digit code.': 'أدخل الرمز المكوّن من ٣ أرقام.', + // Nav + 'Operations': 'العمليات', + 'Overview': 'نظرة عامة', + 'Rides': 'الرحلات', + 'Captains': 'الكباتن', + 'Passengers': 'الركاب', + 'Business': 'الأعمال', + 'Financials': 'المالية', + 'Approvals': 'الموافقات', + 'Complaints': 'الشكاوى', + 'Session & Security': 'الجلسة والأمان', + 'Realtime & Analytics': 'الوقت الحقيقي والتحليلات', + 'Live Operations': 'العمليات الحيّة', + 'Growth': 'النمو', + 'Advanced Analytics': 'التحليلات المتقدمة', + 'Ride Monitor': 'مراقب الرحلات', + 'Finance': 'المالية', + 'Financial V2': 'المالية V2', + 'Invoices': 'الفواتير', + 'Growth & Pricing': 'النمو والتسعير', + 'Marketing Intelligence': 'استخبارات التسويق', + 'Pricing Engine': 'محرك التسعير', + 'Tariff Editor': 'محرر التعرفة', + 'Promo Codes': 'أكواد الخصم', + 'Campaign Launcher': 'مطلق الحملات', + 'Demand Heatmap': 'خريطة الطلب', + 'Quality': 'الجودة', + 'Fleet Performance': 'أداء الأسطول', + 'Blacklist & Removal': 'القائمة السوداء والحذف', + 'Driver Scorecard': 'تقييم الكباتن', + 'Driver Documents': 'وثائق السائقين', + 'Transit': 'المواصلات', + 'Mawasalati Organisations': 'مؤسسات مواصلاتي', + 'Route Approvals': 'اعتماد المسارات', + 'Administration': 'الإدارة', + 'Staff & Employees': 'الكوادر والموظفون', + 'App Versions': 'إصدارات التطبيق', + 'Broadcast Notification': 'إشعار جماعي', + 'Audit Log': 'سجل العمليات', + 'Error Log': 'سجل الأخطاء', + // Chrome + 'Filter the current table…': 'ابحث في الجدول الحالي…', + 'Live database': 'قاعدة بيانات حيّة', + 'Loading live data…': 'جارٍ تحميل البيانات…', + 'Connecting…': 'جارٍ الاتصال…', + 'No data from API': 'لا توجد بيانات من الخادم', + 'Data unavailable': 'البيانات غير متاحة', + 'Sign Out': 'تسجيل الخروج', + 'Refresh data': 'تحديث البيانات', + 'Reload': 'إعادة تحميل', + // Common + 'Loading…': 'جارٍ التحميل…', + 'Name': 'الاسم', + 'Phone': 'الهاتف', + 'Email': 'البريد الإلكتروني', + 'Status': 'الحالة', + 'Rating': 'التقييم', + 'Trips': 'الرحلات', + 'Cancellations': 'الإلغاءات', + 'Joined': 'تاريخ الانضمام', + 'Action': 'إجراء', + 'Show more': 'عرض المزيد', + 'Approve': 'اعتماد', + 'Reject': 'رفض', + 'Activate': 'تفعيل', + 'Save': 'حفظ', + 'Nothing changed on this tariff row.': 'لم يتغيّر شيء في هذا السطر.', + // Ride statuses + 'Completed': 'مكتملة', + 'In progress': 'جارية', + 'Waiting': 'بانتظار كابتن', + 'Cancelled': 'ملغاة', + 'Cancelled by captain': 'ألغاها الكابتن', + 'Cancelled by passenger': 'ألغاها الراكب', + 'Captain assigned': 'تم تعيين كابتن', + 'Captain arrived': 'وصل الكابتن', + 'No captain found': 'لم يُعثر على كابتن', + 'Timed out': 'انتهت المهلة', + 'Searching for a captain': 'جارٍ البحث عن كابتن', + 'Pending review': 'قيد المراجعة', + 'Refused': 'مرفوضة', + 'Unknown': 'غير معروف', + 'The server accepted the request but issued no code and no token.': 'قبل الخادم الطلب لكنه لم يُصدر رمزاً ولا توكن.', + 'last 21 days': 'آخر ٢١ يوماً', + 'Updated': 'آخر تحديث', + 'Morning 6–11': 'صباحاً ٦–١١', + 'Afternoon 12–17': 'ظهراً ١٢–١٧', + 'Night 18–5': 'ليلاً ١٨–٥', + 'Comfort': 'كومفورت', + 'Speed': 'سبيد', + 'Lady': 'ليدي', + 'Average trip duration': 'متوسط مدة الرحلة', + 'Longest trip duration': 'أطول مدة رحلة', + 'Average distance': 'متوسط المسافة', + 'Longest distance': 'أطول مسافة', + 'Cancelled rides': 'الرحلات الملغاة', + 'Captains with completed trips': 'كباتن أتمّوا رحلات', + 'Operations Overview': 'نظرة عامة على العمليات', + 'Live figures read directly from the production database': 'أرقام حيّة مقروءة مباشرة من قاعدة بيانات الإنتاج', + 'Total Rides': 'إجمالي الرحلات', + 'Completed Rides': 'الرحلات المكتملة', + 'Registered Captains': 'الكباتن المسجّلون', + 'Registered Passengers': 'الركاب المسجّلون', + 'this month': 'هذا الشهر', + 'completion rate': 'نسبة الإتمام', + 'joined this month': 'انضموا هذا الشهر', + 'Rides per Day': 'الرحلات يومياً', + 'Ride Outcomes': 'نتائج الرحلات', + 'Demand by Time of Day': 'الطلب حسب وقت اليوم', + 'Service Mix': 'توزيع الخدمات', + 'Trips, assigned captains, fares and live status': 'الرحلات والكباتن المعيّنون والأجور والحالة الحيّة', + 'Fleet roster, ratings and trip volume': 'قائمة الأسطول والتقييمات وعدد الرحلات', + 'Most active customer accounts by trip count': 'أكثر حسابات العملاء نشاطاً حسب عدد الرحلات', + 'Earnings, fares and distance aggregates across all recorded trips': 'الأرباح والأجور والمسافات لكل الرحلات المسجّلة', + 'Admin and service accounts awaiting activation': 'حسابات المشرفين والخدمة بانتظار التفعيل', + 'Volume of customer complaints filed': 'عدد الشكاوى المقدَّمة', + 'Details of the credential currently authorising this console': 'تفاصيل الاعتماد الذي يخوّل هذه المنصة حالياً', + 'Trip': 'الرحلة', + 'Passenger': 'الراكب', + 'Captain': 'الكابتن', + 'Route': 'المسار', + 'Fare': 'الأجرة', + 'Created': 'أُنشئت', + 'ID': 'المعرّف', + 'Customer ID': 'معرّف العميل', + 'Driver ID': 'معرّف السائق', + 'Requested role': 'الدور المطلوب', + 'Requested at': 'تاريخ الطلب', + 'Type': 'النوع', + 'Filed today': 'قُدّمت اليوم', + 'This week': 'هذا الأسبوع', + 'This month': 'هذا الشهر', + 'Current session': 'الجلسة الحالية', + 'Server diagnostics': 'تشخيص الخادم', + 'API endpoint': 'عنوان الـ API', + 'Compose': 'إنشاء الرسالة', + 'Preview': 'معاينة', + 'Pending Approvals': 'الموافقات المعلّقة', + 'Captain earnings (completed trips)': 'أرباح الكباتن (الرحلات المكتملة)', + 'Average passenger fare': 'متوسط أجرة الراكب', + 'Total distance covered': 'إجمالي المسافة المقطوعة', + 'Trip Performance': 'أداء الرحلات', + 'Pick a captain above to inspect their documents.': 'اختر كابتناً بالأعلى لفحص وثائقه.', + }; + + const t = (key) => (lang === 'ar' ? (AR[key] || key) : key); + + function applyLanguage() { + const rtl = lang === 'ar'; + document.documentElement.lang = rtl ? 'ar' : 'en'; + document.documentElement.dir = rtl ? 'rtl' : 'ltr'; + + document.querySelectorAll('[data-i18n]').forEach((node) => { + node.textContent = t(node.dataset.i18n); + }); + document.querySelectorAll('[data-i18n-ph]').forEach((node) => { + node.placeholder = t(node.dataset.i18nPh); + }); + document.querySelectorAll('[data-i18n-title]').forEach((node) => { + node.title = t(node.dataset.i18nTitle); + }); + + const toggle = $('langToggle'); + if (toggle) toggle.textContent = rtl ? 'EN' : 'ع'; + } + + function setLanguage(next) { + lang = next; + localStorage.setItem(LANG_KEY, next); + // Nav labels, module pages and rendered tables all hold translated text, + // so the simplest correct refresh is a reload of the rendered views. + location.reload(); + } const SESSION_KEY = 'siro_admin_user'; const FP_KEY = 'siro_web_fp'; @@ -47,6 +239,7 @@ async function init() { cacheElements(); + applyLanguage(); deviceFingerprint = await resolveFingerprint(); if (el.fpPreview) el.fpPreview.textContent = deviceFingerprint.slice(0, 12) + '…'; @@ -67,7 +260,7 @@ function cacheElements() { [ - 'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview', + 'authWrapper', 'loginForm', 'loginEmail', 'loginPass', 'loginSubmitBtn', 'fpPreview', 'loginError', 'otpModal', 'otpInput', 'otpPhoneText', 'submitOtpBtn', 'sidebar', 'toggleSidebar', 'logoutBtn', 'refreshBtn', 'globalSearch', 'connectionPill', 'connectionText', 'lastUpdated', @@ -196,7 +389,7 @@ signOut(`Session ended: ${err.message}`); return true; } - setConnection('error', 'Data unavailable'); + setConnection('error', t('Data unavailable')); return false; } @@ -211,13 +404,28 @@ window.closeOtpModal = () => el.otpModal?.classList.remove('active'); } + + function showLoginError(message, detail) { + if (!el.loginError) return; + el.loginError.hidden = false; + el.loginError.innerHTML = `
+ ${esc(message)} + ${detail ? `${esc(detail)}` : ''} +
`; + } + + function clearLoginError() { + if (el.loginError) el.loginError.hidden = true; + } + async function onLogin(e) { e.preventDefault(); const phone = el.loginEmail.value.trim(); const password = el.loginPass.value; if (!phone || !password) return; - busy(el.loginSubmitBtn, true, 'Verifying…'); + clearLoginError(); + busy(el.loginSubmitBtn, true, t('Verifying…')); try { const form = new FormData(); form.append('phone', phone); @@ -238,12 +446,16 @@ // Trusted-device renewal path — no OTP required. establishSession(payload); } else { - toast('Unexpected response from the authentication service.', 'danger'); + showLoginError( + t('The server accepted the request but issued no code and no token.'), + JSON.stringify(payload).slice(0, 300) + ); } } catch (err) { + showLoginError(err.message, err.status ? `HTTP ${err.status}` : null); toast(err.message, 'danger'); } finally { - busy(el.loginSubmitBtn, false, 'Sign In'); + busy(el.loginSubmitBtn, false, t('Sign In')); } } @@ -267,9 +479,10 @@ el.otpModal.classList.remove('active'); establishSession(payload); } catch (err) { + showLoginError(err.message, err.status ? `HTTP ${err.status}` : null); toast(err.message, 'danger'); } finally { - busy(el.submitOtpBtn, false, 'Verify & Sign In'); + busy(el.submitOtpBtn, false, t('Verify & Sign In')); } } @@ -328,7 +541,7 @@ // ── Data loading ───────────────────────────────────────────────────────── function loadEverything() { - setConnection('loading', 'Loading live data…'); + setConnection('loading', t('Loading live data…')); Promise.allSettled([ loadStats(), loadRides(), @@ -340,10 +553,10 @@ renderSessionInfo(); const ok = results.some((r) => r.status === 'fulfilled'); if (ok) { - setConnection('live', 'Live database'); - el.lastUpdated.textContent = 'Updated ' + new Date().toLocaleTimeString(); + setConnection('live', t('Live database')); + el.lastUpdated.textContent = t('Updated') + ' ' + new Date().toLocaleTimeString(); } else if (session) { - setConnection('error', 'No data from API'); + setConnection('error', t('No data from API')); el.lastUpdated.textContent = 'No data — check diagnostics'; toast('Every endpoint failed. Open “Session & Security” → Run diagnostics.', 'danger'); } @@ -454,15 +667,15 @@ setKpi('complaintsMonth', fmtInt(stats.countComplaintThisMonth)); drawDonut('statusDonut', [ - { label: 'Completed', value: num(stats.completed_rides), color: '#10b981' }, - { label: 'Waiting', value: num(stats.ongoing_rides), color: '#6366f1' }, - { label: 'Cancelled', value: num(stats.cancelled_rides), color: '#f43f5e' }, + { label: t('Completed'), value: num(stats.completed_rides), color: '#10b981' }, + { label: t('Waiting'), value: num(stats.ongoing_rides), color: '#6366f1' }, + { label: t('Cancelled'), value: num(stats.cancelled_rides), color: '#f43f5e' }, ], el.statusLegend); drawBarChart('timeOfDayChart', [ - { label: 'Morning 6–11', value: num(stats.morning_ride_count) }, - { label: 'Afternoon 12–17', value: num(stats.evening_ride_count) }, - { label: 'Night 18–5', value: num(stats.night_ride_count) }, + { label: t('Morning 6–11'), value: num(stats.morning_ride_count) }, + { label: t('Afternoon 12–17'), value: num(stats.evening_ride_count) }, + { label: t('Night 18–5'), value: num(stats.night_ride_count) }, ]); renderServiceMix(); @@ -471,9 +684,9 @@ function renderServiceMix() { const types = [ - { label: 'Comfort', value: num(stats.comfort), color: '#6366f1' }, - { label: 'Speed', value: num(stats.speed), color: '#06b6d4' }, - { label: 'Lady', value: num(stats.lady), color: '#8b5cf6' }, + { label: t('Comfort'), value: num(stats.comfort), color: '#6366f1' }, + { label: t('Speed'), value: num(stats.speed), color: '#06b6d4' }, + { label: t('Lady'), value: num(stats.lady), color: '#8b5cf6' }, ]; const total = types.reduce((s, t) => s + t.value, 0) || 1; el.serviceMix.innerHTML = types.map((t) => ` @@ -489,12 +702,12 @@ function renderTripPerformance() { const rows = [ - ['Average trip duration', stats.driver_avg_duration || '—'], - ['Longest trip duration', stats.longest_duration || '—'], - ['Average distance', `${fmtNum(stats.average_distance)} km`], - ['Longest distance', `${fmtNum(stats.longest_distance)} km`], - ['Cancelled rides', fmtInt(stats.cancelled_rides)], - ['Captains with completed trips', fmtInt(stats.num_Driver)], + [t('Average trip duration'), stats.driver_avg_duration || '—'], + [t('Longest trip duration'), stats.longest_duration || '—'], + [t('Average distance'), `${fmtNum(stats.average_distance)} km`], + [t('Longest distance'), `${fmtNum(stats.longest_distance)} km`], + [t('Cancelled rides'), fmtInt(stats.cancelled_rides)], + [t('Captains with completed trips'), fmtInt(stats.num_Driver)], ]; el.tripPerformance.innerHTML = rows.map(([k, v]) => `
${k}${v}
@@ -970,7 +1183,7 @@ groups.forEach((group) => { const label = document.createElement('div'); label.className = 'menu-label'; - label.textContent = group; + label.textContent = t(group); menu.appendChild(label); MODULES.filter((m) => m.group === group).forEach((mod) => { @@ -982,7 +1195,7 @@ item.setAttribute('data-requires-super', ''); item.hidden = true; } - item.innerHTML = `${esc(mod.title)}`; + item.innerHTML = `${esc(t(mod.title))}`; menu.appendChild(item); const section = document.createElement('section'); @@ -991,8 +1204,8 @@ section.innerHTML = ` + +
+

Lift a block

+

Removes the phone number from the blacklist so it can register again. It does + not restore a deleted account.

+
+ + +
+
+ + +
+
`; + + $('rmSubmit').addEventListener('click', () => removeAndBlacklist(host)); + $('ubSubmit').addEventListener('click', () => liftBlock(host)); + loadBlacklist(); + } + + async function loadBlacklist() { + const panel = $('blacklistCurrent'); + try { + const payload = await api('/Admin/v2/quality/blacklist_manager.php', { + params: { action_type: 'get_all' }, + }); + panel.innerHTML = '

Currently blocked

'; + renderPayload(panel.querySelector('.panel-body'), payload); + } catch (err) { + if (handleApiError(err, 'blacklist')) return; + panel.innerHTML = `

Currently blocked

${esc(err.message)}
`; + } + } + + async function removeAndBlacklist(host) { + const type = $('rmType').value; + const phone = $('rmPhone').value.trim(); + const id = $('rmId').value.trim(); + const reason = $('rmReason').value.trim(); + const confirmPhone = $('rmConfirmPhone').value.trim(); + const status = $('rmStatus'); + const label = type === 'driver' ? 'captain' : 'passenger'; + + if (!phone) { + toast('Enter the phone number of the account to remove.', 'warning'); + return; + } + if (confirmPhone !== phone) { + toast('The retyped phone does not match — nothing was deleted.', 'danger'); + return; + } + if (type === 'driver' && !id) { + toast('Removing a captain requires their account ID.', 'warning'); + return; + } + + if (!confirm( + `Permanently delete the ${label} on ${phone} and blacklist that number?\n\n` + + `${reason ? `Reason: ${reason}\n\n` : ''}` + + 'The account is deleted from the database. This cannot be undone.' + )) return; + + busy($('rmSubmit'), true, 'Deleting…'); + status.textContent = ''; + try { + if (type === 'driver') { + await api('/Admin/driver/deleteCaptain.php', { params: { driver_id: id, phone, reason } }); + } else { + await api('/Admin/passenger/admin_delete_and_blacklist_passenger.php', { + params: { id, phone, reason }, + }); + } + status.textContent = `Removed and blacklisted ${phone}`; + toast(`${label === 'captain' ? 'Captain' : 'Passenger'} removed and blacklisted.`, 'success'); + ['rmPhone', 'rmId', 'rmReason', 'rmConfirmPhone'].forEach((f) => { $(f).value = ''; }); + loadBlacklist(); + } catch (err) { + if (!handleApiError(err, 'remove-account')) toast(err.message, 'danger'); + } finally { + busy($('rmSubmit'), false, 'Delete and blacklist'); + } + } + + async function liftBlock() { + const type = $('ubType').value; + const phone = $('ubPhone').value.trim(); + const status = $('ubStatus'); + + if (!phone) { + toast('Enter the blocked phone number.', 'warning'); + return; + } + if (!confirm(`Lift the block on ${phone}? They will be able to register again.`)) return; + + busy($('ubSubmit'), true, 'Working…'); + try { + const path = type === 'driver' + ? '/Admin/driver/remove_from_blacklist.php' + : '/Admin/passenger/admin_unblacklist.php'; + await api(path, { params: { phone } }); + status.textContent = `Block lifted for ${phone}`; + toast('Block lifted.', 'success'); + $('ubPhone').value = ''; + loadBlacklist(); + } catch (err) { + if (!handleApiError(err, 'lift-block')) toast(err.message, 'danger'); + } finally { + busy($('ubSubmit'), false, 'Lift block'); + } + } + // ── Campaign launcher ──────────────────────────────────────────────────── // trigger_campaign.php asks Gemini for a campaign, writes a promo code valid // for seven days, and pushes it to every passenger in the country. The @@ -2444,6 +2824,7 @@ function setupDataEvents() { el.refreshBtn?.addEventListener('click', () => { if (session) loadEverything(); }); + $('langToggle')?.addEventListener('click', () => setLanguage(lang === 'ar' ? 'en' : 'ar')); el.rideStatusFilter?.addEventListener('change', () => loadRides().catch(() => {})); el.driversPrev?.addEventListener('click', () => { @@ -2565,7 +2946,7 @@ function labelStatus(status) { if (!status) return 'Unknown'; - return STATUS_LABELS[String(status).toLowerCase()] || humanize(status); + return t(STATUS_LABELS[String(status).toLowerCase()] || humanize(status)); } function badgeClass(status) {
IDID Name PhoneTypeRequested roleRequested atTypeRequested roleRequested at