From 3edd21d2b60b5bbfc6e6dbd7e005e34416764b97 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 20 Jun 2026 19:12:38 +0330 Subject: [PATCH] Breadcrumbs: visible trail + BreadcrumbList JSON-LD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/JobsMedical.Web/Pages/Jobs/Details.cshtml | 7 +++++- src/JobsMedical.Web/Pages/Jobs/Index.cshtml | 6 +++++ .../Pages/Jobs/Index.cshtml.cs | 8 +++++++ .../Pages/Shared/_Breadcrumbs.cshtml | 20 ++++++++++++++++ .../Pages/Shifts/Details.cshtml | 7 +++++- src/JobsMedical.Web/Pages/Shifts/Index.cshtml | 6 +++++ .../Pages/Shifts/Index.cshtml.cs | 8 +++++++ src/JobsMedical.Web/Services/SeoJsonLd.cs | 23 +++++++++++++++++++ src/JobsMedical.Web/wwwroot/css/site.css | 6 +++++ 9 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/JobsMedical.Web/Pages/Shared/_Breadcrumbs.cshtml diff --git a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml index 7867d6d..11d72ea 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Jobs/Details.cshtml @@ -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 { 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)); }
+
@empLabel @if (j.Role is not null) { @j.Role.Name } @@ -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("") @if (JobsMedical.Web.Services.SeoJsonLd.HasRealEmployer(f)) { - var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; @Html.Raw("") } } diff --git a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml index 2db769e..b66206a 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml @@ -6,6 +6,7 @@
+

@Model.PageHeading

@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) موقعیت شغلی پیدا شد @@ -140,3 +141,8 @@ } } + +@section Head { + @{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; } + @Html.Raw("") +} diff --git a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs index 82c96b3..af1289a 100644 --- a/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Jobs/Index.cshtml.cs @@ -37,6 +37,9 @@ public class IndexModel : PageModel ///

A short unique intro shown on role/city landing pages (avoids thin-content). public string? PageIntro { get; private set; } + /// Breadcrumb trail (also emitted as BreadcrumbList JSON-LD). + public IReadOnlyList Breadcrumbs { get; private set; } = Array.Empty(); + public async Task 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 { 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; } } diff --git a/src/JobsMedical.Web/Pages/Shared/_Breadcrumbs.cshtml b/src/JobsMedical.Web/Pages/Shared/_Breadcrumbs.cshtml new file mode 100644 index 0000000..903714d --- /dev/null +++ b/src/JobsMedical.Web/Pages/Shared/_Breadcrumbs.cshtml @@ -0,0 +1,20 @@ +@model IReadOnlyList +@* 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 }) +{ + +} diff --git a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml index d14e055..9c78f6a 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Details.cshtml +++ b/src/JobsMedical.Web/Pages/Shifts/Details.cshtml @@ -21,10 +21,14 @@ ShiftType.Night => ("badge-night", "شیفت شب"), _ => ("badge-oncall", "آنکال"), }; + var crumbs = new List { 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)); }
+
@typeLabel @if (f.IsVerified) @@ -216,10 +220,11 @@ } @section Head { + @{ var bu = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; } + @Html.Raw("") @* 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("") } } diff --git a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml index 3ce1ae6..5ccb325 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml +++ b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml @@ -6,6 +6,7 @@
+

@Model.PageHeading

@JalaliDate.ToPersianDigits(Model.Results.Count.ToString()) شیفت باز پیدا شد @@ -173,3 +174,8 @@ } } + +@section Head { + @{ var bcUrl = $"{ViewContext.HttpContext.Request.Scheme}://{ViewContext.HttpContext.Request.Host}"; } + @Html.Raw("") +} diff --git a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs index 053816e..faa9fb9 100644 --- a/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs +++ b/src/JobsMedical.Web/Pages/Shifts/Index.cshtml.cs @@ -43,6 +43,9 @@ public class IndexModel : PageModel ///

A short unique intro shown on role/city landing pages (avoids thin-content). public string? PageIntro { get; private set; } + /// Breadcrumb trail (also emitted as BreadcrumbList JSON-LD). + public IReadOnlyList Breadcrumbs { get; private set; } = Array.Empty(); + public async Task OnGetAsync() { var today = DateOnly.FromDateTime(DateTime.UtcNow); @@ -128,5 +131,10 @@ public class IndexModel : PageModel PageIntro = $"در این صفحه جدیدترین {PageHeading}، گردآوری‌شده از منابع معتبر، را می‌بینید. " + "روی هر شیفت بزنید تا تاریخ، ساعت، پرداخت و راهِ تماسِ مستقیم با مرکز درمانی نمایش داده شود. " + "برای موارد مرتبط، نقش یا شهر دیگری را از لینک‌های بالا انتخاب کنید."; + + var crumbs = new List { 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; } } diff --git a/src/JobsMedical.Web/Services/SeoJsonLd.cs b/src/JobsMedical.Web/Services/SeoJsonLd.cs index 2179f91..4f79916 100644 --- a/src/JobsMedical.Web/Services/SeoJsonLd.cs +++ b/src/JobsMedical.Web/Services/SeoJsonLd.cs @@ -125,9 +125,32 @@ public static class SeoJsonLd }, }, Opts)); + /// BreadcrumbList JSON-LD from an ordered crumb trail (relative URLs are made absolute). + /// Google can then show the breadcrumb path in search results. + public static string Breadcrumb(IReadOnlyList items, string baseUrl) + { + var els = new List(); + for (var i = 0; i < items.Count; i++) + { + var el = new Dictionary { ["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 + { + ["@context"] = "https://schema.org", + ["@type"] = "BreadcrumbList", + ["itemListElement"] = els, + }, Opts)); + } + // Nested anonymous objects use "type"/"queryyy" placeholders for @type / query-input; // restore the @-prefixed schema.org keys here. private static string Fix(string json) => json .Replace("\"type\":", "\"@type\":") .Replace("\"queryyy\":", "\"query-input\":"); } + +/// One step in a breadcrumb trail. is null for the current (last) page. +public record Crumb(string Name, string? Url = null); diff --git a/src/JobsMedical.Web/wwwroot/css/site.css b/src/JobsMedical.Web/wwwroot/css/site.css index 1c0fe7d..bb49267 100644 --- a/src/JobsMedical.Web/wwwroot/css/site.css +++ b/src/JobsMedical.Web/wwwroot/css/site.css @@ -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); padding: 5px 12px; border-radius: 999px; font-size: 13px; transition: all .15s; } .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) { /* Smaller, tighter typography on phones */ .hero { padding: 28px 0 32px; }