Files
hamkadr/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml
T
soroush.asadi 7acf94695f
CI/CD / CI · dotnet build (push) Successful in 2m38s
CI/CD / Deploy · hamkadr (push) Successful in 3m30s
Make sw.js non-cacheable and self-update so the worker fix actually reaches clients
The sw.js file was cacheable (text/javascript, not covered by the HTML no-cache rule), so browsers/CDN kept serving the old v1 worker and never picked up the v2 fix. Serve sw.js with no-cache/no-store, call reg.update() on load, and reload once on controllerchange so stale cached pages are dropped immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:24:02 +03:30

449 lines
25 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">
@* Browse items only — personal ones (پیشنهادها/پسندیده‌ها) live in the profile menu. *@
<nav class="main-nav">
<a asp-page="/Index" class="@(path == "/" ? "active" : null)">خانه</a>
<a href="/Jobs" data-tour="jobs" class="@(path.StartsWith("/Jobs") ? "active" : null)">استخدام</a>
<a href="/Shifts" data-tour="shifts" class="@(path.StartsWith("/Shifts") ? "active" : null)">شیفت‌ها</a>
<a asp-page="/Talent/Index" class="@(path.StartsWith("/Talent") ? "active" : null)">آماده به کار</a>
@if (User.Identity?.IsAuthenticated != true)
{
<a asp-page="/Recommendations/Index" class="@(path.StartsWith("/Recommendations") ? "active" : null)">✨ پیشنهادها</a>
}
<details class="nav-more">
<summary class="@(path.StartsWith("/Facilities") || path.StartsWith("/Calendar") ? "active" : null)">بیشتر ▾</summary>
<div class="nav-more-menu">
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "active" : null)">🏥 مراکز درمانی</a>
<a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">🗓️ تقویم هفتگی</a>
</div>
</details>
<a asp-page="/Search" class="nav-search-link @(path.StartsWith("/Search") ? "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-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 asp-page="/Recommendations/Index">✨ پیشنهادهای ویژه شما</a>
<a asp-page="/Me/Liked">❤️ پسندیده‌ها</a>
<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').then(function (reg) {
reg.update(); // always check for a newer worker so fixes reach clients fast
// When a new worker takes control, reload once so stale cached pages are dropped.
var refreshed = false;
navigator.serviceWorker.addEventListener('controllerchange', function () {
if (refreshed) return; refreshed = true; location.reload();
});
}).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;
// Close the «بیشتر» nav dropdown when clicking outside it.
document.querySelectorAll('details.nav-more[open]').forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute('open');
});
});
</script>
@* Instant search suggestions (typeahead) — attaches to every form[data-suggest]
(header pill + homepage hero). *@
<script>
(function () {
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 attach(form) {
var input = form.querySelector('input[type=search], input[name=Q]');
if (!input) return;
var box = document.createElement('div');
box.className = 'nav-search-results';
box.style.display = 'none';
// Anchor the dropdown to the input's box (the hero pill) so it sits
// directly under the input rather than below the popular-search chips.
(input.closest('.hero-search-pill') || form).appendChild(box);
var timer;
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 (data) {
var items = (data && data.items) || [];
var total = (data && data.total) || items.length;
if (!items.length) { hide(); return; }
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
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) + '">مشاهده همه ' + fa(total) + ' نتیجه برای «' + 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(); });
}
document.querySelectorAll('[data-suggest]').forEach(attach);
})();
</script>
@* Contact modal — any element with data-contact-type + data-contact-id opens it; numbers are
fetched from /contact on click (so they never sit in list HTML and bots can't scrape them). *@
<div id="contactModal" class="contact-modal" aria-hidden="true">
<div class="contact-modal-box" role="dialog" aria-modal="true" aria-labelledby="contactModalTitle">
<div class="contact-modal-head">
<h3 id="contactModalTitle">راه‌های ارتباطی</h3>
<button type="button" class="contact-modal-x" data-contact-close aria-label="بستن">✕</button>
</div>
<div id="contactModalBody" class="contact-modal-body"></div>
</div>
</div>
@* Like («پسندیدن») toggle — login-gated, updates the button state + count in place. *@
<script>
(function () {
function fa(n) { return String(n).replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'[+d]; }); }
document.addEventListener('click', function (e) {
var b = e.target.closest('.like-trigger');
if (!b) return;
e.preventDefault();
if (document.body.dataset.authed !== '1') {
location.href = '/Account/Login?returnUrl=' + encodeURIComponent(location.pathname);
return;
}
var fd = new FormData();
fd.append('type', b.dataset.likeType);
fd.append('id', b.dataset.likeId);
b.disabled = true;
fetch('/like', { method: 'POST', body: fd })
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (d) {
b.dataset.liked = d.liked ? 'true' : 'false';
b.classList.toggle('btn-accent', d.liked);
b.classList.toggle('btn-outline', !d.liked);
var ico = b.querySelector('.like-ico'); if (ico) ico.textContent = d.liked ? '♥' : '♡';
var c = b.querySelector('.like-count'); if (c) c.textContent = fa(d.count);
})
.catch(function () {})
.finally(function () { b.disabled = false; });
});
})();
</script>
<script>
(function () {
var modal = document.getElementById('contactModal');
var box = document.getElementById('contactModalBody');
var titleEl = document.getElementById('contactModalTitle');
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
function open() { modal.classList.add('show'); modal.setAttribute('aria-hidden', 'false'); }
function close() { modal.classList.remove('show'); modal.setAttribute('aria-hidden', 'true'); box.innerHTML = ''; }
function render(d) {
titleEl.textContent = d.title || 'راه‌های ارتباطی';
var html = '';
(d.contacts || []).forEach(function (c) {
html += '<div class="contact-row"><span class="c-meta"><span class="c-type">' + esc(c.icon + ' ' + c.label) +
'</span><span class="c-val" dir="ltr">' + esc(c.value) + '</span></span>' +
(c.href ? '<a class="btn btn-accent" href="' + esc(c.href) + '" target="_blank" rel="nofollow noopener">تماس</a>' : '') + '</div>';
});
if (d.fallbackUrl) html += '<a class="btn btn-accent btn-block" href="' + esc(d.fallbackUrl) +
'" target="_blank" rel="nofollow noopener">' + esc(d.fallbackLabel || 'مشاهده') + '</a>';
box.innerHTML = html || '<p class="muted" style="margin:0;">شماره‌ای ثبت نشده است.</p>';
}
document.addEventListener('click', function (e) {
var trigger = e.target.closest('[data-contact-type]');
if (trigger) {
e.preventDefault(); e.stopPropagation(); // don't follow the card link
titleEl.textContent = 'راه‌های ارتباطی';
box.innerHTML = '<p class="muted" style="margin:0;">در حال دریافت…</p>';
open();
fetch('/contact?type=' + encodeURIComponent(trigger.getAttribute('data-contact-type')) +
'&id=' + encodeURIComponent(trigger.getAttribute('data-contact-id')))
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(render)
.catch(function () { box.innerHTML = '<p class="muted" style="margin:0;">خطا در دریافت اطلاعات تماس.</p>'; });
return;
}
if (e.target.closest('[data-contact-close]') || e.target === modal) close();
});
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
})();
</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>