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;