Files
hamkadr/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml
T
soroush.asadi 69e4f305e9
CI/CD / CI · dotnet build (push) Successful in 3m26s
CI/CD / Deploy · hamkadr (push) Failing after 2m41s
[Nav] Add ثبت آگهی CTA, streamline menu, active-link highlight, role dashboards
Header gets a prominent accent +ثبت آگهی CTA → /Employer/Index (auth redirect handles login → register/post). Main nav trimmed to the 5 core public links (خانه/شیفت‌ها/استخدام/مراکز/تقویم); دریافت اپ + راهنما live in the footer and علاقه‌مندی‌ها in the profile menu, so the bar is far less crowded. Added active-page highlight (accent underline on desktop, soft background on mobile). Login now sends admins to /Admin/Overview (dashboard) instead of the ingestion queue; employers→/Employer/Index, job-seekers→/Me already in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:38:08 +03:30

259 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@using System.Security.Claims
@using Microsoft.EntityFrameworkCore
@inject JobsMedical.Web.Services.NotificationService Notifications
@inject JobsMedical.Web.Data.AppDbContext Db
@{
var title = ViewData["Title"] as string;
int unreadCount = 0;
int meId = 0;
string? meName = null;
bool meHasAvatar = false;
if (User.Identity?.IsAuthenticated == true && int.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out meId))
{
unreadCount = await Notifications.UnreadCountAsync(meId);
var info = await Db.Users.Where(u => u.Id == meId)
.Select(u => new { u.FullName, u.Phone, HasAvatar = u.Avatar != null }).FirstOrDefaultAsync();
meName = string.IsNullOrWhiteSpace(info?.FullName) ? info?.Phone : info!.FullName;
meHasAvatar = info?.HasAvatar ?? false;
}
var meInitial = string.IsNullOrWhiteSpace(meName) ? "؟" : meName!.Trim().Substring(0, 1);
// --- SEO context ---
var baseUrl = $"{Context.Request.Scheme}://{Context.Request.Host}";
var path = Context.Request.Path.Value ?? "/";
var canonical = baseUrl + (path == "/" ? "" : path); // canonical ignores query string
var pageDesc = ViewData["Description"] as string
?? "همکادر؛ سامانه یافتن شیفت و موقعیت استخدامی برای کادر درمان (پزشک، پرستار، ماما و تکنسین) در بیمارستان‌ها و کلینیک‌های تهران.";
var pageTitle = title is null ? "همکادر | شیفت و استخدام کادر درمان" : title + " | همکادر";
var ogImage = ViewData["OgImage"] as string ?? baseUrl + "/icons/icon-512.png";
// Private/applicant areas must never be indexed.
string[] noindexPrefixes = { "/Admin", "/Me", "/Employer", "/Account", "/Preferences" };
var noIndex = (ViewData["NoIndex"] as bool? ?? false)
|| noindexPrefixes.Any(p => path.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@pageTitle</title>
<meta name="description" content="@pageDesc" />
@if (noIndex)
{
<meta name="robots" content="noindex, nofollow" />
}
else
{
<link rel="canonical" href="@canonical" />
}
@* Open Graph / Twitter — rich previews when shared in Telegram/Bale/etc. *@
<meta property="og:type" content="website" />
<meta property="og:site_name" content="همکادر" />
<meta property="og:title" content="@pageTitle" />
<meta property="og:description" content="@pageDesc" />
<meta property="og:url" content="@canonical" />
<meta property="og:image" content="@ogImage" />
<meta property="og:locale" content="fa_IR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="@pageTitle" />
<meta name="twitter:description" content="@pageDesc" />
@* Preload the body-weight font so the swap from Tahoma happens fast. Vazirmatn is
self-hosted under wwwroot/fonts (@@font-face in site.css) — no external CDN. *@
<link rel="preload" href="~/fonts/Vazirmatn-Regular.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
@* PWA: installable app (Web/Windows/Android via this manifest; iOS via apple-* tags) *@
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0e8f8a" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="همکادر" />
@await RenderSectionAsync("Head", required: false)
</head>
<body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")">
<header class="site-header">
<div class="container header-inner">
<a class="brand" asp-page="/Index" data-tour="home">
<span class="brand-mark">ه</span>
<span class="brand-text">همکادر</span>
</a>
@* Always-visible bell on mobile (next to the burger) so notifications stay one tap away *@
@if (User.Identity?.IsAuthenticated == true)
{
<a class="bell-mobile js-bell" asp-page="/Me/Notifications" title="اعلان‌ها">🔔@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
}
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden />
<label for="nav-toggle" class="nav-burger" aria-label="باز/بستن منو" data-tour="menu">
<span></span><span></span><span></span>
</label>
<div class="nav-collapse">
<nav class="main-nav">
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
<a asp-page="/Shifts/Index" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a>
<a asp-page="/Jobs/Index" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">مراکز درمانی</a>
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">تقویم هفتگی</a>
</nav>
<div class="header-actions">
<a class="btn btn-accent btn-sm cta-post" asp-page="/Employer/Index" data-tour="post"> ثبت آگهی</a>
@if (User.Identity?.IsAuthenticated == true)
{
<a class="nav-action bell-inline js-bell" asp-page="/Me/Notifications" title="اعلان‌ها" data-tour="bell"><span class="bell-ico">🔔</span><span class="bell-label">اعلان‌ها</span>@if (unreadCount > 0) {<span class="bell-badge">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
<div class="profile-menu">
<input type="checkbox" id="profile-toggle" class="profile-toggle" hidden />
<label for="profile-toggle" class="avatar-btn" data-tour="profile" aria-label="منوی کاربر">
@if (meHasAvatar)
{
<img class="avatar-img" src="/avatar/@meId" alt="پروفایل" />
}
else
{
<span class="avatar-fallback">@meInitial</span>
}
<span class="avatar-caret">▾</span>
</label>
<nav class="profile-dropdown">
<div class="pd-head">@meName</div>
<a asp-page="/Me/Profile">👤 ویرایش پروفایل</a>
<a asp-page="/Me/Index" data-tour="panel">🗂️ پنل کارجو</a>
<a asp-page="/Me/Alerts">🔎 هشدارهای شغلی</a>
<a asp-page="/Preferences/Index">⭐ علاقه‌مندی‌ها</a>
<a asp-page="/Me/Notifications">🔔 اعلان‌ها@if (unreadCount > 0) {<span class="bell-badge" style="position:static; margin-inline-start:6px;">@JalaliDate.ToPersianDigits(unreadCount > 99 ? "99+" : unreadCount.ToString())</span>}</a>
@if (User.IsInRole("FacilityAdmin"))
{
<a asp-page="/Employer/Index">🏥 پنل کارفرما</a>
}
@if (User.IsInRole("Admin"))
{
<div class="pd-sep"></div>
<a asp-page="/Admin/Overview">🛠️ پنل مدیریت</a>
<a asp-page="/Admin/Settings">⚙️ تنظیمات</a>
}
<div class="pd-sep"></div>
<form method="post" asp-page="/Account/Logout">
<button type="submit" class="pd-logout">🚪 خروج</button>
</form>
</nav>
</div>
}
else
{
<a class="btn btn-outline btn-sm" asp-page="/Account/Login" data-tour="login">ورود</a>
}
</div>
</div>
</div>
</header>
<main role="main">
@RenderBody()
</main>
<footer class="site-footer">
<div class="container footer-inner">
<div>
<span class="brand-mark sm">ه</span>
<strong>همکادر</strong>
<p class="muted">سامانه واسط میان کادر درمان و مراکز درمانی برای شیفت و استخدام</p>
</div>
<div class="muted">
<div class="footer-links">
<a asp-page="/Download" style="font-weight:700;">📲 دریافت اپلیکیشن</a>
<a asp-page="/Help">راهنما</a>
<a asp-page="/Privacy">حریم خصوصی</a>
<a asp-page="/Rules">قوانین و مقررات</a>
<a asp-page="/Terms">شرایط استفاده</a>
</div>
© ۱۴۰۵ همکادر — همه حقوق محفوظ است
</div>
</div>
</footer>
<div id="toast-host" class="toast-host" aria-live="polite"></div>
@* Register the PWA service worker (offline + push notifications). *@
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () { navigator.serviceWorker.register('/sw.js').catch(function () {}); });
}
</script>
@* Self-hosted guided app tour (no CDN). Auto-runs once for new visitors; re-runnable from /Help. *@
<script src="~/js/tour.js" asp-append-version="true" defer></script>
@* Close the profile dropdown when clicking outside it. *@
<script>
document.addEventListener('click', function (e) {
var t = document.getElementById('profile-toggle');
if (t && t.checked && !e.target.closest('.profile-menu')) t.checked = false;
});
</script>
@* Live in-app notifications over SSE (our own origin — works in Iran, no Google push).
Updates the bell badge, shows a toast, and fires a local OS notification when allowed. *@
@if (User.Identity?.IsAuthenticated == true)
{
<script>
(function () {
var faDigits = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
function toFa(s){ return String(s).replace(/[0-9]/g, function(d){ return faDigits[+d]; }); }
var count = parseInt(document.body.getAttribute('data-unread') || '0', 10) || 0;
function paintBell() {
document.querySelectorAll('.js-bell').forEach(function (bell) {
var badge = bell.querySelector('.bell-badge');
if (count > 0) {
if (!badge) { badge = document.createElement('span'); badge.className = 'bell-badge'; bell.appendChild(badge); }
badge.textContent = toFa(count > 99 ? '99+' : count);
} else if (badge) { badge.remove(); }
});
}
function toast(n) {
var host = document.getElementById('toast-host');
if (!host) return;
var el = document.createElement('a');
el.className = 'toast';
el.href = n.url || '/';
el.innerHTML = '<span class="toast-ico">🔔</span><span class="toast-body"><strong></strong><span></span></span>';
el.querySelector('strong').textContent = n.title || 'همکادر';
el.querySelector('.toast-body span').textContent = n.body || '';
host.appendChild(el);
requestAnimationFrame(function(){ el.classList.add('show'); });
setTimeout(function(){ el.classList.remove('show'); setTimeout(function(){ el.remove(); }, 300); }, 6000);
}
function osNotify(n) {
if (!('Notification' in window) || Notification.permission !== 'granted' || !navigator.serviceWorker) return;
navigator.serviceWorker.ready.then(function (reg) {
reg.showNotification(n.title || 'همکادر', {
body: n.body || '', icon: '/icons/icon-192.png', badge: '/icons/icon-192.png',
dir: 'rtl', lang: 'fa', tag: n.url || '/', data: { url: n.url || '/' }
});
}).catch(function(){});
}
if (!('EventSource' in window)) return;
var es;
function connect() {
es = new EventSource('/notifications/stream');
es.addEventListener('notice', function (ev) {
var n; try { n = JSON.parse(ev.data); } catch (_) { return; }
count++; paintBell(); toast(n); osNotify(n);
});
// EventSource auto-reconnects on transient errors; nothing else needed.
}
connect();
})();
</script>
}
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>