Breadcrumbs: visible trail + BreadcrumbList JSON-LD
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:
@@ -23,10 +23,14 @@
|
|||||||
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
|
if (j.SalaryMin is null && j.SalaryMax is null) salary = "توافقی";
|
||||||
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
|
else if (j.SalaryMin == j.SalaryMax) salary = JalaliDate.Toman(j.SalaryMin) + " ماهانه";
|
||||||
else salary = $"از {JalaliDate.ToPersianDigits((j.SalaryMin ?? 0).ToString("#,0"))} تا {JalaliDate.Toman(j.SalaryMax)} ماهانه";
|
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="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<partial name="_Breadcrumbs" model="crumbs" />
|
||||||
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
||||||
<span class="badge badge-job">@empLabel</span>
|
<span class="badge badge-job">@empLabel</span>
|
||||||
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
|
@if (j.Role is not null) { <span class="badge badge-type">@j.Role.Name</span> }
|
||||||
@@ -199,9 +203,10 @@
|
|||||||
@section Head {
|
@section Head {
|
||||||
@* Only emit JobPosting structured data for a real named employer — Google for Jobs rejects a
|
@* 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). *@
|
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))
|
@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>")
|
@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="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
||||||
<h1>@Model.PageHeading</h1>
|
<h1>@Model.PageHeading</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد
|
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد
|
||||||
@@ -140,3 +141,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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>
|
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
|
||||||
public string? PageIntro { get; private set; }
|
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()
|
public async Task<IActionResult> OnGetAsync()
|
||||||
{
|
{
|
||||||
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
Cities = await _db.Cities.Where(c => c.IsActive).OrderBy(c => c.Name).ToListAsync();
|
||||||
@@ -110,5 +113,10 @@ public class IndexModel : PageModel
|
|||||||
PageIntro = $"در این صفحه جدیدترین فرصتهای {PageHeading}، گردآوریشده از منابع معتبر، را میبینید. "
|
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", "شیفت شب"),
|
ShiftType.Night => ("badge-night", "شیفت شب"),
|
||||||
_ => ("badge-oncall", "آنکال"),
|
_ => ("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="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<partial name="_Breadcrumbs" model="crumbs" />
|
||||||
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
<div class="row" style="display:flex; gap:10px; align-items:center;">
|
||||||
<span class="badge @badgeClass">@typeLabel</span>
|
<span class="badge @badgeClass">@typeLabel</span>
|
||||||
@if (f.IsVerified)
|
@if (f.IsVerified)
|
||||||
@@ -216,10 +220,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@section Head {
|
@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. *@
|
@* Only for a real named employer — Google for Jobs rejects a placeholder hiringOrganization. *@
|
||||||
@if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f))
|
@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>")
|
@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="page-head">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<partial name="_Breadcrumbs" model="Model.Breadcrumbs" />
|
||||||
<h1>@Model.PageHeading</h1>
|
<h1>@Model.PageHeading</h1>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد
|
@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد
|
||||||
@@ -173,3 +174,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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>
|
/// <summary>A short unique intro shown on role/city landing pages (avoids thin-content).</summary>
|
||||||
public string? PageIntro { get; private set; }
|
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()
|
public async Task<IActionResult> OnGetAsync()
|
||||||
{
|
{
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
@@ -128,5 +131,10 @@ public class IndexModel : PageModel
|
|||||||
PageIntro = $"در این صفحه جدیدترین {PageHeading}، گردآوریشده از منابع معتبر، را میبینید. "
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,9 +125,32 @@ public static class SeoJsonLd
|
|||||||
},
|
},
|
||||||
}, Opts));
|
}, Opts));
|
||||||
|
|
||||||
|
/// <summary>BreadcrumbList JSON-LD from an ordered crumb trail (relative URLs are made absolute).
|
||||||
|
/// Google can then show the breadcrumb path in search results.</summary>
|
||||||
|
public static string Breadcrumb(IReadOnlyList<Crumb> items, string baseUrl)
|
||||||
|
{
|
||||||
|
var els = new List<object>();
|
||||||
|
for (var i = 0; i < items.Count; i++)
|
||||||
|
{
|
||||||
|
var el = new Dictionary<string, object?> { ["type"] = "ListItem", ["position"] = i + 1, ["name"] = items[i].Name };
|
||||||
|
if (!string.IsNullOrEmpty(items[i].Url))
|
||||||
|
el["item"] = items[i].Url!.StartsWith("http") ? items[i].Url : baseUrl + items[i].Url;
|
||||||
|
els.Add(el);
|
||||||
|
}
|
||||||
|
return Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["@context"] = "https://schema.org",
|
||||||
|
["@type"] = "BreadcrumbList",
|
||||||
|
["itemListElement"] = els,
|
||||||
|
}, Opts));
|
||||||
|
}
|
||||||
|
|
||||||
// Nested anonymous objects use "type"/"queryyy" placeholders for @type / query-input;
|
// Nested anonymous objects use "type"/"queryyy" placeholders for @type / query-input;
|
||||||
// restore the @-prefixed schema.org keys here.
|
// restore the @-prefixed schema.org keys here.
|
||||||
private static string Fix(string json) => json
|
private static string Fix(string json) => json
|
||||||
.Replace("\"type\":", "\"@type\":")
|
.Replace("\"type\":", "\"@type\":")
|
||||||
.Replace("\"queryyy\":", "\"query-input\":");
|
.Replace("\"queryyy\":", "\"query-input\":");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>One step in a breadcrumb trail. <see cref="Url"/> is null for the current (last) page.</summary>
|
||||||
|
public record Crumb(string Name, string? Url = null);
|
||||||
|
|||||||
@@ -326,6 +326,12 @@ mark { background: #fff3bf; color: inherit; padding: 0 2px; border-radius: 3px;
|
|||||||
.role-links .rl-chip { background: var(--surface); border: 1px solid var(--line); color: var(--ink);
|
.role-links .rl-chip { background: var(--surface); border: 1px solid var(--line); color: var(--ink);
|
||||||
padding: 5px 12px; border-radius: 999px; font-size: 13px; transition: all .15s; }
|
padding: 5px 12px; border-radius: 999px; font-size: 13px; transition: all .15s; }
|
||||||
.role-links .rl-chip:hover { border-color: var(--primary); color: var(--primary); }
|
.role-links .rl-chip:hover { border-color: var(--primary); color: var(--primary); }
|
||||||
|
/* Breadcrumb trail (paired with BreadcrumbList JSON-LD). */
|
||||||
|
.breadcrumbs { font-size: 12.5px; color: var(--muted); margin: 0 0 12px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
|
.breadcrumbs a { color: var(--muted); }
|
||||||
|
.breadcrumbs a:hover { color: var(--primary); }
|
||||||
|
.breadcrumbs .bc-sep { opacity: .55; }
|
||||||
|
.breadcrumbs .bc-current { color: var(--ink); font-weight: 600; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
/* Smaller, tighter typography on phones */
|
/* Smaller, tighter typography on phones */
|
||||||
.hero { padding: 28px 0 32px; }
|
.hero { padding: 28px 0 32px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user