PWA: installable app (web/win/android/ios) + download/help page + push notifications
CI/CD / CI · dotnet build (push) Successful in 40s
CI/CD / Deploy · hamkadr (push) Successful in 55s

- manifest.webmanifest + service worker (offline shell + push + notificationclick) + PNG icons (192/512/apple) + iOS meta + SW registration → installable everywhere
- /Download page: per-OS install help (web/windows/android/ios), install button (beforeinstallprompt), 'enable notifications' flow, usage guide, Bazaar/TWA note; nav + footer links
- Web Push foundation: WebPushSubscription entity + /push/subscribe (stores), VAPID + push settings in /Admin/Settings, on-device local notification; server broadcast documented (WebPush via Nexus)
- docs/PWA-TWA.md: VAPID keygen, server-push wiring, Bubblewrap→Cafe Bazaar + assetlinks steps
- Verified: manifest/sw/icons served, download page, subscribe stores (200), layout wired

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-04 11:23:13 +03:30
parent 9a92da42e6
commit a02eb6a985
17 changed files with 1417 additions and 1 deletions
@@ -142,6 +142,28 @@
<p class="muted" style="font-size:12px; margin:4px 0 0;">برای انتخاب موقعیت روی نقشه در فرم ثبت مرکز. بدون کلید، فقط دکمه «موقعیت فعلی من» نمایش داده می‌شود.</p>
</div>
<hr style="border:none; border-top:1px solid var(--line); margin:18px 0;" />
<h3 style="margin-top:0;">اعلان‌ها (Web Push / PWA)</h3>
<div class="filter-group">
<label style="display:flex; align-items:center; gap:8px; font-weight:700;">
<input type="checkbox" name="PushEnabled" value="true" style="width:auto;" checked="@Model.PushEnabled" />
فعال‌سازی اشتراک اعلان مرورگری
</label>
</div>
<div class="filter-group">
<label>VAPID Public Key</label>
<input type="text" name="VapidPublicKey" value="@Model.VapidPublicKey" dir="ltr" />
</div>
<div class="filter-group">
<label>VAPID Private Key</label>
<input type="password" name="VapidPrivateKey" value="@Model.VapidPrivateKey" dir="ltr" />
</div>
<div class="filter-group">
<label>VAPID Subject</label>
<input type="text" name="VapidSubject" value="@Model.VapidSubject" dir="ltr" placeholder="mailto:admin@hamkadr.ir" />
</div>
<p class="muted" style="font-size:12px;">جفت‌کلید VAPID را یک‌بار بساز (web-push). بدون آن، اعلان محلی روی دستگاه کار می‌کند ولی ارسال از سرور نیاز به کلید دارد.</p>
<button type="submit" class="btn btn-accent btn-block btn-lg">ذخیره تنظیمات</button>
</form>
</div>
@@ -37,6 +37,10 @@ public class SettingsModel : PageModel
[BindProperty] public string? SmsTemplate { get; set; }
[BindProperty] public string? SmsSender { get; set; }
[BindProperty] public string? NeshanMapKey { get; set; }
[BindProperty] public bool PushEnabled { get; set; }
[BindProperty] public string? VapidPublicKey { get; set; }
[BindProperty] public string? VapidPrivateKey { get; set; }
[BindProperty] public string? VapidSubject { get; set; }
[TempData] public string? Saved { get; set; }
public async Task OnGetAsync()
@@ -66,6 +70,10 @@ public class SettingsModel : PageModel
SmsTemplate = s.SmsTemplate;
SmsSender = s.SmsSender;
NeshanMapKey = s.NeshanMapKey;
PushEnabled = s.PushEnabled;
VapidPublicKey = s.VapidPublicKey;
VapidPrivateKey = s.VapidPrivateKey;
VapidSubject = s.VapidSubject;
}
public async Task<IActionResult> OnPostAsync()
@@ -96,6 +104,10 @@ public class SettingsModel : PageModel
SmsTemplate = SmsTemplate,
SmsSender = SmsSender,
NeshanMapKey = NeshanMapKey,
PushEnabled = PushEnabled,
VapidPublicKey = VapidPublicKey,
VapidPrivateKey = VapidPrivateKey,
VapidSubject = VapidSubject,
});
Saved = "تنظیمات ذخیره شد.";
return RedirectToPage();
+96
View File
@@ -0,0 +1,96 @@
@page
@model JobsMedical.Web.Pages.DownloadModel
@{
ViewData["Title"] = "دریافت اپلیکیشن همکادر";
ViewData["Description"] = "نصب اپلیکیشن همکادر روی موبایل و دسکتاپ — اندروید، iOS، ویندوز و وب. دریافت اعلان فرصت‌های شغلی کادر درمان.";
}
<div class="page-head">
<div class="container">
<h1>دریافت اپلیکیشن همکادر</h1>
<p class="muted">همکادر یک «اپ تحت وب» (PWA) است؛ بدون فروشگاه هم نصب می‌شود و مثل یک اپ واقعی روی صفحه‌ی اصلی می‌نشیند و اعلان می‌فرستد.</p>
</div>
</div>
<div class="container section">
<div class="rec-banner">
<div>
<h2 style="margin:0 0 4px;">نصب سریع روی این دستگاه</h2>
<span style="opacity:.9; font-size:14px;">با یک دکمه، همکادر را به صفحه‌ی اصلی اضافه کن</span>
</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button id="installBtn" class="btn btn-outline" style="display:none;">⬇️ نصب اپلیکیشن</button>
<button id="notifyBtn" class="btn btn-outline">🔔 فعال‌سازی اعلان‌ها</button>
</div>
</div>
<p id="pwaMsg" class="muted" style="font-size:13px; margin-top:-6px;"></p>
<div class="grid grid-4" style="margin-top:8px;">
<div class="card card-pad">
<h3 style="margin-top:0;">🌐 وب</h3>
<p class="muted" style="font-size:13.5px;">همین حالا در مرورگر باز است. برای نصب، دکمه‌ی «نصب اپلیکیشن» بالا را بزن (کروم/اج).</p>
</div>
<div class="card card-pad">
<h3 style="margin-top:0;">🪟 ویندوز</h3>
<p class="muted" style="font-size:13.5px;">در Chrome/Edge، آیکن نصب (⊕) در نوار آدرس را بزن یا منو ← «Install همکادر». روی دسکتاپ مثل یک برنامه باز می‌شود.</p>
</div>
<div class="card card-pad">
<h3 style="margin-top:0;">🤖 اندروید</h3>
<p class="muted" style="font-size:13.5px;">در Chrome دکمه‌ی «نصب اپلیکیشن» یا منو ← «Add to Home screen». به‌زودی از <strong>کافه‌بازار</strong> هم قابل نصب خواهد بود.</p>
</div>
<div class="card card-pad">
<h3 style="margin-top:0;"> iOS</h3>
<p class="muted" style="font-size:13.5px;">در Safari، دکمه‌ی Share (□↑) ← «Add to Home Screen» ← Add. آیکن همکادر روی صفحه‌ی اصلی می‌آید.</p>
</div>
</div>
<div class="card card-pad" style="margin-top:20px;">
<h2 style="margin-top:0; font-size:20px;">راهنمای استفاده</h2>
<ol style="margin:0; padding-inline-start:20px; line-height:2;">
<li><strong>نصب کن</strong> — طبق راهنمای دستگاهت بالا، همکادر را به صفحه‌ی اصلی اضافه کن.</li>
<li><strong>وارد شو</strong> — با شماره‌ موبایل و کد پیامکی؛ هنگام ثبت‌نام نوع حساب (کادر درمان یا کارفرما) را انتخاب کن.</li>
<li><strong>کادر درمان:</strong> در «علاقه‌مندی‌ها» نقش/شهر/نوع شیفت را تعیین کن تا پیشنهادهای متناسب بگیری؛ با «نزدیک من» نزدیک‌ترین فرصت‌ها را ببین؛ روی شیفت/استخدام «اعلام تمایل» بزن.</li>
<li><strong>کارفرما:</strong> مرکزت را ثبت کن (موقعیت را روی نقشه بگذار)، سپس شیفت/استخدام منتشر کن و متقاضیان را در پنل ببین.</li>
<li><strong>اعلان‌ها</strong> — دکمه‌ی «فعال‌سازی اعلان‌ها» را بزن تا از فرصت‌های جدید باخبر شوی.</li>
</ol>
</div>
</div>
@section Scripts {
<script>
var VAPID = '@Model.VapidPublicKey';
var msg = document.getElementById('pwaMsg');
// --- Install (Add to Home Screen) ---
var deferred = null, installBtn = document.getElementById('installBtn');
window.addEventListener('beforeinstallprompt', function (e) { e.preventDefault(); deferred = e; installBtn.style.display = 'inline-flex'; });
installBtn.addEventListener('click', async function () {
if (!deferred) { msg.textContent = 'برای نصب از منوی مرورگر «Add to Home screen / Install» استفاده کن.'; return; }
deferred.prompt(); await deferred.userChoice; deferred = null; installBtn.style.display = 'none';
});
if (window.matchMedia('(display-mode: standalone)').matches) { msg.textContent = '✓ اپلیکیشن نصب شده و در حالت مستقل اجرا می‌شود.'; }
// --- Notifications ---
function b64ToUint8(b64) {
var pad = '='.repeat((4 - b64.length % 4) % 4);
var s = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
var raw = atob(s); var arr = new Uint8Array(raw.length);
for (var i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
return arr;
}
document.getElementById('notifyBtn').addEventListener('click', async function () {
if (!('Notification' in window) || !('serviceWorker' in navigator)) { msg.textContent = 'مرورگر شما از اعلان‌ها پشتیبانی نمی‌کند.'; return; }
var perm = await Notification.requestPermission();
if (perm !== 'granted') { msg.textContent = 'اجازه‌ی اعلان داده نشد.'; return; }
var reg = await navigator.serviceWorker.ready;
if (VAPID) {
try {
var sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToUint8(VAPID) });
await fetch('/push/subscribe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub) });
} catch (e) { /* push service may be unreachable; local notifications still work */ }
}
reg.showNotification('همکادر', { body: 'اعلان‌ها فعال شد ✓ از فرصت‌های جدید باخبر می‌شوی.', icon: '/icons/icon-192.png', dir: 'rtl', lang: 'fa' });
msg.textContent = '✓ اعلان‌ها فعال شد.';
});
</script>
}
@@ -0,0 +1,20 @@
using JobsMedical.Web.Services.Scraping;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JobsMedical.Web.Pages;
public class DownloadModel : PageModel
{
private readonly SettingsService _settings;
public DownloadModel(SettingsService settings) => _settings = settings;
public string? VapidPublicKey { get; private set; }
public bool PushReady { get; private set; }
public async Task OnGetAsync()
{
var s = await _settings.GetAsync();
VapidPublicKey = s.VapidPublicKey;
PushReady = s.PushEnabled && !string.IsNullOrWhiteSpace(s.VapidPublicKey);
}
}
@@ -12,6 +12,15 @@
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="همکادر" />
</head>
<body>
<header class="site-header">
@@ -25,6 +34,7 @@
<a asp-page="/Shifts/Index">شیفت‌ها</a>
<a asp-page="/Jobs/Index">استخدام</a>
<a asp-page="/Calendar/Index">تقویم هفتگی</a>
<a asp-page="/Download">دریافت اپ</a>
<a asp-page="/Facilities/Index">مراکز درمانی</a>
<a asp-page="/Preferences/Index">علاقه‌مندی‌ها</a>
</nav>
@@ -63,10 +73,19 @@
<strong>همکادر</strong>
<p class="muted">سامانه واسط میان کادر درمان و مراکز درمانی برای شیفت و استخدام</p>
</div>
<div class="muted">© ۱۴۰۵ همکادر — همه حقوق محفوظ است</div>
<div class="muted">
<a asp-page="/Download" style="font-weight:700;">📲 دریافت اپلیکیشن</a>
· © ۱۴۰۵ همکادر — همه حقوق محفوظ است
</div>
</div>
</footer>
@* 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>
@await RenderSectionAsync("Scripts", required: false)
</body>
</html>