diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 44a326d..9441863 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -6,6 +6,7 @@ - Authentication must support local accounts plus Microsoft and Google login; admin invite links are single-use and not email-locked. - Money participation defaults to EUR and tracks fulfilled amount per item via bank-transfer contributions with item-linked messages. - Registry visibility is public-by-link initially, but logged-in users who visited a registry should be able to rediscover it in-app. +- Identities must be hidden from non-admin users for privacy, only admins and the user who purchased/contributed can see that he did so. Other users may see a generic indication that a contrubution/purchase was made but never a name/email of another user. - Use SQL Server as the default provider and set `RequireConfirmedAccount=false` for MVP testing; the first registered account should be the owner with full access. ## Technical Specifications diff --git a/src/BirthList.Domain/Entities/RegistrySettings.cs b/src/BirthList.Domain/Entities/RegistrySettings.cs index 568eb4c..71da9cb 100644 --- a/src/BirthList.Domain/Entities/RegistrySettings.cs +++ b/src/BirthList.Domain/Entities/RegistrySettings.cs @@ -7,6 +7,8 @@ public class RegistrySettings public string? BankAccountBic { get; set; } public string? BankAccountDisplayName { get; set; } public bool ShowBankAccountName { get; set; } + public string? ContributionQrCodeUrl { get; set; } + public string? ContributionAmountQrCodesJson { get; set; } public Registry Registry { get; set; } = null!; } diff --git a/src/BirthList.Infrastructure/Migrations/20260518194616_AddContributionPaymentOptionsToRegistrySettings.Designer.cs b/src/BirthList.Infrastructure/Migrations/20260518194616_AddContributionPaymentOptionsToRegistrySettings.Designer.cs new file mode 100644 index 0000000..dbb1b9c --- /dev/null +++ b/src/BirthList.Infrastructure/Migrations/20260518194616_AddContributionPaymentOptionsToRegistrySettings.Designer.cs @@ -0,0 +1,489 @@ +// +using System; +using BirthList.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace BirthList.Infrastructure.Migrations +{ + [DbContext(typeof(RegistryDbContext))] + [Migration("20260518194616_AddContributionPaymentOptionsToRegistrySettings")] + partial class AddContributionPaymentOptionsToRegistrySettings + { + /// + 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.Domain.Entities.ItemContribution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ContributedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("RegistryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("TransferMessage") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RegistryItemId"); + + b.ToTable("ItemContributions"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.ItemPurchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("PurchasedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("RegistryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RegistryItemId"); + + b.ToTable("ItemPurchases"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.PlatformOwner", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AssignedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("PlatformOwners"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.Registry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BabyName") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("HeaderContentHtml") + .HasColumnType("nvarchar(max)"); + + b.Property("PublicLinkCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RegistryType") + .HasColumnType("int"); + + b.Property("ShippingAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("ThemeKey") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.HasKey("Id"); + + b.HasIndex("PublicLinkCode") + .IsUnique(); + + b.ToTable("Registries"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdmin", b => + { + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("AddedAtUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("RegistryId", "UserId"); + + b.ToTable("RegistryAdmins"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdminInvite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ExpiresAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("RedeemedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("SentToEmail") + .HasMaxLength(320) + .HasColumnType("nvarchar(320)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.HasKey("Id"); + + b.HasIndex("RegistryId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("RegistryAdminInvites"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("CurrencyCode") + .IsRequired() + .HasMaxLength(3) + .HasColumnType("nvarchar(3)"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("DesiredQuantity") + .HasColumnType("int"); + + b.Property("IsGiven") + .HasColumnType("bit"); + + b.Property("MoneyFulfilledAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("ParticipationAllowed") + .HasColumnType("bit"); + + b.Property("ParticipationTargetAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("PictureUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("PreferSecondHand") + .HasColumnType("bit"); + + b.Property("PriceAmount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("ProductUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("PurchasedQuantity") + .HasColumnType("int"); + + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RegistryId"); + + b.ToTable("RegistryItems"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b => + { + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("BankAccountBic") + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("BankAccountDisplayName") + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("BankAccountIban") + .HasMaxLength(34) + .HasColumnType("nvarchar(34)"); + + b.Property("ContributionAmountQrCodesJson") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ContributionQrCodeUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ShowBankAccountName") + .HasColumnType("bit"); + + b.HasKey("RegistryId"); + + b.ToTable("RegistrySettings"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryVisit", b => + { + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("LastVisitedAtUtc") + .HasColumnType("datetimeoffset"); + + b.HasKey("RegistryId", "UserId"); + + b.ToTable("RegistryVisits"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.UserActionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActionType") + .HasColumnType("int"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAtUtc") + .HasColumnType("datetimeoffset"); + + b.Property("Details") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.Property("RegistryId") + .HasColumnType("uniqueidentifier"); + + b.Property("RegistryItemId") + .HasColumnType("uniqueidentifier"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RegistryId"); + + b.ToTable("UserActionLogs"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.ItemContribution", b => + { + b.HasOne("BirthList.Domain.Entities.RegistryItem", "RegistryItem") + .WithMany("Contributions") + .HasForeignKey("RegistryItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RegistryItem"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.ItemPurchase", b => + { + b.HasOne("BirthList.Domain.Entities.RegistryItem", "RegistryItem") + .WithMany("Purchases") + .HasForeignKey("RegistryItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RegistryItem"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdmin", b => + { + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithMany("Admins") + .HasForeignKey("RegistryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Registry"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdminInvite", b => + { + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithMany("AdminInvites") + .HasForeignKey("RegistryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Registry"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b => + { + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithMany("Items") + .HasForeignKey("RegistryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Registry"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b => + { + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithOne() + .HasForeignKey("BirthList.Domain.Entities.RegistrySettings", "RegistryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Registry"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryVisit", b => + { + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithMany("Visits") + .HasForeignKey("RegistryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Registry"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.UserActionLog", b => + { + b.HasOne("BirthList.Domain.Entities.Registry", "Registry") + .WithMany("ActionLogs") + .HasForeignKey("RegistryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Registry"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.Registry", b => + { + b.Navigation("ActionLogs"); + + b.Navigation("AdminInvites"); + + b.Navigation("Admins"); + + b.Navigation("Items"); + + b.Navigation("Visits"); + }); + + modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b => + { + b.Navigation("Contributions"); + + b.Navigation("Purchases"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/BirthList.Infrastructure/Migrations/20260518194616_AddContributionPaymentOptionsToRegistrySettings.cs b/src/BirthList.Infrastructure/Migrations/20260518194616_AddContributionPaymentOptionsToRegistrySettings.cs new file mode 100644 index 0000000..c4d2278 --- /dev/null +++ b/src/BirthList.Infrastructure/Migrations/20260518194616_AddContributionPaymentOptionsToRegistrySettings.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace BirthList.Infrastructure.Migrations +{ + /// + public partial class AddContributionPaymentOptionsToRegistrySettings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ContributionAmountQrCodesJson", + table: "RegistrySettings", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: true); + + migrationBuilder.AddColumn( + name: "ContributionQrCodeUrl", + table: "RegistrySettings", + type: "nvarchar(2048)", + maxLength: 2048, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ContributionAmountQrCodesJson", + table: "RegistrySettings"); + + migrationBuilder.DropColumn( + name: "ContributionQrCodeUrl", + table: "RegistrySettings"); + } + } +} diff --git a/src/BirthList.Infrastructure/Migrations/RegistryDbContextModelSnapshot.cs b/src/BirthList.Infrastructure/Migrations/RegistryDbContextModelSnapshot.cs index 3edc8fd..14c56ca 100644 --- a/src/BirthList.Infrastructure/Migrations/RegistryDbContextModelSnapshot.cs +++ b/src/BirthList.Infrastructure/Migrations/RegistryDbContextModelSnapshot.cs @@ -299,6 +299,14 @@ namespace BirthList.Infrastructure.Migrations .HasMaxLength(34) .HasColumnType("nvarchar(34)"); + b.Property("ContributionAmountQrCodesJson") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + + b.Property("ContributionQrCodeUrl") + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + b.Property("ShowBankAccountName") .HasColumnType("bit"); diff --git a/src/BirthList.Infrastructure/Persistence/RegistryDbContext.cs b/src/BirthList.Infrastructure/Persistence/RegistryDbContext.cs index 60390db..d268a23 100644 --- a/src/BirthList.Infrastructure/Persistence/RegistryDbContext.cs +++ b/src/BirthList.Infrastructure/Persistence/RegistryDbContext.cs @@ -57,6 +57,8 @@ public class RegistryDbContext(DbContextOptions options) : Db entity.Property(x => x.BankAccountIban).HasMaxLength(34); entity.Property(x => x.BankAccountBic).HasMaxLength(11); entity.Property(x => x.BankAccountDisplayName).HasMaxLength(120); + entity.Property(x => x.ContributionQrCodeUrl).HasMaxLength(2048); + entity.Property(x => x.ContributionAmountQrCodesJson).HasMaxLength(4000); entity.HasOne(x => x.Registry) .WithOne() .HasForeignKey(x => x.RegistryId) diff --git a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor index 16c9d19..0f8370d 100644 --- a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor +++ b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor @@ -247,6 +247,50 @@ else +
+

Contribution Payment Options

+
+
+ + + Optional: one QR code that donors can scan for any amount. +
+
+ +
+
+

Amount-specific QR codes

+ +
+ + @if (SettingsModel.ContributionAmountQrCodes.Count == 0) + { +

No amount-specific QR codes configured.

+ } + else + { +
+ @foreach (var amountQr in SettingsModel.ContributionAmountQrCodes) + { +
+
+ + +
+
+ + +
+
+ +
+
+ } +
+ } +
+
+ diff --git a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs index 1539e6c..25f4e1a 100644 --- a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs +++ b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs @@ -39,6 +39,9 @@ public partial class RegistryAdmin : ComponentBase builder.AddMarkupContent(3, ""); }; + private bool _pendingEditorLoad; + private string? _lastLoadedHeaderContentHtml; + protected override async Task OnParametersSetAsync() { var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); @@ -60,14 +63,16 @@ public partial class RegistryAdmin : ComponentBase protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender || TextEditor is null || string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml)) + if (TextEditor is null || !_pendingEditorLoad) { return; } try { - await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml).ConfigureAwait(false); + await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml ?? string.Empty).ConfigureAwait(false); + _lastLoadedHeaderContentHtml = SettingsModel.HeaderContentHtml ?? string.Empty; + _pendingEditorLoad = false; } catch (JSException) { @@ -229,6 +234,17 @@ public partial class RegistryAdmin : ComponentBase Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); } + protected void AddContributionAmountQrCode() + { + SettingsModel.ContributionAmountQrCodes.Add(new ContributionAmountQrCodeModel()); + } + + protected void RemoveContributionAmountQrCode(ContributionAmountQrCodeModel model) + { + ArgumentNullException.ThrowIfNull(model); + SettingsModel.ContributionAmountQrCodes.Remove(model); + } + private async Task LoadAsync() { var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); @@ -244,6 +260,11 @@ public partial class RegistryAdmin : ComponentBase SettingsModel.BankAccountIban = settings.BankAccountIban; SettingsModel.BankAccountBic = settings.BankAccountBic; SettingsModel.ShowBankAccountName = settings.ShowBankAccountName; + SettingsModel.ContributionQrCodeUrl = settings.ContributionQrCodeUrl; + SettingsModel.ContributionAmountQrCodes = settings.ContributionAmountQrCodes; + + var currentHeaderContent = SettingsModel.HeaderContentHtml ?? string.Empty; + _pendingEditorLoad = currentHeaderContent != _lastLoadedHeaderContentHtml; } Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); diff --git a/src/BirthList.Web/Components/Pages/RegistryContributionAmount.razor b/src/BirthList.Web/Components/Pages/RegistryContributionAmount.razor new file mode 100644 index 0000000..1928ca7 --- /dev/null +++ b/src/BirthList.Web/Components/Pages/RegistryContributionAmount.razor @@ -0,0 +1,75 @@ +@page "/registry/{Code}/item/{ItemId:guid}/contribution-amount" +@rendermode InteractiveServer + +@using BirthList.Web.Features.Registries + +Contribution Amount + +@if (Registry is null || Item is null) +{ +

Item not found.

+} +else if (!IsAuthenticated) +{ +

Please log in first.

+} +else +{ +
+

Partially fulfill: @Item.Name

+ +
+ @if (RepresentableAmounts.Count == 0) + { +

No representable amount can be formed from configured QR codes up to €200.

+ } + else + { + + + } + + @if (Suggestions.Count == 0) + { +

No QR combination available for this amount.

+ } + else + { +

Suggested QR combination:

+
    + @foreach (var suggestion in Suggestions) + { +
  • @suggestion.RepeatCount x €@suggestion.Amount
  • + } +
+ +
+ @foreach (var suggestion in Suggestions) + { + @for (var i = 0; i < suggestion.RepeatCount; i++) + { +
+
€@suggestion.Amount
+ QR code @suggestion.Amount + +
+ } + } +
+ } + +
+ + +
+
+
+} diff --git a/src/BirthList.Web/Components/Pages/RegistryContributionAmount.razor.cs b/src/BirthList.Web/Components/Pages/RegistryContributionAmount.razor.cs new file mode 100644 index 0000000..d10b89e --- /dev/null +++ b/src/BirthList.Web/Components/Pages/RegistryContributionAmount.razor.cs @@ -0,0 +1,210 @@ +using BirthList.Web.Features.Registries; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; + +namespace BirthList.Web.Components.Pages; + +[Authorize] +public partial class RegistryContributionAmount : ComponentBase +{ + [Parameter] public string Code { get; set; } = string.Empty; + [Parameter] public Guid ItemId { get; set; } + + [Inject] private RegistryService RegistryService { get; set; } = null!; + [Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!; + [Inject] private RegistryThemeService RegistryThemeService { get; set; } = null!; + [Inject] private NavigationManager NavigationManager { get; set; } = null!; + + protected RegistryPublicViewModel? Registry { get; private set; } + protected RegistryPublicItemViewModel? Item { get; private set; } + protected bool IsAuthenticated { get; private set; } + protected int SelectedAmount { get; private set; } + protected int SliderMin { get; private set; } = 1; + protected int SliderIndex { get; set; } + protected IReadOnlyList RepresentableAmounts { get; private set; } = []; + protected List Suggestions { get; private set; } = []; + + protected override async Task OnParametersSetAsync() + { + var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + IsAuthenticated = !string.IsNullOrWhiteSpace(userId); + if (!IsAuthenticated) + { + return; + } + + Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false); + Item = Registry?.Items.FirstOrDefault(x => x.Id == ItemId); + + if (Registry is null || Item is null) + { + return; + } + + var configuredAmounts = Registry.ContributionAmountQrCodes + .Select(x => (int)Math.Round(x.Amount, MidpointRounding.AwayFromZero)) + .Where(x => x > 0) + .Distinct() + .OrderBy(x => x) + .ToList(); + + SliderMin = configuredAmounts.Count > 0 ? configuredAmounts[0] : 1; + + RepresentableAmounts = Enumerable.Range(SliderMin, 201 - SliderMin) + .Where(x => BuildSuggestions(x).Count > 0) + .ToList(); + + if (RepresentableAmounts.Count == 0) + { + SelectedAmount = 0; + SliderIndex = 0; + Suggestions = []; + return; + } + + SliderIndex = 0; + SelectedAmount = RepresentableAmounts[SliderIndex]; + RecalculateSuggestions(); + } + + protected void RecalculateSuggestions() + { + if (RepresentableAmounts.Count == 0) + { + Suggestions = []; + SelectedAmount = 0; + return; + } + + var maxIndex = RepresentableAmounts.Count - 1; + if (SliderIndex < 0) + { + SliderIndex = 0; + } + else if (SliderIndex > maxIndex) + { + SliderIndex = maxIndex; + } + + SelectedAmount = RepresentableAmounts[SliderIndex]; + Suggestions = BuildSuggestions(SelectedAmount); + } + + protected async Task ConfirmAsync() + { + var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId) || Item is null || SelectedAmount <= 0) + { + return; + } + + await RegistryService.AddContributionAsync(Item.Id, userId, SelectedAmount, $"Participation for item: {Item.Name}", CancellationToken.None).ConfigureAwait(false); + BackToRegistry(); + } + + protected void BackToRegistry() + { + NavigationManager.NavigateTo($"/registry/{Code}"); + } + + private List BuildSuggestions(decimal requestedAmount) + { + if (Registry is null || requestedAmount <= 0) + { + return []; + } + + var target = (int)Math.Round(requestedAmount, MidpointRounding.AwayFromZero); + + var options = Registry.ContributionAmountQrCodes + .Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl)) + .Select(x => new + { + Amount = (int)Math.Round(x.Amount, MidpointRounding.AwayFromZero), + QrCodeUrl = x.QrCodeUrl.Trim() + }) + .Where(x => x.Amount > 0) + .DistinctBy(x => x.Amount) + .OrderByDescending(x => x.Amount) + .ToList(); + + if (options.Count == 0 || target <= 0) + { + return []; + } + + var max = int.MaxValue / 4; + var minCoins = Enumerable.Repeat(max, target + 1).ToArray(); + var choice = Enumerable.Repeat(-1, target + 1).ToArray(); + minCoins[0] = 0; + + for (var amount = 1; amount <= target; amount++) + { + for (var i = 0; i < options.Count; i++) + { + var option = options[i]; + if (option.Amount > amount) + { + continue; + } + + var candidate = minCoins[amount - option.Amount]; + if (candidate == max) + { + continue; + } + + candidate += 1; + if (candidate < minCoins[amount]) + { + minCoins[amount] = candidate; + choice[amount] = i; + } + } + } + + if (minCoins[target] == max) + { + return []; + } + + var counts = new Dictionary(); + var current = target; + + while (current > 0) + { + var optionIndex = choice[current]; + if (optionIndex < 0) + { + return []; + } + + var optionAmount = options[optionIndex].Amount; + counts[optionIndex] = counts.TryGetValue(optionIndex, out var count) ? count + 1 : 1; + current -= optionAmount; + } + + return counts + .Select(x => new QrCombinationSuggestion + { + Amount = options[x.Key].Amount, + QrCodeUrl = options[x.Key].QrCodeUrl, + RepeatCount = x.Value + }) + .OrderBy(x => x.Amount) + .ToList(); + } + + protected static string BuildQrImageUrl(string paymentLink) + { + ArgumentException.ThrowIfNullOrWhiteSpace(paymentLink); + return $"https://api.qrserver.com/v1/create-qr-code/?size=320x320&data={Uri.EscapeDataString(paymentLink)}"; + } + + protected sealed class QrCombinationSuggestion + { + public int Amount { get; init; } + public string QrCodeUrl { get; init; } = string.Empty; + public int RepeatCount { get; init; } + } +} diff --git a/src/BirthList.Web/Components/Pages/RegistryPublic.razor b/src/BirthList.Web/Components/Pages/RegistryPublic.razor index 810404c..1701269 100644 --- a/src/BirthList.Web/Components/Pages/RegistryPublic.razor +++ b/src/BirthList.Web/Components/Pages/RegistryPublic.razor @@ -64,14 +64,17 @@ else {

@item.Description

} -

Qty: @item.PurchasedQuantity/@item.DesiredQuantity purchased

+ @if (item.DesiredQuantity > 1) + { +

Qty: @item.PurchasedQuantity/@item.DesiredQuantity purchased

+ } @if (item.PriceAmount.HasValue) {

Price: @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode

} - @if (item.ParticipationAllowed && item.ParticipationTargetAmount.HasValue) + @if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue) { -

Participation: @item.MoneyFulfilledAmount.ToString("0.00") / @item.ParticipationTargetAmount.Value.ToString("0.00") @item.CurrencyCode

+

Participation: €@item.MoneyFulfilledAmount.ToString("0.00") out of €@GetParticipationTotalAmount(item)!.Value.ToString("0.00") fulfilled

} @if (item.Purchasers.Count > 0 || item.Contributors.Count > 0) @@ -110,13 +113,39 @@ else @if (item.Contributors.Count > 0) {
- Contributed by: -
- @foreach (var contributor in item.Contributors) + @if (Registry.IsAdmin) + { + Contributed by: +
+ @foreach (var contributor in item.Contributors) + { + @contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode) + } +
+ } + else if (!string.IsNullOrWhiteSpace(Registry.CurrentUserId)) + { + var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId); + if (currentUserContribution is not null) { - @contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode) + Contributed by: +
+ @currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode) + @if (item.Contributors.Count > 1) + { + and others + } +
} -
+ else + { + Contributed + } + } + else + { + Contributed + }
} @@ -147,7 +176,7 @@ else } @if (item.ParticipationAllowed) { - + } } @@ -219,3 +248,91 @@ else } + +@if (ShowPartialFulfillWizard) +{ +
+
+

Partially fulfill item

+ + @if (PartialFulfillStep == 1) + { +

Select how you want to donate:

+ +
+ @if (HasIbanPaymentOption()) + { + + } + @if (HasSingleQrPaymentOption()) + { + + } + @if (HasAmountSpecificQrPaymentOption()) + { + + } +
+ + @if (SelectedPaymentMethod == ContributionPaymentMethodType.Iban && !string.IsNullOrWhiteSpace(Registry?.BankAccountIban)) + { +
+ IBAN: @Registry.BankAccountIban + @if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic)) + { + | BIC: @Registry.BankAccountBic + } +
+ } + + @if (!string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl)) + { +
+ Payment QR code + +
+ } + +
+ + +
+ } + else if (PartialFulfillStep == 2) + { +

How much did you add for this item?

+ +

Message: @ContributionMessage

+ + @if (SelectedPaymentMethod == ContributionPaymentMethodType.AmountSpecificQrCode && ContributionAmount > 0 && string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl)) + { +

No QR code configured for this exact amount.

+ } + + @if (!string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl)) + { +
+ Payment QR code + +
+ } + +
+ + + +
+ } +
+
+} diff --git a/src/BirthList.Web/Components/Pages/RegistryPublic.razor.cs b/src/BirthList.Web/Components/Pages/RegistryPublic.razor.cs index 133bc83..71e9e5a 100644 --- a/src/BirthList.Web/Components/Pages/RegistryPublic.razor.cs +++ b/src/BirthList.Web/Components/Pages/RegistryPublic.razor.cs @@ -27,6 +27,11 @@ public partial class RegistryPublic : ComponentBase protected string? PurchaseItemUrl { get; private set; } protected IReadOnlyList PurchasersToUnmark { get; private set; } = []; + protected bool ShowPartialFulfillWizard { get; private set; } + protected int PartialFulfillStep { get; private set; } = 1; + protected ContributionPaymentMethodType? SelectedPaymentMethod { get; set; } + protected string? SelectedPaymentQrCodeUrl { get; private set; } + protected override async Task OnParametersSetAsync() { var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); @@ -97,27 +102,95 @@ public partial class RegistryPublic : ComponentBase ContributionMessage = Registry?.Items.FirstOrDefault(x => x.Id == itemId) is { } item ? $"Participation for item: {item.Name}" : string.Empty; - ShowContributionPrompt = true; + ShowPartialFulfillWizard = true; + PartialFulfillStep = 1; + SelectedPaymentMethod = null; + SelectedPaymentQrCodeUrl = null; } protected void CloseContributionPrompt() { - ShowContributionPrompt = false; + ShowPartialFulfillWizard = false; + PartialFulfillStep = 1; + SelectedPaymentMethod = null; + SelectedPaymentQrCodeUrl = null; ActiveItemId = Guid.Empty; ContributionMessage = string.Empty; } - protected async Task ConfirmContributionAsync() + protected bool HasIbanPaymentOption() { - var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty || ContributionAmount <= 0) + return Registry is not null && !string.IsNullOrWhiteSpace(Registry.BankAccountIban); + } + + protected bool HasSingleQrPaymentOption() + { + return Registry is not null && !string.IsNullOrWhiteSpace(Registry.ContributionQrCodeUrl); + } + + protected bool HasAmountSpecificQrPaymentOption() + { + return Registry is not null && Registry.ContributionAmountQrCodes.Count > 0; + } + + protected void SelectPaymentMethod(ContributionPaymentMethodType method) + { + SelectedPaymentMethod = method; + SelectedPaymentQrCodeUrl = method switch { - return; + ContributionPaymentMethodType.SingleQrCode => Registry?.ContributionQrCodeUrl, + ContributionPaymentMethodType.AmountSpecificQrCode => FindAmountSpecificQrCode(ContributionAmount), + _ => null + }; + } + + protected void ContinueContributionWizard() + { + if (PartialFulfillStep == 1) + { + if (SelectedPaymentMethod is null) + { + return; + } + + if (SelectedPaymentMethod == ContributionPaymentMethodType.AmountSpecificQrCode && ActiveItemId != Guid.Empty) + { + ShowPartialFulfillWizard = false; + NavigationManager.NavigateTo($"/registry/{Code}/item/{ActiveItemId}/contribution-amount"); + return; + } + + PartialFulfillStep = 2; + } + } + + protected void BackContributionWizard() + { + if (PartialFulfillStep > 1) + { + PartialFulfillStep--; + } + } + + protected void OnContributionAmountChanged() + { + if (SelectedPaymentMethod == ContributionPaymentMethodType.AmountSpecificQrCode) + { + SelectedPaymentQrCodeUrl = FindAmountSpecificQrCode(ContributionAmount); + } + } + + private string? FindAmountSpecificQrCode(decimal amount) + { + if (Registry is null || amount <= 0) + { + return null; } - await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false); - ShowContributionPrompt = false; - Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false); + var match = Registry.ContributionAmountQrCodes + .FirstOrDefault(x => x.Amount == amount); + + return match?.QrCodeUrl; } protected async Task UnmarkPurchaseAsync(Guid itemId) @@ -184,4 +257,32 @@ public partial class RegistryPublic : ComponentBase ShowPurchaserSelectionPrompt = false; Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false); } + + protected async Task ConfirmContributionAsync() + { + var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty || ContributionAmount <= 0) + { + return; + } + + await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false); + ShowPartialFulfillWizard = false; + PartialFulfillStep = 1; + SelectedPaymentMethod = null; + SelectedPaymentQrCodeUrl = null; + Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false); + } + + protected static decimal? GetParticipationTotalAmount(RegistryPublicItemViewModel item) + { + ArgumentNullException.ThrowIfNull(item); + return item.ParticipationTargetAmount ?? item.PriceAmount; + } + + protected static string BuildQrImageUrl(string paymentLink) + { + ArgumentException.ThrowIfNullOrWhiteSpace(paymentLink); + return $"https://api.qrserver.com/v1/create-qr-code/?size=320x320&data={Uri.EscapeDataString(paymentLink)}"; + } } diff --git a/src/BirthList.Web/Features/Registries/RegistryModels.cs b/src/BirthList.Web/Features/Registries/RegistryModels.cs index bd7d914..775fbe0 100644 --- a/src/BirthList.Web/Features/Registries/RegistryModels.cs +++ b/src/BirthList.Web/Features/Registries/RegistryModels.cs @@ -2,6 +2,19 @@ using BirthList.Domain.Entities; namespace BirthList.Web.Features.Registries; +public enum ContributionPaymentMethodType +{ + Iban = 1, + SingleQrCode = 2, + AmountSpecificQrCode = 3 +} + +public sealed class ContributionAmountQrCodeModel +{ + public decimal Amount { get; set; } + public string QrCodeUrl { get; set; } = string.Empty; +} + public sealed class RegistryCreateModel { public string Title { get; set; } = string.Empty; @@ -47,6 +60,8 @@ public sealed class RegistrySettingsEditModel public string? BankAccountBic { get; set; } public string? BankAccountDisplayName { get; set; } public bool ShowBankAccountName { get; set; } + public string? ContributionQrCodeUrl { get; set; } + public List ContributionAmountQrCodes { get; set; } = []; } public sealed class RegistrySummaryViewModel @@ -73,6 +88,8 @@ public sealed class RegistryPublicViewModel public string? BankAccountBic { get; init; } public string? BankAccountDisplayName { get; init; } public bool ShowBankAccountName { get; init; } + public string? ContributionQrCodeUrl { get; init; } + public IReadOnlyList ContributionAmountQrCodes { get; init; } = []; public string? CurrentUserId { get; init; } public bool IsAdmin { get; init; } public IReadOnlyList Items { get; init; } = []; diff --git a/src/BirthList.Web/Features/Registries/RegistryService.cs b/src/BirthList.Web/Features/Registries/RegistryService.cs index 9501ad6..9ab78e3 100644 --- a/src/BirthList.Web/Features/Registries/RegistryService.cs +++ b/src/BirthList.Web/Features/Registries/RegistryService.cs @@ -4,6 +4,7 @@ using BirthList.Web.Data; using Microsoft.EntityFrameworkCore; using System.Security.Cryptography; using System.Text; +using System.Text.Json; namespace BirthList.Web.Features.Registries; @@ -210,6 +211,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli BankAccountBic = settings?.BankAccountBic, BankAccountDisplayName = settings?.BankAccountDisplayName, ShowBankAccountName = settings?.ShowBankAccountName ?? false, + ContributionQrCodeUrl = settings?.ContributionQrCodeUrl, + ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson), CurrentUserId = userId, IsAdmin = isAdmin, Items = registry.Items @@ -289,7 +292,9 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli BankAccountIban = settings?.BankAccountIban, BankAccountBic = settings?.BankAccountBic, BankAccountDisplayName = settings?.BankAccountDisplayName, - ShowBankAccountName = settings?.ShowBankAccountName ?? false + ShowBankAccountName = settings?.ShowBankAccountName ?? false, + ContributionQrCodeUrl = settings?.ContributionQrCodeUrl, + ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson).ToList() }; } @@ -325,10 +330,68 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli settings.BankAccountBic = string.IsNullOrWhiteSpace(model.BankAccountBic) ? null : model.BankAccountBic.Trim(); settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim(); settings.ShowBankAccountName = model.ShowBankAccountName; + settings.ContributionQrCodeUrl = string.IsNullOrWhiteSpace(model.ContributionQrCodeUrl) ? null : model.ContributionQrCodeUrl.Trim(); + settings.ContributionAmountQrCodesJson = SerializeContributionAmountQrCodes(model.ContributionAmountQrCodes); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + private static IReadOnlyList ParseContributionAmountQrCodes(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + try + { + var items = JsonSerializer.Deserialize>(json); + if (items is null) + { + return []; + } + + return items + .Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl)) + .Select(x => new ContributionAmountQrCodeModel + { + Amount = x.Amount, + QrCodeUrl = x.QrCodeUrl.Trim() + }) + .OrderBy(x => x.Amount) + .ToList(); + } + catch (JsonException) + { + return []; + } + } + + private static string? SerializeContributionAmountQrCodes(IEnumerable? amountQrCodes) + { + if (amountQrCodes is null) + { + return null; + } + + var normalized = amountQrCodes + .Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl)) + .Select(x => new ContributionAmountQrCodeModel + { + Amount = x.Amount, + QrCodeUrl = x.QrCodeUrl.Trim() + }) + .OrderBy(x => x.Amount) + .ToList(); + + if (normalized.Count == 0) + { + return null; + } + + return JsonSerializer.Serialize(normalized); + } + public async Task> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken) { var items = await registryDbContext.RegistryItems