refactor(admin): optimize dashboard layout and enhance sidebar UX

Refactor the Filament admin dashboard by decoupling the recent shipments
logic into a dedicated widget and improving the sidebar navigation
experience. Additionally, implement several improvements to the
WordPress theme for better client-side usability.

Laravel/Filament:
- Extract `RecentShipmentsWidget` from `DashboardInfoWidget` to improve
  modularization and dashboard performance.
- Update widget sorting and layout to accommodate the new structure.
- Inject custom CSS and Alpine.js logic into the admin head to provide
  sidebar group toggling and a "collapse all" utility.
- Improve sidebar group label visibility and hover states.

WordPress:
- Implement `ifnex_login_url()` to support Polylang-aware, language-specific
  login pages.
- Hide the WordPress admin bar for non-administrator users to provide a
  cleaner client interface.
- Add conditional rendering for the registration link in the login
  template based on WordPress user registration settings.
This commit is contained in:
Kazem Alghasi 2026-10-03 07:01:54 +03:30
parent 40cb104a86
commit c54530d8da
11 changed files with 327 additions and 154 deletions

View File

@ -30,6 +30,15 @@ function ifnex_theme_setup() {
}
add_action('after_setup_theme', 'ifnex_theme_setup');
// ─── مخفی‌کردن نوار مدیریت وردپرس برای غیرمدیرها ──────
// مشتریان نباید نوار پیش‌فرض ادمین را در پنل خود ببینند
add_filter('show_admin_bar', function ($show) {
if (current_user_can('administrator')) {
return $show;
}
return false;
});
// ─── بارگذاری فایل‌های کمکی ──────────────────────────
$ifnex_includes = ['/inc/customizer.php'];
foreach ($ifnex_includes as $file) {
@ -68,6 +77,22 @@ function ifnex_text_direction() {
return ifnex_is_english() ? 'ltr' : 'rtl';
}
// ─── آدرس صفحه لاگین سازگار با زبان جاری (Polylang) ───
// صفحه EN با slug یگانه login-en ساخته شده تا با صفحه FA تداخل canonical رخ ندهد
function ifnex_login_url() {
$page = get_page_by_path('login');
if (!$page) {
return home_url('/login/');
}
if (function_exists('pll_get_post')) {
$translated = pll_get_post($page->ID, ifnex_get_current_lang());
if ($translated) {
return get_permalink($translated);
}
}
return get_permalink($page->ID);
}
// ─── سوئیچر زبان (سازگار با Polylang) ───────────────
function ifnex_language_switcher() {
if (function_exists('pll_the_languages')) {

View File

@ -16,7 +16,7 @@ if (is_user_logged_in()) {
$login_btn_text = ifnex_t('پنل کاربری', 'Dashboard');
$login_btn_class = 'btn btn-primary';
} else {
$login_btn_url = esc_url(home_url('/login/'));
$login_btn_url = esc_url(ifnex_login_url());
$login_btn_text = ifnex_t('ورود مشتریان', 'Client Login');
$login_btn_class = 'btn btn-secondary';
}

View File

@ -142,10 +142,13 @@ get_header();
</form>
<div class="ifnx-login-footer">
<?php // لینک ثبت‌نام فقط وقتی «عضویت» در تنظیمات وردپرس فعال است نمایش داده می‌شود؛ در غیر این صورت wp_registration_url به صفحه پیش‌فرض lostpassword می‌رود ?>
<?php if (get_option('users_can_register')): ?>
<p><?php echo $is_en ? "Don't have an account?" : 'حساب کاربری ندارید؟'; ?>
<a href="<?php echo esc_url(wp_registration_url()); ?>">
<?php echo $is_en ? 'Register now' : 'ثبت‌نام کنید'; ?>
</a></p>
<?php endif; ?>
<a href="<?php echo esc_url(home_url('/')); ?>" class="ifnx-login-back">
← <?php echo $is_en ? 'Back to home' : 'بازگشت به سایت'; ?>
</a>

View File

@ -2,84 +2,32 @@
namespace App\Filament\Widgets;
use App\Models\Shipment;
use Filament\Widgets\Widget;
use Morilog\Jalali\Jalalian;
/**
* کارت کوچک تاریخ/ساعت/خوش‌آمد — گوشه داشبورد.
* جدول «آخرین سفارشات» به RecentShipmentsWidget جدا منتقل شده است.
*/
class DashboardInfoWidget extends Widget
{
protected static ?int $sort = 1; // اولین ویجت
protected static ?int $sort = 1;
protected static bool $isLazy = false;
protected int | string | array $columnSpan = 'full';
protected int | string | array $columnSpan = 1;
protected static string $view = 'filament.widgets.dashboard-info';
protected function getViewData(): array
{
$now = now();
// تاریخ میلادی
$gregorianDate = $now->format('l, j F Y');
// تاریخ شمسی با Morilog\Jalali (ساده‌تر)
$jalaliDate = Jalalian::fromCarbon($now)->format('l، d F Y');
// ساعت
$time = $now->format('H:i');
// آخرین ۵ سفارش
$recentShipments = Shipment::query()
->with(['fromCountry', 'toCountry'])
->orderBy('created_at', 'desc')
->limit(5)
->get()
->map(function ($s) {
return [
'awb' => $s->awb_no,
'route' => ($s->fromCountry?->name ?? '?') . ' → ' . ($s->toCountry?->name ?? '?'),
'status' => $s->status?->value ?? '—',
'status_label' => $this->statusLabel($s->status?->value),
'status_color' => $this->statusColor($s->status?->value),
'date' => Jalalian::fromCarbon($s->created_at)->format('Y/m/d'),
'amount' => $s->total_fee ? number_format($s->total_fee) . ' ریال' : '—',
];
});
return [
'gregorianDate' => $gregorianDate,
'jalaliDate' => $jalaliDate,
'time' => $time,
'recentShipments' => $recentShipments,
'userName' => auth()->user()?->name ?? 'کاربر',
'gregorianDate' => $now->format('l, j F Y'),
'jalaliDate' => Jalalian::fromCarbon($now)->format('l، d F Y'),
'time' => $now->format('H:i'),
'userName' => auth()->user()?->name ?? 'کاربر',
];
}
private function statusLabel(?string $status): string
{
return match($status) {
'processed' => 'پردازش شده',
'picked_up' => 'تحویل گرفته شده',
'in_transit' => 'در حال حمل',
'out_for_delivery' => 'در مسیر تحویل',
'delivered' => 'تحویل داده شده',
'failed' => 'ناموفق',
'returned' => 'برگشتی',
default => '—',
};
}
private function statusColor(?string $status): string
{
return match($status) {
'processed' => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
'picked_up' => 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
'in_transit' => 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
'out_for_delivery' => 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
'delivered' => 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
'failed' => 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
'returned' => 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
default => 'bg-gray-100 text-gray-700',
};
}
}
}

View File

@ -9,7 +9,7 @@ use App\Models\ExchangeRateHistory;
class ExchangeRateWidget extends BaseWidget
{
protected static ?int $sort = 2;
protected static ?int $sort = 3;
protected static bool $isLazy = false;
protected int | string | array $columnSpan = 'full';

View File

@ -0,0 +1,73 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Shipment;
use Filament\Widgets\Widget;
use Morilog\Jalali\Jalalian;
/**
* جدول آخرین ۵ سفارش — تمام‌عرض، زیر کارت تاریخ/ساعت.
*/
class RecentShipmentsWidget extends Widget
{
protected static ?int $sort = 2;
protected static bool $isLazy = false;
protected int | string | array $columnSpan = 'full';
protected static string $view = 'filament.widgets.recent-shipments';
protected function getViewData(): array
{
$recentShipments = Shipment::query()
->with(['fromCountry', 'toCountry'])
->orderBy('created_at', 'desc')
->limit(5)
->get()
->map(function ($s) {
return [
'awb' => $s->awb_no,
'route' => ($s->fromCountry?->name ?? '?') . ' → ' . ($s->toCountry?->name ?? '?'),
'status' => $s->status?->value ?? '—',
'status_label' => $this->statusLabel($s->status?->value),
'status_color' => $this->statusColor($s->status?->value),
'date' => Jalalian::fromCarbon($s->created_at)->format('Y/m/d'),
'amount' => $s->total_fee ? number_format($s->total_fee) . ' ریال' : '—',
];
});
return [
'recentShipments' => $recentShipments,
];
}
private function statusLabel(?string $status): string
{
return match($status) {
'processed' => 'پردازش شده',
'picked_up' => 'تحویل گرفته شده',
'in_transit' => 'در حال حمل',
'out_for_delivery' => 'در مسیر تحویل',
'delivered' => 'تحویل داده شده',
'failed' => 'ناموفق',
'returned' => 'برگشتی',
default => '—',
};
}
private function statusColor(?string $status): string
{
return match($status) {
'processed' => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
'picked_up' => 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
'in_transit' => 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
'out_for_delivery' => 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
'delivered' => 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
'failed' => 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
'returned' => 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
default => 'bg-gray-100 text-gray-700',
};
}
}

View File

@ -10,7 +10,7 @@ use Illuminate\Support\Facades\DB;
class WalletStats extends BaseWidget
{
protected static ?int $sort = 3; // سومین ویجت
protected static ?int $sort = 4; // چهارمین ویجت
protected static bool $isLazy = false;
protected int | string | array $columnSpan = 'full';

View File

@ -73,6 +73,7 @@ class AdminPanelProvider extends PanelProvider
->widgets([
// ویجت‌های سفارشی IFNEX (به ترتیب sort)
\App\Filament\Widgets\DashboardInfoWidget::class,
\App\Filament\Widgets\RecentShipmentsWidget::class,
\App\Filament\Widgets\ExchangeRateWidget::class,
\App\Filament\Widgets\WalletStats::class,
])

View File

@ -187,4 +187,132 @@
.fi-ta-text-wrap {
gap: 0.125rem !important;
}
</style>
/* ─── عناوین گروه‌های سایدبار: سفید روی پس‌زمینه تیره ─── */
.fi-sidebar-group-label {
color: #e2e8f0 !important;
font-weight: 600 !important;
}
.fi-sidebar-group-icon {
color: #cbd5e1 !important;
}
.fi-sidebar-group-collapse-button {
color: #cbd5e1 !important;
}
.fi-sidebar-group-button:hover .fi-sidebar-group-label,
.fi-sidebar-group-button:hover .fi-sidebar-group-icon,
.fi-sidebar-group-button:hover .fi-sidebar-group-collapse-button {
color: #f59e0b !important;
}
/* ─── دکمه بستن/باز کردن همه منوها ─── */
.ifnex-sidebar-collapse-all {
display: block;
width: calc(100% - 16px);
margin: 4px 8px 8px;
padding: 7px 10px;
font-size: 11.5px;
font-family: inherit;
color: #cbd5e1;
background: rgba(255, 255, 255, .06);
border: 1px solid rgba(255, 255, 255, .14);
border-radius: 8px;
cursor: pointer;
text-align: center;
transition: all .2s;
}
.ifnex-sidebar-collapse-all:hover {
background: rgba(245, 158, 11, .15);
border-color: rgba(245, 158, 11, .35);
color: #f59e0b;
}
</style>
<script>
/* ─── گروه‌های سایدبار: پیش‌فرض بسته + کلیک روی عنوان + دکمه بستن/باز کردن همه ───
از استور بومی Alpine فیلامنت (sidebar.collapsedGroups) استفاده می‌کند؛
بنابراین وضعیت هر گروه در localStorage فیلامنت حفظ می‌شود. */
(function () {
function groupLabels() {
return Array.from(document.querySelectorAll('.fi-sidebar-group-label'))
.map(function (el) { return el.textContent.trim(); })
.filter(Boolean);
}
function collapseAll(store) {
groupLabels().forEach(function (label) { store.collapseGroup(label); });
}
function expandAll(store) {
groupLabels().forEach(function (label) {
if (store.groupIsCollapsed(label)) store.toggleCollapsedGroup(label);
});
}
// بار اول برای هر مرورگر: همه منوها بسته باشند؛ بعد از آن انتخاب کاربر محترم است
function initDefaultCollapsed() {
if (!window.Alpine) return;
var store = window.Alpine.store('sidebar');
if (!store || typeof store.collapseGroup !== 'function') return;
if (!localStorage.getItem('ifnex-sidebar-default-collapsed')) {
collapseAll(store);
localStorage.setItem('ifnex-sidebar-default-collapsed', '1');
}
}
// کلیک روی ردیف عنوان گروه = باز/بسته کردن گروه (علاوه بر دکمه فلش)
document.addEventListener('click', function (e) {
var btn = e.target.closest('.fi-sidebar-group-button');
if (!btn || e.target.closest('.fi-sidebar-group-collapse-button')) return;
if (!window.Alpine) return;
var store = window.Alpine.store('sidebar');
if (!store) return;
var labelEl = btn.querySelector('.fi-sidebar-group-label');
// در حالت جمع‌شدهٔ کناری (فقط آیکون)، عنوان مخفی است — نادیده بگیر
if (!labelEl || labelEl.offsetParent === null) return;
var label = labelEl.textContent.trim();
if (label) store.toggleCollapsedGroup(label);
});
// دکمه «بستن/باز کردن همه» بالای سایدبار
function injectToggle() {
if (!document.querySelector('.fi-main-sidebar') || document.querySelector('.ifnex-sidebar-collapse-all')) return;
var nav = document.querySelector('.fi-main-sidebar .fi-sidebar-nav');
if (!nav) return;
var b = document.createElement('button');
b.type = 'button';
b.className = 'ifnex-sidebar-collapse-all';
b.textContent = 'بستن همه منوها ⌃';
b.dataset.mode = 'collapse';
b.addEventListener('click', function () {
if (!window.Alpine) return;
var store = window.Alpine.store('sidebar');
if (!store) return;
if (b.dataset.mode === 'collapse') {
collapseAll(store);
b.textContent = 'باز کردن همه منوها ⌄';
b.dataset.mode = 'expand';
} else {
expandAll(store);
b.textContent = 'بستن همه منوها ⌃';
b.dataset.mode = 'collapse';
}
});
nav.insertBefore(b, nav.firstChild);
}
function boot() { initDefaultCollapsed(); injectToggle(); }
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
// SPA: بعد از هر ناوبری لایووایر، اگر سایدبار تازه رندر شده دکمه را تزریق کن
document.addEventListener('livewire:navigated', injectToggle);
})();
</script>

View File

@ -1,92 +1,29 @@
<x-filament-widgets::widget>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
{{-- کارت تاریخ و ساعت --}}
<div class="lg:col-span-1 bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl p-6 border border-amber-200 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center text-white">
<x-heroicon-o-calendar-days class="w-6 h-6" />
</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">امروز</div>
<div class="text-sm font-bold text-gray-800 dark:text-white">{{ $time }}</div>
</div>
<div class="bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl p-6 border border-amber-200 dark:from-gray-800 dark:to-gray-900 dark:border-gray-700">
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 bg-amber-500 rounded-xl flex items-center justify-center text-white">
<x-heroicon-o-calendar-days class="w-6 h-6" />
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400 w-16">📅 شمسی:</span>
<span class="font-bold text-amber-700 dark:text-amber-400">{{ $jalaliDate }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400 w-16">🌍 میلادی:</span>
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ $gregorianDate }}</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-amber-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">خوش آمدید</div>
<div class="font-bold text-gray-800 dark:text-white">{{ $userName }}</div>
<div>
<div class="text-xs text-gray-500 dark:text-gray-400">امروز</div>
<div class="text-sm font-bold text-gray-800 dark:text-white">{{ $time }}</div>
</div>
</div>
{{-- کارت آخرین سفارشات --}}
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2">
<x-heroicon-o-truck class="w-5 h-5 text-amber-500" />
<h3 class="font-bold text-gray-800 dark:text-white">آخرین سفارشات</h3>
</div>
<a href="{{ route('filament.admin.resources.shipments.index') }}"
class="text-xs text-amber-600 hover:text-amber-700 font-semibold">
مشاهده همه ←
</a>
<div class="space-y-2">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400 w-16">📅 شمسی:</span>
<span class="font-bold text-amber-700 dark:text-amber-400">{{ $jalaliDate }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500 dark:text-gray-400 w-16">🌍 میلادی:</span>
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ $gregorianDate }}</span>
</div>
@if($recentShipments->isEmpty())
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<x-heroicon-o-inbox class="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>هنوز سفارشی ثبت نشده است</p>
</div>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-400">
<tr>
<th class="text-right px-4 py-2 font-medium">AWB</th>
<th class="text-right px-4 py-2 font-medium">مسیر</th>
<th class="text-right px-4 py-2 font-medium">وضعیت</th>
<th class="text-right px-4 py-2 font-medium">تاریخ</th>
<th class="text-right px-4 py-2 font-medium">مبلغ</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($recentShipments as $shipment)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td class="px-4 py-3 font-mono text-xs font-semibold text-gray-800 dark:text-white">
{{ $shipment['awb'] }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ $shipment['route'] }}
</td>
<td class="px-4 py-3">
<span class="inline-block px-2 py-1 rounded-full text-xs font-semibold {{ $shipment['status_color'] }}">
{{ $shipment['status_label'] }}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{{ $shipment['date'] }}
</td>
<td class="px-4 py-3 text-xs font-semibold text-gray-700 dark:text-gray-300">
{{ $shipment['amount'] }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
<div class="mt-4 pt-4 border-t border-amber-200 dark:border-gray-700">
<div class="text-xs text-gray-500 dark:text-gray-400">خوش آمدید</div>
<div class="font-bold text-gray-800 dark:text-white">{{ $userName }}</div>
</div>
</div>
</x-filament-widgets::widget>
</x-filament-widgets::widget>

View File

@ -0,0 +1,58 @@
<x-filament-widgets::widget>
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2">
<x-heroicon-o-truck class="w-5 h-5 text-amber-500" />
<h3 class="font-bold text-gray-800 dark:text-white">آخرین سفارشات</h3>
</div>
<a href="{{ route('filament.admin.resources.shipments.index') }}"
class="text-xs text-amber-600 hover:text-amber-700 font-semibold">
مشاهده همه ←
</a>
</div>
@if($recentShipments->isEmpty())
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<x-heroicon-o-inbox class="w-12 h-12 mx-auto mb-2 text-gray-300" />
<p>هنوز سفارشی ثبت نشده است</p>
</div>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-400">
<tr>
<th class="text-right px-4 py-2 font-medium">AWB</th>
<th class="text-right px-4 py-2 font-medium">مسیر</th>
<th class="text-right px-4 py-2 font-medium">وضعیت</th>
<th class="text-right px-4 py-2 font-medium">تاریخ</th>
<th class="text-right px-4 py-2 font-medium">مبلغ</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
@foreach($recentShipments as $shipment)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-900/50">
<td class="px-4 py-3 font-mono text-xs font-semibold text-gray-800 dark:text-white">
{{ $shipment['awb'] }}
</td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-300">
{{ $shipment['route'] }}
</td>
<td class="px-4 py-3">
<span class="inline-block px-2 py-1 rounded-full text-xs font-semibold {{ $shipment['status_color'] }}">
{{ $shipment['status_label'] }}
</span>
</td>
<td class="px-4 py-3 text-xs text-gray-500 dark:text-gray-400">
{{ $shipment['date'] }}
</td>
<td class="px-4 py-3 text-xs font-semibold text-gray-700 dark:text-gray-300">
{{ $shipment['amount'] }}
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</x-filament-widgets::widget>