Site-wide rich search with keyword highlighting + header search box
CI/CD / CI · dotnet build (push) Successful in 1m53s
CI/CD / Deploy · hamkadr (push) Successful in 2m47s

- /Search: searches shifts, hiring openings, and applicants together via
  Postgres ILIKE (every term must match across role/city/facility/title/
  description/tags/person). Results grouped per type.
- Keyword highlighting (<mark>) extended to shift & job cards (was talent-only),
  so matches stand out everywhere.
- Persistent header search box (.nav-search) → /Search; big hero box on the
  page itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-08 11:40:26 +03:30
parent 234bcd1f88
commit 9db4deafbc
6 changed files with 148 additions and 5 deletions
+56
View File
@@ -0,0 +1,56 @@
@page
@model JobsMedical.Web.Pages.SearchModel
@{
ViewData["Title"] = Model.HasQuery ? $"جستجو: {Model.Q}" : "جستجو";
ViewData["q"] = Model.Q; // drives highlighting in the cards
ViewData["NoIndex"] = true;
}
<div class="page-head">
<div class="container">
<h1>جستجو</h1>
<form method="get" class="search-hero">
<input type="search" name="Q" value="@Model.Q" placeholder="مثلاً: پرستار شب تهران، mmt، دندانپزشک پروانه‌دار…" autofocus />
<button type="submit" class="btn btn-accent">🔎 جستجو</button>
</form>
@if (Model.HasQuery)
{
<p class="muted">@JalaliDate.ToPersianDigits(Model.Total.ToString()) نتیجه برای «@Model.Q»</p>
}
</div>
</div>
<div class="container section">
@if (!Model.HasQuery)
{
<div class="card empty-state">یک عبارت بنویس تا در شیفت‌ها، استخدام‌ها و آماده‌به‌کارها جستجو شود. هر کلمه باید جایی پیدا شود.</div>
}
else if (Model.Total == 0)
{
<div class="card empty-state">نتیجه‌ای پیدا نشد. عبارت دیگری امتحان کن.</div>
}
else
{
@if (Model.Shifts.Count > 0)
{
<div class="section-head"><h2>شیفت‌ها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))</h2><a asp-page="/Shifts/Index">همه شیفت‌ها ←</a></div>
<div class="grid grid-3">
@foreach (var s in Model.Shifts) { <partial name="_ShiftCard" model="s" /> }
</div>
}
@if (Model.Jobs.Count > 0)
{
<div class="section-head" style="margin-top:24px;"><h2>استخدام‌ها (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))</h2><a asp-page="/Jobs/Index">همه استخدام‌ها ←</a></div>
<div class="grid grid-3">
@foreach (var j in Model.Jobs) { <partial name="_JobCard" model="j" /> }
</div>
}
@if (Model.Talent.Count > 0)
{
<div class="section-head" style="margin-top:24px;"><h2>آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))</h2><a asp-page="/Talent/Index">همه ←</a></div>
<div class="grid grid-3">
@foreach (var t in Model.Talent) { <partial name="_TalentCard" model="t" /> }
</div>
}
}
</div>
@@ -0,0 +1,68 @@
using JobsMedical.Web.Data;
using JobsMedical.Web.Models;
using JobsMedical.Web.Services.Scraping;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.EntityFrameworkCore;
namespace JobsMedical.Web.Pages;
/// <summary>Site-wide rich search across shifts, hiring openings, and applicants with keyword
/// highlighting. Every query term must match somewhere (Postgres ILIKE over the relevant fields).</summary>
public class SearchModel : PageModel
{
private readonly AppDbContext _db;
public SearchModel(AppDbContext db) => _db = db;
[BindProperty(SupportsGet = true)] public string? Q { get; set; }
public List<Shift> Shifts { get; private set; } = new();
public List<JobOpening> Jobs { get; private set; } = new();
public List<TalentListing> Talent { get; private set; } = new();
public int Total => Shifts.Count + Jobs.Count + Talent.Count;
public bool HasQuery => !string.IsNullOrWhiteSpace(Q);
public async Task OnGetAsync()
{
if (!HasQuery) return;
var terms = Q!.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var jobCut = ListingPolicy.JobCutoffUtc;
var talentCut = ListingPolicy.TalentCutoffUtc;
var sq = _db.Shifts.Include(s => s.Facility).ThenInclude(f => f.City)
.Include(s => s.Facility).ThenInclude(f => f.District).Include(s => s.Role)
.Where(s => s.Status == ShiftStatus.Open && s.Date >= today);
foreach (var t in terms)
{
var like = $"%{t}%";
sq = sq.Where(s => EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Facility.City.Name, like)
|| EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like)
|| EF.Functions.ILike(s.Description ?? "", like));
}
Shifts = await sq.OrderByDescending(s => s.CreatedAt).Take(30).ToListAsync();
var jq = _db.JobOpenings.Include(j => j.Facility).ThenInclude(f => f.City)
.Include(j => j.Facility).ThenInclude(f => f.District).Include(j => j.Role)
.Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut);
foreach (var t in terms)
{
var like = $"%{t}%";
jq = jq.Where(j => EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like)
|| EF.Functions.ILike(j.Facility.City.Name, like) || EF.Functions.ILike(j.Role.Name, like)
|| EF.Functions.ILike(j.Description ?? "", like));
}
Jobs = await jq.OrderByDescending(j => j.CreatedAt).Take(30).ToListAsync();
var tq = _db.TalentListings.Include(t => t.City).Include(t => t.District).Include(t => t.Role)
.Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut);
foreach (var t in terms)
{
var like = $"%{t}%";
tq = tq.Where(x => EF.Functions.ILike(x.Tags ?? "", like) || EF.Functions.ILike(x.Description ?? "", like)
|| EF.Functions.ILike(x.PersonName ?? "", like) || EF.Functions.ILike(x.AreaNote ?? "", like)
|| EF.Functions.ILike(x.Role.Name, like) || EF.Functions.ILike(x.City.Name, like));
}
Talent = await tq.OrderByDescending(x => x.CreatedAt).Take(30).ToListAsync();
}
}
@@ -11,22 +11,23 @@
if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی"; if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی";
else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه"; else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه";
else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه"; else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه";
var q = ViewData["q"] as string;
} }
<a class="card card-pad shift-card" asp-page="/Jobs/Details" asp-route-id="@Model.Id"> <a class="card card-pad shift-card" asp-page="/Jobs/Details" asp-route-id="@Model.Id">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<span class="facility">@Model.Title</span> <span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Title, q)</span>
<span class="badge badge-job">@empLabel</span> <span class="badge badge-job">@empLabel</span>
</div> </div>
<div class="row"> <div class="row">
@if (Model.Role is not null) @if (Model.Role is not null)
{ {
<span class="badge badge-type">@Model.Role.Name</span> <span class="badge badge-type">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role.Name, q)</span>
} }
@if (Model.GenderRequirement != Gender.Any) @if (Model.GenderRequirement != Gender.Any)
{ {
<span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span> <span class="badge badge-gender">@JalaliDate.GenderLabel(Model.GenderRequirement)</span>
} }
<span>🏥 @Model.Facility?.Name</span> <span>🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
</div> </div>
<div class="row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div> <div class="row">📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")</div>
@if (Model.DistanceKm is double km) @if (Model.DistanceKm is double km)
@@ -117,6 +117,10 @@
<a asp-page="/Facilities/Index" class="@(path.StartsWith("/Facilities") ? "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> <a asp-page="/Calendar/Index" class="@(path.StartsWith("/Calendar") ? "active" : null)">تقویم هفتگی</a>
</nav> </nav>
<form class="nav-search" method="get" action="/Search" role="search">
<input type="search" name="Q" placeholder="جستجو…" aria-label="جستجو" />
<button type="submit" aria-label="جستجو">🔎</button>
</form>
<div class="header-actions"> <div class="header-actions">
<a class="btn btn-accent btn-sm cta-post" asp-page="/Employer/Index" data-tour="post"> ثبت آگهی</a> <a class="btn btn-accent btn-sm cta-post" asp-page="/Employer/Index" data-tour="post"> ثبت آگهی</a>
@if (User.Identity?.IsAuthenticated == true) @if (User.Identity?.IsAuthenticated == true)
@@ -7,16 +7,17 @@
ShiftType.Night => ("badge-night", "شب"), ShiftType.Night => ("badge-night", "شب"),
_ => ("badge-oncall", "آنکال"), _ => ("badge-oncall", "آنکال"),
}; };
var q = ViewData["q"] as string;
} }
<a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@Model.Id"> <a class="card card-pad shift-card" asp-page="/Shifts/Details" asp-route-id="@Model.Id">
<div class="row" style="justify-content: space-between;"> <div class="row" style="justify-content: space-between;">
<span class="facility">@Model.Facility?.Name</span> <span class="facility">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)</span>
<span class="badge @badgeClass">@typeLabel</span> <span class="badge @badgeClass">@typeLabel</span>
</div> </div>
<div class="row"> <div class="row">
@if (Model.Role is not null) @if (Model.Role is not null)
{ {
<span class="badge badge-type">@Model.Role.Name</span> <span class="badge badge-type">@JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role.Name, q)</span>
} }
@if (Model.GenderRequirement != Gender.Any) @if (Model.GenderRequirement != Gender.Any)
{ {
+13
View File
@@ -273,6 +273,19 @@ label { font-size: 13px; }
background: var(--primary-soft); color: var(--primary-dark); } background: var(--primary-soft); color: var(--primary-dark); }
mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px; } mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px; }
/* Header search */
.nav-search { display: flex; align-items: center; gap: 0; }
.nav-search input { width: 150px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 10px 0 0 10px;
font-family: inherit; font-size: 13px; background: var(--bg); }
.nav-search input:focus { outline: none; border-color: var(--primary); width: 190px; }
.nav-search button { padding: 7px 11px; border: 1px solid var(--primary); background: var(--primary); color: #fff;
border-radius: 0 10px 10px 0; cursor: pointer; font-size: 14px; }
/* Big search box on the /Search page head */
.search-hero { display: flex; gap: 8px; max-width: 640px; margin: 6px 0 4px; }
.search-hero input { flex: 1; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px;
font-family: inherit; font-size: 15px; background: var(--surface); }
.search-hero input:focus { outline: none; border-color: var(--primary); }
/* Animated contact reveal box (shift/job/talent details) */ /* Animated contact reveal box (shift/job/talent details) */
.contact-reveal { border: 1px solid var(--primary); border-radius: 14px; padding: 14px; .contact-reveal { border: 1px solid var(--primary); border-radius: 14px; padding: 14px;
background: var(--primary-soft); animation: revealIn .35s cubic-bezier(.2,.7,.3,1); } background: var(--primary-soft); animation: revealIn .35s cubic-bezier(.2,.7,.3,1); }