using System.Text.Json; using JobsMedical.Web.Models; namespace JobsMedical.Web.Services; /// /// Builds schema.org JSON-LD for listings so Google surfaces them as rich results /// (Google for Jobs uses JobPosting). System.Text.Json guarantees valid, script-safe /// output (Persian + < > & are \u-escaped), so it can be emitted inside a /// <script type="application/ld+json"> tag directly. /// public static class SeoJsonLd { private static readonly JsonSerializerOptions Opts = new() { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; /// Whether a facility is a REAL named employer (not the «نامشخص» placeholder used for /// aggregated ads with no named center). Google for Jobs rejects a JobPosting whose /// hiringOrganization is empty/placeholder, so callers should skip the JSON-LD when this is false. public static bool HasRealEmployer(Facility? f) => f is not null && !string.IsNullOrWhiteSpace(f.Name) && !f.Name.Contains("نامشخص") && !f.Name.Contains("ثبت نشده"); public static string ShiftPosting(Shift s, string baseUrl) { var typeLabel = s.ShiftType switch { ShiftType.Day => "شیفت صبح", ShiftType.Evening => "شیفت عصر", ShiftType.Night => "شیفت شب", _ => "آنکال", }; object? salary = s.PayAmount is long amt && amt > 0 ? new { type = "MonetaryAmount", currency = "IRR", value = new { type = "QuantitativeValue", value = amt, unitText = "DAY" } } : null; var obj = new Dictionary { ["@context"] = "https://schema.org/", ["@type"] = "JobPosting", ["title"] = $"{s.Role?.Name} — {typeLabel}", ["description"] = string.IsNullOrWhiteSpace(s.Description) ? $"{typeLabel} برای {s.Role?.Name} در {s.Facility?.Name}، {s.Facility?.City?.Name}." : s.Description, ["datePosted"] = s.CreatedAt.ToString("yyyy-MM-dd"), ["validThrough"] = s.Date.ToString("yyyy-MM-dd"), ["employmentType"] = "PER_DIEM", ["directApply"] = true, ["identifier"] = new { type = "PropertyValue", name = "همکادر", value = $"shift-{s.Id}" }, ["hiringOrganization"] = new { type = "Organization", name = s.Facility?.Name, sameAs = $"{baseUrl}/Facilities/Details/{s.FacilityId}" }, ["jobLocation"] = new { type = "Place", address = new { type = "PostalAddress", addressLocality = s.Facility?.City?.Name, addressCountry = "IR", streetAddress = s.Facility?.Address } }, }; if (salary is not null) obj["baseSalary"] = salary; // rename "type" keys to "@type" after serialization (anonymous objects can't use @type directly) return Fix(JsonSerializer.Serialize(obj, Opts)); } public static string JobPosting(JobOpening j, string baseUrl) { var empType = j.EmploymentType switch { EmploymentType.FullTime => "FULL_TIME", EmploymentType.PartTime => "PART_TIME", EmploymentType.Contract => "CONTRACTOR", _ => "OTHER", }; long? min = j.SalaryMin, max = j.SalaryMax; object? salary = (min ?? max) is long ? new { type = "MonetaryAmount", currency = "IRR", value = new { type = "QuantitativeValue", minValue = min, maxValue = max ?? min, unitText = "MONTH" } } : null; var obj = new Dictionary { ["@context"] = "https://schema.org/", ["@type"] = "JobPosting", ["title"] = j.Title, ["description"] = string.IsNullOrWhiteSpace(j.Description) ? $"استخدام {j.Role?.Name} در {j.Facility?.Name}، {j.Facility?.City?.Name}." : j.Description, ["datePosted"] = j.CreatedAt.ToString("yyyy-MM-dd"), ["validThrough"] = j.CreatedAt.AddDays(30).ToString("yyyy-MM-dd"), ["employmentType"] = empType, ["directApply"] = true, ["identifier"] = new { type = "PropertyValue", name = "همکادر", value = $"job-{j.Id}" }, ["hiringOrganization"] = new { type = "Organization", name = j.Facility?.Name, sameAs = $"{baseUrl}/Facilities/Details/{j.FacilityId}" }, ["jobLocation"] = new { type = "Place", address = new { type = "PostalAddress", addressLocality = j.Facility?.City?.Name, addressCountry = "IR", streetAddress = j.Facility?.Address } }, }; if (salary is not null) obj["baseSalary"] = salary; return Fix(JsonSerializer.Serialize(obj, Opts)); } /// schema.org structured data for a facility page — a Hospital/MedicalClinic with its /// address, map coordinates, and aggregate review rating, so Google can show a rich place result. public static string MedicalOrganization(Facility f, string baseUrl, double avgRating = 0, int ratingCount = 0) { var schemaType = f.Type == FacilityType.Hospital ? "Hospital" : "MedicalClinic"; var obj = new Dictionary { ["@context"] = "https://schema.org", ["@type"] = schemaType, ["name"] = f.Name, ["url"] = $"{baseUrl}/Facilities/Details/{f.Id}", ["address"] = new { type = "PostalAddress", addressLocality = f.City?.Name, addressCountry = "IR", streetAddress = f.Address }, }; if (f.Lat is double la && f.Lng is double lo) obj["geo"] = new { type = "GeoCoordinates", latitude = la, longitude = lo }; if (ratingCount > 0) obj["aggregateRating"] = new { type = "AggregateRating", ratingValue = Math.Round(avgRating, 1), reviewCount = ratingCount }; return Fix(JsonSerializer.Serialize(obj, Opts)); } public static string Organization(string baseUrl) => Fix(JsonSerializer.Serialize(new Dictionary { ["@context"] = "https://schema.org", ["@type"] = "Organization", ["name"] = "همکادر", ["url"] = baseUrl, ["logo"] = $"{baseUrl}/icons/icon-512.png", ["description"] = "سامانه یافتن شیفت و استخدام کادر درمان در ایران.", }, Opts)); public static string WebSite(string baseUrl) => Fix(JsonSerializer.Serialize(new Dictionary { ["@context"] = "https://schema.org", ["@type"] = "WebSite", ["name"] = "همکادر", ["url"] = baseUrl, ["inLanguage"] = "fa-IR", ["potentialAction"] = new { type = "SearchAction", target = $"{baseUrl}/Shifts?q={{search_term_string}}", queryyy = "required name=search_term_string", }, }, 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)); } /// ItemList JSON-LD for a results/landing page — an ordered list of the listing URLs, /// so Google understands it as a curated collection. Relative URLs are made absolute. public static string ItemList(IEnumerable urls, string baseUrl) { var els = urls.Select((u, i) => (object)new Dictionary { ["type"] = "ListItem", ["position"] = i + 1, ["url"] = u.StartsWith("http") ? u : baseUrl + u, }).ToList(); return Fix(JsonSerializer.Serialize(new Dictionary { ["@context"] = "https://schema.org", ["@type"] = "ItemList", ["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);