Breadcrumbs: visible trail + BreadcrumbList JSON-LD
CI/CD / CI · dotnet build (push) Successful in 2m8s
CI/CD / Deploy · hamkadr (push) Has been cancelled

Add SeoJsonLd.Breadcrumb + Crumb record + _Breadcrumbs partial, and wire a trail
into the Jobs/Shifts list (landing) and detail pages: خانه › استخدام/شیفت › {نقش}
› {شهر|عنوان}. The role crumb links to the role landing page (more internal
links), and Google can show the breadcrumb path in results. Detail pages emit it
alongside the existing JobPosting JSON-LD.

Improvement 5 of the backlog (SEO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-20 19:12:38 +03:30
parent 142136ebc9
commit 3edd21d2b6
9 changed files with 89 additions and 2 deletions
@@ -23,10 +23,14 @@
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه";
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
if (j.Role is not null) crumbs.Add(new(j.Role.Name, "/استخدام/" + JobsMedical.Web.Services.SeoSlug.Of(j.Role.Name)));
crumbs.Add(new(j.Title, null));
}
<div class="page-head">
<div class="container">
<partial name="_Breadcrumbs" model="crumbs" />
<div class="row" style="display:flex; gap:10px; align-items:center;">
<span class="badge badge-job">@empLabel</span>
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
@@ -199,9 +203,10 @@
@section Head {
@* Only emit JobPosting structured data for a real named employer — Google for Jobs rejects a
placeholder/empty hiringOrganization (most aggregated ads have no named center). *@
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
{
var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}";
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.JobPosting(j, bu) + "</script>")
}
}
@@ -6,6 +6,7 @@
<div class="page-head">
<div class="container">
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
<h1>@Model.PageHeading</h1>
<p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد
@@ -140,3 +141,8 @@
}
</script>
}
@section Head {
@{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(Model.Breadcrumbs, bcUrl) + "</script>")
}
@@ -37,6 +37,9 @@ public class IndexModel : PageModel
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
public string? PageIntro { get; private set; }
/// <summary>Breadcrumb trail (also emitted as BreadcrumbList JSON-LD).</summary>
public IReadOnlyList<Crumb> Breadcrumbs { get; private set; } = Array.Empty<Crumb>();
public async Task<IActionResult> OnGetAsync()
{
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
@@ -110,5 +113,10 @@ public class IndexModel : PageModel
PageIntro = $"در این صفحه جدیدترین فرصت‌های {PageHeading}، گردآوری‌شده از منابع معتبر، را می‌بینید. "
+ "روی هر آگهی بزنید تا جزئیات، شرایط و راهِ تماسِ مستقیم با مرکز درمانی نمایش داده شود. "
+ "برای فرصت‌های مرتبط، نقش یا شهر دیگری را از لینک‌های بالا انتخاب کنید.";
var crumbs = new List<Crumb> { new("خانه", "/"), new("استخدام", "/Jobs") };
if (role is not null) crumbs.Add(new(role, "/استخدام/" + SeoSlug.Of(role)));
if (city is not null) crumbs.Add(new(city, null));
Breadcrumbs = crumbs;
}
}
@@ -0,0 +1,20 @@
@model IReadOnlyList<JobsMedical.Web.Services.Crumb>
@* Visible breadcrumb trail. The last crumb is the current page (no link). Pair with the
BreadcrumbList JSON-LD (SeoJsonLd.Breadcrumb) emitted in @@section Head. *@
@if (Model is { Count: > 1 })
{
<nav class="breadcrumbs" aria-label="مسیر">
@for (var i = 0; i < Model.Count; i++)
{
if (i > 0) { <span class="bc-sep" aria-hidden="true"></span> }
@if (!string.IsNullOrEmpty(Model[i].Url) && i < Model.Count - 1)
{
<a href="@Model[i].Url">@Model[i].Name</a>
}
else
{
<span class="bc-current">@Model[i].Name</span>
}
}
</nav>
}
@@ -21,10 +21,14 @@
ShiftType.Night => ("badge-night", "شیفت شب"),
_ => ("badge-oncall", "آنکال"),
};
var crumbs = new List<JobsMedical.Web.Services.Crumb> { new("خانه", "/"), new("شیفت‌ها", "/Shifts") };
if (s.Role is not null) crumbs.Add(new(s.Role.Name, "/شیفت/" + JobsMedical.Web.Services.SeoSlug.Of(s.Role.Name)));
crumbs.Add(new(JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f) ? f.Name : "جزئیات شیفت", null));
}
<div class="page-head">
<div class="container">
<partial name="_Breadcrumbs" model="crumbs" />
<div class="row" style="display:flex; gap:10px; align-items:center;">
<span class="badge @badgeClass">@typeLabel</span>
@if (f.IsVerified)
@@ -216,10 +220,11 @@
}
@section Head {
@{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(crumbs, bu) + "</script>")
@* Only for a real named employer — Google for Jobs rejects a placeholder hiringOrganization. *@
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
{
var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}";
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.ShiftPosting(s, bu) + "</script>")
}
}
@@ -6,6 +6,7 @@
<div class="page-head">
<div class="container">
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
<h1>@Model.PageHeading</h1>
<p class="muted">
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد
@@ -173,3 +174,8 @@
}
</script>
}
@section Head {
@{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; }
@Html.Raw("<script type=\"application/ld+json\">" + JobsMedical.Web.Services.SeoJsonLd.Breadcrumb(Model.Breadcrumbs, bcUrl) + "</script>")
}
@@ -43,6 +43,9 @@ public class IndexModel : PageModel
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
public string? PageIntro { get; private set; }
/// <summary>Breadcrumb trail (also emitted as BreadcrumbList JSON-LD).</summary>
public IReadOnlyList<Crumb> Breadcrumbs { get; private set; } = Array.Empty<Crumb>();
public async Task<IActionResult> OnGetAsync()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
@@ -128,5 +131,10 @@ public class IndexModel : PageModel
PageIntro = $"در این صفحه جدیدترین {PageHeading}، گردآوری‌شده از منابع معتبر، را می‌بینید. "
+ "روی هر شیفت بزنید تا تاریخ، ساعت، پرداخت و راهِ تماسِ مستقیم با مرکز درمانی نمایش داده شود. "
+ "برای موارد مرتبط، نقش یا شهر دیگری را از لینک‌های بالا انتخاب کنید.";
var crumbs = new List<Crumb> { new("خانه", "/"), new("شیفت‌ها", "/Shifts") };
if (role is not null) crumbs.Add(new(role, "/شیفت/" + SeoSlug.Of(role)));
if (city is not null) crumbs.Add(new(city, null));
Breadcrumbs = crumbs;
}
}