Add contribution payment options to registries
- Added support for single and amount-specific QR codes. - Updated `RegistrySettings` model and database schema. - Enhanced `RegistryAdmin` to configure payment options. - Introduced `RegistryContributionAmount` for partial fulfillment. - Updated `RegistryPublic` to display contribution progress. - Added new models for payment methods and QR code handling. - Improved privacy by limiting contributor visibility.
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
- Authentication must support local accounts plus Microsoft and Google login; admin invite links are single-use and not email-locked.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
## Technical Specifications
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ public class RegistrySettings
|
|||||||
public string? BankAccountBic { get; set; }
|
public string? BankAccountBic { get; set; }
|
||||||
public string? BankAccountDisplayName { get; set; }
|
public string? BankAccountDisplayName { get; set; }
|
||||||
public bool ShowBankAccountName { get; set; }
|
public bool ShowBankAccountName { get; set; }
|
||||||
|
public string? ContributionQrCodeUrl { get; set; }
|
||||||
|
public string? ContributionAmountQrCodesJson { get; set; }
|
||||||
|
|
||||||
public Registry Registry { get; set; } = null!;
|
public Registry Registry { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|||||||
+489
@@ -0,0 +1,489 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ContributedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("CurrencyCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(3)
|
||||||
|
.HasColumnType("nvarchar(3)");
|
||||||
|
|
||||||
|
b.Property<Guid>("RegistryItemId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("TransferMessage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("PurchasedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<int>("Quantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("RegistryItemId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("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<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("AssignedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("BabyName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateOnly?>("BirthDate")
|
||||||
|
.HasColumnType("date");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("CurrencyCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(3)
|
||||||
|
.HasColumnType("nvarchar(3)");
|
||||||
|
|
||||||
|
b.Property<string>("HeaderContentHtml")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("PublicLinkCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<int>("RegistryType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ShippingAddress")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ThemeKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("RegistryId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("AddedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.HasKey("RegistryId", "UserId");
|
||||||
|
|
||||||
|
b.ToTable("RegistryAdmins");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BirthList.Domain.Entities.RegistryAdminInvite", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("RedeemedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<Guid>("RegistryId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("SentToEmail")
|
||||||
|
.HasMaxLength(320)
|
||||||
|
.HasColumnType("nvarchar(320)");
|
||||||
|
|
||||||
|
b.Property<string>("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<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("CurrencyCode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(3)
|
||||||
|
.HasColumnType("nvarchar(3)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("DesiredQuantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsGiven")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal>("MoneyFulfilledAmount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("nvarchar(300)");
|
||||||
|
|
||||||
|
b.Property<bool>("ParticipationAllowed")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("ParticipationTargetAmount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("PictureUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<bool?>("PreferSecondHand")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("PriceAmount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ProductUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<int>("PurchasedQuantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("RegistryId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RegistryId");
|
||||||
|
|
||||||
|
b.ToTable("RegistryItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("RegistryId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("BankAccountBic")
|
||||||
|
.HasMaxLength(11)
|
||||||
|
.HasColumnType("nvarchar(11)");
|
||||||
|
|
||||||
|
b.Property<string>("BankAccountDisplayName")
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("nvarchar(120)");
|
||||||
|
|
||||||
|
b.Property<string>("BankAccountIban")
|
||||||
|
.HasMaxLength(34)
|
||||||
|
.HasColumnType("nvarchar(34)");
|
||||||
|
|
||||||
|
b.Property<string>("ContributionAmountQrCodesJson")
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("nvarchar(4000)");
|
||||||
|
|
||||||
|
b.Property<string>("ContributionQrCodeUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<bool>("ShowBankAccountName")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.HasKey("RegistryId");
|
||||||
|
|
||||||
|
b.ToTable("RegistrySettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BirthList.Domain.Entities.RegistryVisit", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("RegistryId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("LastVisitedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.HasKey("RegistryId", "UserId");
|
||||||
|
|
||||||
|
b.ToTable("RegistryVisits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BirthList.Domain.Entities.UserActionLog", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("ActionType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
|
.HasColumnType("datetimeoffset");
|
||||||
|
|
||||||
|
b.Property<string>("Details")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<int>("Quantity")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<Guid>("RegistryId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<Guid?>("RegistryItemId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<string>("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BirthList.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddContributionPaymentOptionsToRegistrySettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ContributionAmountQrCodesJson",
|
||||||
|
table: "RegistrySettings",
|
||||||
|
type: "nvarchar(4000)",
|
||||||
|
maxLength: 4000,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ContributionQrCodeUrl",
|
||||||
|
table: "RegistrySettings",
|
||||||
|
type: "nvarchar(2048)",
|
||||||
|
maxLength: 2048,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ContributionAmountQrCodesJson",
|
||||||
|
table: "RegistrySettings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ContributionQrCodeUrl",
|
||||||
|
table: "RegistrySettings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -299,6 +299,14 @@ namespace BirthList.Infrastructure.Migrations
|
|||||||
.HasMaxLength(34)
|
.HasMaxLength(34)
|
||||||
.HasColumnType("nvarchar(34)");
|
.HasColumnType("nvarchar(34)");
|
||||||
|
|
||||||
|
b.Property<string>("ContributionAmountQrCodesJson")
|
||||||
|
.HasMaxLength(4000)
|
||||||
|
.HasColumnType("nvarchar(4000)");
|
||||||
|
|
||||||
|
b.Property<string>("ContributionQrCodeUrl")
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
b.Property<bool>("ShowBankAccountName")
|
b.Property<bool>("ShowBankAccountName")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
|
|||||||
entity.Property(x => x.BankAccountIban).HasMaxLength(34);
|
entity.Property(x => x.BankAccountIban).HasMaxLength(34);
|
||||||
entity.Property(x => x.BankAccountBic).HasMaxLength(11);
|
entity.Property(x => x.BankAccountBic).HasMaxLength(11);
|
||||||
entity.Property(x => x.BankAccountDisplayName).HasMaxLength(120);
|
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)
|
entity.HasOne(x => x.Registry)
|
||||||
.WithOne()
|
.WithOne()
|
||||||
.HasForeignKey<RegistrySettings>(x => x.RegistryId)
|
.HasForeignKey<RegistrySettings>(x => x.RegistryId)
|
||||||
|
|||||||
@@ -247,6 +247,50 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<h3>Contribution Payment Options</h3>
|
||||||
|
<div class="row g-3 mt-2">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label">Single QR code URL</label>
|
||||||
|
<InputText class="form-control" @bind-Value="SettingsModel.ContributionQrCodeUrl" />
|
||||||
|
<small class="form-text text-muted">Optional: one QR code that donors can scan for any amount.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h4 class="mb-0">Amount-specific QR codes</h4>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="AddContributionAmountQrCode">Add QR amount</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (SettingsModel.ContributionAmountQrCodes.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted mt-2 mb-0">No amount-specific QR codes configured.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="mt-2 d-flex flex-column gap-2">
|
||||||
|
@foreach (var amountQr in SettingsModel.ContributionAmountQrCodes)
|
||||||
|
{
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Amount</label>
|
||||||
|
<InputNumber class="form-control" @bind-Value="amountQr.Amount" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<label class="form-label">QR code URL</label>
|
||||||
|
<InputText class="form-control" @bind-Value="amountQr.QrCodeUrl" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="button" class="btn btn-outline-danger w-100" @onclick="() => RemoveContributionAmountQrCode(amountQr)">Remove</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn btn-primary mt-4" type="submit">Save settings</button>
|
<button class="btn btn-primary mt-4" type="submit">Save settings</button>
|
||||||
</EditForm>
|
</EditForm>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
builder.AddMarkupContent(3, "<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>");
|
builder.AddMarkupContent(3, "<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private bool _pendingEditorLoad;
|
||||||
|
private string? _lastLoadedHeaderContentHtml;
|
||||||
|
|
||||||
protected override async Task OnParametersSetAsync()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
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)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender || TextEditor is null || string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml))
|
if (TextEditor is null || !_pendingEditorLoad)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
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)
|
catch (JSException)
|
||||||
{
|
{
|
||||||
@@ -229,6 +234,17 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
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()
|
private async Task LoadAsync()
|
||||||
{
|
{
|
||||||
var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
var settings = await RegistryService.GetRegistrySettingsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||||
@@ -244,6 +260,11 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
SettingsModel.BankAccountIban = settings.BankAccountIban;
|
SettingsModel.BankAccountIban = settings.BankAccountIban;
|
||||||
SettingsModel.BankAccountBic = settings.BankAccountBic;
|
SettingsModel.BankAccountBic = settings.BankAccountBic;
|
||||||
SettingsModel.ShowBankAccountName = settings.ShowBankAccountName;
|
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);
|
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@page "/registry/{Code}/item/{ItemId:guid}/contribution-amount"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
|
@using BirthList.Web.Features.Registries
|
||||||
|
|
||||||
|
<PageTitle>Contribution Amount</PageTitle>
|
||||||
|
|
||||||
|
@if (Registry is null || Item is null)
|
||||||
|
{
|
||||||
|
<p>Item not found.</p>
|
||||||
|
}
|
||||||
|
else if (!IsAuthenticated)
|
||||||
|
{
|
||||||
|
<p>Please log in first.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="registry-shell @RegistryThemeService.GetCssClass(Registry.RegistryType, Registry.ThemeKey)">
|
||||||
|
<h1>Partially fulfill: @Item.Name</h1>
|
||||||
|
|
||||||
|
<div class="card p-3">
|
||||||
|
@if (RepresentableAmounts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted mb-0">No representable amount can be formed from configured QR codes up to €200.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<label class="form-label">Select amount: €@SelectedAmount</label>
|
||||||
|
<input type="range"
|
||||||
|
class="form-range"
|
||||||
|
min="0"
|
||||||
|
max="@(RepresentableAmounts.Count - 1)"
|
||||||
|
step="1"
|
||||||
|
@bind="SliderIndex"
|
||||||
|
@bind:after="RecalculateSuggestions" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Suggestions.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted mt-2">No QR combination available for this amount.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<p class="mt-3 mb-2"><strong>Suggested QR combination:</strong></p>
|
||||||
|
<ul class="mb-3">
|
||||||
|
@foreach (var suggestion in Suggestions)
|
||||||
|
{
|
||||||
|
<li>@suggestion.RepeatCount x €@suggestion.Amount</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-3">
|
||||||
|
@foreach (var suggestion in Suggestions)
|
||||||
|
{
|
||||||
|
@for (var i = 0; i < suggestion.RepeatCount; i++)
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<div class="small mb-1">€@suggestion.Amount</div>
|
||||||
|
<img src="@BuildQrImageUrl(suggestion.QrCodeUrl)" alt="QR code @suggestion.Amount" class="img-fluid" style="max-height: 220px;" />
|
||||||
|
<div class="small text-muted text-break">
|
||||||
|
<a href="@suggestion.QrCodeUrl" target="_blank" rel="noopener noreferrer">Open payment link</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button class="btn btn-success" @onclick="ConfirmAsync" disabled="@(SelectedAmount <= 0)">I transferred this amount</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackToRegistry">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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<int> RepresentableAmounts { get; private set; } = [];
|
||||||
|
protected List<QrCombinationSuggestion> 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<QrCombinationSuggestion> 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<int, int>();
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,14 +64,17 @@ else
|
|||||||
{
|
{
|
||||||
<p class="card-text item-description-text">@item.Description</p>
|
<p class="card-text item-description-text">@item.Description</p>
|
||||||
}
|
}
|
||||||
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
|
@if (item.DesiredQuantity > 1)
|
||||||
|
{
|
||||||
|
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
|
||||||
|
}
|
||||||
@if (item.PriceAmount.HasValue)
|
@if (item.PriceAmount.HasValue)
|
||||||
{
|
{
|
||||||
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
|
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
|
||||||
}
|
}
|
||||||
@if (item.ParticipationAllowed && item.ParticipationTargetAmount.HasValue)
|
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue)
|
||||||
{
|
{
|
||||||
<p class="mb-2"><strong>Participation:</strong> @item.MoneyFulfilledAmount.ToString("0.00") / @item.ParticipationTargetAmount.Value.ToString("0.00") @item.CurrencyCode</p>
|
<p class="mb-2"><strong>Participation:</strong> €@item.MoneyFulfilledAmount.ToString("0.00") out of €@GetParticipationTotalAmount(item)!.Value.ToString("0.00") fulfilled</p>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
|
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
|
||||||
@@ -110,13 +113,39 @@ else
|
|||||||
@if (item.Contributors.Count > 0)
|
@if (item.Contributors.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<strong class="text-sm">Contributed by:</strong>
|
@if (Registry.IsAdmin)
|
||||||
<div class="contributor-list">
|
{
|
||||||
@foreach (var contributor in item.Contributors)
|
<strong class="text-sm">Contributed by:</strong>
|
||||||
|
<div class="contributor-list">
|
||||||
|
@foreach (var contributor in item.Contributors)
|
||||||
|
{
|
||||||
|
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(Registry.CurrentUserId))
|
||||||
|
{
|
||||||
|
var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId);
|
||||||
|
if (currentUserContribution is not null)
|
||||||
{
|
{
|
||||||
<span class="contributor-badge">@contributor.DisplayName (@contributor.Amount.ToString("0.00") @item.CurrencyCode)</span>
|
<strong class="text-sm">Contributed by:</strong>
|
||||||
|
<div class="contributor-list">
|
||||||
|
<span class="contributor-badge">@currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode)</span>
|
||||||
|
@if (item.Contributors.Count > 1)
|
||||||
|
{
|
||||||
|
<span class="contributor-badge">and others</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted small">Contributed</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted small">Contributed</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -147,7 +176,7 @@ else
|
|||||||
}
|
}
|
||||||
@if (item.ParticipationAllowed)
|
@if (item.ParticipationAllowed)
|
||||||
{
|
{
|
||||||
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">I transferred money</button>
|
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -219,3 +248,91 @@ else
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (ShowPartialFulfillWizard)
|
||||||
|
{
|
||||||
|
<div class="prompt-overlay">
|
||||||
|
<div class="prompt-card">
|
||||||
|
<h3>Partially fulfill item</h3>
|
||||||
|
|
||||||
|
@if (PartialFulfillStep == 1)
|
||||||
|
{
|
||||||
|
<p>Select how you want to donate:</p>
|
||||||
|
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
@if (HasIbanPaymentOption())
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.Iban)">
|
||||||
|
IBAN transfer
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (HasSingleQrPaymentOption())
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.SingleQrCode)">
|
||||||
|
Single QR code
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (HasAmountSpecificQrPaymentOption())
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.AmountSpecificQrCode)">
|
||||||
|
QR code per amount
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (SelectedPaymentMethod == ContributionPaymentMethodType.Iban && !string.IsNullOrWhiteSpace(Registry?.BankAccountIban))
|
||||||
|
{
|
||||||
|
<div class="alert alert-secondary mt-3 mb-0">
|
||||||
|
<strong>IBAN:</strong> @Registry.BankAccountIban
|
||||||
|
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic))
|
||||||
|
{
|
||||||
|
<span> | <strong>BIC:</strong> @Registry.BankAccountBic</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl))
|
||||||
|
{
|
||||||
|
<div class="mt-3 text-center">
|
||||||
|
<img src="@BuildQrImageUrl(SelectedPaymentQrCodeUrl)" alt="Payment QR code" class="img-fluid" style="max-height: 240px;" />
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="@SelectedPaymentQrCodeUrl" target="_blank" rel="noopener noreferrer">Open payment link</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button class="btn btn-primary" @onclick="ContinueContributionWizard" disabled="@(SelectedPaymentMethod is null)">Next</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (PartialFulfillStep == 2)
|
||||||
|
{
|
||||||
|
<p>How much did you add for this item?</p>
|
||||||
|
<InputNumber class="form-control" @bind-Value="ContributionAmount" @bind-Value:after="OnContributionAmountChanged" />
|
||||||
|
<p class="mt-2">Message: @ContributionMessage</p>
|
||||||
|
|
||||||
|
@if (SelectedPaymentMethod == ContributionPaymentMethodType.AmountSpecificQrCode && ContributionAmount > 0 && string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl))
|
||||||
|
{
|
||||||
|
<p class="text-muted mt-2 mb-0">No QR code configured for this exact amount.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl))
|
||||||
|
{
|
||||||
|
<div class="mt-3 text-center">
|
||||||
|
<img src="@BuildQrImageUrl(SelectedPaymentQrCodeUrl)" alt="Payment QR code" class="img-fluid" style="max-height: 240px;" />
|
||||||
|
<div class="mt-2">
|
||||||
|
<a href="@SelectedPaymentQrCodeUrl" target="_blank" rel="noopener noreferrer">Open payment link</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-3">
|
||||||
|
<button class="btn btn-success" @onclick="ConfirmContributionAsync">Confirm</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="BackContributionWizard">Back</button>
|
||||||
|
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public partial class RegistryPublic : ComponentBase
|
|||||||
protected string? PurchaseItemUrl { get; private set; }
|
protected string? PurchaseItemUrl { get; private set; }
|
||||||
protected IReadOnlyList<ItemContributorViewModel> PurchasersToUnmark { get; private set; } = [];
|
protected IReadOnlyList<ItemContributorViewModel> 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()
|
protected override async Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
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
|
ContributionMessage = Registry?.Items.FirstOrDefault(x => x.Id == itemId) is { } item
|
||||||
? $"Participation for item: {item.Name}"
|
? $"Participation for item: {item.Name}"
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
ShowContributionPrompt = true;
|
ShowPartialFulfillWizard = true;
|
||||||
|
PartialFulfillStep = 1;
|
||||||
|
SelectedPaymentMethod = null;
|
||||||
|
SelectedPaymentQrCodeUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void CloseContributionPrompt()
|
protected void CloseContributionPrompt()
|
||||||
{
|
{
|
||||||
ShowContributionPrompt = false;
|
ShowPartialFulfillWizard = false;
|
||||||
|
PartialFulfillStep = 1;
|
||||||
|
SelectedPaymentMethod = null;
|
||||||
|
SelectedPaymentQrCodeUrl = null;
|
||||||
ActiveItemId = Guid.Empty;
|
ActiveItemId = Guid.Empty;
|
||||||
ContributionMessage = string.Empty;
|
ContributionMessage = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task ConfirmContributionAsync()
|
protected bool HasIbanPaymentOption()
|
||||||
{
|
{
|
||||||
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
|
return Registry is not null && !string.IsNullOrWhiteSpace(Registry.BankAccountIban);
|
||||||
if (string.IsNullOrWhiteSpace(userId) || ActiveItemId == Guid.Empty || ContributionAmount <= 0)
|
}
|
||||||
|
|
||||||
|
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);
|
var match = Registry.ContributionAmountQrCodes
|
||||||
ShowContributionPrompt = false;
|
.FirstOrDefault(x => x.Amount == amount);
|
||||||
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
|
|
||||||
|
return match?.QrCodeUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async Task UnmarkPurchaseAsync(Guid itemId)
|
protected async Task UnmarkPurchaseAsync(Guid itemId)
|
||||||
@@ -184,4 +257,32 @@ public partial class RegistryPublic : ComponentBase
|
|||||||
ShowPurchaserSelectionPrompt = false;
|
ShowPurchaserSelectionPrompt = false;
|
||||||
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(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)}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ using BirthList.Domain.Entities;
|
|||||||
|
|
||||||
namespace BirthList.Web.Features.Registries;
|
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 sealed class RegistryCreateModel
|
||||||
{
|
{
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
@@ -47,6 +60,8 @@ public sealed class RegistrySettingsEditModel
|
|||||||
public string? BankAccountBic { get; set; }
|
public string? BankAccountBic { get; set; }
|
||||||
public string? BankAccountDisplayName { get; set; }
|
public string? BankAccountDisplayName { get; set; }
|
||||||
public bool ShowBankAccountName { get; set; }
|
public bool ShowBankAccountName { get; set; }
|
||||||
|
public string? ContributionQrCodeUrl { get; set; }
|
||||||
|
public List<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class RegistrySummaryViewModel
|
public sealed class RegistrySummaryViewModel
|
||||||
@@ -73,6 +88,8 @@ public sealed class RegistryPublicViewModel
|
|||||||
public string? BankAccountBic { get; init; }
|
public string? BankAccountBic { get; init; }
|
||||||
public string? BankAccountDisplayName { get; init; }
|
public string? BankAccountDisplayName { get; init; }
|
||||||
public bool ShowBankAccountName { get; init; }
|
public bool ShowBankAccountName { get; init; }
|
||||||
|
public string? ContributionQrCodeUrl { get; init; }
|
||||||
|
public IReadOnlyList<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; init; } = [];
|
||||||
public string? CurrentUserId { get; init; }
|
public string? CurrentUserId { get; init; }
|
||||||
public bool IsAdmin { get; init; }
|
public bool IsAdmin { get; init; }
|
||||||
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
|
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using BirthList.Web.Data;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace BirthList.Web.Features.Registries;
|
namespace BirthList.Web.Features.Registries;
|
||||||
|
|
||||||
@@ -210,6 +211,8 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
|
|||||||
BankAccountBic = settings?.BankAccountBic,
|
BankAccountBic = settings?.BankAccountBic,
|
||||||
BankAccountDisplayName = settings?.BankAccountDisplayName,
|
BankAccountDisplayName = settings?.BankAccountDisplayName,
|
||||||
ShowBankAccountName = settings?.ShowBankAccountName ?? false,
|
ShowBankAccountName = settings?.ShowBankAccountName ?? false,
|
||||||
|
ContributionQrCodeUrl = settings?.ContributionQrCodeUrl,
|
||||||
|
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson),
|
||||||
CurrentUserId = userId,
|
CurrentUserId = userId,
|
||||||
IsAdmin = isAdmin,
|
IsAdmin = isAdmin,
|
||||||
Items = registry.Items
|
Items = registry.Items
|
||||||
@@ -289,7 +292,9 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
|
|||||||
BankAccountIban = settings?.BankAccountIban,
|
BankAccountIban = settings?.BankAccountIban,
|
||||||
BankAccountBic = settings?.BankAccountBic,
|
BankAccountBic = settings?.BankAccountBic,
|
||||||
BankAccountDisplayName = settings?.BankAccountDisplayName,
|
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.BankAccountBic = string.IsNullOrWhiteSpace(model.BankAccountBic) ? null : model.BankAccountBic.Trim();
|
||||||
settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim();
|
settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim();
|
||||||
settings.ShowBankAccountName = model.ShowBankAccountName;
|
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);
|
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ContributionAmountQrCodeModel> ParseContributionAmountQrCodes(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var items = JsonSerializer.Deserialize<List<ContributionAmountQrCodeModel>>(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<ContributionAmountQrCodeModel>? 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<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var items = await registryDbContext.RegistryItems
|
var items = await registryDbContext.RegistryItems
|
||||||
|
|||||||
Reference in New Issue
Block a user