Initial commit — Hamkadr (همکادر) healthcare-staffing marketplace
ASP.NET Core 10 Razor Pages + PostgreSQL/EF Core. RTL Persian, Jalali dates, self-hosted Vazirmatn, teal/coral brand. Features: - Shift listings: browse/filter (city, district, role, type, pay), weekly Jalali calendar, detail + interest handoff, near-me distance sort - Hiring (استخدام) listings with employment type + salary range - Pattern-engine recommendations + anonymous interest tracking (visitor cookie) - Heuristic Persian listing-parser + admin queue (raw channel post → shift/job) - Phone-OTP cookie auth + visitor-history linking + profile Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Account.LoginModel
|
||||
@{
|
||||
ViewData["Title"] = "ورود کادر درمان";
|
||||
}
|
||||
|
||||
<div class="container section" style="max-width:440px;">
|
||||
<div class="card card-pad">
|
||||
<h1 style="margin-top:0; font-size:22px;">ورود / ثبتنام</h1>
|
||||
<p class="muted">با شماره موبایل وارد شو تا فرصتهای متناسب با تو را ذخیره و پیشنهاد کنیم.</p>
|
||||
|
||||
@if (Model.Error is not null)
|
||||
{
|
||||
<div class="alert" style="background:#fdeaea; color:var(--danger);">@Model.Error</div>
|
||||
}
|
||||
|
||||
@if (!Model.CodeSent)
|
||||
{
|
||||
<form method="post">
|
||||
<div class="filter-group">
|
||||
<label>شماره موبایل</label>
|
||||
<input type="tel" name="Phone" value="@Model.Phone" placeholder="۰۹۱۲ ..." dir="ltr" />
|
||||
</div>
|
||||
<button type="submit" asp-page-handler="RequestCode" class="btn btn-accent btn-block btn-lg">دریافت کد تأیید</button>
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (Model.DevCode is not null)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
کد تأیید (حالت توسعه): <strong dir="ltr">@Model.DevCode</strong><br />
|
||||
<span style="font-size:12px;">در نسخهی نهایی این کد از طریق پیامک (کاوهنگار/SMS.ir) ارسال میشود.</span>
|
||||
</div>
|
||||
}
|
||||
<form method="post">
|
||||
<input type="hidden" name="Phone" value="@Model.Phone" />
|
||||
<div class="filter-group">
|
||||
<label>کد تأیید پنجرقمی</label>
|
||||
<input type="text" name="Code" placeholder="- - - - -" dir="ltr" inputmode="numeric" />
|
||||
</div>
|
||||
<button type="submit" asp-page-handler="Verify" class="btn btn-accent btn-block btn-lg">ورود</button>
|
||||
</form>
|
||||
<form method="post" style="margin-top:8px;">
|
||||
<input type="hidden" name="Phone" value="@Model.Phone" />
|
||||
<button type="submit" asp-page-handler="RequestCode" class="btn btn-outline btn-block">ارسال مجدد کد</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.Security.Claims;
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using JobsMedical.Web.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Pages.Account;
|
||||
|
||||
public class LoginModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly OtpService _otp;
|
||||
private readonly VisitorContext _visitor;
|
||||
private readonly IConfiguration _config;
|
||||
|
||||
public LoginModel(AppDbContext db, OtpService otp, VisitorContext visitor, IConfiguration config)
|
||||
{
|
||||
_db = db;
|
||||
_otp = otp;
|
||||
_visitor = visitor;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
[BindProperty] public string Phone { get; set; } = "";
|
||||
[BindProperty] public string? Code { get; set; }
|
||||
|
||||
public bool CodeSent { get; private set; }
|
||||
public string? DevCode { get; private set; } // shown only in dev (no SMS gateway yet)
|
||||
public string? Error { get; private set; }
|
||||
|
||||
public void OnGet() { }
|
||||
|
||||
public IActionResult OnPostRequestCode()
|
||||
{
|
||||
var phone = OtpService.Normalize(Phone);
|
||||
if (phone.Length < 10)
|
||||
{
|
||||
Error = "شماره موبایل معتبر وارد کنید.";
|
||||
return Page();
|
||||
}
|
||||
Phone = phone;
|
||||
DevCode = _otp.Issue(phone); // dev: surface the code; prod: SMS gateway sends it
|
||||
CodeSent = true;
|
||||
return Page();
|
||||
}
|
||||
|
||||
public async Task<IActionResult> OnPostVerifyAsync(string? returnUrl)
|
||||
{
|
||||
var phone = OtpService.Normalize(Phone);
|
||||
if (!_otp.Verify(phone, Code ?? ""))
|
||||
{
|
||||
Error = "کد واردشده نادرست یا منقضی شده است.";
|
||||
CodeSent = true;
|
||||
return Page();
|
||||
}
|
||||
|
||||
// Find or create the user. The configured admin phone is granted the Admin role.
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.Phone == phone);
|
||||
var isAdmin = phone == OtpService.Normalize(_config["Auth:AdminPhone"] ?? "");
|
||||
if (user is null)
|
||||
{
|
||||
user = new User { Phone = phone, IsPhoneVerified = true,
|
||||
Role = isAdmin ? UserRole.Admin : UserRole.Doctor };
|
||||
_db.Users.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.IsPhoneVerified = true;
|
||||
if (isAdmin) user.Role = UserRole.Admin;
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Link the anonymous visitor (and its interest history) to this account.
|
||||
var vid = _visitor.VisitorId;
|
||||
if (!string.IsNullOrEmpty(vid))
|
||||
{
|
||||
var visitor = await _db.Visitors.FirstOrDefaultAsync(v => v.Id == vid);
|
||||
if (visitor is null) { visitor = new Visitor { Id = vid }; _db.Visitors.Add(visitor); }
|
||||
visitor.UserId = user.Id;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.MobilePhone, user.Phone),
|
||||
new(ClaimTypes.Name, user.FullName ?? user.Phone),
|
||||
new(ClaimTypes.Role, user.Role.ToString()),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
new ClaimsPrincipal(identity));
|
||||
|
||||
return LocalRedirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Account.LogoutModel
|
||||
@* POST-only; OnGet redirects home. *@
|
||||
@@ -0,0 +1,17 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace JobsMedical.Web.Pages.Account;
|
||||
|
||||
public class LogoutModel : PageModel
|
||||
{
|
||||
public async Task<IActionResult> OnPostAsync()
|
||||
{
|
||||
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
return RedirectToPage("/Index");
|
||||
}
|
||||
|
||||
public IActionResult OnGet() => RedirectToPage("/Index");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
@page
|
||||
@model JobsMedical.Web.Pages.Account.ProfileModel
|
||||
@{
|
||||
ViewData["Title"] = "پروفایل من";
|
||||
string RoleLabel(UserRole r) => r switch
|
||||
{
|
||||
UserRole.Admin => "مدیر",
|
||||
UserRole.FacilityAdmin => "مدیر مرکز درمانی",
|
||||
_ => "کادر درمان",
|
||||
};
|
||||
}
|
||||
|
||||
<div class="page-head">
|
||||
<div class="container">
|
||||
<h1>پروفایل من</h1>
|
||||
<p class="muted">
|
||||
📱 <span dir="ltr">@JalaliDate.ToPersianDigits(Model.CurrentUser?.Phone ?? "")</span>
|
||||
— @RoleLabel(Model.CurrentUser?.Role ?? UserRole.Doctor)
|
||||
</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>
|
||||
<a class="btn btn-outline" asp-page="/Preferences/Index">تنظیم علاقهمندیها</a>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:20px;">شیفتهای ذخیرهشده</h2>
|
||||
@if (Model.SavedShifts.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">هنوز شیفتی ذخیره نکردهای.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-3">
|
||||
@foreach (var s in Model.SavedShifts) { <partial name="_ShiftCard" model="s" /> }
|
||||
</div>
|
||||
}
|
||||
|
||||
<h2 style="font-size:20px; margin-top:32px;">شیفتهایی که اعلام تمایل کردی</h2>
|
||||
@if (Model.AppliedShifts.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">هنوز برای شیفتی اعلام تمایل نکردهای.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-3">
|
||||
@foreach (var s in Model.AppliedShifts) { <partial name="_ShiftCard" model="s" /> }
|
||||
</div>
|
||||
}
|
||||
|
||||
<h2 style="font-size:20px; margin-top:32px;">موقعیتهای استخدامی که اعلام تمایل کردی</h2>
|
||||
@if (Model.AppliedJobs.Count == 0)
|
||||
{
|
||||
<div class="card empty-state">هنوز برای موقعیتی اعلام تمایل نکردهای.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="grid grid-3">
|
||||
@foreach (var j in Model.AppliedJobs) { <partial name="_JobCard" model="j" /> }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Security.Claims;
|
||||
using JobsMedical.Web.Data;
|
||||
using JobsMedical.Web.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsMedical.Web.Pages.Account;
|
||||
|
||||
[Authorize]
|
||||
public class ProfileModel : PageModel
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
public ProfileModel(AppDbContext db) => _db = db;
|
||||
|
||||
public User? CurrentUser { get; private set; }
|
||||
public List<Shift> SavedShifts { get; private set; } = new();
|
||||
public List<JobOpening> AppliedJobs { get; private set; } = new();
|
||||
public List<Shift> AppliedShifts { get; private set; } = new();
|
||||
|
||||
public async Task OnGetAsync()
|
||||
{
|
||||
var userId = int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
CurrentUser = await _db.Users.FindAsync(userId);
|
||||
|
||||
// All visitor ids this account has been linked to (across devices).
|
||||
var visitorIds = await _db.Visitors.Where(v => v.UserId == userId).Select(v => v.Id).ToListAsync();
|
||||
|
||||
var events = await _db.InterestEvents
|
||||
.Where(e => visitorIds.Contains(e.VisitorId))
|
||||
.OrderByDescending(e => e.CreatedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var savedShiftIds = events.Where(e => e.EventType == InterestEventType.Save && e.ShiftId != null)
|
||||
.Select(e => e.ShiftId!.Value).Distinct().ToList();
|
||||
var appliedShiftIds = events.Where(e => e.EventType == InterestEventType.Apply && e.ShiftId != null)
|
||||
.Select(e => e.ShiftId!.Value).Distinct().ToList();
|
||||
var appliedJobIds = events.Where(e => e.EventType == InterestEventType.Apply && e.JobOpeningId != null)
|
||||
.Select(e => e.JobOpeningId!.Value).Distinct().ToList();
|
||||
|
||||
SavedShifts = await ShiftsByIds(savedShiftIds);
|
||||
AppliedShifts = await ShiftsByIds(appliedShiftIds);
|
||||
AppliedJobs = await _db.JobOpenings
|
||||
.Include(j => j.Facility).ThenInclude(f => f.City).Include(j => j.Role)
|
||||
.Where(j => appliedJobIds.Contains(j.Id)).ToListAsync();
|
||||
}
|
||||
|
||||
private Task<List<Shift>> ShiftsByIds(List<int> ids) => _db.Shifts
|
||||
.Include(s => s.Facility).ThenInclude(f => f.City)
|
||||
.Include(s => s.Facility).ThenInclude(f => f.District)
|
||||
.Include(s => s.Role)
|
||||
.Where(s => ids.Contains(s.Id)).ToListAsync();
|
||||
}
|
||||
Reference in New Issue
Block a user