Files
hamkadr/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml
T
soroush.asadi 8b0b21f24d
CI/CD / CI · dotnet build (push) Successful in 6m9s
CI/CD / Deploy · hamkadr (push) Has been cancelled
Search: Elasticsearch-style highlighted match snippets (results + typeahead)
- SearchHighlight.Snippet: extracts a ±70-char window around the first
  matching term and marks it (with ellipses) — the ES "highlight" fragment.
- Result cards (shift/job/talent) now show that snippet from the matched
  description/tags when a query is present, so you SEE where the term hit
  (e.g. «…دارای مدرک <mark>mmt</mark>…») instead of just the role.
- Typeahead suggestions gain a highlighted "sub" line (talent→tags,
  shift→city·specialty, job→facility·city) so matches show in the dropdown too.

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

339 lines
18 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? meFullName = null;
string? mePhone = 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();
meFullName = string.IsNullOrWhiteSpace(info?.FullName) ? null : info!.FullName!.Trim();
mePhone = info?.Phone;
meHasAvatar = info?.HasAvatar ?? false;
}
// Avatar glyph/label: prefer a real name; never show a bare phone digit like "0".
var meInitial = meFullName is not null ? meFullName.Substring(0, 1) : "👤";
var meLabel = meFullName ?? "حساب من";
// Single, role-aware dashboard entry — the full menu lives in the panel sub-nav (_PanelNav).
var dashUrl = "/Me/Index"; var dashLabel = "داشبورد من"; var dashIcon = "🗂️";
if (User.IsInRole("Admin")) { dashUrl = "/Admin/Overview"; dashLabel = "پنل مدیریت"; dashIcon = "🛠️"; }
else if (User.IsInRole("FacilityAdmin")) { dashUrl = "/Employer/Index"; dashLabel = "پنل کارفرما"; dashIcon = "🏥"; }
// --- 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));
// Show the centralized dashboard sub-nav on any logged-in panel page.
string[] panelPrefixes = { "/Admin", "/Me", "/Employer", "/Preferences" };
var showPanelNav = User.Identity?.IsAuthenticated == true
&& panelPrefixes.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="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<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="/Talent/Index" class="@(path.StartsWith("/Talent") ? "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>
<form class="nav-search" method="get" action="/Search" role="search">
<div class="nav-search-pill">
<input type="search" name="Q" placeholder="جستجو…" aria-label="جستجو" autocomplete="off" />
<button type="submit" aria-label="جستجو">🔎</button>
</div>
</form>
<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-name">@meLabel</span>
<span class="avatar-caret">▾</span>
</label>
<nav class="profile-dropdown">
<div class="pd-id">
@if (meHasAvatar)
{
<img class="avatar-img" src="/avatar/@meId" alt="" />
}
else
{
<span class="avatar-fallback">@meInitial</span>
}
<div class="pd-id-text">
<strong>@(meFullName ?? "کاربر همکادر")</strong>
@if (mePhone is not null)
{
<span class="muted" dir="ltr">@mePhone</span>
}
</div>
</div>
<div class="pd-sep"></div>
<a href="@dashUrl" data-tour="panel">@dashIcon @dashLabel</a>
<a asp-page="/Me/Profile">👤 ویرایش پروفایل</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">
@if (showPanelNav)
{
<partial name="_PanelNav" />
}
@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>
@* Instant search suggestions (typeahead) for the header search box. *@
<script>
(function () {
var form = document.querySelector('.nav-search');
if (!form) return;
var input = form.querySelector('input');
var box = document.createElement('div');
box.className = 'nav-search-results';
box.style.display = 'none';
form.appendChild(box);
var timer;
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function hi(text, q) {
var safe = esc(text);
var terms = q.split(/\s+/).filter(function (t) { return t.length >= 2; })
.map(function (t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
if (!terms.length) return safe;
try { return safe.replace(new RegExp('(' + terms.join('|') + ')', 'gi'), '<mark>$1</mark>'); }
catch (e) { return safe; }
}
function hide() { box.style.display = 'none'; box.innerHTML = ''; }
input.addEventListener('input', function () {
var q = input.value.trim();
clearTimeout(timer);
if (q.length < 2) { hide(); return; }
timer = setTimeout(function () {
fetch('/search/suggest?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (items) {
if (!items || !items.length) { hide(); return; }
var html = items.map(function (it) {
var sub = it.sub ? '<span class="ns-sub">' + hi(it.sub, q) + '</span>' : '';
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
'</span><span class="ns-text"><span class="ns-label">' + hi(it.label, q) +
'</span>' + sub + '</span></a>';
}).join('');
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">همه نتایج برای «' + esc(q) + '» ←</a>';
box.innerHTML = html;
box.style.display = 'block';
}).catch(function () { hide(); });
}, 200);
});
document.addEventListener('click', function (e) { if (!form.contains(e.target)) hide(); });
input.addEventListener('keydown', function (e) { if (e.key === 'Escape') hide(); });
})();
</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>