diff --git a/.editorconfig b/.editorconfig index c73a38b3..8b6b66f0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -125,9 +125,9 @@ csharp_preserve_single_line_blocks = true # IntelliTect Conventions # ############################### # var preferences -csharp_style_var_for_built_in_types = false:warning +csharp_style_var_for_built_in_types = false:none csharp_style_var_when_type_is_apparent = true:suggestion -csharp_style_var_elsewhere = false:warning +csharp_style_var_elsewhere = false:none ## Naming # Style Definitions diff --git a/Directory.Packages.props b/Directory.Packages.props index 95b2f353..e9818788 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -16,13 +16,10 @@ - - - - - + + @@ -31,6 +28,9 @@ + + + diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs index 55c91fe8..a13c4bfb 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebContext.cs @@ -4,7 +4,7 @@ namespace EssentialCSharp.Web.Data; -public class EssentialCSharpWebContext(DbContextOptions options) : IdentityDbContext(options) +public class EssentialCSharpWebContext(DbContextOptions options) : IdentityDbContext(options) { protected override void OnModelCreating(ModelBuilder builder) { diff --git a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs index ff5e62f3..04e02555 100644 --- a/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs +++ b/EssentialCSharp.Web/Areas/Identity/Data/EssentialCSharpWebUser.cs @@ -9,5 +9,6 @@ public class EssentialCSharpWebUser : IdentityUser public virtual string? FirstName { get; set; } [ProtectedPersonalData] public virtual string? LastName { get; set; } + public int ReferralCount { get; set; } } diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs index b876456f..a3ea80a7 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Services.Referrals; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -7,7 +8,7 @@ namespace EssentialCSharp.Web.Areas.Identity.Pages.Account; -public class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger) : PageModel +public class LoginModel(SignInManager signInManager, UserManager userManager, ILogger logger, IReferralService referralService) : PageModel { private InputModel? _Input; [BindProperty] @@ -77,6 +78,8 @@ public async Task OnPostAsync(string? returnUrl = null) if (foundUser is not null) { result = await signInManager.PasswordSignInAsync(foundUser, Input.Password, Input.RememberMe, lockoutOnFailure: true); + // Call the referral service to get the referral ID and set it onto the user claim + _ = await referralService.EnsureReferralIdAsync(foundUser); } else { diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs index a2952af2..e362bf1c 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -24,6 +24,8 @@ public static class ManageNavPages public static string TwoFactorAuthentication => "TwoFactorAuthentication"; + public static string Referrals => "Referrals"; + public static string? IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); public static string? EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email); @@ -40,6 +42,8 @@ public static class ManageNavPages public static string? TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication); + public static string? ReferralsNavClass(ViewContext viewContext) => PageNavClass(viewContext, Referrals); + public static string? PageNavClass(ViewContext viewContext, string page) { string? activePage = viewContext.ViewData["ActivePage"] as string diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml new file mode 100644 index 00000000..f228413a --- /dev/null +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml @@ -0,0 +1,18 @@ +@page +@model ReferralsDataModel +@{ + ViewData["Title"] = "Referrals"; + ViewData["ActivePage"] = ManageNavPages.Referrals; +} + +@ViewData["Title"] + + + + You have @Model.ReferralCount referrals. + + + +@section Scripts { + +} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs new file mode 100644 index 00000000..f6136cf5 --- /dev/null +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/Referrals.cshtml.cs @@ -0,0 +1,34 @@ +using EssentialCSharp.Web.Areas.Identity.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace EssentialCSharp.Web.Areas.Identity.Pages.Account.Manage; + +public class ReferralsDataModel : PageModel +{ + private readonly UserManager _UserManager; + private readonly ILogger _Logger; + + public int ReferralCount { get; private set; } + + public ReferralsDataModel( + UserManager userManager, + ILogger logger) + { + _UserManager = userManager; + _Logger = logger; + } + + public async Task OnGetAsync() + { + EssentialCSharpWebUser? user = await _UserManager.GetUserAsync(User); + if (user is null) + { + return NotFound($"Unable to load user with ID '{_UserManager.GetUserId(User)}'."); + } + ReferralCount = user.ReferralCount; + + return Page(); + } +} diff --git a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml index d4e28666..15dfee37 100644 --- a/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml +++ b/EssentialCSharp.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -3,13 +3,14 @@ var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any(); } - Profile - Email - Password + Profile + Email + Password @if (hasExternalLogins) { - External logins + External logins } - Two-factor authentication - Personal data + Two-factor authentication + Referrals + Personal data diff --git a/EssentialCSharp.Web/Controllers/HomeController.cs b/EssentialCSharp.Web/Controllers/HomeController.cs index 32237400..2d36d2cb 100644 --- a/EssentialCSharp.Web/Controllers/HomeController.cs +++ b/EssentialCSharp.Web/Controllers/HomeController.cs @@ -7,25 +7,12 @@ namespace EssentialCSharp.Web.Controllers; -public class HomeController : Controller +public class HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IHttpContextAccessor httpContextAccessor) : Controller { - private readonly IConfiguration _Configuration; - private readonly IWebHostEnvironment _HostingEnvironment; - private readonly ISiteMappingService _SiteMappingService; - private readonly ILogger _Logger; - - public HomeController(ILogger logger, IWebHostEnvironment hostingEnvironment, ISiteMappingService siteMappingService, IConfiguration configuration) - { - _Logger = logger; - _HostingEnvironment = hostingEnvironment; - _SiteMappingService = siteMappingService; - _Configuration = configuration; - } - public IActionResult Index(string key) { // if no key (default case), then load up home page - SiteMapping? siteMapping = _SiteMappingService.SiteMappings.Find(key); + SiteMapping? siteMapping = siteMappingService.SiteMappings.Find(key); if (string.IsNullOrEmpty(key)) { @@ -33,7 +20,7 @@ public IActionResult Index(string key) } else if (siteMapping is not null) { - string filePath = Path.Combine(_HostingEnvironment.ContentRootPath, Path.Combine(siteMapping.PagePath)); + string filePath = Path.Combine(hostingEnvironment.ContentRootPath, Path.Combine(siteMapping.PagePath)); HtmlDocument doc = new(); doc.Load(filePath); string headHtml = doc.DocumentNode.Element("html").Element("head").InnerHtml; @@ -44,6 +31,8 @@ public IActionResult Index(string key) ViewBag.PreviousPage = FlipPage(siteMapping.ChapterNumber, siteMapping.PageNumber, false); ViewBag.HeadContents = headHtml; ViewBag.Contents = html; + // Set the referral Id for use in the front end if available + ViewBag.ReferralId = httpContextAccessor.HttpContext?.User?.Claims?.FirstOrDefault(f => f.Type == ClaimsExtensions.ReferrerIdClaimType)?.Value; return View(); } else @@ -84,19 +73,19 @@ public IActionResult Home() public IActionResult Guidelines() { ViewBag.PageTitle = "Coding Guidelines"; - FileInfo fileInfo = new(Path.Combine(_HostingEnvironment.ContentRootPath, "Guidelines", "guidelines.json")); + FileInfo fileInfo = new(Path.Combine(hostingEnvironment.ContentRootPath, "Guidelines", "guidelines.json")); if (!fileInfo.Exists) { return RedirectToAction(nameof(Error), new { errorMessage = "Guidelines could not be found", statusCode = 404 }); } - ViewBag.Guidelines = fileInfo.ReadGuidelineJsonFromInputDirectory(_Logger); + ViewBag.Guidelines = fileInfo.ReadGuidelineJsonFromInputDirectory(logger); ViewBag.GuidelinesUrl = Request.Path.Value; return View(); } private string FlipPage(int currentChapter, int currentPage, bool next) { - if (_SiteMappingService.SiteMappings.Count == 0) + if (siteMappingService.SiteMappings.Count == 0) { return ""; } @@ -107,18 +96,18 @@ private string FlipPage(int currentChapter, int currentPage, bool next) page = 1; } - SiteMapping? siteMap = _SiteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter && f.PageNumber == currentPage + page); + SiteMapping? siteMap = siteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter && f.PageNumber == currentPage + page); if (siteMap is null) { if (next) { - siteMap = _SiteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter + 1 && f.PageNumber == 1); + siteMap = siteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter + 1 && f.PageNumber == 1); } else { - int? previousPage = _SiteMappingService.SiteMappings.LastOrDefault(f => f.ChapterNumber == currentChapter - 1)?.PageNumber; - siteMap = _SiteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter - 1 && f.PageNumber == previousPage); + int? previousPage = siteMappingService.SiteMappings.LastOrDefault(f => f.ChapterNumber == currentChapter - 1)?.PageNumber; + siteMap = siteMappingService.SiteMappings.FirstOrDefault(f => f.ChapterNumber == currentChapter - 1 && f.PageNumber == previousPage); } if (siteMap is null) { diff --git a/EssentialCSharp.Web/EssentialCSharp.Web.csproj b/EssentialCSharp.Web/EssentialCSharp.Web.csproj index 398bd245..cb8f0cd1 100644 --- a/EssentialCSharp.Web/EssentialCSharp.Web.csproj +++ b/EssentialCSharp.Web/EssentialCSharp.Web.csproj @@ -14,22 +14,22 @@ + + - - + runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs b/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs new file mode 100644 index 00000000..3705cb83 --- /dev/null +++ b/EssentialCSharp.Web/Extensions/ClaimsExtensions.cs @@ -0,0 +1,17 @@ +using System.Security.Claims; + +namespace EssentialCSharp.Web.Extensions; + +public static class ClaimsExtensions +{ + public static string? GetReferrerId(this ClaimsPrincipal claimsPrincipal) + { + return claimsPrincipal.FindFirstValue(ReferrerIdClaimType); + } + + public static string? GetReferrerId(this IList claims) + { + return claims.FirstOrDefault(claim => claim.Type == ReferrerIdClaimType)?.Value; + } + public const string ReferrerIdClaimType = "ReferrerId"; +} diff --git a/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs new file mode 100644 index 00000000..27e72eef --- /dev/null +++ b/EssentialCSharp.Web/Middleware/ReferralTrackingMiddleware.cs @@ -0,0 +1,37 @@ +using System.Security.Claims; +using System.Web; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Services.Referrals; +using Microsoft.AspNetCore.Identity; + +namespace EssentialCSharp.Web.Middleware; + +public sealed class ReferralMiddleware +{ + private readonly RequestDelegate _Next; + + public ReferralMiddleware(RequestDelegate next) + { + _Next = next; + } + + public async Task InvokeAsync(HttpContext context, IReferralService referralService, UserManager userManager) + { + // Retrieve current referral Id for processing + System.Collections.Specialized.NameValueCollection query = HttpUtility.ParseQueryString(context.Request.QueryString.Value!); + string? referralId = query["rid"]; + if (string.IsNullOrWhiteSpace(referralId)) + { + await _Next(context); + return; + } + if (context.User is { Identity.IsAuthenticated: true } claimsUser) + { + referralService.TrackReferralAsync(referralId, claimsUser); + } + else + { + referralService.TrackReferralAsync(referralId, null); + } + } +} diff --git a/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.Designer.cs b/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.Designer.cs new file mode 100644 index 00000000..828dc5a8 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.Designer.cs @@ -0,0 +1,292 @@ +// +using System; +using EssentialCSharp.Web.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + [DbContext(typeof(EssentialCSharpWebContext))] + [Migration("20250217215739_ReferralCount")] + partial class ReferralCount + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); + + b.Property("LastName") + .HasColumnType("nvarchar(max)"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("ReferralCount") + .HasColumnType("int"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.cs b/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.cs new file mode 100644 index 00000000..4b69bc67 --- /dev/null +++ b/EssentialCSharp.Web/Migrations/20250217215739_ReferralCount.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EssentialCSharp.Web.Migrations +{ + /// + public partial class ReferralCount : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ReferralCount", + table: "AspNetUsers", + type: "int", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ReferralCount", + table: "AspNetUsers"); + } + } +} diff --git a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs index ad3d0a1c..e220d69c 100644 --- a/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs +++ b/EssentialCSharp.Web/Migrations/EssentialCSharpWebContextModelSnapshot.cs @@ -8,278 +8,282 @@ #nullable disable -namespace EssentialCSharp.Web.Migrations; - -[DbContext(typeof(EssentialCSharpWebContext))] -partial class EssentialCSharpWebContextModelSnapshot : ModelSnapshot +namespace EssentialCSharp.Web.Migrations { - protected override void BuildModel(ModelBuilder modelBuilder) + [DbContext(typeof(EssentialCSharpWebContext))] + partial class EssentialCSharpWebContextModelSnapshot : ModelSnapshot { + protected override void BuildModel(ModelBuilder modelBuilder) + { #pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "7.0.12") - .HasAnnotation("Relational:MaxIdentifierLength", 128); + modelBuilder + .HasAnnotation("ProductVersion", "8.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); - modelBuilder.Entity("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); + b.Property("AccessFailedCount") + .HasColumnType("int"); - b.Property("AccessFailedCount") - .HasColumnType("int"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("EmailConfirmed") + .HasColumnType("bit"); - b.Property("EmailConfirmed") - .HasColumnType("bit"); + b.Property("FirstName") + .HasColumnType("nvarchar(max)"); - b.Property("FirstName") - .HasColumnType("nvarchar(max)"); + b.Property("LastName") + .HasColumnType("nvarchar(max)"); - b.Property("LastName") - .HasColumnType("nvarchar(max)"); + b.Property("LockoutEnabled") + .HasColumnType("bit"); - b.Property("LockoutEnabled") - .HasColumnType("bit"); + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); + b.Property("ReferralCount") + .HasColumnType("int"); - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); - b.ToTable("AspNetUsers", (string)null); - }); + b.ToTable("AspNetUsers", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); - b.ToTable("AspNetRoles", (string)null); - }); + b.ToTable("AspNetRoles", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("RoleId"); + b.HasIndex("RoleId"); - b.ToTable("AspNetRoleClaims", (string)null); - }); + b.ToTable("AspNetRoleClaims", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); - b.HasKey("Id"); + b.HasKey("Id"); - b.HasIndex("UserId"); + b.HasIndex("UserId"); - b.ToTable("AspNetUserClaims", (string)null); - }); + b.ToTable("AspNetUserClaims", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("ProviderKey") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); - b.HasKey("LoginProvider", "ProviderKey"); + b.HasKey("LoginProvider", "ProviderKey"); - b.HasIndex("UserId"); + b.HasIndex("UserId"); - b.ToTable("AspNetUserLogins", (string)null); - }); + b.ToTable("AspNetUserLogins", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); - b.HasKey("UserId", "RoleId"); + b.HasKey("UserId", "RoleId"); - b.HasIndex("RoleId"); + b.HasIndex("RoleId"); - b.ToTable("AspNetUserRoles", (string)null); - }); + b.ToTable("AspNetUserRoles", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); - b.Property("LoginProvider") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("Name") - .HasMaxLength(128) - .HasColumnType("nvarchar(128)"); + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); - b.Property("Value") - .HasColumnType("nvarchar(max)"); + b.Property("Value") + .HasColumnType("nvarchar(max)"); - b.HasKey("UserId", "LoginProvider", "Name"); + b.HasKey("UserId", "LoginProvider", "Name"); - b.ToTable("AspNetUserTokens", (string)null); - }); + b.ToTable("AspNetUserTokens", (string)null); + }); - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("EssentialCSharp.Web.Areas.Identity.Data.EssentialCSharpWebUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 + } } } diff --git a/EssentialCSharp.Web/Program.cs b/EssentialCSharp.Web/Program.cs index 6cfbd3ce..310ed18f 100644 --- a/EssentialCSharp.Web/Program.cs +++ b/EssentialCSharp.Web/Program.cs @@ -4,6 +4,7 @@ using EssentialCSharp.Web.Extensions; using EssentialCSharp.Web.Middleware; using EssentialCSharp.Web.Services; +using EssentialCSharp.Web.Services.Referrals; using Mailjet.Client; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; @@ -104,8 +105,8 @@ private static void Main(string[] args) builder.Services.AddRazorPages(); builder.Services.AddCaptchaService(builder.Configuration.GetSection(CaptchaOptions.CaptchaSender)); builder.Services.AddSingleton(); - builder.Services.AddHostedService(); - + builder.Services.AddHostedService(); + builder.Services.AddScoped(); if (!builder.Environment.IsDevelopment()) { @@ -147,12 +148,6 @@ private static void Main(string[] args) WebApplication app = builder.Build(); - app.Use((context, next) => - { - context.Request.Scheme = "https"; - return next(context); - }); - app.UseForwardedHeaders(); // Configure the HTTP request pipeline. @@ -173,14 +168,21 @@ private static void Main(string[] args) app.UseAuthentication(); app.UseAuthorization(); + app.UseMiddleware(); + + app.Use((context, next) => + { + context.Request.Scheme = "https"; + return next(context); + }); app.MapDefaultControllerRoute(); + app.MapRazorPages(); app.MapControllerRoute( name: "slug", pattern: "{*key}", defaults: new { controller = "Home", action = "Index" }); - app.MapRazorPages(); app.Run(); } diff --git a/EssentialCSharp.Web/Services/Referrals/IReferralService.cs b/EssentialCSharp.Web/Services/Referrals/IReferralService.cs new file mode 100644 index 00000000..80bd8ccd --- /dev/null +++ b/EssentialCSharp.Web/Services/Referrals/IReferralService.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; +using EssentialCSharp.Web.Areas.Identity.Data; + +namespace EssentialCSharp.Web.Services.Referrals; + +public interface IReferralService +{ + void TrackReferralAsync(string referralId, ClaimsPrincipal? user); + Task GetReferralIdAsync(string userId); + Task EnsureReferralIdAsync(EssentialCSharpWebUser? user); +} diff --git a/EssentialCSharp.Web/Services/Referrals/ReferralService.cs b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs new file mode 100644 index 00000000..fea2cfb5 --- /dev/null +++ b/EssentialCSharp.Web/Services/Referrals/ReferralService.cs @@ -0,0 +1,86 @@ +using System.Security.Claims; +using EssentialCSharp.Web.Areas.Identity.Data; +using EssentialCSharp.Web.Data; +using EssentialCSharp.Web.Extensions; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +namespace EssentialCSharp.Web.Services.Referrals; + +public class ReferralService(EssentialCSharpWebContext dbContext, UserManager userManager) : IReferralService +{ + public async Task GetReferralIdAsync(string userId) + { + EssentialCSharpWebUser? user = await userManager.FindByIdAsync(userId); + return await EnsureReferralIdAsync(user); + } + + /// + /// Ensure that the user has a referral ID. If the user does not have a referral ID, generate one and save it to the user. + /// + /// + /// + public async Task EnsureReferralIdAsync(EssentialCSharpWebUser? user) + { + if (user is null) + { + return null; + } + else + { + // Check if the user already has a referrer ID + string? referrerId = dbContext.UserClaims.FirstOrDefault(claim => claim.UserId == user.Id && claim.ClaimType == ClaimsExtensions.ReferrerIdClaimType)?.ClaimValue; + if (!string.IsNullOrEmpty(referrerId)) + { + // Add the referrer ID to the user's claims if it does not exist + if (!(await userManager.GetClaimsAsync(user)).Any(claim => claim.Type == ClaimsExtensions.ReferrerIdClaimType)) + { + await userManager.AddClaimAsync(user, new Claim(ClaimsExtensions.ReferrerIdClaimType, referrerId)); + } + return referrerId; + } + else + { + do + { + referrerId = Base64UrlEncoder.Encode(Guid.NewGuid().ToByteArray())[..8]; + } + while (dbContext.UserClaims.Any(claim => claim.ClaimType == ClaimsExtensions.ReferrerIdClaimType && claim.ClaimValue == referrerId)); + + await userManager.AddClaimAsync(user, new Claim(ClaimsExtensions.ReferrerIdClaimType, referrerId)); + return referrerId; + } + } + } + + /// + /// Track the referral in the database. + /// + /// The referrer ID to track. + /// True if the referral was successfully tracked, otherwise false. + public void TrackReferralAsync(string referralId, ClaimsPrincipal? user) + { + // Check if the referrer ID exists in the claims principal + string? claimsReferrerId = user?.Claims.FirstOrDefault(c => c.Type == ClaimsExtensions.ReferrerIdClaimType)?.Value; + + if (claimsReferrerId == referralId) + { + // If the referrer ID in the claims principal matches the referral ID, do not track the referral + return; + } + + TrackReferral(dbContext, referralId); + } + + private static void TrackReferral(EssentialCSharpWebContext dbContext, string referralId) + { + var userClaim = dbContext.UserClaims.FirstOrDefault(claim => claim.ClaimType == ClaimsExtensions.ReferrerIdClaimType && claim.ClaimValue == referralId); + if (userClaim is null) + { + return; + } + + dbContext.Users.Where(user => user.Id == userClaim.UserId).ExecuteUpdate(setters => setters.SetProperty(b => b.ReferralCount, b => b.ReferralCount + 1)); + } +} diff --git a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml index 88f94ce1..c7268170 100644 --- a/EssentialCSharp.Web/Views/Shared/_Layout.cshtml +++ b/EssentialCSharp.Web/Views/Shared/_Layout.cshtml @@ -271,8 +271,9 @@ } PREVIOUS_PAGE = @Json.Serialize(ViewBag.PreviousPage) - NEXT_PAGE = @Json.Serialize(ViewBag.NextPage) - TOC_DATA = @Json.Serialize(tocData) + NEXT_PAGE = @Json.Serialize(ViewBag.NextPage) + TOC_DATA = @Json.Serialize(tocData) + REFERRAL_ID = @Json.Serialize(ViewBag.ReferralId) @* Recursive vue component template for rendering the table of contents. *@ diff --git a/EssentialCSharp.Web/wwwroot/css/styles.css b/EssentialCSharp.Web/wwwroot/css/styles.css index d39016ad..baf5d91a 100644 --- a/EssentialCSharp.Web/wwwroot/css/styles.css +++ b/EssentialCSharp.Web/wwwroot/css/styles.css @@ -305,6 +305,18 @@ a:hover { color: var(--primary-accent-color); } +.account-nav-link { + color: black; +} + +.account-nav-link:hover { + color: var(--primary-accent-color); +} + +.account-nav-item .active { + color: var(--primary-accent-color); +} + /* Page Navigation Arrow Buttons */ .turn-page { diff --git a/EssentialCSharp.Web/wwwroot/js/site.js b/EssentialCSharp.Web/wwwroot/js/site.js index a3e95e07..83636bb7 100644 --- a/EssentialCSharp.Web/wwwroot/js/site.js +++ b/EssentialCSharp.Web/wwwroot/js/site.js @@ -125,8 +125,13 @@ const app = createApp({ const snackbarColor = ref(); function copyToClipboard(copyText) { + let url = window.location.origin + "/" + copyText; + let referralId = REFERRAL_ID; + if (referralId && referralId.trim()) { + url = addQueryParam(url, 'rid', referralId); + } navigator.clipboard - .writeText(window.location.origin + "/" + copyText) + .writeText(url) .then( function () { /* Success */ @@ -151,6 +156,12 @@ const app = createApp({ ); } + function addQueryParam(url, key, value) { + let urlObj = new URL(url, window.location.origin); + urlObj.searchParams.set(key, value); + return urlObj.toString(); + } + function goToPrevious() { let previousPage = PREVIOUS_PAGE; if (previousPage !== null) {
+ You have @Model.ReferralCount referrals. +