From 9db4deafbc44fdea628584b97978525a04ca669b Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 8 Jun 2026 11:40:26 +0330 Subject: [PATCH] Site-wide rich search with keyword highlighting + header search box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 () 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 --- src/JobsMedical.Web/Pages/Search.cshtml | 56 +++++++++++++++ src/JobsMedical.Web/Pages/Search.cshtml.cs | 68 +++++++++++++++++++ .../Pages/Shared/_JobCard.cshtml | 7 +- .../Pages/Shared/_Layout.cshtml | 4 ++ .../Pages/Shared/_ShiftCard.cshtml | 5 +- src/JobsMedical.Web/wwwroot/css/site.css | 13 ++++ 6 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 src/JobsMedical.Web/Pages/Search.cshtml create mode 100644 src/JobsMedical.Web/Pages/Search.cshtml.cs diff --git a/src/JobsMedical.Web/Pages/Search.cshtml b/src/JobsMedical.Web/Pages/Search.cshtml new file mode 100644 index 0000000..a68ab5d --- /dev/null +++ b/src/JobsMedical.Web/Pages/Search.cshtml @@ -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; +} + +
+
+

جستجو

+
+ + +
+ @if (Model.HasQuery) + { +

@JalaliDate.ToPersianDigits(Model.Total.ToString()) نتیجه برای «@Model.Q»

+ } +
+
+ +
+ @if (!Model.HasQuery) + { +
یک عبارت بنویس تا در شیفت‌ها، استخدام‌ها و آماده‌به‌کارها جستجو شود. هر کلمه باید جایی پیدا شود.
+ } + else if (Model.Total == 0) + { +
نتیجه‌ای پیدا نشد. عبارت دیگری امتحان کن.
+ } + else + { + @if (Model.Shifts.Count > 0) + { +

شیفت‌ها (@JalaliDate.ToPersianDigits(Model.Shifts.Count.ToString()))

همه شیفت‌ها ←
+
+ @foreach (var s in Model.Shifts) { } +
+ } + @if (Model.Jobs.Count > 0) + { +

استخدام‌ها (@JalaliDate.ToPersianDigits(Model.Jobs.Count.ToString()))

همه استخدام‌ها ←
+
+ @foreach (var j in Model.Jobs) { } +
+ } + @if (Model.Talent.Count > 0) + { +

آماده به کار (@JalaliDate.ToPersianDigits(Model.Talent.Count.ToString()))

همه ←
+
+ @foreach (var t in Model.Talent) { } +
+ } + } +
diff --git a/src/JobsMedical.Web/Pages/Search.cshtml.cs b/src/JobsMedical.Web/Pages/Search.cshtml.cs new file mode 100644 index 0000000..e23253d --- /dev/null +++ b/src/JobsMedical.Web/Pages/Search.cshtml.cs @@ -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; + +/// 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). +public class SearchModel : PageModel +{ + private readonly AppDbContext _db; + public SearchModel(AppDbContext db) => _db = db; + + [BindProperty(SupportsGet = true)] public string? Q { get; set; } + + public List Shifts { get; private set; } = new(); + public List Jobs { get; private set; } = new(); + public List 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(); + } +} diff --git a/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml index 05089eb..631a09d 100644 --- a/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_JobCard.cshtml @@ -11,22 +11,23 @@ if (Model.SalaryMin is null && Model.SalaryMax is null) salary = "توافقی"; else if (Model.SalaryMin == Model.SalaryMax) salary = JalaliDate.Toman(Model.SalaryMin) + " ماهانه"; else salary = $"از {JalaliDate.ToPersianDigits((Model.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(Model.SalaryMax)} ماهانه"; + var q = ViewData["q"] as string; }
- @Model.Title + @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Title, q) @empLabel
@if (Model.Role is not null) { - @Model.Role.Name + @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role.Name, q) } @if (Model.GenderRequirement != Gender.Any) { @JalaliDate.GenderLabel(Model.GenderRequirement) } - 🏥 @Model.Facility?.Name + 🏥 @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q)
📍 @Model.Facility?.City?.Name@(Model.Facility?.District is not null ? "، " + Model.Facility.District.Name : "")
@if (Model.DistanceKm is double km) diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml index 84e6837..71aef0d 100644 --- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml @@ -117,6 +117,10 @@
مراکز درمانی تقویم هفتگی +
+ ثبت آگهی @if (User.Identity?.IsAuthenticated == true) diff --git a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml index d47cab7..e79ec14 100644 --- a/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_ShiftCard.cshtml @@ -7,16 +7,17 @@ ShiftType.Night => ("badge-night", "شب"), _ => ("badge-oncall", "آنکال"), }; + var q = ViewData["q"] as string; }
- @Model.Facility?.Name + @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Facility?.Name, q) @typeLabel
@if (Model.Role is not null) { - @Model.Role.Name + @JobsMedical.Web.Services.SearchHighlight.Mark(Model.Role.Name, q) } @if (Model.GenderRequirement != Gender.Any) { diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css index 3957cff..2d08d05 100644 --- a/src/JobsMedical.Web/wwwroot/css/site.css +++ b/src/JobsMedical.Web/wwwroot/css/site.css @@ -273,6 +273,19 @@ label { font-size: 13px; } background: var(--primary-soft); color: var(--primary-dark); } 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) */ .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); }