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