[Help] Add help/learning page + interactive guided app tour
CI/CD / CI · dotnet build (push) Successful in 46s
CI/CD / Deploy · hamkadr (push) Successful in 1m10s

New /Help page: quick-start, separate guides for کادر درمان (search/near-me/preferences/اعلام تمایل/saved) and مراکز درمانی (register/post/verify-with-docs/applicants), notifications+install, report/complaint, and an FAQ accordion. Self-hosted tour.js (no CDN, RTL): spotlights elements via data-tour hooks in the nav, auto-runs once for new visitors on the home page (localStorage flag), re-runnable from the Help page button or ?tour=1; skips steps whose target is hidden so it works on mobile/other pages. Help linked from nav + footer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 17:39:03 +03:30
parent 70bab6b916
commit 698565c460
4 changed files with 278 additions and 8 deletions
+105
View File
@@ -0,0 +1,105 @@
@page
@{
ViewData["Title"] = "راهنما و آموزش";
ViewData["Description"] = "راهنمای کامل استفاده از همکادر برای کادر درمان و مراکز درمانی: جستجوی شیفت و استخدام، اعلام تمایل، انتشار آگهی، تأیید مرکز و اعلان‌ها.";
}
<div class="page-head">
<div class="container">
<h1>راهنما و آموزش</h1>
<p class="muted">یاد بگیر چطور از همکادر بیشترین استفاده را ببری — برای کادر درمان و مراکز درمانی.</p>
</div>
</div>
<div class="container section" style="max-width:820px;">
<div class="rec-banner" style="margin-bottom:18px;">
<div>
<h2 style="margin:0 0 4px;">تور تعاملی برنامه</h2>
<span style="opacity:.9; font-size:14px;">در چند ثانیه بخش‌های اصلی را روی همین صفحه نشانت می‌دهیم.</span>
</div>
<div>
<button type="button" class="btn btn-outline" onclick="window.hamkadrTour &amp;&amp; window.hamkadrTour.start()">▶ شروع تور راهنما</button>
</div>
</div>
<div class="card card-pad legal" style="margin-bottom:14px;">
<h2>شروع سریع (۳ گام)</h2>
<ol style="padding-inline-start:20px; line-height:2;">
<li><strong>وارد شو</strong> — با شماره موبایل و کد پیامکی. هنگام ورود نوع حساب را انتخاب کن: «کادر درمان» یا «کارفرما / مرکز درمانی».</li>
<li><strong>تنظیم کن</strong> — اگر کادر درمانی، در «علاقه‌مندی‌ها» نقش و شهر را مشخص کن؛ اگر کارفرمایی، مرکزت را ثبت کن.</li>
<li><strong>شروع کن</strong> — فرصت‌ها را ببین و «اعلام تمایل» بده، یا آگهی منتشر کن.</li>
</ol>
</div>
<div class="card card-pad legal" style="margin-bottom:14px;">
<h2>👩‍⚕️ برای کادر درمان (کارجو)</h2>
<ul>
<li><strong>یافتن شیفت/استخدام:</strong> از منو وارد «شیفت‌ها» یا «استخدام» شو و با فیلترِ شهر، محله، نقش و نوع شیفت نتایج را محدود کن.</li>
<li><strong>نزدیک من:</strong> با اجازه‌ی دسترسی به موقعیت، نزدیک‌ترین فرصت‌ها بر اساس فاصله مرتب می‌شوند.</li>
<li><strong>علاقه‌مندی‌ها:</strong> نقش/شهر/نوع شیفت موردنظرت را ذخیره کن تا پیشنهادهای شخصی‌سازی‌شده و اعلانِ فرصت‌های تازه بگیری.</li>
<li><strong>اعلام تمایل:</strong> روی آگهی، دکمه‌ی «اعلام تمایل و مشاهده راه ارتباطی» را بزن تا اطلاعات تماس مرکز نمایش داده شود.</li>
<li><strong>ذخیره و حذف:</strong> فرصت‌های جالب را ذخیره کن یا «علاقه‌مند نیستم» را بزن تا پیشنهادها دقیق‌تر شوند.</li>
<li><strong>پنل کارجو:</strong> فرصت‌های ذخیره‌شده و فعالیت‌هایت را اینجا دنبال کن.</li>
</ul>
</div>
<div class="card card-pad legal" style="margin-bottom:14px;">
<h2>🏥 برای مراکز درمانی (کارفرما)</h2>
<ul>
<li><strong>ثبت مرکز:</strong> از «پنل کارفرما» مرکزت را ثبت کن و موقعیت را روی نقشه یا با «موقعیت فعلی من» مشخص کن.</li>
<li><strong>انتشار آگهی:</strong> شیفت یا موقعیت استخدامی منتشر کن؛ نوع پرداخت (مبلغ ثابت یا درصد سهم) و شرط جنسیت را تعیین کن.</li>
<li><strong>تأیید مرکز:</strong> در پنل، «درخواست تأیید و بارگذاری مدارک» را بزن و مجوز/پروانه را آپلود کن. پس از بررسی، نشان «✓ تأیید شده» روی آگهی‌هایت نمایش داده می‌شود.</li>
<li><strong>مدیریت متقاضیان:</strong> فهرست افرادی که «اعلام تمایل» کرده‌اند را با نام و شماره ببین و هماهنگ کن.</li>
</ul>
</div>
<div class="card card-pad legal" style="margin-bottom:14px;">
<h2>🔔 اعلان‌ها و نصب اپ</h2>
<ul>
<li>اعلان‌های درون‌برنامه‌ای (زنگوله) به‌صورت زنده کار می‌کند و در ایران بدون نیاز به سرویس‌های خارجی در دسترس است.</li>
<li>برای دریافت بهتر اعلان‌ها، همکادر را به‌صورت اپ نصب کن: <a asp-page="/Download">صفحه‌ی دریافت اپلیکیشن</a> (اندروید، iOS، ویندوز، وب).</li>
</ul>
</div>
<div class="card card-pad legal" style="margin-bottom:14px;">
<h2>🛡️ گزارش و شکایت</h2>
<p>
اگر آگهی نادرست یا مرکز متخلفی دیدی، از دکمه‌ی «گزارش تخلف» یا «شکایت از این مرکز» در صفحه‌ی همان آگهی استفاده کن.
تیم همکادر بررسی می‌کند. قوانین کامل را در <a asp-page="/Rules">قوانین و مقررات</a> ببین.
</p>
</div>
<div class="card card-pad legal">
<h2>❓ سؤالات متداول</h2>
<details>
<summary>ورود به همکادر هزینه دارد؟</summary>
<p>خیر؛ استفاده برای کادر درمان و انتشار آگهی برای مراکز در حال حاضر رایگان است.</p>
</details>
<details>
<summary>کد ورود برایم نیامد، چه کنم؟</summary>
<p>چند ثانیه صبر کن و «ارسال مجدد کد» را بزن. از درست‌بودن شماره موبایل مطمئن شو. اگر مشکل ادامه داشت، بعداً دوباره تلاش کن.</p>
</details>
<details>
<summary>«اعلام تمایل» یعنی چه؟</summary>
<p>یعنی به آن فرصت علاقه‌مندی؛ با زدن آن، اطلاعات تماس مرکز برایت نمایش داده می‌شود و فرصت در سوابق تو ثبت می‌شود تا پیشنهادها دقیق‌تر شوند.</p>
</details>
<details>
<summary>نشان «✓ تأیید شده» چه معنایی دارد؟</summary>
<p>یعنی مدارک آن مرکز توسط همکادر بررسی شده است. این نشان بررسی اولیه است و جای راستی‌آزمایی مستقیم پیش از توافق را نمی‌گیرد.</p>
</details>
<details>
<summary>چطور مرکزم را تأیید کنم؟</summary>
<p>وارد «پنل کارفرما» شو، روی مرکز موردنظر «درخواست تأیید و بارگذاری مدارک» را بزن و مجوز/پروانه را آپلود کن تا بررسی شود.</p>
</details>
<details>
<summary>چطور این تور را دوباره ببینم؟</summary>
<p>دکمه‌ی «▶ شروع تور راهنما» در بالای همین صفحه را بزن.</p>
</details>
<p class="muted" style="font-size:13px; margin-top:14px; margin-bottom:0;">
پاسخت را نیافتی؟ با ما در تماس باش: <span dir="ltr">support@@hamkadr.ir</span>
</p>
</div>
</div>
@@ -32,7 +32,7 @@
<body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")"> <body data-unread="@unreadCount" data-authed="@(User.Identity?.IsAuthenticated == true ? "1" : "0")">
<header class="site-header"> <header class="site-header">
<div class="container header-inner"> <div class="container header-inner">
<a class="brand" asp-page="/Index"> <a class="brand" asp-page="/Index" data-tour="home">
<span class="brand-mark">ه</span> <span class="brand-mark">ه</span>
<span class="brand-text">همکادر</span> <span class="brand-text">همکادر</span>
</a> </a>
@@ -44,19 +44,20 @@
} }
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden /> <input type="checkbox" id="nav-toggle" class="nav-toggle" hidden />
<label for="nav-toggle" class="nav-burger" aria-label="باز/بستن منو"> <label for="nav-toggle" class="nav-burger" aria-label="باز/بستن منو" data-tour="menu">
<span></span><span></span><span></span> <span></span><span></span><span></span>
</label> </label>
<div class="nav-collapse"> <div class="nav-collapse">
<nav class="main-nav"> <nav class="main-nav">
<a asp-page="/Index">خانه</a> <a asp-page="/Index">خانه</a>
<a asp-page="/Shifts/Index">شیفت‌ها</a> <a asp-page="/Shifts/Index" data-tour="shifts">شیفت‌ها</a>
<a asp-page="/Jobs/Index">استخدام</a> <a asp-page="/Jobs/Index" data-tour="jobs">استخدام</a>
<a asp-page="/Calendar/Index">تقویم هفتگی</a> <a asp-page="/Calendar/Index">تقویم هفتگی</a>
<a asp-page="/Download">دریافت اپ</a> <a asp-page="/Download">دریافت اپ</a>
<a asp-page="/Facilities/Index">مراکز درمانی</a> <a asp-page="/Facilities/Index">مراکز درمانی</a>
<a asp-page="/Preferences/Index">علاقه‌مندی‌ها</a> <a asp-page="/Preferences/Index" data-tour="prefs">علاقه‌مندی‌ها</a>
<a asp-page="/Help" data-tour="help">راهنما</a>
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
@@ -70,15 +71,15 @@
{ {
<a class="nav-action" asp-page="/Employer/Index">پنل کارفرما</a> <a class="nav-action" asp-page="/Employer/Index">پنل کارفرما</a>
} }
<a class="nav-action bell-inline js-bell" asp-page="/Me/Notifications" title="اعلان‌ها"><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> <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>
<a class="nav-action" asp-page="/Me/Index">پنل کارجو</a> <a class="nav-action" asp-page="/Me/Index" data-tour="panel">پنل کارجو</a>
<form method="post" asp-page="/Account/Logout" style="display:contents;"> <form method="post" asp-page="/Account/Logout" style="display:contents;">
<button type="submit" class="btn btn-outline btn-sm">خروج</button> <button type="submit" class="btn btn-outline btn-sm">خروج</button>
</form> </form>
} }
else else
{ {
<a class="btn btn-outline btn-sm" asp-page="/Account/Login">ورود</a> <a class="btn btn-outline btn-sm" asp-page="/Account/Login" data-tour="login">ورود</a>
} }
</div> </div>
</div> </div>
@@ -99,6 +100,7 @@
<div class="muted"> <div class="muted">
<div class="footer-links"> <div class="footer-links">
<a asp-page="/Download" style="font-weight:700;">📲 دریافت اپلیکیشن</a> <a asp-page="/Download" style="font-weight:700;">📲 دریافت اپلیکیشن</a>
<a asp-page="/Help">راهنما</a>
<a asp-page="/Privacy">حریم خصوصی</a> <a asp-page="/Privacy">حریم خصوصی</a>
<a asp-page="/Rules">قوانین و مقررات</a> <a asp-page="/Rules">قوانین و مقررات</a>
<a asp-page="/Terms">شرایط استفاده</a> <a asp-page="/Terms">شرایط استفاده</a>
@@ -117,6 +119,9 @@
} }
</script> </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>
@* Live in-app notifications over SSE (our own origin — works in Iran, no Google push). @* 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. *@ Updates the bell badge, shows a toast, and fires a local OS notification when allowed. *@
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
+18
View File
@@ -235,6 +235,24 @@ label { font-size: 13px; }
.footer-links a { color: var(--muted); } .footer-links a { color: var(--muted); }
.footer-links a:hover { color: var(--primary); } .footer-links a:hover { color: var(--primary); }
/* ---------- Guided tour ---------- */
.tour-overlay { position: fixed; inset: 0; z-index: 1000; }
.tour-hole {
position: fixed; border-radius: 12px; pointer-events: none;
box-shadow: 0 0 0 9999px rgba(13,30,40,.62);
outline: 2px solid var(--accent); transition: all .2s ease;
}
.tour-bubble {
position: fixed; z-index: 1001; background: var(--surface); color: var(--text);
border-radius: 14px; box-shadow: 0 14px 40px rgba(0,0,0,.28);
padding: 16px; max-width: 320px;
}
.tour-title { font-weight: 800; font-size: 16px; margin-bottom: 6px; }
.tour-text { font-size: 14px; color: var(--muted); line-height: 1.9; }
.tour-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 14px; }
.tour-count { font-size: 12px; color: var(--muted); }
.tour-btns { display: flex; gap: 6px; }
/* Legal/policy pages (privacy, rules, terms) — comfortable long-form reading. */ /* Legal/policy pages (privacy, rules, terms) — comfortable long-form reading. */
.legal { line-height: 2; } .legal { line-height: 2; }
.legal h2 { font-size: 17px; margin: 22px 0 8px; color: var(--primary-dark); } .legal h2 { font-size: 17px; margin: 22px 0 8px; color: var(--primary-dark); }
+142
View File
@@ -0,0 +1,142 @@
/* همکادر — lightweight guided tour (no external libs, RTL/Persian).
Spotlights elements marked with [data-tour]. Auto-runs once for new visitors on the
home page; re-runnable any time via window.hamkadrTour.start() or ?tour=1.
Steps whose target is missing/hidden are skipped, so it works across pages & mobile. */
(function () {
'use strict';
var DONE_KEY = 'hamkadr_tour_done_v1';
// Ordered steps. `sel` is a [data-tour] value; first matching visible element is used.
var STEPS = [
{ sel: 'home', title: 'به همکادر خوش آمدید 👋', text: 'سامانه‌ی یافتن شیفت و استخدام برای کادر درمان. این تور کوتاه بخش‌های اصلی را نشان می‌دهد.' },
{ sel: 'menu', title: 'منو', text: 'از این دکمه به همه‌ی بخش‌ها دسترسی داری.' },
{ sel: 'shifts', title: 'شیفت‌ها', text: 'فرصت‌های شیفت کاری را اینجا ببین و با فیلتر شهر/محله/نقش پیدا کن.' },
{ sel: 'jobs', title: 'استخدام', text: 'موقعیت‌های استخدامی تمام‌وقت، پاره‌وقت و طرح را اینجا مرور کن.' },
{ sel: 'prefs', title: 'علاقه‌مندی‌ها', text: 'نقش، شهر و نوع شیفتِ موردنظرت را تعیین کن تا پیشنهادهای متناسب بگیری.' },
{ sel: 'bell', title: 'اعلان‌ها', text: 'وقتی فرصت تازه‌ای مطابق علاقه‌ات منتشر شود، همین‌جا باخبر می‌شوی.' },
{ sel: 'login', title: 'ورود', text: 'با شماره موبایل وارد شو تا فرصت‌ها را ذخیره کنی و «اعلام تمایل» بدهی.' },
{ sel: 'panel', title: 'پنل تو', text: 'فرصت‌های ذخیره‌شده و درخواست‌هایت را در پنل کاربری دنبال کن.' },
{ sel: 'help', title: 'راهنما', text: 'هر زمان خواستی، راهنمای کامل و همین تور را از این بخش باز کن. موفق باشی! 🌟' }
];
var overlay, hole, bubble, idx, steps;
function visible(el) {
if (!el) return false;
var r = el.getBoundingClientRect();
return el.offsetParent !== null && r.width > 0 && r.height > 0;
}
function build() {
overlay = document.createElement('div'); overlay.className = 'tour-overlay';
hole = document.createElement('div'); hole.className = 'tour-hole';
bubble = document.createElement('div'); bubble.className = 'tour-bubble';
bubble.innerHTML =
'<div class="tour-title"></div>' +
'<div class="tour-text"></div>' +
'<div class="tour-foot">' +
'<span class="tour-count"></span>' +
'<span class="tour-btns">' +
'<button type="button" class="btn btn-outline btn-sm tour-skip">رد کردن</button>' +
'<button type="button" class="btn btn-outline btn-sm tour-prev">قبلی</button>' +
'<button type="button" class="btn btn-accent btn-sm tour-next">بعدی</button>' +
'</span>' +
'</div>';
overlay.appendChild(hole);
document.body.appendChild(overlay);
document.body.appendChild(bubble);
overlay.addEventListener('click', finish);
bubble.querySelector('.tour-skip').addEventListener('click', finish);
bubble.querySelector('.tour-prev').addEventListener('click', function () { go(idx - 1); });
bubble.querySelector('.tour-next').addEventListener('click', function () { go(idx + 1); });
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, true);
document.addEventListener('keydown', onKey);
}
function onKey(e) {
if (!overlay) return;
if (e.key === 'Escape') finish();
else if (e.key === 'ArrowLeft') go(idx + 1); // RTL: left = next
else if (e.key === 'ArrowRight') go(idx - 1);
}
function target() { return document.querySelector('[data-tour="' + steps[idx].sel + '"]'); }
function reposition() {
if (!overlay || idx == null) return;
var el = target();
if (!el) return;
var r = el.getBoundingClientRect(), pad = 6;
hole.style.top = (r.top - pad) + 'px';
hole.style.left = (r.left - pad) + 'px';
hole.style.width = (r.width + pad * 2) + 'px';
hole.style.height = (r.height + pad * 2) + 'px';
// place the bubble below the target if room, else above
var below = r.bottom + 12, bw = Math.min(320, window.innerWidth - 24);
bubble.style.width = bw + 'px';
var left = Math.min(Math.max(12, r.left + r.width / 2 - bw / 2), window.innerWidth - bw - 12);
bubble.style.left = left + 'px';
var bh = bubble.offsetHeight || 150;
if (below + bh > window.innerHeight - 8 && r.top - 12 - bh > 8) bubble.style.top = (r.top - 12 - bh) + 'px';
else bubble.style.top = below + 'px';
}
function render() {
var s = steps[idx];
bubble.querySelector('.tour-title').textContent = s.title;
bubble.querySelector('.tour-text').textContent = s.text;
bubble.querySelector('.tour-count').textContent = toFa((idx + 1) + ' / ' + steps.length);
bubble.querySelector('.tour-prev').style.visibility = idx === 0 ? 'hidden' : 'visible';
bubble.querySelector('.tour-next').textContent = idx === steps.length - 1 ? 'پایان' : 'بعدی';
}
function go(n) {
if (n < 0) return;
if (n >= steps.length) return finish();
idx = n;
var el = target();
if (el && el.scrollIntoView) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
render();
setTimeout(reposition, 60);
}
function finish() {
try { localStorage.setItem(DONE_KEY, '1'); } catch (e) {}
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', reposition);
window.removeEventListener('scroll', reposition, true);
if (overlay) overlay.remove();
if (bubble) bubble.remove();
overlay = bubble = null; idx = null;
}
function start() {
// recompute visible steps each run (depends on auth state / page / viewport)
steps = STEPS.filter(function (s) { return visible(document.querySelector('[data-tour="' + s.sel + '"]')); });
if (steps.length === 0) return;
if (overlay) finish();
build();
go(0);
}
function toFa(s) {
var d = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
return String(s).replace(/[0-9]/g, function (c) { return d[+c]; });
}
window.hamkadrTour = { start: start, reset: function () { try { localStorage.removeItem(DONE_KEY); } catch (e) {} } };
function maybeAutoStart() {
var params = new URLSearchParams(location.search);
if (params.get('tour') === '1') { start(); return; }
var onHome = location.pathname === '/' || location.pathname === '';
var done = false; try { done = localStorage.getItem(DONE_KEY) === '1'; } catch (e) {}
if (onHome && !done) setTimeout(start, 900);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', maybeAutoStart);
else maybeAutoStart();
})();