From 61afc957aa1bbcff8c36aff4eae2067b9bea2bcc Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Mon, 8 Jun 2026 11:58:30 +0330 Subject: [PATCH] Search: fix header UI + instant typeahead (5 highlighted matches) + ranking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Header search restyled as one clean RTL pill (input + button flush). - Google-style autocomplete: typing ≥2 chars fetches /search/suggest and shows up to 5 live matches (round-robin across shifts/jobs/applicants) with the query highlighted, plus a «همه نتایج» link. Debounced, closes on outside-click/Escape. - Search results page now RANKS by relevance (term hits in role/title/ facility/city/tags weighted ×3, description ×1) instead of date-only. Co-Authored-By: Claude Opus 4.8 --- src/JobsMedical.Web/Pages/Search.cshtml.cs | 29 ++++++++++-- .../Pages/Shared/_Layout.cshtml | 45 +++++++++++++++++++ src/JobsMedical.Web/Program.cs | 40 +++++++++++++++++ src/JobsMedical.Web/wwwroot/css/site.css | 28 +++++++++--- 4 files changed, 132 insertions(+), 10 deletions(-) diff --git a/src/JobsMedical.Web/Pages/Search.cshtml.cs b/src/JobsMedical.Web/Pages/Search.cshtml.cs index e23253d..4039bd9 100644 --- a/src/JobsMedical.Web/Pages/Search.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Search.cshtml.cs @@ -40,7 +40,11 @@ public class SearchModel : PageModel || 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 shiftPool = await sq.OrderByDescending(s => s.CreatedAt).Take(60).ToListAsync(); + Shifts = shiftPool + .OrderByDescending(s => Rank(terms, 3, s.Role?.Name, s.Facility?.Name, s.Facility?.City?.Name, s.SpecialtyRequired) + + Rank(terms, 1, s.Description)) + .ThenByDescending(s => s.CreatedAt).Take(30).ToList(); var jq = _db.JobOpenings.Include(j => j.Facility).ThenInclude(f => f.City) .Include(j => j.Facility).ThenInclude(f => f.District).Include(j => j.Role) @@ -52,7 +56,11 @@ public class SearchModel : PageModel || 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 jobPool = await jq.OrderByDescending(j => j.CreatedAt).Take(60).ToListAsync(); + Jobs = jobPool + .OrderByDescending(j => Rank(terms, 3, j.Title, j.Role?.Name, j.Facility?.Name, j.Facility?.City?.Name) + + Rank(terms, 1, j.Description)) + .ThenByDescending(j => j.CreatedAt).Take(30).ToList(); 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); @@ -63,6 +71,21 @@ public class SearchModel : PageModel || 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(); + var talentPool = await tq.OrderByDescending(x => x.CreatedAt).Take(60).ToListAsync(); + Talent = talentPool + .OrderByDescending(x => Rank(terms, 3, x.Role?.Name, x.City?.Name, x.PersonName, x.Tags) + + Rank(terms, 1, x.Description, x.AreaNote)) + .ThenByDescending(x => x.CreatedAt).Take(30).ToList(); + } + + /// Relevance score: +weight per term found in any of the given fields. + private static int Rank(string[] terms, int weight, params string?[] fields) + { + var score = 0; + foreach (var term in terms) + foreach (var f in fields) + if (!string.IsNullOrEmpty(f) && f.Contains(term, StringComparison.OrdinalIgnoreCase)) + { score += weight; break; } + return score; } } diff --git a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml index 71aef0d..8cccfa6 100644 --- a/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml +++ b/src/JobsMedical.Web/Pages/Shared/_Layout.cshtml @@ -226,6 +226,51 @@ }); + @* Instant search suggestions (typeahead) for the header search box. *@ + + @* 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. *@ @if (User.Identity?.IsAuthenticated == true) diff --git a/src/JobsMedical.Web/Program.cs b/src/JobsMedical.Web/Program.cs index 5367034..560baeb 100644 --- a/src/JobsMedical.Web/Program.cs +++ b/src/JobsMedical.Web/Program.cs @@ -359,4 +359,44 @@ app.MapGet("/sitemap.xml", async (HttpContext ctx, AppDbContext db) => return Results.Content(sb.ToString(), "application/xml"); }); +// ---- Instant search suggestions (typeahead dropdown) ---- +app.MapGet("/search/suggest", async (string? q, AppDbContext db) => +{ + var term = (q ?? "").Trim(); + if (term.Length < 2) return Results.Json(Array.Empty()); + var like = $"%{term}%"; + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var jobCut = JobsMedical.Web.Services.Scraping.ListingPolicy.JobCutoffUtc; + var talentCut = JobsMedical.Web.Services.Scraping.ListingPolicy.TalentCutoffUtc; + + var shifts = await db.Shifts + .Where(s => s.Status == ShiftStatus.Open && s.Date >= today && + (EF.Functions.ILike(s.Facility.Name, like) || EF.Functions.ILike(s.Role.Name, like) || EF.Functions.ILike(s.SpecialtyRequired, like))) + .OrderByDescending(s => s.CreatedAt).Take(5) + .Select(s => new SuggestItem("شیفت", s.Role.Name + " — " + s.Facility.Name, "/Shifts/Details/" + s.Id)).ToListAsync(); + var jobs = await db.JobOpenings + .Where(j => j.Status == ShiftStatus.Open && j.CreatedAt >= jobCut && + (EF.Functions.ILike(j.Title, like) || EF.Functions.ILike(j.Facility.Name, like) || EF.Functions.ILike(j.Role.Name, like))) + .OrderByDescending(j => j.CreatedAt).Take(5) + .Select(j => new SuggestItem("استخدام", j.Title, "/Jobs/Details/" + j.Id)).ToListAsync(); + var talent = await db.TalentListings + .Where(t => t.Status == ShiftStatus.Open && t.CreatedAt >= talentCut && + (EF.Functions.ILike(t.Tags ?? "", like) || EF.Functions.ILike(t.Role.Name, like) || EF.Functions.ILike(t.City.Name, like) || EF.Functions.ILike(t.PersonName ?? "", like))) + .OrderByDescending(t => t.CreatedAt).Take(5) + .Select(t => new SuggestItem("آماده‌به‌کار", (t.PersonName ?? t.Role.Name) + " — " + t.City.Name, "/Talent/Details/" + t.Id)).ToListAsync(); + + // round-robin merge so all three types appear, capped at 5 + var merged = new List(); + for (var i = 0; i < 5 && merged.Count < 5; i++) + { + if (i < shifts.Count) merged.Add(shifts[i]); + if (merged.Count < 5 && i < jobs.Count) merged.Add(jobs[i]); + if (merged.Count < 5 && i < talent.Count) merged.Add(talent[i]); + } + return Results.Json(merged.Take(5)); +}); + app.Run(); + +/// One typeahead suggestion row (lowercase props → camelCase JSON for the client). +public record SuggestItem(string type, string label, string url); diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css index 2d08d05..7e444d5 100644 --- a/src/JobsMedical.Web/wwwroot/css/site.css +++ b/src/JobsMedical.Web/wwwroot/css/site.css @@ -273,13 +273,27 @@ 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; } +/* Header search — single rounded pill (input + button flush), RTL-correct */ +.nav-search { position: relative; display: flex; align-items: stretch; border: 1px solid var(--line); + border-radius: 999px; overflow: hidden; background: var(--bg); transition: border-color .15s; } +.nav-search:focus-within { border-color: var(--primary); } +.nav-search input { border: none; background: transparent; padding: 7px 14px; width: 150px; + font-family: inherit; font-size: 13px; transition: width .15s; } +.nav-search input:focus { outline: none; width: 200px; } +.nav-search button { border: none; background: var(--primary); color: #fff; padding: 0 14px; + cursor: pointer; font-size: 14px; line-height: 1; } +/* Autocomplete dropdown */ +.nav-search-results { position: absolute; top: calc(100% + 6px); inset-inline-start: 0; inset-inline-end: 0; + background: var(--surface); border: 1px solid var(--line); border-radius: 12px; z-index: 80; + box-shadow: 0 14px 34px rgba(0,0,0,.14); overflow: hidden; max-height: 70vh; overflow-y: auto; } +.nav-search-results a { display: flex; align-items: center; gap: 8px; padding: 9px 12px; + border-bottom: 1px solid var(--line); color: var(--ink); font-size: 13.5px; } +.nav-search-results a:last-child { border-bottom: none; } +.nav-search-results a:hover { background: var(--primary-soft); } +.nav-search-results .ns-type { flex: 0 0 auto; font-size: 11px; font-weight: 700; color: var(--primary-dark); + background: var(--primary-soft); border-radius: 6px; padding: 2px 7px; } +.nav-search-results .ns-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-search-results .ns-all { font-weight: 700; color: var(--primary-dark); justify-content: center; } /* 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;