Update: 2026-07-26 01:03:40

This commit is contained in:
Hamza-Ayed
2026-07-26 01:03:41 +03:00
parent 76c8652bf0
commit 1dfc302a4f
5 changed files with 357 additions and 129 deletions
+18 -14
View File
@@ -39,6 +39,7 @@ if ($redis && !empty($phone)) {
// البحث عن المشرف باستخدام بصمة الجهاز (Fingerprint Hash) // البحث عن المشرف باستخدام بصمة الجهاز (Fingerprint Hash)
$fpHash = hash('sha256', $fingerprint); $fpHash = hash('sha256', $fingerprint);
$isTrustedDevice = false;
// تسجيل محاولة تسجيل الدخول للتدقيق // تسجيل محاولة تسجيل الدخول للتدقيق
$loginAuditData = [ $loginAuditData = [
@@ -57,8 +58,9 @@ try {
$stmt->execute([':fp' => $fpHash]); $stmt->execute([':fp' => $fpHash]);
$admin = $stmt->fetch(PDO::FETCH_ASSOC); $admin = $stmt->fetch(PDO::FETCH_ASSOC);
// إذا لم يتم العثور بالبصمة، نبحث بالـ ID المباشر أو بفك تشفير البيانات (AES-GCM Decryption in PHP) if ($admin) {
if (!$admin && !empty($phone)) { $isTrustedDevice = true;
} else if (!empty($phone)) {
// 1. بحث مباشر بالـ ID المعياري Unencrypted // 1. بحث مباشر بالـ ID المعياري Unencrypted
$stmtId = $con->prepare("SELECT * FROM adminUser WHERE id = :id LIMIT 1"); $stmtId = $con->prepare("SELECT * FROM adminUser WHERE id = :id LIMIT 1");
$stmtId->execute([':id' => $phone]); $stmtId->execute([':id' => $phone]);
@@ -79,16 +81,9 @@ try {
} }
} }
// عند إيجاد الحساب وتأكيد كلمة المرور، نقوم بتحديث وبث بصمة الجهاز للجلسة // فحص ما إذا كانت بصمة الجهاز محفوظة ومطابقة للجهاز الحالي
if ($admin && password_verify($password, $admin['password'])) { if ($admin && !empty($admin['fingerprint_hash']) && hash_equals($admin['fingerprint_hash'], $fpHash)) {
$encFpRaw = $encryptionHelper ? $encryptionHelper->encryptData($fingerprint) : $fingerprint; $isTrustedDevice = true;
$updateStmt = $con->prepare("UPDATE adminUser SET fingerprint = :fp_raw, fingerprint_hash = :fp WHERE id = :id");
$updateStmt->execute([
':fp_raw' => $encFpRaw,
':fp' => $fpHash,
':id' => $admin['id']
]);
$admin['fingerprint_hash'] = $fpHash;
} }
} }
@@ -110,8 +105,17 @@ try {
// 2. التحقق من كلمة المرور // 2. التحقق من كلمة المرور
if (password_verify($password, $admin['password'])) { if (password_verify($password, $admin['password'])) {
// إذا كان تجديد توكن تلقائي من التطبيق/الجهاز الموثوق // إذا كان الجهاز موثوقاً (البصمة محفوظة ومطابقة) أو طلب تجديد توكن تلقائي
if ($isRenewal) { if ($isTrustedDevice || $isRenewal) {
$encFpRaw = $encryptionHelper ? $encryptionHelper->encryptData($fingerprint) : $fingerprint;
$updateStmt = $con->prepare("UPDATE adminUser SET fingerprint = :fp_raw, fingerprint_hash = :fp WHERE id = :id");
$updateStmt->execute([
':fp_raw' => $encFpRaw,
':fp' => $fpHash,
':id' => $admin['id']
]);
$admin['fingerprint_hash'] = $fpHash;
$jwtService = new JwtService($redis); $jwtService = new JwtService($redis);
$role = $admin['role'] ?? 'admin'; $role = $admin['role'] ?? 'admin';
+44 -6
View File
@@ -22,14 +22,55 @@ $rateLimiter->enforce(RateLimiter::identifier(), 'otp');
try { try {
$con = Database::get('main'); $con = Database::get('main');
// 1. جلب بيانات المسؤول عبر البصمة (مصدر موثوق وغير مشفر) // 1. جلب بيانات المسؤول عبر البصمة أو من الـ OTP المعلق للجهاز الجديد
$fpHash = hash('sha256', $fingerprint); $fpHash = hash('sha256', $fingerprint);
$stmt = $con->prepare("SELECT * FROM adminUser WHERE fingerprint_hash = :fp LIMIT 1"); $stmt = $con->prepare("SELECT * FROM adminUser WHERE fingerprint_hash = :fp LIMIT 1");
$stmt->execute([':fp' => $fpHash]); $stmt->execute([':fp' => $fpHash]);
$admin = $stmt->fetch(PDO::FETCH_ASSOC); $admin = $stmt->fetch(PDO::FETCH_ASSOC);
$otpHash = hash('sha256', (string)$otp);
if (!$admin) { if (!$admin) {
jsonError("المسؤول غير موجود أو البصمة غير مطابقة."); // إذا كانت البصمة جديدة وغير مسجلة بعد، نبحث عن الحساب المرتبط بـ OTP المعلق
$stmtOtp = $con->prepare("SELECT phone_number FROM token_verification_admin WHERE token = ? AND expiration_time >= NOW() LIMIT 1");
$stmtOtp->execute([$otpHash]);
$otpRow = $stmtOtp->fetch(PDO::FETCH_ASSOC);
if ($otpRow && !empty($otpRow['phone_number'])) {
// $targetPhone هو الرقم المشفر من token_verification_admin (نفس القيمة المخزنة في adminUser.phone)
$targetPhone = $otpRow['phone_number'];
global $encryptionHelper;
// البحث المباشر: phone المشفر مطابق لنفس النص المشفر في adminUser.phone
$stmtAdmin = $con->prepare("SELECT * FROM adminUser WHERE phone = :p OR id = :p LIMIT 1");
$stmtAdmin->execute([':p' => $targetPhone]);
$admin = $stmtAdmin->fetch(PDO::FETCH_ASSOC);
// مسار احتياطي: فك التشفير لمقارنة القيم (ضروري لـ AES-GCM حيث التشفير غير حتمي)
if (!$admin) {
$decTarget = ($encryptionHelper && !empty($targetPhone)) ? $encryptionHelper->decryptData($targetPhone) : null;
$stmtAll = $con->query("SELECT * FROM adminUser");
while ($row = $stmtAll->fetch(PDO::FETCH_ASSOC)) {
// مقارنة مباشرة للنصوص المشفرة (نفس ciphertext)
if ($targetPhone === $row['phone']) {
$admin = $row;
break;
}
// مقارنة عبر فك التشفير (AES-GCM: ciphertexts مختلفة لنفس النص)
if ($decTarget) {
$decPhone = ($encryptionHelper && !empty($row['phone'])) ? $encryptionHelper->decryptData($row['phone']) : $row['phone'];
if ($decTarget === $decPhone) {
$admin = $row;
break;
}
}
}
}
}
}
if (!$admin) {
jsonError("المسؤول غير موجود أو رمز التحقق غير صالح.");
exit; exit;
} }
@@ -39,10 +80,7 @@ try {
// فك تشفيره لو احتجنا إرساله أو عرضه، لكن هنا نحن نحتاج المشفر للبحث // فك تشفيره لو احتجنا إرساله أو عرضه، لكن هنا نحن نحتاج المشفر للبحث
// $phone = $encryptionHelper->decryptData($encryptedPhone); // $phone = $encryptionHelper->decryptData($encryptedPhone);
// هاش الرمز (OTP) القادم من التطبيق للمقارنة // 3. التحقق من الـ OTP (الهاش محسوب مسبقاً في المتغير $otpHash)
$otpHash = hash('sha256', (string)$otp);
// 3. التحقق من الـ OTP
$stmt = $con->prepare("SELECT * FROM token_verification_admin $stmt = $con->prepare("SELECT * FROM token_verification_admin
WHERE phone_number = ? AND token = ? WHERE phone_number = ? AND token = ?
AND expiration_time >= NOW()"); AND expiration_time >= NOW()");
+38 -8
View File
@@ -2,13 +2,45 @@
require_once __DIR__ . '/../../connect.php'; require_once __DIR__ . '/../../connect.php';
global $blindIndex;
$email = filterRequest('email'); $email = filterRequest('email');
$phone = filterRequest('phone'); $phone = filterRequest('phone');
$password = filterRequest('password'); $password = filterRequest('password');
// تشفير الحقول المطلوبة قبل الاستعلام if (empty($phone) && empty($email)) {
$email = $encryptionHelper->encryptData($email); jsonError("Phone or email is required.");
$phone = $encryptionHelper->encryptData($phone); exit;
}
$conditions = [];
$params = [];
if (!empty($phone)) {
$phoneEnc = $encryptionHelper->encryptData($phone);
$conditions[] = "driver.phone = :phone";
$params[':phone'] = $phoneEnc;
$phoneBidx = $blindIndex ? $blindIndex->index('driver.phone', $phone) : null;
if ($phoneBidx) {
$conditions[] = "driver.phone_bidx = :phone_bidx";
$params[':phone_bidx'] = $phoneBidx;
}
}
if (!empty($email)) {
$emailEnc = $encryptionHelper->encryptData($email);
$conditions[] = "driver.email = :email";
$params[':email'] = $emailEnc;
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $email) : null;
if ($emailBidx) {
$conditions[] = "driver.email_bidx = :email_bidx";
$params[':email_bidx'] = $emailBidx;
}
}
$whereClause = implode(' OR ', $conditions);
$sql = "SELECT $sql = "SELECT
driver.id, driver.id,
@@ -29,7 +61,7 @@ $sql = "SELECT
FROM FROM
driver driver
WHERE WHERE
driver.phone = :phone AND driver.email = :email"; $whereClause";
/** /**
@@ -54,11 +86,9 @@ function fetchEmailVerified(PDO $con, ?string $plainEmail): ?int
} }
$stmt = $con->prepare($sql); $stmt = $con->prepare($sql);
$stmt->bindParam(':email', $email); $stmt->execute($params);
$stmt->bindParam(':phone', $phone);
$stmt->execute();
$data = $stmt->fetchAll(PDO::FETCH_ASSOC); $data = $stmt->fetchAll(PDO::FETCH_ASSOC);
$count = $stmt->rowCount(); $count = count($data);
if ($count > 0) { if ($count > 0) {
$plainEmail = $encryptionHelper->decryptData($data[0]['_email_enc'] ?? null) ?: null; $plainEmail = $encryptionHelper->decryptData($data[0]['_email_enc'] ?? null) ?: null;
@@ -44,7 +44,10 @@ try {
// Auto-seed/create tester driver logic removed for security // Auto-seed/create tester driver logic removed for security
// SQL لاسترجاع المستخدم بناءً على البريد الإلكتروني المشفر global $blindIndex;
$emailBidx = $blindIndex ? $blindIndex->index('driver.email', $email) : null;
// SQL لاسترجاع المستخدم بناءً على البريد الإلكتروني المشفر أو الفهرس الأعمى
$sql = "SELECT $sql = "SELECT
driver.*, driver.*,
phone_verification.is_verified, phone_verification.is_verified,
@@ -55,12 +58,11 @@ try {
LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone_key LEFT JOIN phone_verification ON phone_verification.phone_number = driver.phone_key
LEFT JOIN CarRegistration ON CarRegistration.driverID = driver.id LEFT JOIN CarRegistration ON CarRegistration.driverID = driver.id
WHERE WHERE
driver.email = :email driver.email = :email OR (:email_bidx IS NOT NULL AND driver.email_bidx = :email_bidx)
LIMIT 1"; LIMIT 1";
$stmt = $con->prepare($sql); $stmt = $con->prepare($sql);
$stmt->bindParam(':email', $encryptedEmail); $stmt->execute([':email' => $encryptedEmail, ':email_bidx' => $emailBidx]);
$stmt->execute();
$data = $stmt->fetch(PDO::FETCH_ASSOC); $data = $stmt->fetch(PDO::FETCH_ASSOC);
+251 -97
View File
@@ -290,9 +290,9 @@
'Administrator disabled.': 'تم تعطيل المدير.', 'Administrator disabled.': 'تم تعطيل المدير.',
// Blacklist // Blacklist
'Removal is permanent.': 'الحذف دائم.', 'Removal is permanent.': 'الحذف دائم.',
'Remove & blacklist': 'حذف و加入 القائمة السوداء', 'Remove & blacklist': 'حذف ووضع في القائمة السوداء',
'Lift a block': 'رفع الحظر', 'Lift a block': 'رفع الحظر',
'Delete and blacklist': 'حذف و加入 القائمة السوداء', 'Delete and blacklist': 'حذف ووضع في القائمة السوداء',
'Currently blocked': 'المحظورون حالياً', 'Currently blocked': 'المحظورون حالياً',
'Enter the phone number of the account to remove.': 'أدخل رقم هاتف الحساب المراد حذفه.', 'Enter the phone number of the account to remove.': 'أدخل رقم هاتف الحساب المراد حذفه.',
'The retyped phone does not match': 'رقم الهاتف المُعاد إدخاله غير متطابق', 'The retyped phone does not match': 'رقم الهاتف المُعاد إدخاله غير متطابق',
@@ -506,8 +506,8 @@
'Deleting…': 'جارٍ الحذف…', 'Deleting…': 'جارٍ الحذف…',
'Captain removed and blacklisted.': 'تم حذف الكابتن وإضافته للقائمة السوداء.', 'Captain removed and blacklisted.': 'تم حذف الكابتن وإضافته للقائمة السوداء.',
'Passenger removed and blacklisted.': 'تم حذف الراكب وإضافته للقائمة السوداء.', 'Passenger removed and blacklisted.': 'تم حذف الراكب وإضافته للقائمة السوداء.',
'Remove and blacklist': 'حذف و加入 القائمة السوداء', 'Remove and blacklist': 'حذف ووضع في القائمة السوداء',
'Delete and blacklist': 'حذف و加入 القائمة السوداء', 'Delete and blacklist': 'حذف ووضع في القائمة السوداء',
// Lift block // Lift block
'Enter the blocked phone number.': 'أدخل رقم الهاتف المحظور.', 'Enter the blocked phone number.': 'أدخل رقم الهاتف المحظور.',
'They will be able to register again.': 'سيتمكن من التسجيل مرة أخرى.', 'They will be able to register again.': 'سيتمكن من التسجيل مرة أخرى.',
@@ -615,6 +615,160 @@
'Verifying…': 'جارٍ التحقق…', 'Verifying…': 'جارٍ التحقق…',
'Session ended: ': 'انتهت الجلسة: ', 'Session ended: ': 'انتهت الجلسة: ',
'n/a': 'غير متاح', 'n/a': 'غير متاح',
'You have been signed out.': 'تم تسجيل خروجك.',
// Module panel titles
'Realtime counters': 'عدادات مباشرة',
'Smart alerts': 'تنبيهات ذكية',
'Financial stats': 'إحصائيات مالية',
'Settlements': 'تسويات',
'Market share': 'الحصة السوقية',
'Price comparison': 'مقارنة الأسعار',
'Market anomalies': 'شذوذات السوق',
'Surge opportunity index': 'مؤشر فرصة الارتفاع',
'Win-back hotspots': 'نقاط استعادة العملاء',
'Campaign log': 'سجل الحملات',
'Pricing stability log': 'سجل استقرار الأسعار',
'AI price prediction': 'تنبؤ الأسعار بالذكاء الاصطناعي',
'Price gap heatmap': 'خريطة فروقات الأسعار',
'Telemetry': 'القياس عن بُعد',
'Promo codes': 'أكواد الخصم',
'Heatmap': 'الخريطة الحرارية',
'Best captains': 'أفضل الكابتنات',
'Card charges per captain': 'رسوم البطاقة لكل كابتن',
'Invoice totals': 'إجمالي الفواتير',
'Scorecard': 'بطاقة التقييم',
'Audit entries': 'سجلات العمليات',
'Recent errors': 'الأخطاء الأخيرة',
// Module subtitles
'Realtime fleet counters and the alerts that need attention now': 'عدادات الأسطول المباشرة والتنبيهات التي تحتاج انتباهاً الآن',
'Settlement runs and financial aggregates': 'تسويات ومجموعات مالية',
'Market share, competitor price gaps, anomalies and campaign history': 'الحصة السوقية وفروقات أسعار المنافسين والشذوذات وسجل الحملات',
'Stability log, AI predictions and the live price-gap heatmap': 'سجل الاستقرار وتنبؤات الذكاء الاصطناعي وخريطة فروقات الأسعار المباشرة',
'Universities, schools and companies running their own transport — open one to manage it': 'جامعات ومدارس وشركات تشغّل نقلها الخاص — افتح مؤسسة لإدارتها',
'Draft routes submitted by organisations, awaiting a decision': 'مسارات مقدّمة من المؤسسات بانتظار القرار',
// Org admin
'Enter both a name and a phone number.': 'أدخل الاسم ورقم الهاتف.',
'Give administrator access to this organisation?': 'منح صلاحية المشرف لهذه المؤسسة؟',
'Administrator added.': 'تمت إضافة المشرف.',
'Enable this administrator?': 'تفعيل هذا المشرف؟',
'Disable this administrator?': 'تعطيل هذا المشرف؟',
'Administrator enabled.': 'تم تفعيل المشرف.',
'Administrator disabled.': 'تم تعطيل المشرف.',
'Change the contract from': 'تغيير العقد من',
'Suspending stops the organisation using the service.': 'الإيقاف يمنع المؤسسة من استخدام الخدمة.',
'Organisation updated.': 'تم تحديث المؤسسة.',
'Organisation created.': 'تم إنشاء المؤسسة.',
'Name, founding admin name and phone are required.': 'الاسم واسم المدير المؤسس والهاتف مطلوبون.',
// Route approvals
'route': 'مسار',
'approveed': 'تم اعتماده',
'click to expand': 'اضغط للتوسيع',
'from this organisation': 'من هذه المؤسسة',
// Broadcast
'every captain': 'كل كابتن',
'every passenger': 'كل راكب',
'Sending…': 'جارٍ الإرسال…',
'It is delivered immediately and cannot be recalled.': 'يُسلَّم فوراً ولا يمكن استرجاعه.',
// Tariff
'Tariff': 'التعرفة',
'row #': 'صف #',
'Apply these pricing changes to': 'تطبيق تغييرات التسعير على',
'This takes effect immediately for passengers.': 'يسري فوراً للركاب.',
// Staff
'account': 'حساب',
'Account': 'حساب',
'activated.': 'تم تفعيله.',
'for': 'لـ',
'Phone:': 'الهاتف:',
'Email:': 'البريد:',
'ADMINISTRATOR': 'مدير',
'customer service': 'خدمة عملاء',
// Lift block
'Lift the block on': 'رفع الحظر عن',
// Campaign
'Launched at': 'أُطلقت في',
// Crypto / diagnostics
'Failed:': 'فشل:',
'present (role ': 'موجود (دور ',
'Siro Admin diagnostics —': 'تشخيص سيرو أدمن —',
// Blacklist
'on': 'على',
'captain': 'كابتن',
'passenger': 'راكب',
// Tariff labels
'Platform commission': 'عمولة المنصة',
'% taken by Siro': 'نسبة سيرو',
'Fuel price': 'سعر الوقود',
'Currency': 'العملة',
'Minimum fare — normal': 'الحد الأدنى — عادي',
'Minimum fare — peak': 'الحد الأدنى — ذروة',
'Minimum fare — late night': 'الحد الأدنى — ليل',
'Fixed price': 'سعر ثابت',
'Speed': 'سريع',
'Comfort': 'مريح',
'Lady': 'سيدات',
'Electric': 'كهرباء',
'Van': 'فان',
'Delivery': 'توصيل',
'Mishwar VIP': 'مشوار VIP',
'Awfar': 'أوفر',
// Platform commission AR labels
'normal': 'عادي',
'peak': 'ذروة',
'late_night': 'ليل',
// Transit org
'Organisation #': 'مؤسسة #',
// Ternary conversions
'trip': 'رحلة',
'Searching rides for': 'جارٍ البحث عن رحلات لـ',
'Searching captains for': 'جارٍ البحث عن كباتن لـ',
'Searching passengers for': 'جارٍ البحث عن ركاب لـ',
'days had at least one signup': 'يوماً بها تسجيل واحد على الأقل',
'Organisation': 'مؤسسة',
'required': 'إلزامي',
'Name (Arabic)': 'الاسم (عربي)',
'Name (English)': 'الاسم (إنجليزي)',
'2 letters': 'حرفين',
'Founding admin name': 'اسم المدير المؤسس',
'Founding admin phone': 'هاتف المدير المؤسس',
'Contact email': 'البريد الإلكتروني',
'Website': 'الموقع الإلكتروني',
'Trial ends': 'انتهاء الفترة التجريبية',
'The account row is deleted and the phone number is added to the blacklist so it cannot register again. There is no undo — only lifting the block, which does not restore the deleted account.': 'يتم حذف حساب السائق وإضافة رقم الهاتف إلى القائمة السوداء حتى لا يتمكن من التسجيل مرة أخرى. لا يمكن التراجع عن هذا الإجراء — فقط رفع الحظر، الذي لا يُعيد الحساب المحذوف.',
'required for captains': 'إلزامي للكباتن',
'Recorded on the blacklist entry': 'يُسجَّل في سبب القائمة السوداء',
'safety check': 'فحص أمان',
'Removes the phone number from the blacklist so it can register again. It does not restore a deleted account.': 'يزيل رقم الهاتف من القائمة السوداء حتى يتمكن من التسجيل مرة أخرى. لا يُعيد الحساب المحذوف.',
'Launching creates a real discount code and notifies every passenger in the selected country.': 'الإطلاق ينشئ كود خصم حقيقي ويخبر كل راكب في الدولة المحددة.',
'The promo stays valid for seven days. Always preview first.': 'يبقى كود الخصم صالحاً لمدة سبعة أيام. عاين دائماً أولاً.',
'defaults to the capital': 'العاصمة افتراضياً',
'Each app compares its own build against this number on launch. Raising it can force every user of that app to update before they can continue.': 'كل تطبيق يقارن إصداره بهذا الرقم عند التشغيل. رفعه يجبر كل مستخدم على التحديث قبل المتابعة.',
'Current version': 'الإصدار الحالي',
'showing': 'عرض',
'from': 'من',
'Documents': 'الوثائق',
'no file linked': 'لا ملف مرتبط',
'This captain has uploaded no documents — approving now would activate an unverified account.': 'هذا الكابتن لم يُرسل أي وثائق — الاعتماد الآن يعني تفعيل حساب غير موثّق.',
'Record': 'السجل',
'Creates a login for the Siro admin tools. Choose the password with the new member present, or have them change it at first sign-in — it is stored hashed and cannot be read back.': 'يُنشئ حساب دخول لأدوات سير للمشرفين. اختر كلمة المرور مع العضو الجديد، أو دعهم يغيّروها عند أول تسجيل دخول — هي مشفرة ولا يمكن قراءتها.',
'Customer service': 'خدمة العملاء',
'Administrator': 'مدير',
'Full name': 'الاسم الكامل',
'No accounts are waiting for activation.': 'لا توجد حسابات بانتظار التفعيل.',
'Unnamed route': 'مسار بدون اسم',
'unknown organisation': 'مؤسسة غير معروفة',
'min': 'دقيقة',
'Unnamed stop': 'محطة بدون اسم',
'This reaches every device at once and cannot be recalled.': 'هذا يصل لكل الأجهزة دفعة واحدة ولا يمكن استرجاعه.',
'The message is recorded in the audit log against your account.': 'يُسجَّل الرسالة في سجل العمليات ضد حسابك.',
'max 120': 'حد أقصى 120',
'Message': 'الرسالة',
'max 1000': 'حد أقصى 1000',
'No tariff rows configured.': 'لا توجد صفوف تعرفة معدّة.',
'You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.': 'أنت مسجّل كمدير، لذا تُعرض التعرفة للقراءة فقط. فقط المدير العام يمكنه تغيير الأسعار.',
'These values are live. Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account.': 'هذه القيم حيّة. الحفظ يغيّر ما يدفعه كل راكب من الرحلة التالية فصاعداً. يُسجَّل التغيير في سجل العمليات ضد حسابك.',
'Reset': 'إعادة تعيين',
'Review & save': 'مراجعة وحفظ',
}; };
const t = (key) => (lang === 'ar' ? (AR[key] || key) : key); const t = (key) => (lang === 'ar' ? (AR[key] || key) : key);
@@ -830,9 +984,9 @@
} catch { } catch {
const preview = text.trim().slice(0, 200); const preview = text.trim().slice(0, 200);
const hint = preview.includes('<!DOCTYPE') || preview.includes('<html') const hint = preview.includes('<!DOCTYPE') || preview.includes('<html')
? ' — the endpoint returned HTML, check the API base URL' ? t(' — the endpoint returned HTML, check the API base URL')
: ''; : '';
throw new ApiError(`الخادم أرجع استجابة غير صحيحة (HTTP ${res.status})${hint}`, res.status); throw new ApiError(t('Request failed') + ` (HTTP ${res.status})${hint}`, res.status);
} }
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
@@ -875,7 +1029,7 @@
el.loginForm?.addEventListener('submit', onLogin); el.loginForm?.addEventListener('submit', onLogin);
el.submitOtpBtn?.addEventListener('click', onVerifyOtp); el.submitOtpBtn?.addEventListener('click', onVerifyOtp);
el.otpInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') onVerifyOtp(); }); el.otpInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') onVerifyOtp(); });
el.logoutBtn?.addEventListener('click', () => signOut('You have been signed out.')); el.logoutBtn?.addEventListener('click', () => signOut(t('You have been signed out.')));
window.closeOtpModal = () => el.otpModal?.classList.remove('active'); window.closeOtpModal = () => el.otpModal?.classList.remove('active');
} }
@@ -1192,7 +1346,7 @@
function renderRides() { function renderRides() {
const rides = allRides.slice(0, ridesShown); const rides = allRides.slice(0, ridesShown);
el.ridesMeta.textContent = allRides.length el.ridesMeta.textContent = allRides.length
? `${t('Showing')} ${rides.length} ${t('of')} ${allRides.length} ${allRides.length === 1 ? (lang === 'ar' ? 'رحلة' : 'trip') : t('trips')}` ? `${t('Showing')} ${rides.length} ${t('of')} ${allRides.length} ${allRides.length === 1 ? t('trip') : t('trips')}`
: '—'; : '—';
el.ridesMore.hidden = ridesShown >= allRides.length; el.ridesMore.hidden = ridesShown >= allRides.length;
@@ -1276,7 +1430,7 @@
// ── Lookup by phone / id ───────────────────────────────────────────────── // ── Lookup by phone / id ─────────────────────────────────────────────────
async function lookupRidesByPhone(phone) { async function lookupRidesByPhone(phone) {
tableMessage(el.ridesTableBody, 8, `${lang === 'ar' ? 'جارٍ البحث عن رحلات لـ' : 'Searching rides for'} ${phone}…`); tableMessage(el.ridesTableBody, 8, `${t('Searching rides for')} ${phone}…`);
try { try {
const payload = await api('/Admin/rides/admin_get_rides_by_phone.php', { params: { phone } }); const payload = await api('/Admin/rides/admin_get_rides_by_phone.php', { params: { phone } });
const rows = Array.isArray(payload) ? payload : (payload?.rides || payload?.data || []); const rows = Array.isArray(payload) ? payload : (payload?.rides || payload?.data || []);
@@ -1290,7 +1444,7 @@
} }
async function lookupCaptain(term) { async function lookupCaptain(term) {
tableMessage(el.driversTableBody, 8, `${lang === 'ar' ? 'جارٍ البحث عن كباتن لـ' : 'Searching captains for'} ${term}…`); tableMessage(el.driversTableBody, 8, `${t('Searching captains for')} ${term}…`);
const params = /^\d+$/.test(term) && term.length < 8 const params = /^\d+$/.test(term) && term.length < 8
? { driver_id: term } ? { driver_id: term }
: (term.includes('@') ? { driverEmail: term } : { driverPhone: term }); : (term.includes('@') ? { driverEmail: term } : { driverPhone: term });
@@ -1306,7 +1460,7 @@
} }
async function lookupPassenger(term) { async function lookupPassenger(term) {
tableMessage(el.passengersTableBody, 8, `${lang === 'ar' ? 'جارٍ البحث عن ركاب لـ' : 'Searching passengers for'} ${term}…`); tableMessage(el.passengersTableBody, 8, `${t('Searching passengers for')} ${term}…`);
const params = /^\d+$/.test(term) && term.length < 8 const params = /^\d+$/.test(term) && term.length < 8
? { passengerId: term } ? { passengerId: term }
: (term.includes('@') ? { passengerEmail: term } : { passengerphone: term }); : (term.includes('@') ? { passengerEmail: term } : { passengerphone: term });
@@ -1827,7 +1981,7 @@
${activeDays === 0 ${activeDays === 0
? `<div class="table-msg">${t('Nobody signed up in the last 30 days.')}</div>` ? `<div class="table-msg">${t('Nobody signed up in the last 30 days.')}</div>`
: `<div class="stamp" style="display:block;text-align:center;margin-top:0.5rem;"> : `<div class="stamp" style="display:block;text-align:center;margin-top:0.5rem;">
${activeDays} / 30 ${lang === 'ar' ? 'يوماً بها تسجيل واحد على الأقل' : 'days had at least one signup'} ${activeDays} / 30 ${t('days had at least one signup')}
</div>`} </div>`}
</div>`; </div>`;
@@ -2019,8 +2173,8 @@
<h4 class="sub-panel-title">${t('Administrators')}</h4> <h4 class="sub-panel-title">${t('Administrators')}</h4>
<div class="panel-body" id="orgAdmins"><div class="table-msg">${t('Loading…')}</div></div> <div class="panel-body" id="orgAdmins"><div class="table-msg">${t('Loading…')}</div></div>
<div class="api-base-row" style="margin-top:0.75rem;"> <div class="api-base-row" style="margin-top:0.75rem;">
<input type="text" class="form-input" id="orgAdminName" placeholder="${lang === 'ar' ? 'الاسم' : 'Name'}"> <input type="text" class="form-input" id="orgAdminName" placeholder="${t('Name')}">
<input type="text" class="form-input" id="orgAdminPhone" placeholder="${lang === 'ar' ? 'الهاتف' : 'Phone'}"> <input type="text" class="form-input" id="orgAdminPhone" placeholder="${t('Phone')}">
<button class="btn btn-secondary btn-sm" id="orgAdminAdd"><i class="ph ph-user-plus"></i> ${t('Add')}</button> <button class="btn btn-secondary btn-sm" id="orgAdminAdd"><i class="ph ph-user-plus"></i> ${t('Add')}</button>
</div> </div>
</div>`; </div>`;
@@ -2030,7 +2184,7 @@
$('orgAdminAdd').addEventListener('click', () => addOrgAdmin(orgId)); $('orgAdminAdd').addEventListener('click', () => addOrgAdmin(orgId));
} catch (err) { } catch (err) {
if (handleApiError(err, 'org-details')) return; if (handleApiError(err, 'org-details')) return;
body.innerHTML = `<div class="modal-head"><h3>${lang === 'ar' ? 'مؤسسة' : 'Organisation'}</h3> body.innerHTML = `<div class="modal-head"><h3>${t('Organisation')}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button></div> <button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button></div>
<div class="table-msg is-error">${esc(err.message)}</div>`; <div class="table-msg is-error">${esc(err.message)}</div>`;
} }
@@ -2068,13 +2222,13 @@
const name = $('orgAdminName').value.trim(); const name = $('orgAdminName').value.trim();
const phone = $('orgAdminPhone').value.trim(); const phone = $('orgAdminPhone').value.trim();
if (!name || !phone) { if (!name || !phone) {
toast('Enter both a name and a phone number.', 'warning'); toast(t('Enter both a name and a phone number.'), 'warning');
return; return;
} }
if (!confirm(`Give ${name} (${phone}) administrator access to this organisation?`)) return; if (!confirm(`${t('Give administrator access to this organisation?')}\n\n${name} (${phone})`)) return;
try { try {
await api('/Admin/transit/org/admin_add.php', { params: { org_id: orgId, name, phone } }); await api('/Admin/transit/org/admin_add.php', { params: { org_id: orgId, name, phone } });
toast('Administrator added.', 'success'); toast(t('Administrator added.'), 'success');
$('orgAdminName').value = ''; $('orgAdminName').value = '';
$('orgAdminPhone').value = ''; $('orgAdminPhone').value = '';
loadOrgAdmins(orgId); loadOrgAdmins(orgId);
@@ -2084,10 +2238,10 @@
} }
async function toggleOrgAdmin(adminId, isActive, orgId) { async function toggleOrgAdmin(adminId, isActive, orgId) {
if (!confirm(isActive ? 'Enable this administrator?' : 'Disable this administrator?')) return; if (!confirm(isActive ? t('Enable this administrator?') : t('Disable this administrator?'))) return;
try { try {
await api('/Admin/transit/org/admin_toggle.php', { params: { admin_id: adminId, is_active: isActive } }); await api('/Admin/transit/org/admin_toggle.php', { params: { admin_id: adminId, is_active: isActive } });
toast(isActive ? 'Administrator enabled.' : 'Administrator disabled.', 'success'); toast(isActive ? t('Administrator enabled.') : t('Administrator disabled.'), 'success');
loadOrgAdmins(orgId); loadOrgAdmins(orgId);
} catch (err) { } catch (err) {
if (!handleApiError(err, 'org-admin-toggle')) toast(err.message, 'danger'); if (!handleApiError(err, 'org-admin-toggle')) toast(err.message, 'danger');
@@ -2102,35 +2256,35 @@
body.innerHTML = ` body.innerHTML = `
<div class="modal-head"> <div class="modal-head">
<h3>${editing ? esc(org.name_ar || (lang === 'ar' ? 'تعديل المؤسسة' : 'Edit organisation')) : (lang === 'ar' ? 'مؤسسة جديدة' : 'New organisation')}</h3> <h3>${editing ? esc(org.name_ar || t('Edit organisation')) : t('New organisation')}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button> <button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div> </div>
<div class="tariff-grid"> <div class="tariff-grid">
${editing ? '' : ` ${editing ? '' : `
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Type')} <em>${lang === 'ar' ? 'إلزامي' : 'required'}</em></span> <span class="tariff-label">${t('Type')} <em>${t('required')}</em></span>
<select class="select-input" id="orgType"> <select class="select-input" id="orgType">
${ORG_TYPES.map((t) => `<option value="${t}">${humanize(t)}</option>`).join('')} ${ORG_TYPES.map((t) => `<option value="${t}">${humanize(t)}</option>`).join('')}
</select> </select>
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'الاسم (عربي)' : 'Name (Arabic)'} <em>${lang === 'ar' ? 'إلزامي' : 'required'}</em></span> <span class="tariff-label">${t('Name (Arabic)')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="orgNameAr"> <input type="text" class="form-input" id="orgNameAr">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'الاسم (إنجليزي)' : 'Name (English)'}</span> <span class="tariff-label">${t('Name (English)')}</span>
<input type="text" class="form-input" id="orgNameEn"> <input type="text" class="form-input" id="orgNameEn">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Country')} <em>${lang === 'ar' ? 'حرفين' : '2 letters'}</em></span> <span class="tariff-label">${t('Country')} <em>${t('2 letters')}</em></span>
<input type="text" class="form-input" id="orgCountry" value="JO" maxlength="2"> <input type="text" class="form-input" id="orgCountry" value="JO" maxlength="2">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'اسم المدير المؤسس' : 'Founding admin name'} <em>${lang === 'ar' ? 'إلزامي' : 'required'}</em></span> <span class="tariff-label">${t('Founding admin name')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="orgAdminNameNew"> <input type="text" class="form-input" id="orgAdminNameNew">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'هاتف المدير المؤسس' : 'Founding admin phone'} <em>${lang === 'ar' ? 'إلزامي' : 'required'}</em></span> <span class="tariff-label">${t('Founding admin phone')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="orgAdminPhoneNew"> <input type="text" class="form-input" id="orgAdminPhoneNew">
</label>`} </label>`}
<label class="tariff-field"> <label class="tariff-field">
@@ -2139,11 +2293,11 @@
</label> </label>
${editing ? ` ${editing ? `
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'البريد الإلكتروني' : 'Contact email'}</span> <span class="tariff-label">${t('Contact email')}</span>
<input type="email" class="form-input" id="orgEmail" value="${esc(org.contact_email ?? '')}"> <input type="email" class="form-input" id="orgEmail" value="${esc(org.contact_email ?? '')}">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'الموقع الإلكتروني' : 'Website'}</span> <span class="tariff-label">${t('Website')}</span>
<input type="text" class="form-input" id="orgWebsite" value="${esc(org.website ?? '')}"> <input type="text" class="form-input" id="orgWebsite" value="${esc(org.website ?? '')}">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
@@ -2153,7 +2307,7 @@
</select> </select>
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'انتهاء الفترة التجريبية' : 'Trial ends'}</span> <span class="tariff-label">${t('Trial ends')}</span>
<input type="date" class="form-input" id="orgTrialEnds" value="${esc((org.trial_ends_at || '').slice(0, 10))}"> <input type="date" class="form-input" id="orgTrialEnds" value="${esc((org.trial_ends_at || '').slice(0, 10))}">
</label>` : ''} </label>` : ''}
</div> </div>
@@ -2180,11 +2334,11 @@
trial_ends_at: $('orgTrialEnds').value, trial_ends_at: $('orgTrialEnds').value,
}; };
if (params.contract_status !== org.contract_status && if (params.contract_status !== org.contract_status &&
!confirm(`Change the contract from "${org.contract_status}" to "${params.contract_status}"? ` + !confirm(t('Change the contract from') + ` "${org.contract_status}" ${t('to')} "${params.contract_status}"? ` +
'Suspending stops the organisation using the service.')) return; t('Suspending stops the organisation using the service.'))) return;
await api('/Admin/transit/org/update.php', { params }); await api('/Admin/transit/org/update.php', { params });
toast('Organisation updated.', 'success'); toast(t('Organisation updated.'), 'success');
} else { } else {
const params = { const params = {
type: $('orgType').value, type: $('orgType').value,
@@ -2196,11 +2350,11 @@
admin_phone: $('orgAdminPhoneNew').value.trim(), admin_phone: $('orgAdminPhoneNew').value.trim(),
}; };
if (!params.name_ar || !params.admin_name || !params.admin_phone) { if (!params.name_ar || !params.admin_name || !params.admin_phone) {
toast('Name, founding admin name and phone are required.', 'warning'); toast(t('Name, founding admin name and phone are required.'), 'warning');
return; return;
} }
await api('/Admin/transit/org/create.php', { params }); await api('/Admin/transit/org/create.php', { params });
toast('Organisation created.', 'success'); toast(t('Organisation created.'), 'success');
} }
closeModal(); closeModal();
loadOrgs(host, $('orgSearch')?.value.trim() || ''); loadOrgs(host, $('orgSearch')?.value.trim() || '');
@@ -2218,53 +2372,53 @@
<div class="card notice-card notice-danger"> <div class="card notice-card notice-danger">
<i class="ph-fill ph-warning-octagon"></i> <i class="ph-fill ph-warning-octagon"></i>
<span><strong>${t('Removal is permanent.')}</strong> <span><strong>${t('Removal is permanent.')}</strong>
${lang === 'ar' ? 'يتم حذف حساب السائق وإضافة رقم الهاتف إلى القائمة السوداء حتى لا يتمكن من التسجيل مرة أخرى. لا يمكن التراجع عن هذا الإجراء — فقط رفع الحظر، الذي لا يُعيد الحساب المحذوف.' : 'The account row is deleted and the phone number is added to the blacklist so it cannot register again. There is no undo — only lifting the block, which does not restore the deleted account.'}</span> ${t('The account row is deleted and the phone number is added to the blacklist so it cannot register again. There is no undo — only lifting the block, which does not restore the deleted account.')}</span>
</div> </div>
<div class="card" id="blacklistCurrent"><div class="table-msg">${t('Loading blacklist…')}</div></div> <div class="card" id="blacklistCurrent"><div class="table-msg">${t('Loading blacklist…')}</div></div>
<div class="card"> <div class="card">
<div class="card-header"><h3 class="card-title">${lang === 'ar' ? 'حذف و加入 القائمة السوداء' : 'Remove & blacklist'}</h3></div> <div class="card-header"><h3 class="card-title">${t('Remove & blacklist')}</h3></div>
<div class="tariff-grid"> <div class="tariff-grid">
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Account type')}</span> <span class="tariff-label">${t('Account type')}</span>
<select class="select-input" id="rmType"> <select class="select-input" id="rmType">
<option value="passenger">${t('Passenger')}</option> <option value="passenger">${t('Passenger')}</option>
<option value="driver">${lang === 'ar' ? 'كابتن' : 'Captain'}</option> <option value="driver">${t('Captain')}</option>
</select> </select>
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Phone')} <em>${lang === 'ar' ? 'إلزامي' : 'required'}</em></span> <span class="tariff-label">${t('Phone')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="rmPhone" autocomplete="off"> <input type="text" class="form-input" id="rmPhone" autocomplete="off">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Account ID')} <em>${lang === 'ar' ? 'إلزامي للكباتن' : 'required for captains'}</em></span> <span class="tariff-label">${t('Account ID')} <em>${t('required for captains')}</em></span>
<input type="text" class="form-input" id="rmId" autocomplete="off"> <input type="text" class="form-input" id="rmId" autocomplete="off">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Reason')}</span> <span class="tariff-label">${t('Reason')}</span>
<input type="text" class="form-input" id="rmReason" placeholder="${lang === 'ar' ? 'يُسجَّل في سبب القائمة السوداء' : 'Recorded on the blacklist entry'}"> <input type="text" class="form-input" id="rmReason" placeholder="${t('Recorded on the blacklist entry')}">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Retype the phone to confirm')} <em>${lang === 'ar' ? 'فحص أمان' : 'safety check'}</em></span> <span class="tariff-label">${t('Retype the phone to confirm')} <em>${t('safety check')}</em></span>
<input type="text" class="form-input" id="rmConfirmPhone" autocomplete="off"> <input type="text" class="form-input" id="rmConfirmPhone" autocomplete="off">
</label> </label>
</div> </div>
<div class="api-base-row" style="margin-top:1rem;"> <div class="api-base-row" style="margin-top:1rem;">
<button class="btn btn-danger btn-sm" id="rmSubmit"><i class="ph ph-trash"></i> <span>${lang === 'ar' ? 'حذف و加入 القائمة السوداء' : 'Delete and blacklist'}</span></button> <button class="btn btn-danger btn-sm" id="rmSubmit"><i class="ph ph-trash"></i> <span>${t('Delete and blacklist')}</span></button>
<span class="stamp" id="rmStatus"></span> <span class="stamp" id="rmStatus"></span>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-header"><h3 class="card-title">${t('Lift a block')}</h3></div> <div class="card-header"><h3 class="card-title">${t('Lift a block')}</h3></div>
<p class="card-note">${lang === 'ar' ? 'يزيل رقم الهاتف من القائمة السوداء حتى يتمكن من التسجيل مرة أخرى. لا يُعيد الحساب المحذوف.' : 'Removes the phone number from the blacklist so it can register again. It does not restore a deleted account.'}</p> <p class="card-note">${t('Removes the phone number from the blacklist so it can register again. It does not restore a deleted account.')}</p>
<div class="tariff-grid"> <div class="tariff-grid">
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Account type')}</span> <span class="tariff-label">${t('Account type')}</span>
<select class="select-input" id="ubType"> <select class="select-input" id="ubType">
<option value="passenger">${t('Passenger')}</option> <option value="passenger">${t('Passenger')}</option>
<option value="driver">${lang === 'ar' ? 'كابتن' : 'Captain'}</option> <option value="driver">${t('Captain')}</option>
</select> </select>
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
@@ -2322,7 +2476,7 @@
if (!confirm( if (!confirm(
t('Permanently delete') + ` ${label} ${t('on')} ${phone} ` + t('and blacklist that number?') + '\n\n' + t('Permanently delete') + ` ${label} ${t('on')} ${phone} ` + t('and blacklist that number?') + '\n\n' +
`${reason ? t('Reason') + `: ${reason}\n\n` : ''}` + `${reason ? t('Reason') + `: ${reason}\n\n` : ''}` +
'The account is deleted from the database. This cannot be undone.' t('The account is deleted from the database. This cannot be undone.')
)) return; )) return;
busy($('rmSubmit'), true, t('Deleting…')); busy($('rmSubmit'), true, t('Deleting…'));
@@ -2382,8 +2536,8 @@
host.innerHTML = ` host.innerHTML = `
<div class="card notice-card notice-danger"> <div class="card notice-card notice-danger">
<i class="ph-fill ph-warning"></i> <i class="ph-fill ph-warning"></i>
<span><strong>${lang === 'ar' ? 'الإطلاق ينشئ كود خصم حقيقي ويخبر كل راكب في الدولة المحددة.' : 'Launching creates a real discount code and notifies every passenger in the selected country.'} <span><strong>${t('Launching creates a real discount code and notifies every passenger in the selected country.')}
${lang === 'ar' ? 'يبقى كود الخصم صالحاً لمدة سبعة أيام. عاين دائماً أولاً.' : 'The promo stays valid for seven days. Always preview first.'}</strong></span> ${t('The promo stays valid for seven days. Always preview first.')}</strong></span>
</div> </div>
<div class="card"> <div class="card">
@@ -2399,7 +2553,7 @@
</select> </select>
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${t('Region')} <em>${lang === 'ar' ? 'العاصمة افتراضياً' : 'defaults to the capital'}</em></span> <span class="tariff-label">${t('Region')} <em>${t('defaults to the capital')}</em></span>
<input type="text" class="form-input" id="cmpRegion" placeholder="Amman"> <input type="text" class="form-input" id="cmpRegion" placeholder="Amman">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
@@ -2450,7 +2604,7 @@
if (!confirm( if (!confirm(
t('Launch this campaign in') + ` ${params.country_code}?\n\n` + t('Launch this campaign in') + ` ${params.country_code}?\n\n` +
t('It creates a discount code valid for 7 days and pushes a notification to every passenger there.') + '\n\n' + t('It creates a discount code valid for 7 days and pushes a notification to every passenger there.') + '\n\n' +
'This cannot be undone.' t('This cannot be undone.')
)) return; )) return;
} }
@@ -2526,7 +2680,7 @@
host.innerHTML = ` host.innerHTML = `
<div class="card notice-card"> <div class="card notice-card">
<i class="ph-fill ph-info"></i> <i class="ph-fill ph-info"></i>
<span>${lang === 'ar' ? 'كل تطبيق يقارن إصداره بهذا الرقم عند التشغيل. رفعه يجبر كل مستخدم على التحديث قبل المتابعة.' : 'Each app compares its own build against this number on launch. Raising it can force every user of that app to update before they can continue.'}</span> <span>${t('Each app compares its own build against this number on launch. Raising it can force every user of that app to update before they can continue.')}</span>
</div> </div>
${packages.map((pkg, index) => ` ${packages.map((pkg, index) => `
<div class="card"> <div class="card">
@@ -2539,7 +2693,7 @@
</div> </div>
<div class="tariff-grid"> <div class="tariff-grid">
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'الإصدار الحالي' : 'Current version'}</span> <span class="tariff-label">${t('Current version')}</span>
<input type="text" class="form-input" data-pkg-version="${index}" value="${esc(pkg.version ?? '')}"> <input type="text" class="form-input" data-pkg-version="${index}" value="${esc(pkg.version ?? '')}">
</label> </label>
</div> </div>
@@ -2564,7 +2718,7 @@
} }
if (!confirm( if (!confirm(
t('Version set to') + ` ${pkg.name || `package #${pkg.id}`} \u2192 ${version} (${pkg.version ?? '—'})?\n\n` + t('Version set to') + ` ${pkg.name || `package #${pkg.id}`} \u2192 ${version} (${pkg.version ?? '—'})?\n\n` +
'Users on an older build may be prompted or forced to update.' t('Users on an older build may be prompted or forced to update.')
)) return; )) return;
try { try {
@@ -2607,7 +2761,7 @@
host.innerHTML = ` host.innerHTML = `
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h3 class="card-title">${t('Awaiting review')} <span class="card-sub">${lang === 'ar' ? `عرض ${drivers.length} من #${offset + 1}` : `showing ${drivers.length} from #${offset + 1}`}</span></h3> <h3 class="card-title">${t('Awaiting review')} <span class="card-sub">${t('showing')} ${drivers.length} ${t('from')} #${offset + 1}</span></h3>
<div style="display:flex; gap:0.5rem;"> <div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" id="docsPrev" ${offset === 0 ? 'disabled' : ''}><i class="ph ph-caret-left"></i></button> <button class="btn btn-secondary btn-sm" id="docsPrev" ${offset === 0 ? 'disabled' : ''}><i class="ph ph-caret-left"></i></button>
<button class="btn btn-secondary btn-sm" id="docsNext" ${drivers.length < DOCS_PAGE_SIZE ? 'disabled' : ''}><i class="ph ph-caret-right"></i></button> <button class="btn btn-secondary btn-sm" id="docsNext" ${drivers.length < DOCS_PAGE_SIZE ? 'disabled' : ''}><i class="ph ph-caret-right"></i></button>
@@ -2620,7 +2774,7 @@
${drivers.map((d) => ` ${drivers.map((d) => `
<tr> <tr>
<td><strong>#${esc(d.id)}</strong></td> <td><strong>#${esc(d.id)}</strong></td>
<td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || 'Unnamed')}</td> <td>${esc(`${d.first_name || ''} ${d.last_name || ''}`.trim() || t('Unnamed'))}</td>
<td>${esc(maskPhone(d.phone))}</td> <td>${esc(maskPhone(d.phone))}</td>
<td><button class="btn btn-secondary btn-sm" data-review="${esc(d.id)}"><i class="ph ph-files"></i> ${t('Review documents')}</button></td> <td><button class="btn btn-secondary btn-sm" data-review="${esc(d.id)}"><i class="ph ph-files"></i> ${t('Review documents')}</button></td>
</tr>`).join('')} </tr>`).join('')}
@@ -2675,7 +2829,7 @@
</div> </div>
<div class="sub-panel"> <div class="sub-panel">
<h4 class="sub-panel-title">${lang === 'ar' ? 'الوثائق' : 'Documents'} (${documents.length})</h4> <h4 class="sub-panel-title">${t('Documents')} (${documents.length})</h4>
${documents.length ? ` ${documents.length ? `
<div class="doc-grid"> <div class="doc-grid">
${documents.map((doc) => ` ${documents.map((doc) => `
@@ -2684,18 +2838,18 @@
? `<a href="${esc(doc.link)}" target="_blank" rel="noopener"> ? `<a href="${esc(doc.link)}" target="_blank" rel="noopener">
<img src="${esc(doc.link)}" alt="${esc(doc.doc_type || 'document')}" loading="lazy"> <img src="${esc(doc.link)}" alt="${esc(doc.doc_type || 'document')}" loading="lazy">
</a>` </a>`
: `<div class="doc-missing"><i class="ph ph-file-x"></i> ${lang === 'ar' ? 'لا ملف مرتبط' : 'no file linked'}</div>`} : `<div class="doc-missing"><i class="ph ph-file-x"></i> ${t('no file linked')}</div>`}
<figcaption> <figcaption>
<strong>${esc(humanize(doc.doc_type || 'document'))}</strong> <strong>${esc(humanize(doc.doc_type || 'document'))}</strong>
<span class="stamp">${esc(doc.image_name || '—')}</span> <span class="stamp">${esc(doc.image_name || '—')}</span>
</figcaption> </figcaption>
</figure>`).join('')} </figure>`).join('')}
</div>` </div>`
: `<div class="table-msg">${lang === 'ar' ? 'هذا الكابتن لم يُرسل أي وثائق — الاعتماد الآن يعني تفعيل حساب غير موثّق.' : 'This captain has uploaded no documents — approving now would activate an unverified account.'}</div>`} : `<div class="table-msg">${t('This captain has uploaded no documents — approving now would activate an unverified account.')}</div>`}
</div> </div>
<div class="sub-panel"> <div class="sub-panel">
<h4 class="sub-panel-title">${lang === 'ar' ? 'السجل' : 'Record'}</h4> <h4 class="sub-panel-title">${t('Record')}</h4>
<div class="mini-list"> <div class="mini-list">
${facts.map(([k, v]) => ` ${facts.map(([k, v]) => `
<div class="kv-row"> <div class="kv-row">
@@ -2736,18 +2890,18 @@
<div class="card"> <div class="card">
<div class="card-header"><h3 class="card-title">${t('Add a staff account')}</h3></div> <div class="card-header"><h3 class="card-title">${t('Add a staff account')}</h3></div>
<p class="card-note"> <p class="card-note">
${lang === 'ar' ? 'يُنشئ حساب دخول لأدوات سير للمشرفين. اختر كلمة المرور مع العضو الجديد، أو دعهم يغيّروها عند أول تسجيل دخول — هي مشفرة ولا يمكن قراءتها.' : 'Creates a login for the Siro admin tools. Choose the password with the new member present, or have them change it at first sign-in — it is stored hashed and cannot be read back.'} ${t('Creates a login for the Siro admin tools. Choose the password with the new member present, or have them change it at first sign-in — it is stored hashed and cannot be read back.')}
</p> </p>
<div class="tariff-grid"> <div class="tariff-grid">
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'الدور' : 'Role'}</span> <span class="tariff-label">${t('Role')}</span>
<select class="select-input" id="staffRole"> <select class="select-input" id="staffRole">
<option value="service">${lang === 'ar' ? 'خدمة العملاء' : 'Customer service'}</option> <option value="service">${t('Customer service')}</option>
<option value="admin">${lang === 'ar' ? 'مدير' : 'Administrator'}</option> <option value="admin">${t('Administrator')}</option>
</select> </select>
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'الاسم الكامل' : 'Full name'} <em>${lang === 'ar' ? 'إلزامي' : 'required'}</em></span> <span class="tariff-label">${t('Full name')} <em>${t('required')}</em></span>
<input type="text" class="form-input" id="staffName" autocomplete="off"> <input type="text" class="form-input" id="staffName" autocomplete="off">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
@@ -2759,7 +2913,7 @@
<input type="email" class="form-input" id="staffEmail" autocomplete="off"> <input type="email" class="form-input" id="staffEmail" autocomplete="off">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${lang === 'ar' ? 'كلمة المرور' : 'Password'} <em>${lang === 'ar' ? 'إلزامي' : 'required'}</em></span> <span class="tariff-label">${t('Password')} <em>${t('required')}</em></span>
<input type="password" class="form-input" id="staffPassword" autocomplete="new-password"> <input type="password" class="form-input" id="staffPassword" autocomplete="new-password">
</label> </label>
<label class="tariff-field"> <label class="tariff-field">
@@ -2796,7 +2950,7 @@
${rows.length ? ` ${rows.length ? `
<div class="table-responsive"> <div class="table-responsive">
<table class="data-table"> <table class="data-table">
<thead><tr><th>${t('ID')}</th><th>${t('Name')}</th><th>${t('Phone')}</th><th>${t('Type')}</th><th>${lang === 'ar' ? 'طُلب' : 'Requested'}</th><th></th></tr></thead> <thead><tr><th>${t('ID')}</th><th>${t('Name')}</th><th>${t('Phone')}</th><th>${t('Type')}</th><th>${t('Requested')}</th><th></th></tr></thead>
<tbody> <tbody>
${rows.map((r) => ` ${rows.map((r) => `
<tr> <tr>
@@ -2811,7 +2965,7 @@
</tr>`).join('')} </tr>`).join('')}
</tbody> </tbody>
</table> </table>
</div>` : (notes ? '' : '<div class="table-msg">' + (lang === 'ar' ? 'لا توجد حسابات بانتظار التفعيل.' : 'No accounts are waiting for activation.') + '</div>')}`; </div>` : (notes ? '' : '<div class="table-msg">' + t('No accounts are waiting for activation.') + '</div>')}`;
panel.querySelectorAll('[data-activate]').forEach((btn) => panel.querySelectorAll('[data-activate]').forEach((btn) =>
btn.addEventListener('click', () => activateStaff(btn.dataset.activate, btn.dataset.type, host))); btn.addEventListener('click', () => activateStaff(btn.dataset.activate, btn.dataset.type, host)));
@@ -2866,10 +3020,10 @@
return; return;
} }
const roleLabel = role === 'admin' ? 'ADMINISTRATOR' : 'customer service'; const roleLabel = role === 'admin' ? t('ADMINISTRATOR') : t('customer service');
if (!confirm( if (!confirm(
t('Create a account for') + ` ${roleLabel} \"${name}\"?\n\n` + t('Create a account for') + ` ${roleLabel} \"${name}\"?\n\n` +
`Phone: ${phone || '—'}\nEmail: ${email || '—'}\n\n` + `${t('Phone:')} ${phone || '—'}\n${t('Email:')} ${email || '—'}\n\n` +
(role === 'admin' (role === 'admin'
? t('Administrators can see and change platform data.') ? t('Administrators can see and change platform data.')
: t('Customer service staff can view operational data.')) : t('Customer service staff can view operational data.'))
@@ -2917,8 +3071,8 @@
<div class="card" data-route-card="${index}"> <div class="card" data-route-card="${index}">
<div class="card-header"> <div class="card-header">
<h3 class="card-title"> <h3 class="card-title">
${esc(route.name_ar || route.name_en || (lang === 'ar' ? 'مسار بدون اسم' : 'Unnamed route'))} ${esc(route.name_ar || route.name_en || t('Unnamed route'))}
<span class="card-sub">#${esc(route.id)} · ${esc(route.org_name || (lang === 'ar' ? 'مؤسسة غير معروفة' : 'unknown organisation'))}</span> <span class="card-sub">#${esc(route.id)} · ${esc(route.org_name || t('unknown organisation'))}</span>
</h3> </h3>
<div style="display:flex; gap:0.5rem;"> <div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" data-route-action="reject" data-route="${index}"><i class="ph ph-x"></i> ${t('Reject')}</button> <button class="btn btn-secondary btn-sm" data-route-action="reject" data-route="${index}"><i class="ph ph-x"></i> ${t('Reject')}</button>
@@ -2929,7 +3083,7 @@
<div class="kpi-tiles"> <div class="kpi-tiles">
<div class="kpi-tile"><div class="kpi-tile-value">${esc(route.direction || '—')}</div><div class="kpi-tile-label">${t('Direction')}</div></div> <div class="kpi-tile"><div class="kpi-tile-value">${esc(route.direction || '—')}</div><div class="kpi-tile-label">${t('Direction')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtNum(route.distance_km)} km</div><div class="kpi-tile-label">${t('Distance')}</div></div> <div class="kpi-tile"><div class="kpi-tile-value">${fmtNum(route.distance_km)} km</div><div class="kpi-tile-label">${t('Distance')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.duration_min)} ${lang === 'ar' ? 'دقيقة' : 'min'}</div><div class="kpi-tile-label">${t('Duration')}</div></div> <div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.duration_min)} ${t('min')}</div><div class="kpi-tile-label">${t('Duration')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.stops_count)}</div><div class="kpi-tile-label">${t('Stops')}</div></div> <div class="kpi-tile"><div class="kpi-tile-value">${fmtInt(route.stops_count)}</div><div class="kpi-tile-label">${t('Stops')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${esc(route.country || '—')}</div><div class="kpi-tile-label">${t('Country')}</div></div> <div class="kpi-tile"><div class="kpi-tile-value">${esc(route.country || '—')}</div><div class="kpi-tile-label">${t('Country')}</div></div>
<div class="kpi-tile"><div class="kpi-tile-value">${esc(fmtDate(route.created_at, true))}</div><div class="kpi-tile-label">${t('Submitted')}</div></div> <div class="kpi-tile"><div class="kpi-tile-value">${esc(fmtDate(route.created_at, true))}</div><div class="kpi-tile-label">${t('Submitted')}</div></div>
@@ -2941,7 +3095,7 @@
<ol class="stop-list"> <ol class="stop-list">
${route.stops.map((s) => ` ${route.stops.map((s) => `
<li> <li>
<span>${esc(s.name_ar || (lang === 'ar' ? 'محطة بدون اسم' : 'Unnamed stop'))}</span> <span>${esc(s.name_ar || t('Unnamed stop'))}</span>
${Number(s.is_major) ? `<span class="badge badge-primary">${t('major')}</span>` : ''} ${Number(s.is_major) ? `<span class="badge badge-primary">${t('major')}</span>` : ''}
<span class="stamp">${esc(shortCoord(`${s.latitude},${s.longitude}`))}</span> <span class="stamp">${esc(shortCoord(`${s.latitude},${s.longitude}`))}</span>
</li>`).join('')} </li>`).join('')}
@@ -2962,7 +3116,7 @@
if (!confirm( if (!confirm(
`${verb === 'approve' ? t('Approve') : t('Reject')} ` + t('route') + ` \"${route.name_ar || route.id}\" ` + `${verb === 'approve' ? t('Approve') : t('Reject')} ` + t('route') + ` \"${route.name_ar || route.id}\" ` +
`from ${route.org_name || 'this organisation'}?\n\n` + `${t('from')} ${route.org_name || t('from this organisation')}?\n\n` +
`${fmtInt(route.stops_count)} stops · ${fmtNum(route.distance_km)} km\n\n${consequence}` `${fmtInt(route.stops_count)} stops · ${fmtNum(route.distance_km)} km\n\n${consequence}`
)) return; )) return;
@@ -2970,7 +3124,7 @@
await api('/Admin/transit/route/approve.php', { await api('/Admin/transit/route/approve.php', {
params: { route_id: route.id, action }, params: { route_id: route.id, action },
}); });
toast(t('Route') + ` #${route.id} ${t(verb + 'ed')}.`, 'success'); toast(t('Route') + ` #${route.id} ${t(action === 'approve' ? 'approved' : 'rejected')}.`, 'success');
renderRouteApprovals(host); renderRouteApprovals(host);
} catch (err) { } catch (err) {
if (!handleApiError(err, 'route-decision')) toast(err.message, 'danger'); if (!handleApiError(err, 'route-decision')) toast(err.message, 'danger');
@@ -2984,8 +3138,8 @@
host.innerHTML = ` host.innerHTML = `
<div class="card notice-card notice-danger"> <div class="card notice-card notice-danger">
<i class="ph-fill ph-warning"></i> <i class="ph-fill ph-warning"></i>
<span><strong>${lang === 'ar' ? 'هذا يصل لكل الأجهزة دفعة واحدة ولا يمكن استرجاعه.' : 'This reaches every device at once and cannot be recalled.'} <span><strong>${t('This reaches every device at once and cannot be recalled.')}
${lang === 'ar' ? 'يُسجَّل الرسالة في سجل العمليات ضد حسابك.' : 'The message is recorded in the audit log against your account.'}</strong></span> ${t('The message is recorded in the audit log against your account.')}</strong></span>
</div> </div>
<div class="card"> <div class="card">
@@ -3000,12 +3154,12 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bcTitle">${t('Notification title')} <span class="stamp">${lang === 'ar' ? 'حد أقصى 120' : 'max 120'}</span></label> <label class="form-label" for="bcTitle">${t('Notification title')} <span class="stamp">${t('max 120')}</span></label>
<input type="text" class="form-input" id="bcTitle" maxlength="120" placeholder="${lang === 'ar' ? 'عنوان الإشعار' : 'Notification title'}" style="padding-left:1rem;"> <input type="text" class="form-input" id="bcTitle" maxlength="120" placeholder="${t('Notification title')}" style="padding-left:1rem;">
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="bcBody">${lang === 'ar' ? 'الرسالة' : 'Message'} <span class="stamp">${lang === 'ar' ? 'حد أقصى 1000' : 'max 1000'}</span></label> <label class="form-label" for="bcBody">${t('Message')} <span class="stamp">${t('max 1000')}</span></label>
<textarea class="form-input decrypt-area" id="bcBody" rows="4" maxlength="1000" placeholder="${t('Message text')}"></textarea> <textarea class="form-input decrypt-area" id="bcBody" rows="4" maxlength="1000" placeholder="${t('Message text')}"></textarea>
</div> </div>
@@ -3046,7 +3200,7 @@
const title = $('bcTitle').value.trim(); const title = $('bcTitle').value.trim();
const body = $('bcBody').value.trim(); const body = $('bcBody').value.trim();
const status = $('bcStatus'); const status = $('bcStatus');
const audienceLabel = audience === 'drivers' ? 'every captain' : 'every passenger'; const audienceLabel = audience === 'drivers' ? t('every captain') : t('every passenger');
if (!title || !body) { if (!title || !body) {
toast(t('Enter both a title and a message.'), 'warning'); toast(t('Enter both a title and a message.'), 'warning');
@@ -3056,7 +3210,7 @@
if (!confirm( if (!confirm(
t('Send this notification to') + ` ${audienceLabel} ` + t('on the platform?') + '\n\n' + t('Send this notification to') + ` ${audienceLabel} ` + t('on the platform?') + '\n\n' +
`${title}\n${body}\n\n` + `${title}\n${body}\n\n` +
'It is delivered immediately and cannot be recalled.' t('It is delivered immediately and cannot be recalled.')
)) return; )) return;
busy($('bcSend'), true, t('Sending…')); busy($('bcSend'), true, t('Sending…'));
@@ -3116,7 +3270,7 @@
} }
if (!tariffRows.length) { if (!tariffRows.length) {
host.innerHTML = `<div class="card"><div class="table-msg">${lang === 'ar' ? 'لا توجد صفوف تعرفة معدّة.' : 'No tariff rows configured.'}</div></div>`; host.innerHTML = `<div class="card"><div class="table-msg">${t('No tariff rows configured.')}</div></div>`;
return; return;
} }
@@ -3125,11 +3279,11 @@
${readOnly ? ` ${readOnly ? `
<div class="card notice-card"> <div class="card notice-card">
<i class="ph-fill ph-info"></i> <i class="ph-fill ph-info"></i>
<span>${lang === 'ar' ? 'أنت مسجّل كمدير، لذا تُعرض التعرفة للقراءة فقط. فقط المدير العام يمكنه تغيير الأسعار.' : 'You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.'}</span> <span>${t('You are signed in as an admin, so the tariff is shown read-only. Only a super admin can change prices.')}</span>
</div>` : ` </div>` : `
<div class="card notice-card notice-danger"> <div class="card notice-card notice-danger">
<i class="ph-fill ph-warning"></i> <i class="ph-fill ph-warning"></i>
<span><strong>${lang === 'ar' ? 'هذه القيم حيّة. الحفظ يغيّر ما يدفعه كل راكب من الرحلة التالية فصاعداً. يُسجَّل التغيير في سجل العمليات ضد حسابك.' : 'These values are live. Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account.'}</strong></span> <span><strong>${t('These values are live. Saving changes what every passenger is charged from the next ride onwards. Changes are recorded in the audit log against your account.')}</strong></span>
</div>`} </div>`}
${tariffRows.map((row, index) => tariffCard(row, index, readOnly)).join('')}`; ${tariffRows.map((row, index) => tariffCard(row, index, readOnly)).join('')}`;
@@ -3147,18 +3301,18 @@
<div class="card" data-tariff-card="${index}"> <div class="card" data-tariff-card="${index}">
<div class="card-header"> <div class="card-header">
<h3 class="card-title"> <h3 class="card-title">
${esc(row.country || 'Tariff')} <span class="card-sub">row #${esc(row.id)}</span> ${esc(row.country || t('Tariff'))} <span class="card-sub">${t('row #')}${esc(row.id)}</span>
</h3> </h3>
${readOnly ? '' : ` ${readOnly ? '' : `
<div style="display:flex; gap:0.5rem;"> <div style="display:flex; gap:0.5rem;">
<button class="btn btn-secondary btn-sm" data-tariff-reset="${index}"><i class="ph ph-arrow-counter-clockwise"></i> ${lang === 'ar' ? 'إعادة تعيين' : 'Reset'}</button> <button class="btn btn-secondary btn-sm" data-tariff-reset="${index}"><i class="ph ph-arrow-counter-clockwise"></i> ${t('Reset')}</button>
<button class="btn btn-primary btn-sm" data-tariff-save="${index}"><i class="ph ph-floppy-disk"></i> <span>${lang === 'ar' ? 'مراجعة وحفظ' : 'Review & save'}</span></button> <button class="btn btn-primary btn-sm" data-tariff-save="${index}"><i class="ph ph-floppy-disk"></i> <span>${t('Review & save')}</span></button>
</div>`} </div>`}
</div> </div>
<div class="tariff-grid"> <div class="tariff-grid">
${fields.map((f) => ` ${fields.map((f) => `
<label class="tariff-field"> <label class="tariff-field">
<span class="tariff-label">${esc(f.label)}${f.hint ? ` <em>${esc(f.hint)}</em>` : ''}</span> <span class="tariff-label">${t(esc(f.label))}${f.hint ? ` <em>${t(esc(f.hint))}</em>` : ''}</span>
<input class="form-input" type="${f.type === 'text' ? 'text' : 'number'}" step="any" <input class="form-input" type="${f.type === 'text' ? 'text' : 'number'}" step="any"
data-tariff-input="${index}" data-field="${esc(f.key)}" data-tariff-input="${index}" data-field="${esc(f.key)}"
value="${esc(row[f.key] ?? '')}" ${readOnly ? 'disabled' : ''}> value="${esc(row[f.key] ?? '')}" ${readOnly ? 'disabled' : ''}>
@@ -3197,9 +3351,9 @@
.join('\n'); .join('\n');
const confirmed = confirm( const confirmed = confirm(
`Apply these pricing changes to "${row.country || 'tariff'}" (row #${row.id})?\n\n` + t('Apply these pricing changes to') + ` "${row.country || t('Tariff')}" (${t('row #')}${row.id})?\n\n` +
`${summary}\n\n` + `${summary}\n\n` +
'This takes effect immediately for passengers.' t('This takes effect immediately for passengers.')
); );
if (!confirmed) return; if (!confirmed) return;
@@ -3315,7 +3469,7 @@
// Stack traces and URLs would otherwise stretch the row far past the // Stack traces and URLs would otherwise stretch the row far past the
// viewport and push every other column out of view. // viewport and push every other column out of view.
if (String(text).length > LONG_CELL) { if (String(text).length > LONG_CELL) {
return `<span class="cell-long" title="click to expand">${esc(String(text).slice(0, LONG_CELL))}…</span> return `<span class="cell-long" title="${t('click to expand')}">${esc(String(text).slice(0, LONG_CELL))}…</span>
<span class="cell-full" hidden>${esc(String(text))}</span>`; <span class="cell-full" hidden>${esc(String(text))}</span>`;
} }
return esc(text); return esc(text);
@@ -3372,12 +3526,12 @@
busy(el.runDiagnosticsBtn, true, t('Running…')); busy(el.runDiagnosticsBtn, true, t('Running…'));
const lines = [ const lines = [
`Siro Admin diagnostics — ${new Date().toISOString()}`, t('Siro Admin diagnostics —') + ` ${new Date().toISOString()}`,
`${t('Console build:')} ${BUILD}`, `${t('Console build:')} ${BUILD}`,
`Page origin : ${location.origin}`, `Page origin : ${location.origin}`,
`API base : ${API_BASE}`, `API base : ${API_BASE}`,
`Fingerprint : ${deviceFingerprint.slice(0, 20)}…`, `Fingerprint : ${deviceFingerprint.slice(0, 20)}…`,
`Token : ${session?.jwt ? 'present (role ' + session.role + ')' : 'MISSING — not signed in'}`, `Token : ${session?.jwt ? t('present (role ') + session.role + ')' : t('MISSING — not signed in')}`,
'─'.repeat(64), '─'.repeat(64),
]; ];
@@ -3476,14 +3630,14 @@
} }
function collapse(text) { function collapse(text) {
return String(text).replace(/\s+/g, ' ').trim() || '(empty response body)'; return String(text).replace(/\s+/g, ' ').trim() || t('(empty response body)');
} }
function setupDiagnostics() { function setupDiagnostics() {
if (!el.apiBaseSelect) return; if (!el.apiBaseSelect) return;
el.apiBaseSelect.innerHTML = API_CANDIDATES el.apiBaseSelect.innerHTML = API_CANDIDATES
.map((c) => `<option value="${esc(c.value)}">${esc(c.label)}</option>`) .map((c) => `<option value="${esc(c.value)}">${esc(c.label)}</option>`)
.join('') + '<option value="__custom__">Custom…</option>'; .join('') + `<option value="__custom__">${t('Custom…')}</option>`;
const known = API_CANDIDATES.some((c) => c.value === API_BASE); const known = API_CANDIDATES.some((c) => c.value === API_BASE);
el.apiBaseSelect.value = known ? API_BASE : '__custom__'; el.apiBaseSelect.value = known ? API_BASE : '__custom__';
@@ -3546,7 +3700,7 @@
]; ];
body.innerHTML = ` body.innerHTML = `
<div class="modal-head"> <div class="modal-head">
<h3>${lang === 'ar' ? 'رحلة' : 'Trip'} #${esc(r.id)}</h3> <h3>${t('Trip')} #${esc(r.id)}</h3>
<button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button> <button class="btn-icon" onclick="closeModal()"><i class="ph ph-x"></i></button>
</div> </div>
<div class="mini-list"> <div class="mini-list">