Site-wide rich search with keyword highlighting + header search box
- /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:
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user