From bcedc4ea04c56029e14f98d9172a6842af2b8c11 Mon Sep 17 00:00:00 2001 From: "Muhammad A. Ghorbani" Date: Mon, 28 Sep 2026 18:24:07 +0330 Subject: [PATCH] feat: implement services feature, internationalization support, login page, and core UI components --- scripts/generate-locales.js | 856 ++++++++++++++++ src/components/layout/ServerStatusDialog.tsx | 52 +- src/components/shared/DeleteConfirmDialog.tsx | 26 +- src/features/auth/LoginPage.tsx | 26 +- src/features/dashboard/DashboardHome.tsx | 15 +- .../global-search/GlobalSearchDialog.tsx | 20 +- src/features/services/ServicesPage.tsx | 40 +- .../components/CategoriesServiceView.tsx | 13 +- .../services/components/CitiesServiceView.tsx | 237 ++--- .../components/CreateLocationDialog.tsx | 468 +++++---- .../services/components/DuasServiceView.tsx | 932 +++++++++++++++--- .../components/LocationDetailSheet.tsx | 144 ++- .../components/ZayerGuideServiceView.tsx | 407 ++++---- .../services/services/trip-etiquette-api.ts | 126 ++- .../services/services/zayer-guide-api.ts | 426 +------- src/features/services/types/trip-etiquette.ts | 34 + src/features/services/types/zayer-guide.ts | 33 +- .../tours/components/TourStatusBadge.tsx | 44 +- src/i18n/index.ts | 12 +- src/locales/ar.json | 219 +++- src/locales/en.json | 143 ++- src/locales/fa.json | 201 +++- 22 files changed, 3146 insertions(+), 1328 deletions(-) create mode 100644 scripts/generate-locales.js diff --git a/scripts/generate-locales.js b/scripts/generate-locales.js new file mode 100644 index 0000000..c27e4be --- /dev/null +++ b/scripts/generate-locales.js @@ -0,0 +1,856 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const LOCALES_DIR = path.resolve(__dirname, '../src/locales') + +export const enLocale = { + navigation: { + brand: 'Aqila Admin Panel', + brandShort: 'Aqila', + dashboard: 'Dashboard', + dashboardTitle: 'Admin Dashboard', + tours: 'Tours', + allTours: 'All Tours', + ongoingTours: 'Ongoing Tours', + tourOrders: 'Orders & Bookings', + services: 'Services Center', + simcard: 'SIM Card', + zayerGuide: 'Zayer Guide', + exchange: 'Online Exchange', + duas: 'Duas & Ziyarat', + duasList: 'Duas & Ziyarat', + categories: 'Categories', + cities: 'Holy Cities', + store: 'Massoumah Store', + storeProducts: 'Products', + storeCategories: 'Categories', + costEstimate: 'Cost Estimate', + support: 'Support Center', + supporters: 'Support Staff', + faq: 'FAQ', + faqCategories: 'Subject Categories', + faqQuestions: 'Questions & Answers', + consultant: 'Support', + billing: 'Billing & Finance', + settings: 'Settings', + logout: 'Logout', + soon: 'Soon', + adminRole: 'System Administrator', + }, + topbar: { + serverOnline: 'Server is Online', + serverOffline: 'Server Disconnected', + theme: 'Theme', + language: 'Language', + selectLanguage: 'Select System Language', + search: 'Search...', + }, + auth: { + loginTitle: 'Login to Admin Dashboard', + loginDescription: 'Enter your credentials to access the management portal', + demoBoxTitle: 'Demo Ready Account:', + autoFill: 'Auto Fill', + emailLabel: 'Account Email', + emailPlaceholder: 'you@example.com', + passwordLabel: 'Password', + passwordPlaceholder: '••••••••', + showPassword: 'Show password', + hidePassword: 'Hide password', + submitLogin: 'Login to Account', + loggingIn: 'Logging in...', + loginFailed: 'Login failed. Please try again.', + footerNote: 'Supports custom templates and direct API connection', + }, + dashboard: { + welcome: 'Welcome, {name}', + readyBadge: 'Ready for Development', + welcomeSubtitle: 'Aqila admin panel is ready for your new widgets, tables, and modules.', + cleanBoardTitle: 'Clean Dashboard Ready for Development', + cleanBoardDescription: + 'All mock data and dummy UIs have been cleaned up. You can build your new modules using UI components and connect directly to Aqila database and backend.', + }, + serverStatus: { + title: 'System & Server Status', + description: 'Server connection summary, API configuration, and account details', + backendStatus: 'Django Server Status (Backend)', + connected: 'Online & Connected', + checking: 'Checking...', + disconnected: 'Disconnected', + recheck: 'Re-check', + rechecking: 'Testing...', + recheckFull: 'Re-check Server', + apiConfigTitle: 'API & Server Configuration', + targetServer: 'Target Server', + apiPrefix: 'API Prefix', + authHeader: 'Auth Header', + currentUserTitle: 'Current User Account', + roleSuperuser: 'Super Administrator (Superuser)', + roleStaff: 'System Administrator (Staff)', + roleUser: 'User', + fullName: 'Full Name:', + email: 'Email:', + userId: 'User ID:', + connectionStable: 'Connection to server is stable', + connectionTesting: 'Testing connection...', + connectionUnreachable: 'Server is unreachable', + }, + globalSearch: { + placeholder: 'Search pages, actions, or commands... (Ctrl+K)', + empty: 'No results found.', + systemSections: 'System Sections', + toggleTheme: 'Toggle Theme', + toggleThemeDesc: 'Switch theme between dark and light mode', + logoutAccount: 'Logout from account', + }, + common: { + save: 'Save Changes', + cancel: 'Cancel', + confirm: 'Confirm', + delete: 'Delete', + edit: 'Edit', + close: 'Close', + back: 'Back', + refresh: 'Refresh', + loading: 'Loading...', + status: 'Status', + actions: 'Actions', + all: 'All', + active: 'Active', + inactive: 'Inactive', + noData: 'No records found.', + pageOf: 'Page {current} of {total}', + success: 'Operation completed successfully.', + error: 'Server connection error.', + retry: 'Retry', + previous: 'Previous', + next: 'Next', + showing: 'Showing', + to: 'to', + of: 'of', + testSyncNotice: 'This is a test message for language synchronization verification.', + deleteConfirmTitle: 'Are you sure you want to delete this item?', + deleteConfirmDesc: 'This action cannot be undone and the record will be permanently deleted.', + yesDelete: 'Yes, Delete', + deleting: 'Deleting...', + filter: 'Filter', + search: 'Search...', + export: 'Export', + import: 'Import', + create: 'Create', + add: 'Add', + view: 'View', + details: 'Details', + operations: 'Operations', + enabled: 'Enabled', + disabled: 'Disabled', + unknown: 'Unknown', + days: '{count} Days', + dayOne: '1 Day', + }, + tours: { + title: 'Tour Management', + subtitle: 'Live monitoring of sales, travel itineraries, passengers, pricing, and multi-language translations', + totalRegistered: '{count} Tours Registered', + ongoingTitle: 'Ongoing Tours', + ongoingSubtitle: 'Live tracking of ongoing trips, daily itinerary schedules, current stage position, and passengers', + totalOngoing: '{count} Active Ongoing Tours', + ordersTitle: 'Tour Orders & Bookings', + ordersSubtitle: 'Order tracking, payment monitoring, document validation, and traveler deposit receipts', + totalOrders: 'Total Registered Orders', + approvedBookings: 'Approved Bookings', + pendingReviewOrders: 'Pending Receipt Review', + searchOrdersPlaceholder: 'Search orders (Passenger name, phone, order ID)...', + filterSaleStatus: 'Sale Status', + filterTripStatus: 'Trip Status', + filterOrderStatus: 'Order Status', + createTour: 'Create New Tour', + tourDetails: 'Tour Details', + manageTranslations: 'Manage Translations (5 Languages)', + tabOverview: 'Specifications', + tabItinerary: 'Itinerary', + tabPassengers: 'Passengers', + tabFeatures: 'Amenities', + saleAvailable: 'Available', + saleSoldOut: 'Sold Out', + saleNoShow: 'Hidden', + saleUnknown: 'Unknown', + tripPending: 'Upcoming', + tripTraveling: 'Ongoing', + tripFinished: 'Completed', + orderApproved: 'Approved', + orderAwaitingPayment: 'Awaiting Payment', + orderPending: 'Pending Receipt Review', + orderRejected: 'Rejected', + orderExpired: 'Expired', + countryIraq: 'Iraq', + countryIran: 'Iran', + countrySaudi: 'Saudi Arabia', + countrySyria: 'Syria', + countryOman: 'Oman', + }, + services: { + title: 'Services Center', + subtitle: 'Management of pilgrimage services, SIM cards, Zayer guide, exchange, store, and religious rituals', + allServices: 'All Services', + searchPlaceholder: 'Search services...', + simcardTitle: 'SIM Card', + simcardDesc: 'Internet packages, international SIM cards, and traveler orders (Zain, Asiacell, Korek, STC)', + zayerGuideTitle: 'Zayer Guide', + zayerGuideDesc: 'Holy shrines, historical sites, hospitals, mawakib, and city locations', + exchangeTitle: 'Online Exchange', + exchangeDesc: 'Exchange rates, currency requests, cash reserves, and traveler transfers (USD, IQD, IRR)', + duasTitle: 'Duas & Ziyarat', + duasDesc: 'Mafatih al-Jinan bank, audios, translations, and authentic ziyarats', + categoriesTitle: 'Categories', + categoriesDesc: 'Management of religious, shrine, store, and app content categories', + citiesTitle: 'Holy Cities', + citiesDesc: 'Pilgrimage cities (Najaf, Karbala, Kadhimiya, Samarra, Mashhad, Qom, Mecca, Medina)', + storeTitle: 'Massoumah Store', + storeDesc: 'Massoumah online store products, souvenirs, inventory, and order fulfillment', + costEstimateTitle: 'Cost Estimate', + costEstimateDesc: 'Estimation of total pilgrimage trip costs according to season, services, and hotel class', + supportTitle: 'Support', + supportDesc: 'Direct ticketing system, user requests, and operator responses', + faqTitle: 'FAQ', + faqDesc: 'Frequently asked questions bank, travel guides, and religious rules', + }, + billing: { + title: 'Invoices & Billing Management', + description: 'Review bank receipts, approve or reject payments, and track invoices across Aqila services', + totalInvoices: 'Total Invoices', + pendingReview: 'Pending Receipt Review', + awaitingPayment: 'Awaiting Customer Payment', + approvedPayments: 'Approved Payments', + rejectedOrExpired: 'Rejected / Expired', + searchPlaceholder: 'Search invoices (Customer name, email, card number, invoice ID)...', + statusFilter: 'Invoice Status', + serviceFilter: 'Service Type', + clearFilters: 'Clear Filters', + invoiceList: 'Invoice List', + customer: 'Customer', + amount: 'Amount', + service: 'Service', + date: 'Issue Date', + receipt: 'Payment Receipt', + hasReceipt: 'View Receipt', + noReceipt: 'None', + approve: 'Approve Payment', + reject: 'Reject Invoice', + rejectTitle: 'Reject Invoice', + rejectReason: 'Rejection Reason / Bank Receipt:', + rejectReasonPlaceholder: 'e.g., Unreadable receipt, amount mismatch, invalid transaction reference...', + rejectNotice: 'This explanation will be displayed to the traveler in their invoice details.', + submitReject: 'Submit & Reject Invoice', + submitting: 'Submitting...', + statusPending: 'Pending Receipt Review', + statusAwaiting: 'Awaiting Payment', + statusApproved: 'Payment Approved', + statusRejected: 'Rejected', + statusExpired: 'Expired', + invoice: 'Invoice', + invoices: 'Invoices', + id: 'ID', + viewDetails: 'View Details', + viewReceipt: 'View Receipt', + noReceiptUploaded: 'No receipt uploaded', + serviceTour: 'Pilgrimage Tours', + serviceSimcard: 'SIM Card', + serviceShop: 'Massoumah Store', + serviceExchange: 'Exchange (Tasreef)', + serviceCustomTrip: 'Custom Trip', + allStatuses: 'All Statuses', + allServices: 'All Services', + noFactorsFound: 'No invoices found.', + noFactorsFilter: 'No records match current filters. You can clear filters.', + noFactorsEmpty: 'No invoices have been issued in the system yet.', + confirmClearReceipt: 'Are you sure you want to remove the uploaded receipt image?', + receiptClearedSuccess: 'Payment receipt cleared successfully.', + tabAll: 'All Transactions', + tabTour: 'Tour Orders & Bookings', + tabSimcard: 'SIM Cards', + tabExchange: 'Currency & Exchange', + tabShop: 'Souvenir Store', + tabCustomTrip: 'Custom Trips', + coreTourBadge: 'Primary Core', + liveStatus: 'Live Status', + quickFilter: 'Status Filter', + }, + languages: { + fa: 'فارسی', + ar: 'العربية', + en: 'English', + }, +} + +export const faLocale = { + navigation: { + brand: 'پنل مدیریت عقیله', + brandShort: 'عقیله', + dashboard: 'داشبورد', + dashboardTitle: 'داشبورد مدیریت', + tours: 'تورها', + allTours: 'فهرست همه تورها', + ongoingTours: 'تورهای در حال برگزاری', + tourOrders: 'سفارشات و رزروها', + services: 'مرکز خدمات (همه)', + simcard: 'سیم‌کارت (Simcard)', + zayerGuide: 'راهنمای زائر (Zayer Guide)', + exchange: 'صرافی آنلاین (Exchange)', + duas: 'ادعیه و زیارات (Duas)', + duasList: 'ادعیه و زیارات', + categories: 'دسته‌بندی‌ها', + cities: 'شهرهای زیارتی', + store: 'فروشگاه معصومه (Store)', + storeProducts: 'محصولات', + storeCategories: 'دسته‌بندی‌ها', + costEstimate: 'تخمین هزینه (Cost Estimate)', + support: 'پشتیبانی (Support)', + supporters: 'پشتیبان‌ها (Supporters)', + faq: 'سوالات متداول (FAQ)', + faqCategories: 'دسته‌بندی‌های موضوعی', + faqQuestions: 'پرسش و پاسخ‌ها', + consultant: 'پشتیبانی (Support)', + billing: 'صورت‌حساب‌ها و مالی', + settings: 'تنظیمات', + logout: 'خروج از سیستم', + soon: 'به‌زودی', + adminRole: 'مدیر کل سیستم', + }, + topbar: { + serverOnline: 'سرور متصل است', + serverOffline: 'قطع ارتباط با سرور', + theme: 'تغییر تم', + language: 'تغییر زبان', + selectLanguage: 'انتخاب زبان سامانه', + search: 'جستجو...', + }, + auth: { + loginTitle: 'ورود به پنل مدیریت و داشبورد', + loginDescription: 'برای دسترسی به پرتال مدیریت، اطلاعات ورود خود را وارد نمایید', + demoBoxTitle: 'حساب آزمایشی آماده:', + autoFill: 'پر کردن خودکار', + emailLabel: 'ایمیل حساب کاربری', + emailPlaceholder: 'you@example.com', + passwordLabel: 'رمز عبور', + passwordPlaceholder: '••••••••', + showPassword: 'نمایش رمز', + hidePassword: 'مخفی‌کردن رمز', + submitLogin: 'ورود به حساب کاربری', + loggingIn: 'در حال ورود...', + loginFailed: 'ورود ناموفق بود. دوباره تلاش کنید.', + footerNote: 'پشتیبانی از قالب‌های سفارشی و اتصال مستقیم به API', + }, + dashboard: { + welcome: 'خوش آمدید، {name}', + readyBadge: 'آماده توسعه', + welcomeSubtitle: 'پنل مدیریت عقیله آماده اضافه کردن ویجت‌ها، جداول و ماژول‌های جدید شماست.', + cleanBoardTitle: 'داشبورد تمیز و آماده توسعه', + cleanBoardDescription: + 'تمام اطلاعات و یوآی‌های ساختگی حذف شدند. می‌توانید ماژول‌های جدید خود را با کامپوننت‌های آماده UI پیاده‌سازی و مستقیماً به دیتابیس و بک‌اند عقیله متصل نمایید.', + }, + serverStatus: { + title: 'وضعیت سیستم و سرور', + description: 'خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب', + backendStatus: 'وضعیت سرور جنگو (Backend)', + connected: 'آنلاین و متصل', + checking: 'در حال بررسی...', + disconnected: 'قطع ارتباط', + recheck: 'بررسی مجدد', + rechecking: 'در حال تست...', + recheckFull: 'بررسی مجدد سرور', + apiConfigTitle: 'پیکربندی API و سرور', + targetServer: 'سرور هدف', + apiPrefix: 'پیشوند API', + authHeader: 'هدر احراز هویت', + currentUserTitle: 'اطلاعات حساب کاربری جاری', + roleSuperuser: 'مدیر ارشد (Superuser)', + roleStaff: 'مدیر سیستم (Staff)', + roleUser: 'کاربر', + fullName: 'نام و نام خانوادگی:', + email: 'ایمیل:', + userId: 'شناسه کاربری (ID):', + connectionStable: 'ارتباط با سرور پایدار است', + connectionTesting: 'در حال آزمایش اتصال...', + connectionUnreachable: 'سرور در دسترس نیست', + }, + globalSearch: { + placeholder: 'جستجوی صفحات، عملیات‌ها یا فرمان‌ها... (Ctrl+K)', + empty: 'هیچ نتیجه‌ای یافت نشد.', + systemSections: 'بخش‌های سیستم', + toggleTheme: 'تغییر پوسته (Theme)', + toggleThemeDesc: 'تغییر تم به حالت تیره یا روشن', + logoutAccount: 'خروج از حساب کاربری', + }, + common: { + save: 'ذخیره تغییرات', + cancel: 'انصراف', + confirm: 'تایید', + delete: 'حذف', + edit: 'ویرایش', + close: 'بستن', + back: 'بازگشت', + refresh: 'به‌روزرسانی', + loading: 'در حال بارگذاری...', + status: 'وضعیت', + actions: 'عملیات', + all: 'همه', + active: 'فعال', + inactive: 'غیرفعال', + noData: 'موردی برای نمایش یافت نشد.', + pageOf: 'صفحه {current} از {total}', + success: 'عملیات با موفقیت انجام شد.', + error: 'خطا در برقراری ارتباط با سرور.', + retry: 'تلاش مجدد', + previous: 'قبلی', + next: 'بعدی', + showing: 'نمایش', + to: 'تا', + of: 'از', + testSyncNotice: 'این یک پیام آزمایشی برای تست هماهنگی زبان‌هاست', + deleteConfirmTitle: 'آیا از حذف این مورد اطمینان دارید؟', + deleteConfirmDesc: 'این عملیات غیرقابل بازگشت است و رکورد مورد نظر به طور کامل حذف خواهد شد.', + yesDelete: 'بله، حذف شود', + deleting: 'در حال حذف...', + filter: 'فیلتر', + search: 'جستجو...', + export: 'خروجی اکسل', + import: 'ورود اطلاعات', + create: 'ایجاد جدید', + add: 'افزودن', + view: 'مشاهده', + details: 'جزئیات', + operations: 'عملیات', + enabled: 'فعال', + disabled: 'غیرفعال', + unknown: 'نامشخص', + days: '{count} روز', + dayOne: '۱ روز', + }, + tours: { + title: 'مدیریت تورها', + subtitle: 'پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران، اطلاعات قیمتی و ترجمه‌های چندزبانه', + totalRegistered: '{count} تور ثبت‌شده', + ongoingTitle: 'تورهای در حال برگزاری', + ongoingSubtitle: 'پایش زنده وضعیت سفرهای جاری، زمان‌بندی روزانه (Itinerary)، موقعیت مرحله فعلی و وضعیت مسافران', + totalOngoing: '{count} تور فعال و در جریان', + ordersTitle: 'سفارشات و رزروهای تور', + ordersSubtitle: 'پیگیری سفارشات، نظارت بر پرداخت‌ها و اعتبارسنجی مدارک و فیش‌های واریزی مسافران', + totalOrders: 'کل سفارشات ثبت‌شده', + approvedBookings: 'رزروهای تأییدشده', + pendingReviewOrders: 'در انتظار بررسی فیش', + searchOrdersPlaceholder: 'جستجوی سفارشات (نام مسافر، شماره تماس، کد سفارش)...', + filterSaleStatus: 'وضعیت فروش', + filterTripStatus: 'وضعیت سفر', + filterOrderStatus: 'وضعیت سفارش', + createTour: 'ایجاد تور جدید', + tourDetails: 'جزئیات تور', + manageTranslations: 'مدیریت ترجمه‌ها (۵ زبان)', + tabOverview: 'مشخصات', + tabItinerary: 'برنامه سفر', + tabPassengers: 'مسافران', + tabFeatures: 'امکانات', + saleAvailable: 'در دسترس', + saleSoldOut: 'تکمیل ظرفیت', + saleNoShow: 'عدم نمایش', + saleUnknown: 'نامشخص', + tripPending: 'در انتظار اجرا', + tripTraveling: 'در حال برگزاری', + tripFinished: 'پایان‌یافته', + orderApproved: 'تایید شده', + orderAwaitingPayment: 'در انتظار پرداخت', + orderPending: 'در انتظار بررسی فیش', + orderRejected: 'رد شده', + orderExpired: 'منقضی شده', + countryIraq: 'عراق', + countryIran: 'ایران', + countrySaudi: 'عربستان', + countrySyria: 'سوریه', + countryOman: 'عمان', + }, + services: { + title: 'مرکز خدمات', + subtitle: 'مدیریت جامع خدمات زیارتی، سیم‌کارت، راهنمای زائر، صرافی، فروشگاه و مناسک', + allServices: 'همه خدمات', + searchPlaceholder: 'جستجوی خدمات...', + simcardTitle: 'سیم‌کارت', + simcardDesc: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)', + zayerGuideTitle: 'راهنمای زائر', + zayerGuideDesc: 'بانک جامع اماکن متبرکه، راهنمای اماکن تاریخی، بیمارستان‌ها، مواکب و موقعیت مکانی شهرها', + exchangeTitle: 'صرافی آنلاین', + exchangeDesc: 'نرخ لحظه‌ای ارزها، درخواست‌های ارزی، مدیریت موجودی نقدی و حواله‌های زائران (دلار، دینار، تومان)', + duasTitle: 'ادعیه و زیارات', + duasDesc: 'بانک مفاتیح‌الجنان، ادعیه صوتی، ترجمه و شرح متون ادعیه و زیارات معتبر', + categoriesTitle: 'دسته‌بندی‌ها', + categoriesDesc: 'مدیریت دسته‌بندی‌های موضوعی خدمات، اماکن، فروشگاه و محتواهای اپلیکیشن', + citiesTitle: 'شهرهای زیارتی', + citiesDesc: 'شهرهای زیارتی (نجف، کربلا، کاظمین، سامرا، مشهد، قم، مکه، مدینه)', + storeTitle: 'فروشگاه معصومه', + storeDesc: 'محصولات فروشگاه آنلاین معصومه، سوغات زیارتی، مدیریت انبار و سفارشات خرید', + costEstimateTitle: 'تخمین هزینه', + costEstimateDesc: 'محاسبه آنلاین هزینه‌های سفر انفرادی و خانوادگی به تفکیک فصل، خدمات و سطح هتل', + supportTitle: 'پشتیبانی', + supportDesc: 'سیستم تیکتینگ مستقیم، پشتیبانی از زائران و مدیریت پاسخگویی اپراتورها', + faqTitle: 'سوالات متداول', + faqDesc: 'بانک پرسش و پاسخ‌های پرتکرار زائرین، راهنمای سفر و احکام شرعی', + }, + billing: { + title: 'مدیریت صورت‌حساب‌ها و فاکتورها', + description: 'بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله', + totalInvoices: 'کل صورت‌حساب‌ها', + pendingReview: 'در انتظار بررسی فیش', + awaitingPayment: 'در انتظار پرداخت مشتری', + approvedPayments: 'پرداخت‌های تایید شده', + rejectedOrExpired: 'رد شده / منقضی', + searchPlaceholder: 'جستجو در صورت‌حساب‌ها (نام مشتری، ایمیل، شماره کارت، شماره فاکتور)...', + statusFilter: 'وضعیت صورت‌حساب', + serviceFilter: 'نوع سرویس', + clearFilters: 'پاک کردن فیلترها', + invoiceList: 'فهرست صورت‌حساب‌ها', + customer: 'مشتری', + amount: 'مبلغ', + service: 'سرویس', + date: 'تاریخ صدور', + receipt: 'فیش پرداخت', + hasReceipt: 'مشاهده فیش', + noReceipt: 'ندارد', + approve: 'تایید پرداخت', + reject: 'رد فاکتور', + rejectTitle: 'رد صورت‌حساب', + rejectReason: 'علت رد فاکتور / فیش بانکی:', + rejectReasonPlaceholder: 'مثال: تصویر فیش ناخواناست، مبلغ فیش با فاکتور همخوانی ندارد، کد رهگیری نامعتبر است...', + rejectNotice: 'این توضیح به مسافر در بخش وضعیت فاکتور نمایش داده خواهد شد.', + submitReject: 'ثبت و رد فاکتور', + submitting: 'در حال ثبت...', + statusPending: 'در انتظار بررسی فیش', + statusAwaiting: 'در انتظار پرداخت', + statusApproved: 'پرداخت تایید شده', + statusRejected: 'رد شده', + statusExpired: 'منقضی شده', + invoice: 'صورت‌حساب', + invoices: 'صورت‌حساب‌ها', + id: 'شناسه', + viewDetails: 'مشاهده جزئیات', + viewReceipt: 'مشاهده فیش', + noReceiptUploaded: 'فیشی بارگذاری نشده است', + serviceTour: 'تور زیارتی', + serviceSimcard: 'سیم‌کارت', + serviceShop: 'فروشگاه معصومه', + serviceExchange: 'صرافی (تصریف)', + serviceCustomTrip: 'سفر اختصاصی', + allStatuses: 'همه وضعیت‌ها', + allServices: 'همه سرویس‌ها', + noFactorsFound: 'هیچ صورت‌حسابی یافت نشد.', + noFactorsFilter: 'رکوردی با فیلترهای فعلی همخوانی ندارد. می‌توانید فیلترها را پاک کنید.', + noFactorsEmpty: 'تاکنون هیچ صورت‌حسابی در سیستم صادر نشده است.', + confirmClearReceipt: 'آیا از حذف تصویر فیش بارگذاری شده اطمینان دارید؟', + receiptClearedSuccess: 'تصویر فیش پرداخت با موفقیت حذف شد.', + tabAll: 'کل تراکنش‌ها', + tabTour: 'سفارشات و رزرو تور', + tabSimcard: 'سیم‌کارت', + tabExchange: 'ارز و صرافی', + tabShop: 'فروشگاه سوغات', + tabCustomTrip: 'سفرهای اختصاصی', + coreTourBadge: 'هسته اصلی', + liveStatus: 'وضعیت زنده', + quickFilter: 'فیلتر وضعیت', + }, + languages: { + fa: 'فارسی', + ar: 'العربية', + en: 'English', + }, +} + +export const arLocale = { + navigation: { + brand: 'لوحة تحكم عقيلة', + brandShort: 'عقيلة', + dashboard: 'الرئيسية', + dashboardTitle: 'لوحة التحكم', + tours: 'الرحلات', + allTours: 'جميع الرحلات', + ongoingTours: 'الرحلات الجارية', + tourOrders: 'الطلبات والحجوزات', + services: 'مركز الخدمات (الكل)', + simcard: 'شريحة الاتصال (Simcard)', + zayerGuide: 'دليل الزائر (Zayer Guide)', + exchange: 'الصرافة الإلكترونية (Exchange)', + duas: 'الأدعية والزيارات (Duas)', + duasList: 'الأدعية والزيارات', + categories: 'التصنيفات', + cities: 'المدن المقدسة', + store: 'متجر معصومة (Store)', + storeProducts: 'المنتجات', + storeCategories: 'التصنيفات', + costEstimate: 'تقدير التكلفة (Cost Estimate)', + support: 'الدعم الفني (Support)', + supporters: 'فريق الدعم (Supporters)', + faq: 'الأسئلة الشائعة (FAQ)', + faqCategories: 'التصنيفات الموضوعية', + faqQuestions: 'الأسئلة والأجوبة', + consultant: 'الدعم الفني (Support)', + billing: 'الفواتير والمالية', + settings: 'الإعدادات', + logout: 'تسجيل الخروج', + soon: 'قريباً', + adminRole: 'مدير النظام العام', + }, + topbar: { + serverOnline: 'الخادم متصل', + serverOffline: 'انقطع الاتصال بالخادم', + theme: 'المظهر', + language: 'اللغة', + selectLanguage: 'اختر لغة النظام', + search: 'بحث...', + }, + auth: { + loginTitle: 'تسجيل الدخول إلى لوحة الإدارة', + loginDescription: 'أدخل بيانات الاعتماد الخاصة بك للوصول إلى بوابة الإدارة', + demoBoxTitle: 'حساب تجريبي جاهز:', + autoFill: 'تعبئة تلقائية', + emailLabel: 'البريد الإلكتروني للحساب', + emailPlaceholder: 'you@example.com', + passwordLabel: 'كلمة المرور', + passwordPlaceholder: '••••••••', + showPassword: 'عرض كلمة المرور', + hidePassword: 'إخفاء كلمة المرور', + submitLogin: 'تسجيل الدخول إلى الحساب', + loggingIn: 'جاري تسجيل الدخول...', + loginFailed: 'فشل تسجيل الدخول. يرجى المحاولة مرة أخرى.', + footerNote: 'يدعم القوالب المخصصة والاتصال المباشر بواجهة برمجة التطبيقات (API)', + }, + dashboard: { + welcome: 'مرحباً، {name}', + readyBadge: 'جاهز للتطوير', + welcomeSubtitle: 'لوحة تحكم عقيلة جاهزة لإضافة أدواتك وجداولك ووحداتك الجديدة.', + cleanBoardTitle: 'لوحة تحكم نظيفة وجاهزة للتطوير', + cleanBoardDescription: + 'تم مسح جميع البيانات والواجهات الوهمية. يمكنك بناء وحداتك الجديدة باستخدام مكونات واجهة المستخدم الجاهزة والاتصال مباشرة بقاعدة البيانات وخادم عقيلة.', + }, + serverStatus: { + title: 'حالة النظام والخادم', + description: 'ملخص اتصال الخادم، وتكوين واجهة برمجة التطبيقات وبيانات الحساب', + backendStatus: 'حالة خادم جانغو (Backend)', + connected: 'متصل ونشط', + checking: 'جاري الفحص...', + disconnected: 'انقطع الاتصال', + recheck: 'إعادة الفحص', + rechecking: 'جاري الاختبار...', + recheckFull: 'إعادة فحص الخادم', + apiConfigTitle: 'تكوين واجهة برمجة التطبيقات والخادم', + targetServer: 'الخادم الهدف', + apiPrefix: 'بادئة API', + authHeader: 'ترويسة المصادقة', + currentUserTitle: 'بيانات الحساب الحالي', + roleSuperuser: 'المدير العام (Superuser)', + roleStaff: 'مدير النظام (Staff)', + roleUser: 'مستخدم', + fullName: 'الاسم الكامل:', + email: 'البريد الإلكتروني:', + userId: 'معرف المستخدم (ID):', + connectionStable: 'الاتصال بالخادم مستقر', + connectionTesting: 'جاري اختبار الاتصال...', + connectionUnreachable: 'الخادم غير متاح', + }, + globalSearch: { + placeholder: 'البحث في الصفحات والعمليات والأوامر... (Ctrl+K)', + empty: 'لم يتم العثور على أي نتائج.', + systemSections: 'أقسام النظام', + toggleTheme: 'تغيير السمة (Theme)', + toggleThemeDesc: 'تبديل المظهر بين الوضع الداكن والفاتح', + logoutAccount: 'تسجيل الخروج من الحساب', + }, + common: { + save: 'حفظ التغييرات', + cancel: 'إلغاء', + confirm: 'تأكيد', + delete: 'حذف', + edit: 'تعديل', + close: 'إغلاق', + back: 'رجوع', + refresh: 'تحديث', + loading: 'جاري التحميل...', + status: 'الحالة', + actions: 'الإجراءات', + all: 'الكل', + active: 'نشط', + inactive: 'غير نشط', + noData: 'لا توجد بيانات للعرض.', + pageOf: 'صفحة {current} من {total}', + success: 'تمت العملية بنجاح.', + error: 'حدث خطأ في الاتصال بالخادم.', + retry: 'إعادة المحاولة', + previous: 'السابق', + next: 'التالي', + showing: 'عرض', + to: 'إلى', + of: 'من', + testSyncNotice: 'هذه رسالة تجريبية لاختبار مزامنة اللغات.', + deleteConfirmTitle: 'هل أنت متأكد من حذف هذا العنصر؟', + deleteConfirmDesc: 'هذا الإجراء لا يمكن التراجع عنه وسيتم حذف السجل نهائياً.', + yesDelete: 'نعم، احذف', + deleting: 'جاري الحذف...', + filter: 'تصفية', + search: 'بحث...', + export: 'تصدير إكسل', + import: 'استيراد بيانات', + create: 'إنشاء جديد', + add: 'إضافة', + view: 'عرض', + details: 'تفاصيل', + operations: 'العمليات', + enabled: 'مفعل', + disabled: 'معطل', + unknown: 'غير محدد', + days: '{count} أيام', + dayOne: 'يوم واحد', + }, + tours: { + title: 'إدارة الرحلات', + subtitle: 'متابعة مباشرة لحالة المبيعات وجداول الرحلات والمسافرين وبيانات الأسعار والترجمات متعددة اللغات', + totalRegistered: '{count} رحلة مسجلة', + ongoingTitle: 'الرحلات الجارية', + ongoingSubtitle: 'متابعة مباشرة للرحلات الحالية والجدول اليومي والموقع الحالي وحالة المسافرين', + totalOngoing: '{count} رحلة نشطة وجارية', + ordersTitle: 'طلبات وحجوزات الرحلات', + ordersSubtitle: 'متابعة الطلبات ومراقبة المدفوعات والتحقق من وثائق وإيصالات التحويل للمسافرين', + totalOrders: 'إجمالي الطلبات المسجلة', + approvedBookings: 'الحجوزات المؤكدة', + pendingReviewOrders: 'بانتظار مراجعة الإيصال', + searchOrdersPlaceholder: 'البحث في الطلبات (اسم المسافر، الهاتف، معرف الطلب)...', + filterSaleStatus: 'حالة البيع', + filterTripStatus: 'حالة السفر', + filterOrderStatus: 'حالة الطلب', + createTour: 'إنشاء رحلة جديدة', + tourDetails: 'تفاصيل الرحلة', + manageTranslations: 'إدارة الترجمات (٥ لغات)', + tabOverview: 'المواصفات', + tabItinerary: 'برنامج الرحلة', + tabPassengers: 'المسافرون', + tabFeatures: 'المميزات', + saleAvailable: 'متاح', + saleSoldOut: 'اكتمل العدد', + saleNoShow: 'مخفي', + saleUnknown: 'غير محدد', + tripPending: 'بانتظار الانطلاق', + tripTraveling: 'جارية حالياً', + tripFinished: 'منتهية', + orderApproved: 'تمت الموافقة', + orderAwaitingPayment: 'بانتظار الدفع', + orderPending: 'بانتظار مراجعة الإيصال', + orderRejected: 'مرفوض', + orderExpired: 'منتهي الصلاحية', + countryIraq: 'العراق', + countryIran: 'إيران', + countrySaudi: 'السعودية', + countrySyria: 'سوريا', + countryOman: 'عمان', + }, + services: { + title: 'مركز الخدمات', + subtitle: 'إدارة شاملة لخدمات الزوار والشرائح ودليل الزائر والصرافة والمتجر والمناسك', + allServices: 'جميع الخدمات', + searchPlaceholder: 'البحث في الخدمات...', + simcardTitle: 'شريحة الاتصال', + simcardDesc: 'إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (زين، آسیاسیل، كورك، STC)', + zayerGuideTitle: 'دليل الزائر', + zayerGuideDesc: 'دليل شامل للعتبات المقدسة والمعالم التاريخية والمستشفيات والمواكب ومواقع المدن', + exchangeTitle: 'الصرافة الإلكترونية', + exchangeDesc: 'أسعار الصرف المباشرة وطلبات العملات وإدارة السيولة النقدية وحوالات الزوار (دولار، دينار، تومان)', + duasTitle: 'الأدعية والزيارات', + duasDesc: 'بنك مفاتيح الجنان والتسجيلات الصوتية والترجمات وشروح الأدعية والزيارات المعتبرة', + categoriesTitle: 'التصنيفات', + categoriesDesc: 'إدارة التصنيفات الموضوعية للخدمات والأماكن والمتجر ومحتويات التطبيق', + citiesTitle: 'المدن المقدسة', + citiesDesc: 'المدن المقدسة (النجف، كربلاء، الكاظمية، سامراء، مشهد، قم، مكة، المدينة)', + storeTitle: 'متجر معصومة', + storeDesc: 'منتجات متجر معصومة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء', + costEstimateTitle: 'تقدير التكلفة', + costEstimateDesc: 'حساب تكاليف الرحلات الفردية والعائلية حسب الموسم والخدمات ومستوى الفندق', + supportTitle: 'الدعم الفني', + supportDesc: 'نظام التذاكر المباشر ودعم الزوار وإدارة استجابات فريق العمل', + faqTitle: 'الأسئلة الشائعة', + faqDesc: 'بنك الأسئلة المتكررة للزوار وإرشادات السفر والأحكام الشرعية', + }, + billing: { + title: 'إدارة الفواتير والمدفوعات', + description: 'مراجعة إيصالات التحويل البنكي، واعتماد أو رفض المدفوعات ومتابعة تفاصيل فواتير خدمات عقيلة', + totalInvoices: 'إجمالي الفواتير', + pendingReview: 'بانتظار مراجعة الإيصال', + awaitingPayment: 'بانتظار دفع العميل', + approvedPayments: 'المدفوعات المعتمدة', + rejectedOrExpired: 'مرفوض / منتهي الصلاحية', + searchPlaceholder: 'البحث في الفواتير (اسم العميل، البريد، رقم البطاقة، رقم الفاتورة)...', + statusFilter: 'حالة الفاتورة', + serviceFilter: 'نوع الخدمة', + clearFilters: 'إعادة ضبط التصفية', + invoiceList: 'قائمة الفواتير', + customer: 'العميل', + amount: 'المبلغ', + service: 'الخدمة', + date: 'تاريخ الإصدار', + receipt: 'إيصال الدفع', + hasReceipt: 'عرض الإيصال', + noReceipt: 'لا يوجد', + approve: 'اعتماد الدفع', + reject: 'رفض الفاتورة', + rejectTitle: 'رفض الفاتورة', + rejectReason: 'سبب رفض الفاتورة / الإيصال البنكي:', + rejectReasonPlaceholder: 'مثال: صورة الإيصال غير واضحة، المبلغ غير متطابق، الرقم المرجعي غير صالح...', + rejectNotice: 'سيظهر هذا الشرح للمسافر في تفاصيل الفاتورة.', + submitReject: 'تأكيد ورفض الفاتورة', + submitting: 'جاري الإرسال...', + statusPending: 'بانتظار مراجعة الإيصال', + statusAwaiting: 'بانتظار الدفع', + statusApproved: 'تم اعتماد الدفع', + statusRejected: 'مرفوض', + statusExpired: 'منتهي الصلاحية', + invoice: 'فاتورة', + invoices: 'الفواتير', + id: 'المعرف', + viewDetails: 'عرض التفاصيل', + viewReceipt: 'عرض الإيصال', + noReceiptUploaded: 'لم يتم رفع إيصال', + serviceTour: 'رحلة زيارة', + serviceSimcard: 'شريحة اتصال', + serviceShop: 'متجر معصومة', + serviceExchange: 'صرافة (تصريف)', + serviceCustomTrip: 'رحلة مخصصة', + allStatuses: 'جميع الحالات', + allServices: 'جميع الخدمات', + noFactorsFound: 'لم يتم العثور على فواتير.', + noFactorsFilter: 'لا توجد نتائج تطابق معايير التصفية الحالية.', + noFactorsEmpty: 'لم يتم إصدار أي فواتير في النظام بعد.', + confirmClearReceipt: 'هل أنت متأكد من حذف صورة الإيصال المرفوعة؟', + receiptClearedSuccess: 'تم حذف صورة الإيصال بنجاح.', + tabAll: 'جميع المعاملات', + tabTour: 'طلبات وحجوزات الرحلات', + tabSimcard: 'شرائح الاتصال', + tabExchange: 'العملات والصرافة', + tabShop: 'متجر الهدايا', + tabCustomTrip: 'الرحلات الخاصة', + coreTourBadge: 'الخدمة الأساسية', + liveStatus: 'الحالة المباشرة', + quickFilter: 'تصفية الحالة', + }, + languages: { + fa: 'فارسی', + ar: 'العربية', + en: 'English', + }, +} + +function writeJson(filePath, data) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8') + console.log(`✓ Generated: ${filePath}`) +} + +writeJson(path.join(LOCALES_DIR, 'en.json'), enLocale) +writeJson(path.join(LOCALES_DIR, 'fa.json'), faLocale) +writeJson(path.join(LOCALES_DIR, 'ar.json'), arLocale) + +console.log('\n🎉 Successfully synchronized all 3 locale files with en.json as Source of Truth!') diff --git a/src/components/layout/ServerStatusDialog.tsx b/src/components/layout/ServerStatusDialog.tsx index 0f30b5c..070b74c 100644 --- a/src/components/layout/ServerStatusDialog.tsx +++ b/src/components/layout/ServerStatusDialog.tsx @@ -14,6 +14,7 @@ import { Ic } from '@/icons' import { useServerHealthStore } from '@/stores/server-health' import { useAuthStore } from '@/stores/auth' import { cn } from '@/lib/utils' +import { useTranslation } from '@/i18n' interface ServerStatusDialogProps { open: boolean @@ -23,6 +24,7 @@ interface ServerStatusDialogProps { export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusDialogProps) { const health = useServerHealthStore() const user = useAuthStore((s) => s.user) + const { t } = useTranslation() const [isRefreshing, setIsRefreshing] = useState(false) const handleRefresh = async () => { @@ -49,10 +51,10 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
- وضعیت سیستم و سرور + {t('serverStatus.title')} - خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب + {t('serverStatus.description')}
@@ -72,7 +74,7 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD : 'bg-rose-500 shadow-[0_0_8px_#f43f5e]' }`} /> - وضعیت سرور جنگو (Backend) + {t('serverStatus.backendStatus')}
{health.status === 'connected' - ? 'آنلاین و متصل' + ? t('serverStatus.connected') : health.status === 'checking' - ? 'در حال بررسی...' - : 'قطع ارتباط'} + ? t('serverStatus.checking') + : t('serverStatus.disconnected')} @@ -119,20 +121,20 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
- پیکربندی API و سرور + {t('serverStatus.apiConfigTitle')}
- Target Server: + {t('serverStatus.targetServer')}: http://127.0.0.1:8000
- API Prefix: + {t('serverStatus.apiPrefix')}: /api
- Auth Header: + {t('serverStatus.authHeader')}: Token (DRF)
@@ -143,29 +145,29 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
- اطلاعات حساب کاربری جاری + {t('serverStatus.currentUserTitle')} {user?.role === 'superadmin' - ? 'مدیر ارشد (Superuser)' + ? t('serverStatus.roleSuperuser') : user?.role === 'admin' - ? 'مدیر سیستم (Staff)' - : 'کاربر'} + ? t('serverStatus.roleStaff') + : t('serverStatus.roleUser')}
- نام و نام خانوادگی: - {user?.name || 'مدیر سیستم'} + {t('serverStatus.fullName')} + {user?.name || t('navigation.adminRole')}
- ایمیل: + {t('serverStatus.email')} {user?.email || '-'}
- شناسه کاربری (ID): + {t('serverStatus.userId')} #{user?.id ?? 1} @@ -187,17 +189,17 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD /> {health.status === 'connected' - ? 'ارتباط با سرور پایدار است' + ? t('serverStatus.connectionStable') : health.status === 'checking' - ? 'در حال آزمایش اتصال...' - : 'سرور در دسترس نیست'} + ? t('serverStatus.connectionTesting') + : t('serverStatus.connectionUnreachable')}
diff --git a/src/components/shared/DeleteConfirmDialog.tsx b/src/components/shared/DeleteConfirmDialog.tsx index 6fd6c56..86be98d 100644 --- a/src/components/shared/DeleteConfirmDialog.tsx +++ b/src/components/shared/DeleteConfirmDialog.tsx @@ -9,6 +9,7 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' +import { useTranslation } from '@/i18n' interface DeleteConfirmDialogProps { open: boolean @@ -24,38 +25,45 @@ interface DeleteConfirmDialogProps { export default function DeleteConfirmDialog({ open, onOpenChange, - title = 'آیا از حذف این مورد اطمینان دارید؟', - description = 'این عملیات غیرقابل بازگشت است و رکورد مورد نظر به طور کامل حذف خواهد شد.', - confirmText = 'بله، حذف شود', - cancelText = 'انصراف', + title, + description, + confirmText, + cancelText, isLoading = false, onConfirm, }: DeleteConfirmDialogProps) { + const { t } = useTranslation() + + const finalTitle = title || t('common.deleteConfirmTitle') + const finalDescription = description || t('common.deleteConfirmDesc') + const finalConfirmText = confirmText || t('common.yesDelete') + const finalCancelText = cancelText || t('common.cancel') + return ( - {title} + {finalTitle} - {description} + {finalDescription} diff --git a/src/features/auth/LoginPage.tsx b/src/features/auth/LoginPage.tsx index 60d98e1..867770f 100644 --- a/src/features/auth/LoginPage.tsx +++ b/src/features/auth/LoginPage.tsx @@ -8,6 +8,7 @@ import { useAuth, useLoginMutation } from '@/features/auth/use-auth' import { ApiError } from '@/services/http' import { siteConfig } from '@/config/site' import { AqilaLogo } from '@/components/AqilaLogo' +import { useTranslation } from '@/i18n' function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -22,6 +23,7 @@ export default function LoginPage() { const location = useLocation() const { isAuthenticated } = useAuth() const loginMutation = useLoginMutation() + const { t } = useTranslation() const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -50,7 +52,7 @@ export default function LoginPage() { loginMutation.error instanceof ApiError ? loginMutation.error.message : loginMutation.error - ? 'ورود ناموفق بود. دوباره تلاش کنید.' + ? t('auth.loginFailed') : null return ( @@ -67,7 +69,7 @@ export default function LoginPage() {

{siteConfig.name}

-

ورود به پنل مدیریت و داشبورد

+

{t('auth.loginDescription')}

@@ -79,7 +81,7 @@ export default function LoginPage() { {/* راهنمای لاگین دمو */}
- حساب آزمایشی آماده: + {t('auth.demoBoxTitle')} admin@gmail.com / admin
- + setEmail(e.target.value)} autoFocus /> - +
setPassword(e.target.value)} /> @@ -121,7 +123,7 @@ export default function LoginPage() { type="button" onClick={() => setShowPassword((v) => !v)} className="absolute inset-y-0 start-0 flex w-11 items-center justify-center text-grey-3 transition-colors hover:text-grey-1 [&_svg]:size-4" - aria-label={showPassword ? 'مخفی‌کردن رمز' : 'نمایش رمز'} + aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')} tabIndex={-1} > @@ -142,14 +144,14 @@ export default function LoginPage() { className="w-full font-bold" disabled={!valid} isLoading={loginMutation.isPending} - loadingText="در حال ورود…" + loadingText={t('auth.loggingIn')} > - ورود به حساب کاربری + {t('auth.submitLogin')}

- پشتیبانی از قالب‌های سفارشی و اتصال مستقیم به API + {t('auth.footerNote')}

diff --git a/src/features/dashboard/DashboardHome.tsx b/src/features/dashboard/DashboardHome.tsx index 8a40740..97eca60 100644 --- a/src/features/dashboard/DashboardHome.tsx +++ b/src/features/dashboard/DashboardHome.tsx @@ -14,17 +14,14 @@ export default function DashboardHome() {

- خوش آمدید، {user?.name || 'مدیر سیستم'} + {t('dashboard.welcome', { name: user?.name || t('navigation.adminRole') })}

- آماده توسعه + {t('dashboard.readyBadge')}

- پنل مدیریت عقیله آماده اضافه کردن ویجت‌ها، جداول و ماژول‌های جدید شماست. -

-

- {t('common.testSyncNotice')} + {t('dashboard.welcomeSubtitle')}

@@ -34,9 +31,11 @@ export default function DashboardHome() {
-

داشبورد تمیز و آماده توسعه

+

+ {t('dashboard.cleanBoardTitle')} +

- تمام اطلاعات و یوآی‌های ساختگی حذف شدند. می‌توانید ماژول‌های جدید خود را با کامپوننت‌های آماده UI پیاده‌سازی و مستقیماً به دیتابیس و بک‌اند عقیله متصل نمایید. + {t('dashboard.cleanBoardDescription')}

diff --git a/src/features/global-search/GlobalSearchDialog.tsx b/src/features/global-search/GlobalSearchDialog.tsx index 87a153e..1365ccd 100644 --- a/src/features/global-search/GlobalSearchDialog.tsx +++ b/src/features/global-search/GlobalSearchDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import { Ic } from '@/icons' import { @@ -11,13 +11,17 @@ import { CommandSeparator, } from '@/components/ui/command' import { useGlobalSearchStore } from '@/stores/global-search' -import { navigationConfig } from '@/config/navigation' +import { getNavigationConfig } from '@/config/navigation' import { useLogout } from '@/features/auth/use-auth' +import { useTranslation } from '@/i18n' export default function GlobalSearchDialog() { const { open, setOpen } = useGlobalSearchStore() const navigate = useNavigate() const logout = useLogout() + const { t } = useTranslation() + + const navConfig = useMemo(() => getNavigationConfig(t), [t]) // میانبر صفحه‌کلید Cmd+K یا Ctrl+K useEffect(() => { @@ -38,15 +42,15 @@ export default function GlobalSearchDialog() { return ( - + - هیچ نتیجه‌ای یافت نشد. + {t('globalSearch.empty')} {/* گروه‌های نویگیشن */} - {navigationConfig.map((group) => ( - + {navConfig.map((group) => ( + {group.items.flatMap((item) => { const entries: React.ReactNode[] = [] if (item.to) { @@ -112,13 +116,13 @@ export default function GlobalSearchDialog() { {/* اکشن‌های سریع */} - + runAction(() => logout())} className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-rose-400" > - خروج از حساب کاربری + {t('globalSearch.logoutAccount')} diff --git a/src/features/services/ServicesPage.tsx b/src/features/services/ServicesPage.tsx index c290269..70ecbba 100644 --- a/src/features/services/ServicesPage.tsx +++ b/src/features/services/ServicesPage.tsx @@ -214,28 +214,30 @@ export default function ServicesPage() { return (
- {/* سربرگ بخش خدمات */} -
-
-
-
- + {/* سربرگ بخش خدمات (برای شهرهای زیارتی به صورت یکپارچه داخل کامپوننت با دکمه لود مجدد مدیریت می‌شود) */} + {serviceKey !== 'cities' && ( +
+
+
+
+ +
+

+ {currentService.title} +

+ + {currentService.titleEn} +
-

- {currentService.title} -

- - {currentService.titleEn} - +

+ {currentService.description} +

-

- {currentService.description} -

-
+ )} {/* رندرینگ محتوای اختصاصی سرویس مستقل */} diff --git a/src/features/services/components/CategoriesServiceView.tsx b/src/features/services/components/CategoriesServiceView.tsx index d6b90cf..ef783d3 100644 --- a/src/features/services/components/CategoriesServiceView.tsx +++ b/src/features/services/components/CategoriesServiceView.tsx @@ -21,6 +21,7 @@ import { deleteCategory, fetchDuas, } from '../services/trip-etiquette-api' +import { toast } from '@/stores/toast' export const PILGRIMAGE_CITIES = [ { id: 5, name: 'Karbala', nameFa: 'کربلای معلی', flag: '🇮🇶' }, { id: 6, name: 'Najaf', nameFa: 'نجف اشرف', flag: '🇮🇶' }, @@ -270,8 +271,9 @@ export function CategoriesServiceView() { }) setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item))) setAssigningCategory(null) + toast.success('تخصیص ادعیه به دسته‌بندی با موفقیت ذخیره شد.') } catch (err: any) { - alert(err?.message || 'خطا در تخصیص ادعیه.') + toast.error(err?.message || 'خطا در تخصیص ادعیه.') } finally { setIsSavingQuickAssign(false) } @@ -281,7 +283,7 @@ export function CategoriesServiceView() { const handleSaveCategory = async (e: React.FormEvent) => { e.preventDefault() if (!formNameAr.trim()) { - alert('لطفاً عنوان دسته‌بندی (عربی) را وارد کنید. زبان عربی پیش‌فرض و الزامی است.') + toast.warning('لطفاً عنوان دسته‌بندی (عربی) را وارد کنید. زبان عربی پیش‌فرض و الزامی است.') return } @@ -305,13 +307,15 @@ export function CategoriesServiceView() { if (editingCategory) { const updated = await updateCategory(editingCategory.id, payload) setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item))) + toast.success('دسته‌بندی با موفقیت به‌روزرسانی شد.') } else { const created = await createCategory(payload as CreateCategoryInput) setCategories((prev) => [created, ...prev]) + toast.success('دسته‌بندی جدید با موفقیت ایجاد شد.') } setIsDialogOpen(false) } catch (err: any) { - alert(err?.message || 'خطا در ذخیره‌سازی دسته‌بندی.') + toast.error(err?.message || 'خطا در ذخیره‌سازی دسته‌بندی.') } finally { setIsSaving(false) } @@ -326,8 +330,9 @@ export function CategoriesServiceView() { try { await deleteCategory(c.id) setCategories((prev) => prev.filter((item) => item.id !== c.id)) + toast.success('دسته‌بندی با موفقیت حذف شد.') } catch (err: any) { - alert(err?.message || 'خطا در حذف دسته‌بندی.') + toast.error(err?.message || 'خطا در حذف دسته‌بندی.') } } diff --git a/src/features/services/components/CitiesServiceView.tsx b/src/features/services/components/CitiesServiceView.tsx index 668ca7f..c6b2c15 100644 --- a/src/features/services/components/CitiesServiceView.tsx +++ b/src/features/services/components/CitiesServiceView.tsx @@ -1,18 +1,18 @@ import React, { useState, useEffect, useMemo } from 'react' -import { Link } from 'react-router-dom' import { Ic } from '@/icons' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Badge } from '@/components/ui/badge' import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend' import { toFa } from '@/lib/utils' import type { PilgrimageCityItem, CategoryItem, + DuaItem, } from '../types/trip-etiquette' import { fetchTripEtiquetteCities, fetchCategories, + fetchDuas, } from '../services/trip-etiquette-api' // نام‌های روان و استاندارد فارسی برای شهرهای زیارتی @@ -48,6 +48,7 @@ const COUNTRY_NAMES_FA: Record = { export function CitiesServiceView() { const [cities, setCities] = useState([]) const [categories, setCategories] = useState([]) + const [duas, setDuas] = useState([]) const [isLoading, setIsLoading] = useState(true) const [isRefreshing, setIsRefreshing] = useState(false) const [errorMessage, setErrorMessage] = useState(null) @@ -62,12 +63,14 @@ export function CitiesServiceView() { setErrorMessage(null) try { - const [citiesRes, categoriesRes] = await Promise.all([ + const [citiesRes, categoriesRes, duasRes] = await Promise.all([ fetchTripEtiquetteCities(), fetchCategories(), + fetchDuas(), ]) setCities(citiesRes) setCategories(categoriesRes) + setDuas(duasRes) } catch (err: any) { setErrorMessage(err?.message || 'خطا در بارگذاری فهرست شهرهای زیارتی.') } finally { @@ -125,44 +128,27 @@ export function CitiesServiceView() { return (
- {/* سربرگ معرفی شناسه شهرها و وضعیت استاتیک */} -
-
-
-
- -
-
-

- شهرهای زیارتی مرجع (شناسه‌های ثابت دیتابیس) -

-

- شهرهای استاتیک اپلیکیشن عقیله؛ جهت تخصیص دسته‌بندی‌ها و ادعیه از شناسه‌های زیر استفاده می‌شود. -

-
+ {/* سربرگ شهرهای زیارتی به همراه دکمه تازه‌سازی (لود مجدد) */} +
+
+
+
+

+ شهرهای زیارتی +

-
- - - شهرهای پایه استاتیک (بدون امکان حذف یا تغییر) - - - -
+
{/* نوار فیلتر و جستجو */} @@ -232,50 +218,24 @@ export function CitiesServiceView() {
)} - {/* وضعیت لودینگ اسکلتون منطبق با ساختار واقعی کارت‌های شهر */} + {/* وضعیت لودینگ اسکلتون منطبق با ساختار کارت‌های ساده‌شده شهر */} {isLoading ? (
{Array.from({ length: 6 }).map((_, i) => (
-
- {/* شناسه و کشور */} -
- - -
- - {/* عنوان و اسلاگ */} -
- - -
- - {/* دسته‌بندی‌ها */} -
-
- - -
-
- - -
-
- - {/* دسترسی‌های حمل‌ونقل و موقعیت */} -
- - -
+ {/* عنوان و کشور */} +
+ +
- {/* دکمه‌های فوتر */} -
- - + {/* آمار دو ستونه */} +
+ +
))} @@ -286,111 +246,64 @@ export function CitiesServiceView() {

هیچ شهری با این مشخصات یافت نشد

) : ( - /* شبکه کارت‌های شهرهای زیارتی */ + /* شبکه کارت‌های شهرهای زیارتی (ساده‌شده، فقط خواندنی و بدون واکنش کلیک) */
{filteredCities.map((city) => { const faName = CITY_FA_NAMES[city.slug.toLowerCase()] || city.name const flag = COUNTRY_FLAGS[city.country_code || ''] || '🌍' const countryFa = COUNTRY_NAMES_FA[city.country_name || ''] || city.country_name || '' - // دسته‌بندی‌های متصل به این شهر در دیتابیس - const cityCategories = categories.filter( - (c) => c.city === city.id || c.city_info?.id === city.id - ) + // ۱. تعداد دسته‌بندی‌های متصل به این شهر در دیتابیس + const cityCategoriesCount = categories.filter((c) => { + if (c.city === city.id || c.city_info?.id === city.id) return true + if (c.cities && c.cities.some((ct) => ct.id === city.id)) return true + return false + }).length + + // ۲. تعداد کل ادعیه متصل به این شهر در دیتابیس + const cityDuasCount = duas.filter((d) => { + if (d.cities && d.cities.some((c) => c.id === city.id)) return true + if (d.categories && d.categories.some((cat) => { + if (cat.city_id === city.id) return true + if (cat.cities && cat.cities.some((c) => c.id === city.id)) return true + return false + })) return true + if (d.shrines && d.shrines.some((s: any) => s.city_id === city.id || s.city === city.id)) return true + return false + }).length return (
-
- {/* شناسه دیتابیس و کشور */} -
- - شناسه دیتابیس: #{city.id} - - -
- {flag} - {countryFa} -
-
+ {/* بخش اول: نشان کشور به همراه پرچم (سمت چپ) و عنوان شهر (سمت راست) */} +
+

+ {faName} +

- {/* نام شهر و اسلاگ */} -
-

{faName}

-
- {city.name} - • - slug: {city.slug} -
+
+ {countryFa} + {flag}
+
- {/* دسته‌بندی‌های متصل به این شهر */} -
-
- دسته‌بندی‌های اعمال و آداب: - {toFa(cityCategories.length)} دسته -
-
- {cityCategories.length > 0 ? ( - cityCategories.map((c) => ( - - 📁 {c.name_display} #{c.id} - - )) - ) : ( - بدون دسته‌بندی مستقیم - )} -
+ {/* بخش دوم: دو آمار تکمیلی صرفاً نمایشی */} +
+
+ دسته‌بندی‌ها + + {toFa(cityCategoriesCount)} +
- - {/* دسترسی‌های حمل‌ونقل و موقعیت */} -
-
- - ✈️ فرودگاه - - - 🚆 قطار - - - 🚗 جاده - -
- {city.latitude && city.longitude && ( - - {city.latitude.toFixed(2)}, {city.longitude.toFixed(2)} - - )} +
+ کل ادعیه و زیارات + + {toFa(cityDuasCount)} +
- - {/* پیوندهای دسترسی سریع به ادعیه و دسته‌بندی‌ها */} -
- - - دسته‌بندی‌ها ({toFa(cityCategories.length)}) - - - - - ادعیه و زیارات - -
) })} diff --git a/src/features/services/components/CreateLocationDialog.tsx b/src/features/services/components/CreateLocationDialog.tsx index 2d1c9e7..25eb624 100644 --- a/src/features/services/components/CreateLocationDialog.tsx +++ b/src/features/services/components/CreateLocationDialog.tsx @@ -10,7 +10,6 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Switch } from '@/components/ui/switch' import { Ic } from '@/icons' import type { CityGuideItem, @@ -25,7 +24,8 @@ interface CreateLocationDialogProps { categories: CityGuideCategory[] countries: CityGuideCountry[] editingLocation?: CityGuideItem | null - onSave: (data: CreateCityGuideInput, editingId?: number) => void + isSaving?: boolean + onSave: (data: CreateCityGuideInput, editingId?: number) => void | Promise } const WEEK_DAYS = [ @@ -44,11 +44,28 @@ export function CreateLocationDialog({ categories, countries, editingLocation, + isSaving = false, onSave, }: CreateLocationDialogProps) { - const [title, setTitle] = useState('') - const [description, setDescription] = useState('') - const [address, setAddress] = useState('') + // زبان فعال در تب‌های چندزبانه + const [activeLangTab, setActiveLangTab] = useState<'fa' | 'ar' | 'en'>('fa') + + // عناوین چندزبانه + const [titleFa, setTitleFa] = useState('') + const [titleAr, setTitleAr] = useState('') + const [titleEn, setTitleEn] = useState('') + + // توضیحات چندزبانه + const [descFa, setDescFa] = useState('') + const [descAr, setDescAr] = useState('') + const [descEn, setDescEn] = useState('') + + // آدرس‌های چندزبانه + const [addrFa, setAddrFa] = useState('') + const [addrAr, setAddrAr] = useState('') + const [addrEn, setAddrEn] = useState('') + + // مشخصات عمومی و مکانی const [phoneNumber, setPhoneNumber] = useState('') const [categoryId, setCategoryId] = useState(categories[0]?.id || 1) const [countryId, setCountryId] = useState(countries[0]?.id || 1) @@ -58,25 +75,35 @@ export function CreateLocationDialog({ const [daysOff, setDaysOff] = useState([]) const [latitude, setLatitude] = useState('32.6160') const [longitude, setLongitude] = useState('44.0244') - const [isActive, setIsActive] = useState(true) const [imageUrl, setImageUrl] = useState('') const [validationError, setValidationError] = useState(null) - // لیست شهرهای کشور انتخاب‌شده + // لیست کلیه شهرهای تابعه کشور انتخابی const currentCountry = countries.find((c) => c.id === countryId) || countries[0] const availableCities = currentCountry?.city || [] useEffect(() => { if (editingLocation) { - setTitle(editingLocation.title || '') - setDescription(editingLocation.description || '') - setAddress(editingLocation.address || '') + const trans = editingLocation.translations || {} + + setTitleFa(trans.fa?.title || editingLocation.title || '') + setTitleAr(trans.ar?.title || '') + setTitleEn(trans.en?.title || '') + + setDescFa(trans.fa?.description || editingLocation.description || '') + setDescAr(trans.ar?.description || '') + setDescEn(trans.en?.description || '') + + setAddrFa(trans.fa?.address || editingLocation.address || '') + setAddrAr(trans.ar?.address || '') + setAddrEn(trans.en?.address || '') + setPhoneNumber(editingLocation.phone_number || '') - setCategoryId(editingLocation.category?.id || categories[0]?.id || 1) - setCountryId(editingLocation.country?.id || countries[0]?.id || 1) - setCityId(editingLocation.city?.id || 1) - setWorkingHoursFrom(editingLocation.working_hours_from || '08:00') - setWorkingHoursTo(editingLocation.working_hours_to || '23:00') + setCategoryId(editingLocation.category_id || editingLocation.category?.id || categories[0]?.id || 1) + setCountryId(editingLocation.country_id || editingLocation.country?.id || countries[0]?.id || 1) + setCityId(editingLocation.city_id || editingLocation.city?.id || 1) + setWorkingHoursFrom(editingLocation.working_hours_from ? editingLocation.working_hours_from.substring(0, 5) : '08:00') + setWorkingHoursTo(editingLocation.working_hours_to ? editingLocation.working_hours_to.substring(0, 5) : '23:00') const parsedDays = Array.isArray(editingLocation.days_off) ? editingLocation.days_off @@ -85,9 +112,8 @@ export function CreateLocationDialog({ : [] setDaysOff(parsedDays) - setLatitude(String(editingLocation.latitude || '32.6160')) - setLongitude(String(editingLocation.longitude || '44.0244')) - setIsActive(editingLocation.is_active ?? true) + setLatitude(String(editingLocation.latitude ?? '32.6160')) + setLongitude(String(editingLocation.longitude ?? '44.0244')) const img = (typeof editingLocation.image?.image_url === 'object' @@ -96,9 +122,15 @@ export function CreateLocationDialog({ setImageUrl(typeof img === 'string' ? img : '') } else { // مقادیر پیش‌فرض برای ایجاد مکان جدید - setTitle('') - setDescription('') - setAddress('') + setTitleFa('') + setTitleAr('') + setTitleEn('') + setDescFa('') + setDescAr('') + setDescEn('') + setAddrFa('') + setAddrAr('') + setAddrEn('') setPhoneNumber('') setCategoryId(categories[0]?.id || 1) const firstCountry = countries[0] @@ -113,30 +145,23 @@ export function CreateLocationDialog({ setDaysOff([]) setLatitude('32.6160') setLongitude('44.0244') - setIsActive(true) setImageUrl('') } setValidationError(null) }, [editingLocation, open, categories, countries]) - // به‌روزرسانی شهر پیش‌فرض هنگام تغییر کشور - const handleCountryChange = (newCountryId: number) => { - setCountryId(newCountryId) - const targetCountry = countries.find((c) => c.id === newCountryId) - if (targetCountry && targetCountry.city && targetCountry.city.length > 0) { - setCityId(targetCountry.city[0].id) - } - } - const toggleDayOff = (dayCode: string) => { setDaysOff((prev) => prev.includes(dayCode) ? prev.filter((d) => d !== dayCode) : [...prev, dayCode] ) } - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() - if (!title.trim()) { + + // حداقل عنوان فارسی یا اصلی باید پر باشد + const mainTitle = titleFa.trim() || titleAr.trim() || titleEn.trim() + if (!mainTitle) { setValidationError('لطفاً عنوان مکان را وارد کنید.') return } @@ -148,10 +173,28 @@ export function CreateLocationDialog({ return } + const titles: Record = {} + if (titleFa.trim()) titles.fa = titleFa.trim() + if (titleAr.trim()) titles.ar = titleAr.trim() + if (titleEn.trim()) titles.en = titleEn.trim() + + const descriptions: Record = {} + if (descFa.trim()) descriptions.fa = descFa.trim() + if (descAr.trim()) descriptions.ar = descAr.trim() + if (descEn.trim()) descriptions.en = descEn.trim() + + const addresses: Record = {} + if (addrFa.trim()) addresses.fa = addrFa.trim() + if (addrAr.trim()) addresses.ar = addrAr.trim() + if (addrEn.trim()) addresses.en = addrEn.trim() + const inputData: CreateCityGuideInput = { - title: title.trim(), - description: description.trim(), - address: address.trim(), + title: mainTitle, + description: descFa.trim() || descAr.trim() || descEn.trim() || '', + address: addrFa.trim() || addrAr.trim() || addrEn.trim() || '', + titles, + descriptions, + addresses, phone_number: phoneNumber.trim(), category_id: categoryId, country_id: countryId, @@ -161,11 +204,10 @@ export function CreateLocationDialog({ days_off: daysOff, latitude: latNum, longitude: lngNum, - is_active: isActive, image_url: imageUrl.trim() || undefined, } - onSave(inputData, editingLocation?.id) + await onSave(inputData, editingLocation?.id) onOpenChange(false) } @@ -182,7 +224,7 @@ export function CreateLocationDialog({ {editingLocation ? `ویرایش مکان: ${editingLocation.title}` : 'افزودن مکان جدید به راهنمای زائر'} - فیلدها منطبق بر ساختار رسمی مدل پایگاه‌داده و وب‌سرویس CityGuide + ثبت مستقیم و پایدار در دیتابیس سامانه و همگام با اپلیکیشن زائرین
@@ -196,34 +238,155 @@ export function CreateLocationDialog({
)} - {/* ۱. عنوان و وضعیت فعال بودن */} -
-
- - setTitle(e.target.value)} - placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر" - className="text-xs bg-surface-base" - required - /> + {/* تب‌های انتخاب زبان برای محتوای متنی */} +
+
+
اطلاعات متنی مکان
+
+ + + +
-
- -
- - - {isActive ? 'فعال در سامانه' : 'غیرفعال'} - + {/* بخش زبان فارسی */} + {activeLangTab === 'fa' && ( +
+
+ + setTitleFa(e.target.value)} + placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر" + className="text-xs bg-surface-base" + required + /> +
+
+ +