From 2573729db50d6f104852954145104a254f853871 Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Wed, 1 Jul 2026 21:45:24 +0330 Subject: [PATCH] feat: implement SEO enhancements and sitemap generation - Added SEO configuration settings in appsettings for both development and staging environments, including site URL, name, and default description. - Registered SEO-related services in the dependency injection container, including `SeoMetadataProvider` and `SitemapGenerator`. - Updated the main program to configure SEO settings and map a new endpoint for generating the sitemap. - Enhanced the `_Host.cshtml` page to utilize SEO metadata, including title, description, and Open Graph tags, improving search engine visibility and user engagement. These changes significantly enhance the SEO capabilities of the application, ensuring better indexing and presentation in search results. --- src/FrontOffice.Main/ConfigureServices.cs | 5 + src/FrontOffice.Main/Pages/_Host.cshtml | 39 +++++ src/FrontOffice.Main/Pages/_Host.cshtml.cs | 21 +++ src/FrontOffice.Main/Program.cs | 9 + .../Utilities/Seo/SeoMetadataProvider.cs | 165 ++++++++++++++++++ .../Utilities/Seo/SeoPageMetadata.cs | 10 ++ .../Utilities/Seo/SeoSettings.cs | 11 ++ .../Utilities/Seo/SitemapGenerator.cs | 103 +++++++++++ src/FrontOffice.Main/appsettings.Staging.json | 5 + src/FrontOffice.Main/appsettings.json | 5 + src/FrontOffice.Main/wwwroot/robots.txt | 17 ++ 11 files changed, 390 insertions(+) create mode 100644 src/FrontOffice.Main/Pages/_Host.cshtml.cs create mode 100644 src/FrontOffice.Main/Utilities/Seo/SeoMetadataProvider.cs create mode 100644 src/FrontOffice.Main/Utilities/Seo/SeoPageMetadata.cs create mode 100644 src/FrontOffice.Main/Utilities/Seo/SeoSettings.cs create mode 100644 src/FrontOffice.Main/Utilities/Seo/SitemapGenerator.cs create mode 100644 src/FrontOffice.Main/wwwroot/robots.txt 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