diff --git a/src/FrontOffice.Main/ConfigureServices.cs b/src/FrontOffice.Main/ConfigureServices.cs index 3f6984c..6a8b085 100644 --- a/src/FrontOffice.Main/ConfigureServices.cs +++ b/src/FrontOffice.Main/ConfigureServices.cs @@ -30,6 +30,7 @@ using CMSMicroservice.Protobuf.Protos.DiscountCategory; using CMSMicroservice.Protobuf.Protos.DiscountShoppingCart; using CMSMicroservice.Protobuf.Protos.DiscountOrder; using FrontOffice.Main.Utilities; +using FrontOffice.Main.Utilities.Seo; namespace Microsoft.Extensions.DependencyInjection; @@ -95,6 +96,10 @@ public static class ConfigureServices // SignalR Token Notification Service services.AddScoped(); + // SEO + services.AddScoped(); + services.AddScoped(); + return services; } diff --git a/src/FrontOffice.Main/Pages/_Host.cshtml b/src/FrontOffice.Main/Pages/_Host.cshtml index 37133fe..30e5a6b 100644 --- a/src/FrontOffice.Main/Pages/_Host.cshtml +++ b/src/FrontOffice.Main/Pages/_Host.cshtml @@ -1,4 +1,5 @@ @page "/" +@model FrontOffice.Main.Pages.HostModel @using Microsoft.AspNetCore.Components.Web @namespace FrontOffice.Main.Pages @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @@ -8,6 +9,26 @@ + @Model.Seo.Title + + + @if (Model.Seo.NoIndex) + { + + } + else + { + + } + + + + + + + + + @@ -23,8 +44,26 @@ + + @if (!string.IsNullOrWhiteSpace(Model.Seo.CrawlableContent)) + { +
@Model.Seo.CrawlableContent
+ } +
diff --git a/src/FrontOffice.Main/Pages/_Host.cshtml.cs b/src/FrontOffice.Main/Pages/_Host.cshtml.cs new file mode 100644 index 0000000..e0a7657 --- /dev/null +++ b/src/FrontOffice.Main/Pages/_Host.cshtml.cs @@ -0,0 +1,21 @@ +using FrontOffice.Main.Utilities.Seo; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace FrontOffice.Main.Pages; + +public class HostModel : PageModel +{ + private readonly SeoMetadataProvider _seoMetadataProvider; + + public SeoPageMetadata Seo { get; private set; } = default!; + + public HostModel(SeoMetadataProvider seoMetadataProvider) + { + _seoMetadataProvider = seoMetadataProvider; + } + + public async Task OnGetAsync() + { + Seo = await _seoMetadataProvider.GetForPathAsync(Request.Path); + } +} diff --git a/src/FrontOffice.Main/Program.cs b/src/FrontOffice.Main/Program.cs index c6372b1..0c1449e 100644 --- a/src/FrontOffice.Main/Program.cs +++ b/src/FrontOffice.Main/Program.cs @@ -1,5 +1,6 @@ using FluentValidation; using FrontOffice.Main.Utilities; +using FrontOffice.Main.Utilities.Seo; using System.Net; using FrontOffice.Main.Utilities.Pdf; @@ -12,6 +13,7 @@ builder.Services.AddServerSideBlazor(); #region AddCommonServices builder.Services.AddCommonServices(); +builder.Services.Configure(builder.Configuration.GetSection(SeoSettings.SectionName)); builder.Services.AddSingleton(); #endregion @@ -55,6 +57,13 @@ webApp.UseStaticFiles(); webApp.UseRouting(); webApp.MapBlazorHub(); + +webApp.MapGet("/sitemap.xml", async (SitemapGenerator sitemapGenerator, CancellationToken cancellationToken) => +{ + var xml = await sitemapGenerator.GenerateAsync(cancellationToken); + return Results.Content(xml, "application/xml; charset=utf-8"); +}); + webApp.MapFallbackToPage("/_Host"); diff --git a/src/FrontOffice.Main/Utilities/Seo/SeoMetadataProvider.cs b/src/FrontOffice.Main/Utilities/Seo/SeoMetadataProvider.cs new file mode 100644 index 0000000..5467a5f --- /dev/null +++ b/src/FrontOffice.Main/Utilities/Seo/SeoMetadataProvider.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.Options; + +namespace FrontOffice.Main.Utilities.Seo; + +public class SeoMetadataProvider +{ + private readonly BlogPostService _blogPostService; + private readonly SeoSettings _settings; + + private static readonly Dictionary StaticPages = new(StringComparer.OrdinalIgnoreCase) + { + ["/"] = ( + "کارابازار سلامت | باشگاه مشتریان KBS", + "باشگاه مشتریان کارابازار سلامت — رشد تیم، فروش واقعی و پاداش شفاف. فروشگاه محصولات سلامت، کیف پول و ثبت‌نام رایگان."), + [RouteConstants.About.Index] = ( + "درباره ما | کارابازار سلامت", + "آشنایی با کارابازار سلامت، باشگاه مشتریان KBS و خدمات فروشگاه محصولات سلامت."), + [RouteConstants.FAQ.Index] = ( + "سوالات متداول | کارابازار سلامت", + "پاسخ سوالات متداول درباره باشگاه مشتریان، کیف پول، پاداش‌ها و خرید از کارابازار سلامت."), + [RouteConstants.Contact.Index] = ( + "ارتباط با ما | کارابازار سلامت", + "تماس با تیم پشتیبانی کارابازار سلامت — سوالات، پیشنهادات و پشتیبانی کاربران."), + [RouteConstants.Licenses.Index] = ( + "مجوزها و گواهینامه‌ها | کارابازار سلامت", + "مجوزها، گواهینامه‌ها و مدارک رسمی کارابازار سلامت."), + [RouteConstants.Blog.Index] = ( + "بلاگ | کارابازار سلامت", + "آخرین مقالات و اخبار باشگاه مشتریان کارابازار سلامت."), + [RouteConstants.Store.Products] = ( + "محصولات | کارابازار سلامت", + "فروشگاه محصولات سلامت کارابازار — خرید آنلاین با پاداش باشگاه مشتریان."), + [RouteConstants.Package.List] = ( + "پکیج‌ها | کارابازار سلامت", + "پکیج‌های عضویت باشگاه مشتریان کارابازار سلامت."), + [RouteConstants.Club.Membership] = ( + "عضویت باشگاه مشتریان | کارابازار سلامت", + "عضویت در باشگاه مشتریان KBS و بهره‌مندی از مزایا و پاداش‌ها."), + [RouteConstants.Club.Features] = ( + "ویژگی‌های باشگاه مشتریان | کارابازار سلامت", + "امکانات و ویژگی‌های باشگاه مشتریان کارابازار سلامت."), + [RouteConstants.DiscountStore.Products] = ( + "فروشگاه اعتباری | کارابازار سلامت", + "خرید از فروشگاه اعتباری کارابازار سلامت با کیف پول تخفیفی."), + [RouteConstants.Registration.Wizard] = ( + "ثبت‌نام | کارابازار سلامت", + "ثبت‌نام سریع در باشگاه مشتریان کارابازار سلامت."), + }; + + private static readonly HashSet NoIndexPrefixes = new(StringComparer.OrdinalIgnoreCase) + { + RouteConstants.Profile.Index, + "/profile/", + RouteConstants.Checkout.Index, + RouteConstants.Store.Cart, + RouteConstants.Store.CheckoutSummary, + RouteConstants.Store.Orders, + "/order/", + RouteConstants.DiscountStore.Cart, + RouteConstants.DiscountStore.Checkout, + RouteConstants.DiscountStore.Orders, + "/discount-store/order/", + RouteConstants.Gateway.OrdersChooser, + RouteConstants.Gateway.CartChooser, + }; + + public SeoMetadataProvider(BlogPostService blogPostService, IOptions settings) + { + _blogPostService = blogPostService; + _settings = settings.Value; + } + + public async Task GetForPathAsync(string path, CancellationToken cancellationToken = default) + { + var normalizedPath = NormalizePath(path); + var baseUrl = _settings.SiteUrl.TrimEnd('/'); + + if (IsNoIndexPath(normalizedPath)) + { + return new SeoPageMetadata + { + Title = _settings.SiteName, + Description = _settings.DefaultDescription, + CanonicalUrl = $"{baseUrl}{normalizedPath}", + NoIndex = true + }; + } + + if (normalizedPath.StartsWith(RouteConstants.Blog.Post, StringComparison.OrdinalIgnoreCase) + && normalizedPath.Length > RouteConstants.Blog.Post.Length) + { + var slug = normalizedPath[RouteConstants.Blog.Post.Length..].Trim('/'); + if (!string.IsNullOrWhiteSpace(slug)) + { + var post = await _blogPostService.GetBySlugAsync(slug); + if (post != null) + { + var description = !string.IsNullOrWhiteSpace(post.Summary) + ? post.Summary + : $"مقاله {post.Title} — بلاگ کارابازار سلامت"; + + return new SeoPageMetadata + { + Title = $"{post.Title} | بلاگ کارابازار سلامت", + Description = description, + CanonicalUrl = $"{baseUrl}{RouteConstants.Blog.Post}{post.Slug}", + CrawlableContent = $"{post.Title}. {description}" + }; + } + } + } + + if (StaticPages.TryGetValue(normalizedPath, out var page)) + { + return new SeoPageMetadata + { + Title = page.Title, + Description = page.Description, + CanonicalUrl = $"{baseUrl}{normalizedPath}", + CrawlableContent = normalizedPath == "/" ? GetHomeCrawlableContent() : null + }; + } + + return new SeoPageMetadata + { + Title = _settings.SiteName, + Description = _settings.DefaultDescription, + CanonicalUrl = $"{baseUrl}{normalizedPath}" + }; + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return "/"; + + var normalized = path.Split('?', '#')[0].Trim(); + if (!normalized.StartsWith('/')) + normalized = "/" + normalized; + + if (normalized.Length > 1 && normalized.EndsWith('/')) + normalized = normalized.TrimEnd('/'); + + return normalized; + } + + private static bool IsNoIndexPath(string path) + { + foreach (var prefix in NoIndexPrefixes) + { + if (path.Equals(prefix, StringComparison.OrdinalIgnoreCase) + || path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private static string GetHomeCrawlableContent() => + "کارابازار سلامت (KBS) — باشگاه مشتریان و فروشگاه محصولات سلامت. " + + "با عضویت در باشگاه مشتریان KBS از پاداش شفاف، کیف پول جادویی، فروشگاه اعتباری و شبکه فروش بهره‌مند شوید. " + + "ثبت‌نام رایگان در kbs2.ir."; +} diff --git a/src/FrontOffice.Main/Utilities/Seo/SeoPageMetadata.cs b/src/FrontOffice.Main/Utilities/Seo/SeoPageMetadata.cs new file mode 100644 index 0000000..6b2c10b --- /dev/null +++ b/src/FrontOffice.Main/Utilities/Seo/SeoPageMetadata.cs @@ -0,0 +1,10 @@ +namespace FrontOffice.Main.Utilities.Seo; + +public sealed class SeoPageMetadata +{ + public required string Title { get; init; } + public required string Description { get; init; } + public required string CanonicalUrl { get; init; } + public string? CrawlableContent { get; init; } + public bool NoIndex { get; init; } +} diff --git a/src/FrontOffice.Main/Utilities/Seo/SeoSettings.cs b/src/FrontOffice.Main/Utilities/Seo/SeoSettings.cs new file mode 100644 index 0000000..89d19e9 --- /dev/null +++ b/src/FrontOffice.Main/Utilities/Seo/SeoSettings.cs @@ -0,0 +1,11 @@ +namespace FrontOffice.Main.Utilities.Seo; + +public class SeoSettings +{ + public const string SectionName = "Seo"; + + public string SiteUrl { get; set; } = "https://kbs2.ir"; + public string SiteName { get; set; } = "کارابازار سلامت"; + public string DefaultDescription { get; set; } = + "باشگاه مشتریان کارابازار سلامت (KBS) — فروشگاه محصولات سلامت، پاداش شفاف، کیف پول و شبکه فروش."; +} diff --git a/src/FrontOffice.Main/Utilities/Seo/SitemapGenerator.cs b/src/FrontOffice.Main/Utilities/Seo/SitemapGenerator.cs new file mode 100644 index 0000000..e65e3c0 --- /dev/null +++ b/src/FrontOffice.Main/Utilities/Seo/SitemapGenerator.cs @@ -0,0 +1,103 @@ +using System.Text; +using System.Xml.Linq; +using Microsoft.Extensions.Options; + +namespace FrontOffice.Main.Utilities.Seo; + +public class SitemapGenerator +{ + private readonly BlogPostService _blogPostService; + private readonly SeoSettings _settings; + + private static readonly string[] StaticPaths = + [ + RouteConstants.Main.MainPage, + RouteConstants.About.Index, + RouteConstants.FAQ.Index, + RouteConstants.Contact.Index, + RouteConstants.Licenses.Index, + RouteConstants.Blog.Index, + RouteConstants.Store.Products, + RouteConstants.Package.List, + RouteConstants.Club.Membership, + RouteConstants.Club.Features, + RouteConstants.DiscountStore.Products, + RouteConstants.Registration.Wizard, + ]; + + public SitemapGenerator(BlogPostService blogPostService, IOptions settings) + { + _blogPostService = blogPostService; + _settings = settings.Value; + } + + public async Task GenerateAsync(CancellationToken cancellationToken = default) + { + var baseUrl = _settings.SiteUrl.TrimEnd('/'); + var ns = XNamespace.Get("http://www.sitemaps.org/schemas/sitemap/0.9"); + var urls = new List(); + + foreach (var path in StaticPaths) + { + urls.Add(CreateUrlElement(ns, $"{baseUrl}{path}", priority: path == "/" ? "1.0" : "0.8")); + } + + var page = 1; + const int pageSize = 100; + + while (true) + { + var result = await _blogPostService.GetPublishedPostsAsync(page: page, pageSize: pageSize); + if (result.Posts.Count == 0) + break; + + foreach (var post in result.Posts) + { + if (string.IsNullOrWhiteSpace(post.Slug)) + continue; + + var lastMod = post.PublishedAt?.ToString("yyyy-MM-dd") ?? DateTime.UtcNow.ToString("yyyy-MM-dd"); + urls.Add(CreateUrlElement( + ns, + $"{baseUrl}{RouteConstants.Blog.Post}{post.Slug}", + lastMod: lastMod, + priority: "0.6")); + } + + if (page >= result.TotalPages) + break; + + page++; + } + + var document = new XDocument( + new XDeclaration("1.0", "UTF-8", null), + new XElement(ns + "urlset", urls)); + + var builder = new StringBuilder(); + using (var writer = new StringWriter(builder)) + { + document.Save(writer); + } + + return builder.ToString(); + } + + private static XElement CreateUrlElement( + XNamespace ns, + string loc, + string? lastMod = null, + string changefreq = "weekly", + string priority = "0.5") + { + var element = new XElement(ns + "url", + new XElement(ns + "loc", loc), + new XElement(ns + "changefreq", changefreq), + new XElement(ns + "priority", priority)); + + if (!string.IsNullOrWhiteSpace(lastMod)) + element.Add(new XElement(ns + "lastmod", lastMod)); + + return element; + } +} diff --git a/src/FrontOffice.Main/appsettings.Staging.json b/src/FrontOffice.Main/appsettings.Staging.json index e9c8a09..f07c714 100644 --- a/src/FrontOffice.Main/appsettings.Staging.json +++ b/src/FrontOffice.Main/appsettings.Staging.json @@ -1,4 +1,9 @@ { + "Seo": { + "SiteUrl": "https://kbs1.ir", + "SiteName": "کارابازار سلامت", + "DefaultDescription": "باشگاه مشتریان کارابازار سلامت (KBS) — فروشگاه محصولات سلامت، پاداش شفاف، کیف پول و شبکه فروش." + }, "GwUrl": "https://cms.se.kbs1.ir", "DownloadUrl": "", "EncryptionSettings": { diff --git a/src/FrontOffice.Main/appsettings.json b/src/FrontOffice.Main/appsettings.json index 92c3d39..a8b7510 100644 --- a/src/FrontOffice.Main/appsettings.json +++ b/src/FrontOffice.Main/appsettings.json @@ -1,4 +1,9 @@ { + "Seo": { + "SiteUrl": "http://localhost:5268", + "SiteName": "کارابازار سلامت", + "DefaultDescription": "باشگاه مشتریان کارابازار سلامت (KBS) — فروشگاه محصولات سلامت، پاداش شفاف، کیف پول و شبکه فروش." + }, "GwUrl": "https://cms.se.kbs1.ir", "DownloadUrl": "", "EncryptionSettings": { diff --git a/src/FrontOffice.Main/wwwroot/robots.txt b/src/FrontOffice.Main/wwwroot/robots.txt new file mode 100644 index 0000000..b8c7849 --- /dev/null +++ b/src/FrontOffice.Main/wwwroot/robots.txt @@ -0,0 +1,17 @@ +User-agent: * +Allow: / + +Disallow: /profile/ +Disallow: /checkout +Disallow: /checkout-summary +Disallow: /cart +Disallow: /orders +Disallow: /order/ +Disallow: /discount-store/cart +Disallow: /discount-store/checkout +Disallow: /discount-store/orders +Disallow: /discount-store/order/ +Disallow: /my-orders +Disallow: /my-cart + +Sitemap: https://kbs2.ir/sitemap.xml