@@ -40,16 +52,34 @@
@code {
private string currentUrl = "";
+ private bool ShowProfileCompletionPrompt { get; set; }
- protected override void OnInitialized()
+ protected override async Task OnInitializedAsync()
{
currentUrl = GetRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged;
+
+ await RefreshProfileCompletionPromptAsync().ConfigureAwait(false);
}
- private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
+ private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = GetRelativePath(e.Location);
+ await RefreshProfileCompletionPromptAsync().ConfigureAwait(false);
+ await InvokeAsync(StateHasChanged).ConfigureAwait(false);
+ }
+
+ private async Task RefreshProfileCompletionPromptAsync()
+ {
+ var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(userId))
+ {
+ ShowProfileCompletionPrompt = false;
+ return;
+ }
+
+ var isComplete = await ProfileCompletionService.IsProfileCompleteAsync(userId).ConfigureAwait(false);
+ ShowProfileCompletionPrompt = !isComplete;
}
private string GetRelativePath(string absoluteUri)
diff --git a/src/BirthList.Web/Components/Layout/TopBar.razor.css b/src/BirthList.Web/Components/Layout/TopBar.razor.css
index aee2173..0ec69f6 100644
--- a/src/BirthList.Web/Components/Layout/TopBar.razor.css
+++ b/src/BirthList.Web/Components/Layout/TopBar.razor.css
@@ -1,3 +1,25 @@
+.profile-completion-banner {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 0.75rem;
+ background-color: #fff3cd;
+ color: #664d03;
+ border-bottom: 1px solid #ffecb5;
+ padding: 0.5rem 1rem;
+ font-size: 0.9rem;
+}
+
+.profile-completion-link {
+ color: #664d03;
+ font-weight: 600;
+ text-decoration: underline;
+}
+
+.profile-completion-link:hover {
+ color: #523d02;
+}
+
.top-bar {
display: flex;
justify-content: space-between;
diff --git a/src/BirthList.Web/Data/ApplicationUser.cs b/src/BirthList.Web/Data/ApplicationUser.cs
index 85ed06a..f2d6af7 100644
--- a/src/BirthList.Web/Data/ApplicationUser.cs
+++ b/src/BirthList.Web/Data/ApplicationUser.cs
@@ -5,5 +5,8 @@ namespace BirthList.Web.Data;
// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser
{
+ public string? FirstName { get; set; }
+ public string? LastName { get; set; }
+ public string? Address { get; set; }
}
diff --git a/src/BirthList.Web/Data/Migrations/20260518213355_AddUserProfileFields.Designer.cs b/src/BirthList.Web/Data/Migrations/20260518213355_AddUserProfileFields.Designer.cs
new file mode 100644
index 0000000..24f7866
--- /dev/null
+++ b/src/BirthList.Web/Data/Migrations/20260518213355_AddUserProfileFields.Designer.cs
@@ -0,0 +1,288 @@
+//
+using System;
+using BirthList.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 BirthList.Web.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260518213355_AddUserProfileFields")]
+ partial class AddUserProfileFields
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.26")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("BirthList.Web.Data.ApplicationUser", b =>
+ {
+ b.Property
("Id")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("AccessFailedCount")
+ .HasColumnType("int");
+
+ b.Property("Address")
+ .HasColumnType("nvarchar(max)");
+
+ 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("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")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ProviderKey")
+ .HasColumnType("nvarchar(450)");
+
+ 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")
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("Name")
+ .HasColumnType("nvarchar(450)");
+
+ 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("BirthList.Web.Data.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.HasOne("BirthList.Web.Data.ApplicationUser", 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("BirthList.Web.Data.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.HasOne("BirthList.Web.Data.ApplicationUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/BirthList.Web/Data/Migrations/20260518213355_AddUserProfileFields.cs b/src/BirthList.Web/Data/Migrations/20260518213355_AddUserProfileFields.cs
new file mode 100644
index 0000000..894c565
--- /dev/null
+++ b/src/BirthList.Web/Data/Migrations/20260518213355_AddUserProfileFields.cs
@@ -0,0 +1,48 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BirthList.Web.Migrations
+{
+ ///
+ public partial class AddUserProfileFields : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "Address",
+ table: "AspNetUsers",
+ type: "nvarchar(max)",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "FirstName",
+ table: "AspNetUsers",
+ type: "nvarchar(max)",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "LastName",
+ table: "AspNetUsers",
+ type: "nvarchar(max)",
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "Address",
+ table: "AspNetUsers");
+
+ migrationBuilder.DropColumn(
+ name: "FirstName",
+ table: "AspNetUsers");
+
+ migrationBuilder.DropColumn(
+ name: "LastName",
+ table: "AspNetUsers");
+ }
+ }
+}
diff --git a/src/BirthList.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/BirthList.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs
index 3d28c2e..9e5de3d 100644
--- a/src/BirthList.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/src/BirthList.Web/Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -1,8 +1,9 @@
-//
+//
using System;
using BirthList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
@@ -15,7 +16,11 @@ namespace BirthList.Web.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
- modelBuilder.HasAnnotation("ProductVersion", "8.0.0");
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.26")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("BirthList.Web.Data.ApplicationUser", b =>
{
@@ -25,6 +30,9 @@ namespace BirthList.Web.Migrations
b.Property("AccessFailedCount")
.HasColumnType("int");
+ b.Property("Address")
+ .HasColumnType("nvarchar(max)");
+
b.Property("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
@@ -36,6 +44,12 @@ namespace BirthList.Web.Migrations
b.Property("EmailConfirmed")
.HasColumnType("bit");
+ b.Property("FirstName")
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("LastName")
+ .HasColumnType("nvarchar(max)");
+
b.Property("LockoutEnabled")
.HasColumnType("bit");
@@ -115,6 +129,8 @@ namespace BirthList.Web.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
b.Property("ClaimType")
.HasColumnType("nvarchar(max)");
@@ -138,6 +154,8 @@ namespace BirthList.Web.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
+ SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id"));
+
b.Property("ClaimType")
.HasColumnType("nvarchar(max)");
diff --git a/src/BirthList.Web/Features/Registries/RegistryService.cs b/src/BirthList.Web/Features/Registries/RegistryService.cs
index 9ab78e3..84fa852 100644
--- a/src/BirthList.Web/Features/Registries/RegistryService.cs
+++ b/src/BirthList.Web/Features/Registries/RegistryService.cs
@@ -95,18 +95,22 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
- var usersById = await applicationDbContext.Users
+ var users = await applicationDbContext.Users
.Where(x => admins.Contains(x.Id))
- .Select(x => new { x.Id, x.Email })
- .ToDictionaryAsync(x => x.Id, x => x.Email, cancellationToken)
+ .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName })
+ .ToListAsync(cancellationToken)
.ConfigureAwait(false);
+ var usersById = users.ToDictionary(
+ x => x.Id,
+ x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id));
+
return admins
.Select(userId => new RegistryAdminDisplayModel
{
UserId = userId,
- DisplayName = usersById.TryGetValue(userId, out var email) && !string.IsNullOrWhiteSpace(email)
- ? email
+ DisplayName = usersById.TryGetValue(userId, out var displayName)
+ ? displayName
: userId
})
.ToList();
@@ -180,12 +184,16 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
userIds.Add(contribution.UserId);
}
- var usersById = await applicationDbContext.Users
+ var users = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
- .Select(x => new { x.Id, x.Email })
- .ToDictionaryAsync(x => x.Id, x => x.Email ?? x.Id, cancellationToken)
+ .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName })
+ .ToListAsync(cancellationToken)
.ConfigureAwait(false);
+ var usersById = users.ToDictionary(
+ x => x.Id,
+ x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id));
+
var purchasesByItemId = purchases
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
@@ -422,12 +430,16 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
userIds.Add(contribution.UserId);
}
- var usersById = await applicationDbContext.Users
+ var users = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
- .Select(x => new { x.Id, x.Email })
- .ToDictionaryAsync(x => x.Id, x => x.Email ?? x.Id, cancellationToken)
+ .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName })
+ .ToListAsync(cancellationToken)
.ConfigureAwait(false);
+ var usersById = users.ToDictionary(
+ x => x.Id,
+ x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id));
+
var purchasesByItemId = purchases
.GroupBy(x => x.RegistryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
@@ -508,12 +520,16 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
userIds.Add(contribution.UserId);
}
- var usersById = await applicationDbContext.Users
+ var users = await applicationDbContext.Users
.Where(x => userIds.Contains(x.Id))
- .Select(x => new { x.Id, x.Email })
- .ToDictionaryAsync(x => x.Id, x => x.Email ?? x.Id, cancellationToken)
+ .Select(x => new { x.Id, x.Email, x.FirstName, x.LastName })
+ .ToListAsync(cancellationToken)
.ConfigureAwait(false);
+ var usersById = users.ToDictionary(
+ x => x.Id,
+ x => BuildUserDisplayName(x.FirstName, x.LastName, x.Email, x.Id));
+
return new RegistryItemEditModel
{
Id = item.Id,
@@ -907,4 +923,20 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
var fallback = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString("N")))).Substring(0, 16).ToLowerInvariant();
return fallback;
}
+
+ private static string BuildUserDisplayName(string? firstName, string? lastName, string? email, string userId)
+ {
+ var fullName = $"{firstName} {lastName}".Trim();
+ if (!string.IsNullOrWhiteSpace(fullName))
+ {
+ return fullName;
+ }
+
+ if (!string.IsNullOrWhiteSpace(email))
+ {
+ return email;
+ }
+
+ return userId;
+ }
}
diff --git a/src/BirthList.Web/Program.cs b/src/BirthList.Web/Program.cs
index 9c88ce1..8c39d69 100644
--- a/src/BirthList.Web/Program.cs
+++ b/src/BirthList.Web/Program.cs
@@ -90,6 +90,17 @@ builder.Services.AddDbContext(options =>
options.UseSqlite(connectionString);
});
+builder.Services.AddDbContextFactory(options =>
+{
+ if (string.Equals(dataProvider, "SqlServer", StringComparison.OrdinalIgnoreCase))
+ {
+ options.UseSqlServer(connectionString);
+ return;
+ }
+
+ options.UseSqlite(connectionString);
+}, ServiceLifetime.Scoped);
+
builder.Services.AddDbContext(options =>
{
if (string.Equals(dataProvider, "SqlServer", StringComparison.OrdinalIgnoreCase))
@@ -113,6 +124,7 @@ builder.Services.AddIdentityCore(options =>
builder.Services.AddScoped();
builder.Services.AddScoped>(serviceProvider => serviceProvider.GetRequiredService());
+builder.Services.AddScoped();
var app = builder.Build();
diff --git a/src/BirthList.Web/Services/ProfileCompletionService.cs b/src/BirthList.Web/Services/ProfileCompletionService.cs
new file mode 100644
index 0000000..50aceb1
--- /dev/null
+++ b/src/BirthList.Web/Services/ProfileCompletionService.cs
@@ -0,0 +1,30 @@
+using BirthList.Web.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace BirthList.Web.Services;
+
+internal sealed class ProfileCompletionService(IDbContextFactory dbContextFactory)
+{
+ public async Task IsProfileCompleteAsync(string userId)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(userId);
+
+ await using var dbContext = await dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);
+
+ var user = await dbContext.Users
+ .AsNoTracking()
+ .Where(x => x.Id == userId)
+ .Select(x => new { x.FirstName, x.LastName, x.Address })
+ .FirstOrDefaultAsync()
+ .ConfigureAwait(false);
+
+ if (user is null)
+ {
+ return true;
+ }
+
+ return !string.IsNullOrWhiteSpace(user.FirstName)
+ && !string.IsNullOrWhiteSpace(user.LastName)
+ && !string.IsNullOrWhiteSpace(user.Address);
+ }
+}