Search: fix header UI + instant typeahead (5 highlighted matches) + ranking
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
/// <summary>Relevance score: +weight per term found in any of the given fields.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +226,51 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
@* Instant search suggestions (typeahead) for the header search box. *@
|
||||
<script>
|
||||
(function () {
|
||||
var form = document.querySelector('.nav-search');
|
||||
if (!form) return;
|
||||
var input = form.querySelector('input');
|
||||
var box = document.createElement('div');
|
||||
box.className = 'nav-search-results';
|
||||
box.style.display = 'none';
|
||||
form.appendChild(box);
|
||||
var timer;
|
||||
function esc(s) { var d = document.createElement('div'); d.textContent = s == null ? '' : s; return d.innerHTML; }
|
||||
function hi(text, q) {
|
||||
var safe = esc(text);
|
||||
var terms = q.split(/\s+/).filter(function (t) { return t.length >= 2; })
|
||||
.map(function (t) { return t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); });
|
||||
if (!terms.length) return safe;
|
||||
try { return safe.replace(new RegExp('(' + terms.join('|') + ')', 'gi'), '<mark>$1</mark>'); }
|
||||
catch (e) { return safe; }
|
||||
}
|
||||
function hide() { box.style.display = 'none'; box.innerHTML = ''; }
|
||||
input.addEventListener('input', function () {
|
||||
var q = input.value.trim();
|
||||
clearTimeout(timer);
|
||||
if (q.length < 2) { hide(); return; }
|
||||
timer = setTimeout(function () {
|
||||
fetch('/search/suggest?q=' + encodeURIComponent(q))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (items) {
|
||||
if (!items || !items.length) { hide(); return; }
|
||||
var html = items.map(function (it) {
|
||||
return '<a href="' + it.url + '"><span class="ns-type">' + esc(it.type) +
|
||||
'</span><span class="ns-label">' + hi(it.label, q) + '</span></a>';
|
||||
}).join('');
|
||||
html += '<a class="ns-all" href="/Search?Q=' + encodeURIComponent(q) + '">همه نتایج برای «' + esc(q) + '» ←</a>';
|
||||
box.innerHTML = html;
|
||||
box.style.display = 'block';
|
||||
}).catch(function () { hide(); });
|
||||
}, 200);
|
||||
});
|
||||
document.addEventListener('click', function (e) { if (!form.contains(e.target)) hide(); });
|
||||
input.addEventListener('keydown', function (e) { if (e.key === 'Escape') hide(); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
@* 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)
|
||||
|
||||
Reference in New Issue
Block a user