2026-06-07 08:16:30 +03:30
|
|
|
using System.Text.Json;
|
|
|
|
|
using JobsMedical.Web.Models;
|
|
|
|
|
|
|
|
|
|
namespace JobsMedical.Web.Services;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public static class SeoJsonLd
|
|
|
|
|
{
|
|
|
|
|
private static readonly JsonSerializerOptions Opts = new()
|
|
|
|
|
{
|
|
|
|
|
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-20 18:03:14 +03:30
|
|
|
/// <summary>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.</summary>
|
|
|
|
|
public static bool HasRealEmployer(Facility? f)
|
|
|
|
|
=> f is not null && !string.IsNullOrWhiteSpace(f.Name) && !f.Name.Contains("نامشخص") && !f.Name.Contains("ثبت نشده");
|
|
|
|
|
|
2026-06-07 08:16:30 +03:30
|
|
|
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<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["@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<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["@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));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static string Organization(string baseUrl) => Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["@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<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["@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));
|
|
|
|
|
|
2026-06-20 19:12:38 +03:30
|
|
|
/// <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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 19:15:12 +03:30
|
|
|
/// <summary>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.</summary>
|
|
|
|
|
public static string ItemList(IEnumerable<string> urls, string baseUrl)
|
|
|
|
|
{
|
|
|
|
|
var els = urls.Select((u, i) => (object)new Dictionary<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["type"] = "ListItem", ["position"] = i + 1, ["url"] = u.StartsWith("http") ? u : baseUrl + u,
|
|
|
|
|
}).ToList();
|
|
|
|
|
return Fix(JsonSerializer.Serialize(new Dictionary<string, object?>
|
|
|
|
|
{
|
|
|
|
|
["@context"] = "https://schema.org",
|
|
|
|
|
["@type"] = "ItemList",
|
|
|
|
|
["itemListElement"] = els,
|
|
|
|
|
}, Opts));
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 08:16:30 +03:30
|
|
|
// 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\":");
|
|
|
|
|
}
|
2026-06-20 19:12:38 +03:30
|
|
|
|
|
|
|
|
/// <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);
|