Compare commits

...

13 Commits

Author SHA1 Message Date
Arne Moerman 14fec4fefb Increase hero image max size to 3840x2160 in admin UI
Build and Push Docker Image / build-and-push (push) Successful in 17s
Raised the maximum allowed hero image dimensions from 1920x1080 to 3840x2160 in the RegistryAdmin component, enabling support for higher-resolution uploads.
2026-07-25 19:15:50 +02:00
Arne Moerman cb073ee3d8 Add disk-based hero image storage with path support
Build and Push Docker Image / build-and-push (push) Successful in 36s
Added HeroImagePath to Registries and updated models, UI, and RegistryService to support storing hero images on disk. Images are now saved to /uploads/registry-hero/ and old images are cleaned up on update/removal. Includes migration for new column. Backward compatibility with base64 BLOBs is maintained.
2026-07-25 19:08:24 +02:00
Arne Moerman 78405c6e75 Add HideHeaderName option to registry settings
Build and Push Docker Image / build-and-push (push) Successful in 17s
Introduce HideHeaderName property to RegistrySettings and related view models, allowing admins to hide the registry name/title on the public page. Update RegistryAdmin UI with a localized checkbox, adjust RegistryPublic rendering, and persist the setting in RegistryService. Add EF Core migration and update localization resources for all supported languages.
2026-07-25 18:39:13 +02:00
Arne Moerman 0152a6868a Add public/admin view buttons and update localizations
Build and Push Docker Image / build-and-push (push) Successful in 4m13s
Added "View Public List" button to RegistryAdmin and "Go to Admin" button to public registry view for admins. Exposed and loaded public link code in models and service. Updated localization files with new translations.
2026-07-25 18:30:52 +02:00
Arne Moerman b323cdd47a Add hero image support for registries
- Registry entity and DB updated with hero image fields
- Admin UI: upload, preview, remove hero image with validation
- Public registry page displays hero image if present
- View models and RegistryService handle hero image data (base64)
- Localization updated for hero image UI strings
- Minor: Fix ShowCreateForm property in Home.razor
2026-07-25 18:07:25 +02:00
Arne Moerman f1186b31f9 Add "Condition" column to admin table with localization
Added a "Condition" column to the registry admin table in RegistryAdmin.razor, showing item condition as badges based on PreferSecondHand. Updated all relevant resource files to localize the new column header.
2026-07-25 15:41:34 +02:00
Arne Moerman b1b7525759 Update culture defaults and improve second-hand dropdown
Build and Push Docker Image / build-and-push (push) Successful in 1m21s
- Change default request culture to "nl-BE" and reorder supported cultures; remove "nl-NL"
- Refactor RegistryAdmin second-hand preference to use a string property for better nullable handling in the dropdown
2026-07-01 15:27:49 +02:00
Arne Moerman 50238b57c8 Add Amazon PA API and RapidAPI metadata support
Build and Push Docker Image / build-and-push (push) Successful in 3m50s
Introduce Amazon PA API and RapidAPI clients for fetching product metadata, with fallback strategies for Amazon URLs.
Add `AmazonMetadataOptions` for configuration and update `RegistryMetadataService` to integrate these clients.
Enhance metadata scraping with ASIN extraction, country code handling, and modern browser headers.
Include deployment files and register new services in `Program.cs`.
Update `appsettings.json` with placeholders for API credentials.
2026-05-26 20:13:31 +02:00
Arne Moerman 3b219da4eb Enhance metadata fetching and UI improvements
Build and Push Docker Image / build-and-push (push) Successful in 25s
- Added `MetadataFetchSucceeded` and `MetadataFetchFailed` enums.
- Improved metadata fetch logging with detailed outcomes.
- Enhanced toolbar formatting options in `RegistryAdmin`.
- Made `LanguageOptions` dynamic based on the environment.
- Styled Quill text-size classes in public header content.
- Improved error handling in `RegistryMetadataService`.
- Adjusted supported cultures for development environments.
- Updated localization for new metadata fetch statuses.
2026-05-22 17:17:24 +02:00
Arne Moerman b59ad6e5ab Add localization support and UI enhancements
Build and Push Docker Image / build-and-push (push) Successful in 27s
Introduced centralized RESX localization with hierarchical keys for multi-language support. Added resource files for English, French, Dutch (Belgium), and pseudo-localization.

Replaced hardcoded strings in Razor components with localized string references. Added a `PreferredCulture` property to `ApplicationUser` with a migration to store user-specific culture preferences.

Implemented `LocalizationService` to manage supported cultures and user preferences. Added a language picker in `TopBar.razor` and configured `RequestLocalizationOptions` for culture detection.

Localized registry-related components and error messages. Enhanced UI elements to dynamically display localized content. Added pseudo-localization to identify hardcoded strings during development.
2026-05-19 23:30:30 +02:00
Arne Moerman 9f3ae1051c Refactor currency handling and UI updates
Updated currency handling to use dynamic symbols, defaulting to "€". Adjusted UI components to reflect these changes. Introduced `GetCurrencySymbol` and `NormalizeCurrencySymbol` methods for consistency. Reintroduced QR code parsing/serialization methods. Updated models and services to align with the new currency symbol logic.
2026-05-19 22:05:10 +02:00
Arne Moerman 89c439f80b Enhance purchase and contribution management
Build and Push Docker Image / build-and-push (push) Successful in 41s
Refactored UI to consolidate purchase and contribution actions into dynamic buttons. Added modals for managing purchases and contributions with admin-specific functionality. Introduced new properties and methods in `RegistryPublic.razor.cs` for state management and user filtering.

Updated `RegistryService` with methods for handling contributions and purchases, including admin-specific logic, validation, and logging. Improved database operations for robust handling of item totals and user actions. Added utility methods for generating unique codes and managing admin invites.

Refactored user filtering and display logic for admin actions, ensuring dynamic population of selectable users. Optimized handling of user actions and logs for consistency.
2026-05-19 18:18:02 +02:00
Arne Moerman 09349cb7b7 Add support for item categories in registries
Introduced `RegistryItemCategory` entity for grouping and ordering items within registries. Updated `RegistryItem` and `Registry` entities to support categorization. Added database migrations for `RegistryItemCategories` and updated `RegistryItems` with `CategoryId` and `SortOrder`.

Implemented drag-and-drop functionality for reordering categories and items using JavaScript and Blazor. Enhanced `RegistryAdmin` and `RegistryPublic` components to manage and display categories with collapsible sections.

Updated `RegistryService` to handle category operations, including adding, renaming, removing, and reordering. Added new view models and updated CSS for category styling. Refactored logic to ensure proper ordering and fallback for unassigned items.
2026-05-19 17:02:31 +02:00
59 changed files with 8633 additions and 608 deletions
+3 -2
View File
@@ -6,11 +6,12 @@
- 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.
- 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 contribution/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
- Use SMTP via Google initially for email functionalities.
- Support registry type theming from day one to enhance user experience.
- URL autofetch should utilize OpenGraph/meta tags for better link previews.
- Use Blazored.TextEditor for rich text editing.
- Use Blazored.TextEditor for rich text editing.
- Use centralized RESX localization with hierarchical keys (not sentence keys); prefer a shared resource file over per-component files, and use pseudo-localization (qps-Ploc) to catch hardcoded strings.
+5
View File
@@ -1,4 +1,9 @@
<Solution>
<Folder Name="/deploy/">
<File Path="deploy/portainer-stack.env.example" />
<File Path="deploy/portainer-stack.yml" />
<File Path="deploy/README.md" />
</Folder>
<Folder Name="/gitea build/">
<File Path=".gitea/workflows/build-and-push.yml" />
</Folder>
+5
View File
@@ -23,6 +23,11 @@ services:
- Smtp__FromAddress=${SMTP_FROM_ADDRESS}
- Smtp__FromName=${SMTP_FROM_NAME}
- PublicUrl=${PUBLIC_URL}
- AmazonMetadata__AccessKey=${AMAZON_PA_ACCESS_KEY}
- AmazonMetadata__SecretKey=${AMAZON_PA_SECRET_KEY}
- AmazonMetadata__AssociateTag=${AMAZON_ASSOCIATE_TAG}
- AmazonMetadata__PaApiHost=${AMAZON_PA_API_HOST}
- AmazonMetadata__RapidApiKey=${AMAZON_RAPID_API_KEY}
depends_on:
- mssql
networks:
@@ -7,6 +7,9 @@ public class Registry
public string Title { get; set; } = string.Empty;
public DateOnly? BirthDate { get; set; }
public string? BabyName { get; set; }
public byte[]? HeroImageData { get; set; }
public string? HeroImageContentType { get; set; }
public string? HeroImagePath { get; set; }
public string? HeaderContentHtml { get; set; }
public string? ShippingAddress { get; set; }
public string CurrencyCode { get; set; } = "EUR";
@@ -15,6 +18,7 @@ public class Registry
public DateTimeOffset CreatedAtUtc { get; set; }
public ICollection<RegistryAdmin> Admins { get; set; } = [];
public ICollection<RegistryItemCategory> ItemCategories { get; set; } = [];
public ICollection<RegistryItem> Items { get; set; } = [];
public ICollection<RegistryVisit> Visits { get; set; } = [];
public ICollection<RegistryAdminInvite> AdminInvites { get; set; } = [];
@@ -4,6 +4,8 @@ public class RegistryItem
{
public Guid Id { get; set; }
public Guid RegistryId { get; set; }
public Guid CategoryId { get; set; }
public int SortOrder { get; set; }
public string Name { get; set; } = string.Empty;
public string? PictureUrl { get; set; }
public string? ProductUrl { get; set; }
@@ -20,6 +22,7 @@ public class RegistryItem
public DateTimeOffset CreatedAtUtc { get; set; }
public Registry Registry { get; set; } = null!;
public RegistryItemCategory Category { get; set; } = null!;
public ICollection<ItemPurchase> Purchases { get; set; } = [];
public ICollection<ItemContribution> Contributions { get; set; } = [];
}
@@ -0,0 +1,13 @@
namespace BirthList.Domain.Entities;
public class RegistryItemCategory
{
public Guid Id { get; set; }
public Guid RegistryId { get; set; }
public string Name { get; set; } = string.Empty;
public int SortOrder { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; }
public Registry Registry { get; set; } = null!;
public ICollection<RegistryItem> Items { get; set; } = [];
}
@@ -7,6 +7,7 @@ public class RegistrySettings
public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; }
public bool HideHeaderName { get; set; }
public string? ContributionQrCodeUrl { get; set; }
public string? ContributionAmountQrCodesJson { get; set; }
@@ -22,5 +22,7 @@ public enum UserActionType
MarkPurchased = 3,
UnmarkPurchased = 4,
MarkPartialPurchase = 5,
LogContribution = 6
LogContribution = 6,
MetadataFetchSucceeded = 7,
MetadataFetchFailed = 8
}
@@ -15,10 +15,6 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.26" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -0,0 +1,424 @@
// <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("20260517000000_InitialCreate")]
partial class InitialCreate
{
/// <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<bool>("CanBeSecondHand")
.HasColumnType("bit");
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<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.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.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.Registry", b =>
{
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
}
}
}
@@ -0,0 +1,275 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlatformOwners",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
AssignedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlatformOwners", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Registries",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Title = table.Column<string>(type: "nvarchar(250)", maxLength: 250, nullable: false),
PublicLinkCode = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
BabyName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
BirthDate = table.Column<DateOnly>(type: "date", nullable: true),
RegistryType = table.Column<int>(type: "int", nullable: false),
HeaderContentHtml = table.Column<string>(type: "nvarchar(max)", nullable: true),
ShippingAddress = table.Column<string>(type: "nvarchar(max)", nullable: true),
CurrencyCode = table.Column<string>(type: "nvarchar(3)", maxLength: 3, nullable: false),
ThemeKey = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Registries", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RegistryAdmins",
columns: table => new
{
RegistryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
AddedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegistryAdmins", x => new { x.RegistryId, x.UserId });
table.ForeignKey(
name: "FK_RegistryAdmins_Registries_RegistryId",
column: x => x.RegistryId,
principalTable: "Registries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RegistryAdminInvites",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RegistryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Token = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
SentToEmail = table.Column<string>(type: "nvarchar(320)", maxLength: 320, nullable: true),
ExpiresAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
RedeemedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RegistryAdminInvites", x => x.Id);
table.ForeignKey(
name: "FK_RegistryAdminInvites_Registries_RegistryId",
column: x => x.RegistryId,
principalTable: "Registries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RegistryItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RegistryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
PictureUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
ProductUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: true),
CurrencyCode = table.Column<string>(type: "nvarchar(3)", maxLength: 3, nullable: false),
PriceAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true),
DesiredQuantity = table.Column<int>(type: "int", nullable: false),
PurchasedQuantity = table.Column<int>(type: "int", nullable: false),
ParticipationAllowed = table.Column<bool>(type: "bit", nullable: false),
ParticipationTargetAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: true),
MoneyFulfilledAmount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
CanBeSecondHand = table.Column<bool>(type: "bit", nullable: false),
IsGiven = table.Column<bool>(type: "bit", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegistryItems", x => x.Id);
table.ForeignKey(
name: "FK_RegistryItems_Registries_RegistryId",
column: x => x.RegistryId,
principalTable: "Registries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RegistrySettings",
columns: table => new
{
RegistryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
BankAccountIban = table.Column<string>(type: "nvarchar(34)", maxLength: 34, nullable: true),
BankAccountBic = table.Column<string>(type: "nvarchar(11)", maxLength: 11, nullable: true),
BankAccountDisplayName = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RegistrySettings", x => x.RegistryId);
table.ForeignKey(
name: "FK_RegistrySettings_Registries_RegistryId",
column: x => x.RegistryId,
principalTable: "Registries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RegistryVisits",
columns: table => new
{
RegistryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
LastVisitedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegistryVisits", x => new { x.RegistryId, x.UserId });
table.ForeignKey(
name: "FK_RegistryVisits_Registries_RegistryId",
column: x => x.RegistryId,
principalTable: "Registries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ItemContributions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RegistryItemId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
Amount = table.Column<decimal>(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false),
CurrencyCode = table.Column<string>(type: "nvarchar(3)", maxLength: 3, nullable: false),
TransferMessage = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
ContributedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ItemContributions", x => x.Id);
table.ForeignKey(
name: "FK_ItemContributions_RegistryItems_RegistryItemId",
column: x => x.RegistryItemId,
principalTable: "RegistryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ItemPurchases",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RegistryItemId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<string>(type: "nvarchar(450)", maxLength: 450, nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
PurchasedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ItemPurchases", x => x.Id);
table.ForeignKey(
name: "FK_ItemPurchases_RegistryItems_RegistryItemId",
column: x => x.RegistryItemId,
principalTable: "RegistryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ItemContributions_RegistryItemId",
table: "ItemContributions",
column: "RegistryItemId");
migrationBuilder.CreateIndex(
name: "IX_ItemPurchases_RegistryItemId",
table: "ItemPurchases",
column: "RegistryItemId");
migrationBuilder.CreateIndex(
name: "IX_PlatformOwners_UserId",
table: "PlatformOwners",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Registries_PublicLinkCode",
table: "Registries",
column: "PublicLinkCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegistryAdminInvites_RegistryId",
table: "RegistryAdminInvites",
column: "RegistryId");
migrationBuilder.CreateIndex(
name: "IX_RegistryAdminInvites_Token",
table: "RegistryAdminInvites",
column: "Token",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_RegistryItems_RegistryId",
table: "RegistryItems",
column: "RegistryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ItemContributions");
migrationBuilder.DropTable(
name: "ItemPurchases");
migrationBuilder.DropTable(
name: "PlatformOwners");
migrationBuilder.DropTable(
name: "RegistryAdminInvites");
migrationBuilder.DropTable(
name: "RegistryAdmins");
migrationBuilder.DropTable(
name: "RegistryItems");
migrationBuilder.DropTable(
name: "RegistrySettings");
migrationBuilder.DropTable(
name: "RegistryVisits");
migrationBuilder.DropTable(
name: "Registries");
}
}
}
@@ -0,0 +1,552 @@
// <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("20260519124946_AddRegistryItemCategoriesAndOrdering")]
partial class AddRegistryItemCategoriesAndOrdering
{
/// <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<Guid>("CategoryId")
.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.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("CategoryId", "SortOrder");
b.ToTable("RegistryItems");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId", "Name");
b.HasIndex("RegistryId", "SortOrder");
b.ToTable("RegistryItemCategories");
});
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.RegistryItemCategory", "Category")
.WithMany("Items")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Items")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ItemCategories")
.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("ItemCategories");
b.Navigation("Items");
b.Navigation("Visits");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.Navigation("Contributions");
b.Navigation("Purchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,128 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddRegistryItemCategoriesAndOrdering : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CategoryId",
table: "RegistryItems",
type: "uniqueidentifier",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
migrationBuilder.AddColumn<int>(
name: "SortOrder",
table: "RegistryItems",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "RegistryItemCategories",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RegistryId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RegistryItemCategories", x => x.Id);
table.ForeignKey(
name: "FK_RegistryItemCategories_Registries_RegistryId",
column: x => x.RegistryId,
principalTable: "Registries",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.Sql(
"""
INSERT INTO RegistryItemCategories (Id, RegistryId, Name, SortOrder, CreatedAtUtc)
SELECT NEWID(), r.Id, N'General', 0, SYSUTCDATETIME()
FROM Registries r
WHERE NOT EXISTS (
SELECT 1
FROM RegistryItemCategories c
WHERE c.RegistryId = r.Id
);
;WITH GeneralCategories AS (
SELECT c.RegistryId, c.Id AS CategoryId
FROM RegistryItemCategories c
INNER JOIN (
SELECT RegistryId, MIN(SortOrder) AS MinSortOrder
FROM RegistryItemCategories
GROUP BY RegistryId
) firstCategory ON firstCategory.RegistryId = c.RegistryId AND firstCategory.MinSortOrder = c.SortOrder
), OrderedItems AS (
SELECT i.Id AS ItemId,
gc.CategoryId,
ROW_NUMBER() OVER (PARTITION BY i.RegistryId ORDER BY i.CreatedAtUtc, i.Id) - 1 AS SortOrder
FROM RegistryItems i
INNER JOIN GeneralCategories gc ON gc.RegistryId = i.RegistryId
)
UPDATE i
SET i.CategoryId = oi.CategoryId,
i.SortOrder = oi.SortOrder
FROM RegistryItems i
INNER JOIN OrderedItems oi ON oi.ItemId = i.Id;
""");
migrationBuilder.CreateIndex(
name: "IX_RegistryItems_CategoryId_SortOrder",
table: "RegistryItems",
columns: new[] { "CategoryId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_RegistryItemCategories_RegistryId_Name",
table: "RegistryItemCategories",
columns: new[] { "RegistryId", "Name" });
migrationBuilder.CreateIndex(
name: "IX_RegistryItemCategories_RegistryId_SortOrder",
table: "RegistryItemCategories",
columns: new[] { "RegistryId", "SortOrder" });
migrationBuilder.AddForeignKey(
name: "FK_RegistryItems_RegistryItemCategories_CategoryId",
table: "RegistryItems",
column: "CategoryId",
principalTable: "RegistryItemCategories",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RegistryItems_RegistryItemCategories_CategoryId",
table: "RegistryItems");
migrationBuilder.DropTable(
name: "RegistryItemCategories");
migrationBuilder.DropIndex(
name: "IX_RegistryItems_CategoryId_SortOrder",
table: "RegistryItems");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "RegistryItems");
migrationBuilder.DropColumn(
name: "SortOrder",
table: "RegistryItems");
}
}
}
@@ -0,0 +1,559 @@
// <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("20260519130000_AddHeroImageToRegistry")]
partial class AddHeroImageToRegistry
{
/// <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>("HeroImageContentType")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<byte[]>("HeroImageData")
.HasColumnType("varbinary(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<Guid>("CategoryId")
.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.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("CategoryId", "SortOrder");
b.ToTable("RegistryItems");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId", "Name");
b.HasIndex("RegistryId", "SortOrder");
b.ToTable("RegistryItemCategories");
});
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.RegistryItemCategory", "Category")
.WithMany("Items")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Items")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ItemCategories")
.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("ItemCategories");
b.Navigation("Items");
b.Navigation("Visits");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.Navigation("Contributions");
b.Navigation("Purchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddHeroImageToRegistry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte[]>(
name: "HeroImageData",
table: "Registries",
type: "varbinary(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "HeroImageContentType",
table: "Registries",
type: "nvarchar(100)",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HeroImageData",
table: "Registries");
migrationBuilder.DropColumn(
name: "HeroImageContentType",
table: "Registries");
}
}
}
@@ -0,0 +1,561 @@
// <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("20260725163539_AddHideHeaderNameToRegistrySettings")]
partial class AddHideHeaderNameToRegistrySettings
{
/// <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>("HeroImageContentType")
.HasColumnType("nvarchar(max)");
b.Property<byte[]>("HeroImageData")
.HasColumnType("varbinary(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<Guid>("CategoryId")
.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.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("CategoryId", "SortOrder");
b.ToTable("RegistryItems");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId", "Name");
b.HasIndex("RegistryId", "SortOrder");
b.ToTable("RegistryItemCategories");
});
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>("HideHeaderName")
.HasColumnType("bit");
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.RegistryItemCategory", "Category")
.WithMany("Items")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Items")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ItemCategories")
.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("ItemCategories");
b.Navigation("Items");
b.Navigation("Visits");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.Navigation("Contributions");
b.Navigation("Purchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddHideHeaderNameToRegistrySettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "HideHeaderName",
table: "RegistrySettings",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AlterColumn<string>(
name: "HeroImageContentType",
table: "Registries",
type: "nvarchar(max)",
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(100)",
oldMaxLength: 100,
oldNullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HideHeaderName",
table: "RegistrySettings");
migrationBuilder.AlterColumn<string>(
name: "HeroImageContentType",
table: "Registries",
type: "nvarchar(100)",
maxLength: 100,
nullable: true,
oldClrType: typeof(string),
oldType: "nvarchar(max)",
oldNullable: true);
}
}
}
@@ -0,0 +1,564 @@
// <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("20260725170215_AddHeroImagePathToRegistry")]
partial class AddHeroImagePathToRegistry
{
/// <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>("HeroImageContentType")
.HasColumnType("nvarchar(max)");
b.Property<byte[]>("HeroImageData")
.HasColumnType("varbinary(max)");
b.Property<string>("HeroImagePath")
.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<Guid>("CategoryId")
.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.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("CategoryId", "SortOrder");
b.ToTable("RegistryItems");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId", "Name");
b.HasIndex("RegistryId", "SortOrder");
b.ToTable("RegistryItemCategories");
});
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>("HideHeaderName")
.HasColumnType("bit");
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.RegistryItemCategory", "Category")
.WithMany("Items")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Items")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ItemCategories")
.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("ItemCategories");
b.Navigation("Items");
b.Navigation("Visits");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.Navigation("Contributions");
b.Navigation("Purchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddHeroImagePathToRegistry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "HeroImagePath",
table: "Registries",
type: "nvarchar(max)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HeroImagePath",
table: "Registries");
}
}
}
@@ -135,6 +135,15 @@ namespace BirthList.Infrastructure.Migrations
b.Property<string>("HeaderContentHtml")
.HasColumnType("nvarchar(max)");
b.Property<string>("HeroImageContentType")
.HasColumnType("nvarchar(max)");
b.Property<byte[]>("HeroImageData")
.HasColumnType("varbinary(max)");
b.Property<string>("HeroImagePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("PublicLinkCode")
.IsRequired()
.HasMaxLength(100)
@@ -221,6 +230,9 @@ namespace BirthList.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CategoryId")
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
@@ -275,13 +287,47 @@ namespace BirthList.Infrastructure.Migrations
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId");
b.HasIndex("CategoryId", "SortOrder");
b.ToTable("RegistryItems");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("datetimeoffset");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid>("RegistryId")
.HasColumnType("uniqueidentifier");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("RegistryId", "Name");
b.HasIndex("RegistryId", "SortOrder");
b.ToTable("RegistryItemCategories");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistrySettings", b =>
{
b.Property<Guid>("RegistryId")
@@ -307,6 +353,9 @@ namespace BirthList.Infrastructure.Migrations
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<bool>("HideHeaderName")
.HasColumnType("bit");
b.Property<bool>("ShowBankAccountName")
.HasColumnType("bit");
@@ -419,12 +468,31 @@ namespace BirthList.Infrastructure.Migrations
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItem", b =>
{
b.HasOne("BirthList.Domain.Entities.RegistryItemCategory", "Category")
.WithMany("Items")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("Items")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Category");
b.Navigation("Registry");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.HasOne("BirthList.Domain.Entities.Registry", "Registry")
.WithMany("ItemCategories")
.HasForeignKey("RegistryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Registry");
});
@@ -469,6 +537,8 @@ namespace BirthList.Infrastructure.Migrations
b.Navigation("Admins");
b.Navigation("ItemCategories");
b.Navigation("Items");
b.Navigation("Visits");
@@ -480,6 +550,11 @@ namespace BirthList.Infrastructure.Migrations
b.Navigation("Purchases");
});
modelBuilder.Entity("BirthList.Domain.Entities.RegistryItemCategory", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
@@ -9,6 +9,7 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
public DbSet<RegistryAdmin> RegistryAdmins => Set<RegistryAdmin>();
public DbSet<RegistryAdminInvite> RegistryAdminInvites => Set<RegistryAdminInvite>();
public DbSet<RegistrySettings> RegistrySettings => Set<RegistrySettings>();
public DbSet<RegistryItemCategory> RegistryItemCategories => Set<RegistryItemCategory>();
public DbSet<RegistryItem> RegistryItems => Set<RegistryItem>();
public DbSet<ItemPurchase> ItemPurchases => Set<ItemPurchase>();
public DbSet<ItemContribution> ItemContributions => Set<ItemContribution>();
@@ -65,6 +66,18 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RegistryItemCategory>(entity =>
{
entity.HasKey(x => x.Id);
entity.Property(x => x.Name).HasMaxLength(100);
entity.HasIndex(x => new { x.RegistryId, x.SortOrder });
entity.HasIndex(x => new { x.RegistryId, x.Name });
entity.HasOne(x => x.Registry)
.WithMany(x => x.ItemCategories)
.HasForeignKey(x => x.RegistryId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RegistryItem>(entity =>
{
entity.HasKey(x => x.Id);
@@ -75,10 +88,15 @@ public class RegistryDbContext(DbContextOptions<RegistryDbContext> options) : Db
entity.Property(x => x.PriceAmount).HasPrecision(18, 2);
entity.Property(x => x.ParticipationTargetAmount).HasPrecision(18, 2);
entity.Property(x => x.MoneyFulfilledAmount).HasPrecision(18, 2);
entity.HasIndex(x => new { x.CategoryId, x.SortOrder });
entity.HasOne(x => x.Registry)
.WithMany(x => x.Items)
.HasForeignKey(x => x.RegistryId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Category)
.WithMany(x => x.Items)
.HasForeignKey(x => x.CategoryId)
.OnDelete(DeleteBehavior.NoAction);
});
modelBuilder.Entity<ItemPurchase>(entity =>
+4
View File
@@ -30,4 +30,8 @@
<ProjectReference Include="..\BirthList.Domain\BirthList.Domain.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Components\Pages\Partials\" />
</ItemGroup>
</Project>
@@ -73,9 +73,11 @@
private async Task OnValidSubmitAsync()
{
if (Input.PhoneNumber != phoneNumber)
var normalizedPhone = string.IsNullOrWhiteSpace(Input.PhoneNumber) ? null : Input.PhoneNumber.Trim();
if (normalizedPhone != phoneNumber)
{
var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
var setPhoneResult = await UserManager.SetPhoneNumberAsync(user, normalizedPhone);
if (!setPhoneResult.Succeeded)
{
RedirectManager.RedirectToCurrentPageWithStatus("Error: Failed to set phone number.", HttpContext);
@@ -110,8 +112,28 @@
[Display(Name = "Address")]
public string? Address { get; set; }
[Phone]
[OptionalPhone(ErrorMessage = "The Phone number field is not a valid phone number.")]
[Display(Name = "Phone number")]
public string? PhoneNumber { get; set; }
}
private sealed class OptionalPhoneAttribute : ValidationAttribute
{
private static readonly PhoneAttribute PhoneValidator = new();
public override bool IsValid(object? value)
{
if (value is null)
{
return true;
}
if (value is string stringValue && string.IsNullOrWhiteSpace(stringValue))
{
return true;
}
return PhoneValidator.IsValid(value);
}
}
}
+1
View File
@@ -20,6 +20,7 @@
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
<script src="_content/Blazored.TextEditor/quill-blot-formatter.min.js"></script>
<script src="_content/Blazored.TextEditor/Blazored-BlazorQuill.js"></script>
<script src="js/drag-drop.js"></script>
<script src="_framework/blazor.web.js"></script>
</body>
@@ -9,7 +9,7 @@
</main>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
@L["MainLayout.UnhandledError"]
<a href="" class="reload">@L["MainLayout.Reload"]</a>
<a class="dismiss">🗙</a>
</div>
@@ -4,17 +4,17 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">BirthList</a>
<a class="navbar-brand" href="">@L["NavMenu.Brand"]</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<input type="checkbox" title="@L["NavMenu.NavigationMenu"]" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> @L["NavMenu.Home"]
</NavLink>
</div>
@@ -30,7 +30,7 @@
<AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
<button type="submit" class="nav-link">
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> @L["NavMenu.Logout"]
</button>
</form>
</div>
@@ -38,12 +38,12 @@
<NotAuthorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Register">
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> @L["NavMenu.Register"]
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> @L["NavMenu.Login"]
</NavLink>
</div>
</NotAuthorized>
@@ -1,48 +1,64 @@
@using System.Globalization
@using BirthList.Web.Features.Localization
@using BirthList.Web.Features.Registries
@using BirthList.Web.Services
@using Microsoft.Extensions.Hosting
@implements IDisposable
@inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject RegistryUserContext RegistryUserContext
@inject ProfileCompletionService ProfileCompletionService
@inject IHostEnvironment HostEnvironment
@if (ShowProfileCompletionPrompt)
{
<div class="profile-completion-banner">
<span>Please complete your profile (first name, last name, and address).</span>
<a href="/Account/Manage" class="profile-completion-link">Complete profile</a>
<span>@L["TopBar.ProfilePrompt"]</span>
<a href="/Account/Manage" class="profile-completion-link">@L["TopBar.CompleteProfile"]</a>
</div>
}
<nav class="top-bar">
<div class="top-bar-left">
<a href="/" class="top-bar-brand" title="Gift List">
<a href="/" class="top-bar-brand" title="@L["TopBar.Brand"]">
<i class="bi bi-gift" aria-hidden="true"></i>
<span class="brand-text">Gift List</span>
<span class="brand-text">@L["TopBar.Brand"]</span>
</a>
</div>
<div class="top-bar-right">
<form action="/set-language" method="post" class="d-inline-flex align-items-center me-2">
<AntiforgeryToken />
<input type="hidden" name="returnUrl" value="@currentAbsolutePath" />
<label for="language-picker" class="visually-hidden">@L["TopBar.Language"]</label>
<select id="language-picker" name="culture" class="form-select form-select-sm" onchange="this.form.submit()">
@foreach (var option in LanguageOptions)
{
<option value="@option.Culture" selected="@(string.Equals(option.Culture, CurrentCulture, StringComparison.OrdinalIgnoreCase))">@option.DisplayName</option>
}
</select>
</form>
<AuthorizeView>
<Authorized>
<div class="user-info">
<i class="bi bi-person-circle" aria-hidden="true"></i>
<span class="user-email">@context.User.Identity?.Name</span>
</div>
<a href="Account/Manage" class="top-bar-link" title="Account settings">
<a href="Account/Manage" class="top-bar-link" title="@L["TopBar.AccountSettings"]">
<i class="bi bi-gear" aria-hidden="true"></i>
</a>
<form action="Account/Logout" method="post" class="logout-form">
<AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
<button type="submit" class="top-bar-link logout-btn" title="Sign out">
<button type="submit" class="top-bar-link logout-btn" title="@L["TopBar.SignOut"]">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</button>
</form>
</Authorized>
<NotAuthorized>
<a href="Account/Login" class="top-bar-link" title="Sign in">
<a href="Account/Login" class="top-bar-link" title="@L["TopBar.SignIn"]">
<i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</a>
</NotAuthorized>
@@ -52,11 +68,33 @@
@code {
private string currentUrl = "";
private string currentAbsolutePath = "/";
private bool ShowProfileCompletionPrompt { get; set; }
private string CurrentCulture { get; set; } = "en";
private IReadOnlyList<LanguageOption> LanguageOptions => HostEnvironment.IsDevelopment()
? DevelopmentLanguageOptions
: ProductionLanguageOptions;
private static readonly IReadOnlyList<LanguageOption> ProductionLanguageOptions =
[
new("en", "English"),
new("nl-NL", "Nederlands (NL)"),
new("nl-BE", "Nederlands (BE)"),
new("fr-FR", "Français")
];
private static readonly IReadOnlyList<LanguageOption> DevelopmentLanguageOptions =
[
..ProductionLanguageOptions,
new("qps-Ploc", "Pseudo")
];
protected override async Task OnInitializedAsync()
{
currentUrl = GetRelativePath(NavigationManager.Uri);
currentAbsolutePath = BuildAbsolutePath(NavigationManager.Uri);
CurrentCulture = CultureInfo.CurrentUICulture.Name;
NavigationManager.LocationChanged += OnLocationChanged;
await RefreshProfileCompletionPromptAsync().ConfigureAwait(false);
@@ -65,6 +103,8 @@
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = GetRelativePath(e.Location);
currentAbsolutePath = BuildAbsolutePath(e.Location);
CurrentCulture = CultureInfo.CurrentUICulture.Name;
await RefreshProfileCompletionPromptAsync().ConfigureAwait(false);
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
}
@@ -99,6 +139,19 @@
return relativePath;
}
private string BuildAbsolutePath(string absoluteUri)
{
if (!Uri.TryCreate(absoluteUri, UriKind.Absolute, out var uri))
{
return "/";
}
var pathAndQuery = uri.PathAndQuery;
return string.IsNullOrWhiteSpace(pathAndQuery) ? "/" : pathAndQuery;
}
private sealed record LanguageOption(string Culture, string DisplayName);
void IDisposable.Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
+9 -10
View File
@@ -1,27 +1,26 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<PageTitle>@L["Error.PageTitle"]</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<h1 class="text-danger">@L["Error.Title"]</h1>
<h2 class="text-danger">@L["Error.Subtitle"]</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
<strong>@L["Error.RequestId"]</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<h3>@L["Error.DevMode"]</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
@L["Error.DevHint1"]
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
<strong>@L["Error.DevHint2"]</strong>
@L["Error.DevHint3"]
@L["Error.DevHint4"]
</p>
@code{
+29 -26
View File
@@ -1,24 +1,25 @@
@page "/"
@rendermode InteractiveServer
@using BirthList.Web.Features.Registries
<PageTitle>Birth Registry</PageTitle>
<PageTitle>@L["Home.PageTitle"]</PageTitle>
<h1>Welcome to Gift List</h1>
<h1>@L["Home.Welcome"]</h1>
<AuthorizeView>
<Authorized Context="authState">
<div class="registry-sections">
<div class="mb-4">
<div class="section-header">
<h2>Registries you manage</h2>
<h2>@L["Home.ManagedRegistries"]</h2>
<button class="btn btn-primary btn-sm" @onclick="() => ShowCreateForm = !ShowCreateForm">
<span class="bi bi-plus"></span> Create new
<span class="bi bi-plus"></span> @L["Home.CreateNew"]
</button>
</div>
@if (MyRegistries.Count == 0)
{
<p class="text-muted">No registries yet.</p>
<p class="text-muted">@L["Home.NoRegistries"]</p>
}
else
{
@@ -28,8 +29,8 @@
<div class="registry-card">
<h3>@registry.Title</h3>
<div class="registry-actions">
<a href="/registry/@registry.PublicLinkCode" class="btn btn-outline-primary btn-sm">View</a>
<a href="/registry/@registry.Id/admin" class="btn btn-outline-secondary btn-sm">Manage</a>
<a href="/registry/@registry.PublicLinkCode" class="btn btn-outline-primary btn-sm">@L["Home.View"]</a>
<a href="/registry/@registry.Id/admin" class="btn btn-outline-secondary btn-sm">@L["Home.Manage"]</a>
</div>
</div>
}
@@ -38,10 +39,10 @@
</div>
<div class="mb-4">
<h2>Visited registries</h2>
<h2>@L["Home.VisitedRegistries"]</h2>
@if (VisitedRegistries.Count == 0)
{
<p class="text-muted">No visited registries yet.</p>
<p class="text-muted">@L["Home.NoVisitedRegistries"]</p>
}
else
{
@@ -51,7 +52,7 @@
<div class="registry-card">
<h3>@registry.Title</h3>
<div class="registry-actions">
<a href="/registry/@registry.PublicLinkCode" class="btn btn-outline-primary btn-sm">View</a>
<a href="/registry/@registry.PublicLinkCode" class="btn btn-outline-primary btn-sm">@L["Home.View"]</a>
</div>
</div>
}
@@ -65,30 +66,30 @@
<div class="create-registry-modal-overlay" @onclick="() => ShowCreateForm = false">
<div class="create-registry-modal" @onclick:stopPropagation="true">
<div class="modal-header">
<h3 class="modal-title">Create new registry</h3>
<h3 class="modal-title">@L["Home.CreateRegistryTitle"]</h3>
<button type="button" class="btn-close" @onclick="() => ShowCreateForm = false"></button>
</div>
<div class="modal-body">
<EditForm Model="Model" OnValidSubmit="CreateRegistryAsync" Context="formContext" FormName="create-registry-form">
<DataAnnotationsValidator />
<div class="mb-3">
<label class="form-label">Title</label>
<label class="form-label">@L["Home.Title"]</label>
<InputText class="form-control" @bind-Value="Model.Title" />
</div>
<div class="mb-3">
<label class="form-label">Type</label>
<label class="form-label">@L["Home.Type"]</label>
<InputSelect class="form-select" @bind-Value="Model.RegistryType">
<option value="@BirthList.Domain.Entities.RegistryType.Birth">Birth</option>
<option value="@BirthList.Domain.Entities.RegistryType.Wedding">Wedding</option>
<option value="@BirthList.Domain.Entities.RegistryType.Birthday">Birthday</option>
<option value="@BirthList.Domain.Entities.RegistryType.Birth">@L["Home.RegistryType.Birth"]</option>
<option value="@BirthList.Domain.Entities.RegistryType.Wedding">@L["Home.RegistryType.Wedding"]</option>
<option value="@BirthList.Domain.Entities.RegistryType.Birthday">@L["Home.RegistryType.Birthday"]</option>
</InputSelect>
</div>
<div class="mb-3">
<label class="form-label">Theme</label>
<label class="form-label">@L["Home.Theme"]</label>
<InputSelect class="form-select" @bind-Value="Model.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
<option value="default">@L["Home.Theme.Default"]</option>
<option value="soft">@L["Home.Theme.Soft"]</option>
<option value="modern">@L["Home.Theme.Modern"]</option>
</InputSelect>
</div>
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
@@ -96,8 +97,8 @@
<div class="alert alert-danger mb-3">@ErrorMessage</div>
}
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="() => ShowCreateForm = false">Cancel</button>
<button class="btn btn-primary" type="submit">Create</button>
<button type="button" class="btn btn-outline-secondary" @onclick="() => ShowCreateForm = false">@L["Common.Cancel"]</button>
<button class="btn btn-primary" type="submit">@L["Home.CreateNew"]</button>
</div>
</EditForm>
</div>
@@ -107,7 +108,9 @@
</Authorized>
<NotAuthorized>
<div class="alert alert-info mt-4">
<p>Please <a href="Account/Login">log in</a> to create and manage registries.</p>
<p>
@L["Home.LoginPromptPrefix"]<a href="Account/Login">@L["Home.LoginPromptLinkText"]</a>@L["Home.LoginPromptSuffix"]
</p>
</div>
</NotAuthorized>
</AuthorizeView>
@@ -119,7 +122,7 @@
protected IReadOnlyList<RegistrySummaryViewModel> MyRegistries { get; private set; } = [];
protected IReadOnlyList<RegistrySummaryViewModel> VisitedRegistries { get; private set; } = [];
protected string? ErrorMessage { get; private set; }
protected bool ShowCreateForm { get; private set; }
protected bool ShowCreateForm { get; set; }
[Inject] private RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
@@ -136,13 +139,13 @@
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
ErrorMessage = "You must be logged in to create a registry.";
ErrorMessage = L["Home.MustBeLoggedIn"];
return;
}
if (string.IsNullOrWhiteSpace(Model.Title))
{
ErrorMessage = "Title is required.";
ErrorMessage = L["Home.TitleRequired"];
return;
}
@@ -4,24 +4,24 @@
@using BirthList.Web.Features.Registries
@using BirthList.Web.Authorization
<PageTitle>Action Log - Registry Admin</PageTitle>
<PageTitle>@L["RegistryActionLog.PageTitle"]</PageTitle>
@if (!IsAuthorized)
{
<p>Access denied.</p>
<p>@L["Common.AccessDenied"]</p>
}
else
{
<h1>Registry Action Log</h1>
<p class="text-muted">This log shows all user actions on this registry: purchases, contributions, and other interactions.</p>
<h1>@L["RegistryActionLog.Title"]</h1>
<p class="text-muted">@L["RegistryActionLog.Description"]</p>
@if (ActionLogs is null)
{
<p>Loading...</p>
<p>@L["Common.Loading"]</p>
}
else if (ActionLogs.Count == 0)
{
<p class="text-muted">No actions recorded yet.</p>
<p class="text-muted">@L["RegistryActionLog.NoActions"]</p>
}
else
{
@@ -29,13 +29,13 @@ else
<table class="table table-striped">
<thead>
<tr>
<th>Date/Time</th>
<th>User</th>
<th>Action</th>
<th>Item</th>
<th>Quantity</th>
<th>Amount</th>
<th>Details</th>
<th>@L["RegistryActionLog.DateTime"]</th>
<th>@L["RegistryActionLog.User"]</th>
<th>@L["RegistryActionLog.Action"]</th>
<th>@L["RegistryActionLog.Item"]</th>
<th>@L["RegistryActionLog.Quantity"]</th>
<th>@L["RegistryActionLog.Amount"]</th>
<th>@L["RegistryActionLog.Details"]</th>
</tr>
</thead>
<tbody>
@@ -48,22 +48,28 @@ else
@switch (log.ActionType)
{
case "RegistryLinkOpened":
<span class="badge bg-info">Registry opened</span>
<span class="badge bg-info">@L["RegistryActionLog.Badge.RegistryOpened"]</span>
break;
case "ItemLinkOpened":
<span class="badge bg-info">Item link opened</span>
<span class="badge bg-info">@L["RegistryActionLog.Badge.ItemLinkOpened"]</span>
break;
case "MarkPurchased":
<span class="badge bg-success">Purchase marked</span>
<span class="badge bg-success">@L["RegistryActionLog.Badge.PurchaseMarked"]</span>
break;
case "UnmarkPurchased":
<span class="badge bg-warning">Purchase unmarked</span>
<span class="badge bg-warning">@L["RegistryActionLog.Badge.PurchaseUnmarked"]</span>
break;
case "MarkPartialPurchase":
<span class="badge bg-primary">Partial purchase</span>
<span class="badge bg-primary">@L["RegistryActionLog.Badge.PartialPurchase"]</span>
break;
case "LogContribution":
<span class="badge bg-secondary">Contribution logged</span>
<span class="badge bg-secondary">@L["RegistryActionLog.Badge.ContributionLogged"]</span>
break;
case "MetadataFetchSucceeded":
<span class="badge bg-success">@L["RegistryActionLog.Badge.MetadataFetchSucceeded"]</span>
break;
case "MetadataFetchFailed":
<span class="badge bg-danger">@L["RegistryActionLog.Badge.MetadataFetchFailed"]</span>
break;
default:
<span class="badge bg-dark">@log.ActionType</span>
@@ -3,20 +3,29 @@
@using Blazored.TextEditor
<PageTitle>Registry Admin</PageTitle>
<PageTitle>@L["RegistryAdmin.PageTitle"]</PageTitle>
@if (!IsAuthorized)
{
<p>Access denied.</p>
<p>@L["Common.AccessDenied"]</p>
}
else
{
<h1>Registry Admin</h1>
<h1>@L["RegistryAdmin.Title"]</h1>
@if (!string.IsNullOrWhiteSpace(PublicLinkCode))
{
<div class="mb-3">
<a href="/registry/@PublicLinkCode" class="btn btn-outline-primary">
<span class="bi bi-eye"></span> @L["RegistryAdmin.ViewPublicList"]
</a>
</div>
}
@if (!IsSmtpConfigured)
{
<div class="alert alert-warning" role="alert">
SMTP is not configured. Email features (identity emails and admin invite emails) are disabled. Configure the Smtp section in appsettings or user secrets.
@L["RegistryAdmin.SmtpNotConfigured"]
</div>
}
@@ -29,7 +38,7 @@ else
role="tab"
aria-controls="items-content"
aria-selected="@(ActiveTab == "items" ? "true" : "false")">
<span class="bi bi-box-seam"></span> Items
<span class="bi bi-box-seam"></span> @L["RegistryAdmin.Tab.Items"]
</button>
</li>
<li class="nav-item" role="presentation">
@@ -40,7 +49,7 @@ else
role="tab"
aria-controls="settings-content"
aria-selected="@(ActiveTab == "settings" ? "true" : "false")">
<span class="bi bi-gear"></span> Settings
<span class="bi bi-gear"></span> @L["RegistryAdmin.Tab.Settings"]
</button>
</li>
<li class="nav-item" role="presentation">
@@ -51,12 +60,23 @@ else
role="tab"
aria-controls="admins-content"
aria-selected="@(ActiveTab == "admins" ? "true" : "false")">
<span class="bi bi-people"></span> Administrators
<span class="bi bi-people"></span> @L["RegistryAdmin.Tab.Administrators"]
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(GetTabClass("addresses"))"
id="addresses-tab"
@onclick='() => SetActiveTab("addresses")'
type="button"
role="tab"
aria-controls="addresses-content"
aria-selected="@(ActiveTab == "addresses" ? "true" : "false")">
<span class="bi bi-house"></span> @L["RegistryAdmin.Tab.Addresses"]
</button>
</li>
<li class="nav-item" role="presentation">
<a href="/registry/@RegistryId/admin/action-log" class="nav-link">
<span class="bi bi-clock-history"></span> Action Log
<span class="bi bi-clock-history"></span> @L["RegistryAdmin.Tab.ActionLog"]
</a>
</li>
</ul>
@@ -65,175 +85,316 @@ else
<!-- Items Tab -->
<div class="tab-pane fade @(GetTabPaneClass("items"))" id="items-content" role="tabpanel" aria-labelledby="items-tab">
<section class="mb-4">
<h2>Add or edit item</h2>
<h2>@L["RegistryAdmin.AddOrEditItem"]</h2>
<EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Name</label>
<label class="form-label">@L["RegistryAdmin.Name"]</label>
<InputText class="form-control" @bind-Value="ItemModel.Name" />
</div>
<div class="col-md-6">
<label class="form-label">Product URL</label>
<label class="form-label">@L["RegistryAdmin.ProductUrl"]</label>
<div class="input-group">
<InputText class="form-control" @bind-Value="ItemModel.ProductUrl" />
<button type="button" class="btn btn-outline-secondary" @onclick="FetchMetadataAsync">Auto fetch</button>
<button type="button" class="btn btn-outline-secondary" @onclick="FetchMetadataAsync">@L["RegistryAdmin.AutoFetch"]</button>
</div>
</div>
<div class="col-md-6">
<label class="form-label">Picture URL</label>
<label class="form-label">@L["RegistryAdmin.PictureUrl"]</label>
<InputText class="form-control" @bind-Value="ItemModel.PictureUrl" />
</div>
<div class="col-md-6">
<label class="form-label">Description</label>
<label class="form-label">@L["RegistryAdmin.Description"]</label>
<InputTextArea class="form-control" @bind-Value="ItemModel.Description" rows="2" />
</div>
<div class="col-md-2">
<label class="form-label">Price</label>
<label class="form-label">@L["RegistryAdmin.Price"]</label>
<InputNumber class="form-control" @bind-Value="ItemModel.PriceAmount" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<label class="form-label">@L["RegistryAdmin.CurrencySymbol"]</label>
<InputText class="form-control" @bind-Value="ItemModel.CurrencyCode" />
</div>
<div class="col-md-2">
<label class="form-label">Desired qty</label>
<label class="form-label">@L["RegistryAdmin.DesiredQty"]</label>
<InputNumber class="form-control" @bind-Value="ItemModel.DesiredQuantity" />
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" />
<label>Participation</label>
<label>@L["RegistryAdmin.Participation"]</label>
</div>
<div class="col-md-3">
<label class="form-label">Participation target</label>
<label class="form-label">@L["RegistryAdmin.ParticipationTarget"]</label>
<InputNumber class="form-control" @bind-Value="ItemModel.ParticipationTargetAmount" />
</div>
<div class="col-md-3">
<label class="form-label">Second hand preference</label>
<InputSelect class="form-select" @bind-Value="ItemModel.PreferSecondHand">
<option value="">Second hand optional</option>
<option value="true">Prefer second hand</option>
<option value="false">New only</option>
<label class="form-label">@L["RegistryAdmin.SecondHandPreference"]</label>
<InputSelect class="form-select" @bind-Value="PreferSecondHandString">
<option value="">@L["RegistryAdmin.SecondHandOptional"]</option>
<option value="true">@L["RegistryAdmin.PreferSecondHand"]</option>
<option value="false">@L["RegistryAdmin.NewOnly"]</option>
</InputSelect>
</div>
<div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.IsGiven" />
<label>Given</label>
<label>@L["RegistryAdmin.Given"]</label>
</div>
<div class="col-md-6">
<label class="form-label">@L["RegistryAdmin.Category"]</label>
<InputSelect class="form-select" @bind-Value="ItemModel.CategoryId">
@foreach (var category in ItemCategories)
{
<option value="@category.Id">@category.Name</option>
}
</InputSelect>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Save item</button>
<button class="btn btn-primary mt-3" type="submit">@L["RegistryAdmin.SaveItem"]</button>
</EditForm>
</section>
<section>
<h2>Items</h2>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>Desired Qty</th>
<th>Participation</th>
<th>Purchased by / Contributed by</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Items)
{
<tr>
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</td>
<td>
@if (item.Purchasers.Count > 0)
{
<div class="mb-1">
<small><strong>Purchased:</strong></small>
<div class="small">
@foreach (var purchaser in item.Purchasers)
{
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
}
</div>
</div>
}
@if (item.Contributors.Count > 0)
{
<div>
<small><strong>Contributed:</strong></small>
<div class="small">
@foreach (var contributor in item.Contributors)
{
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
}
</div>
</div>
}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3">
<h2 class="mb-0">@L["RegistryAdmin.CategoriesAndItems"]</h2>
<div class="d-flex gap-2">
<InputText class="form-control" @bind-Value="NewCategoryName" placeholder="@L["RegistryAdmin.NewCategory"]" />
<button type="button" class="btn btn-outline-primary" @onclick="AddCategoryAsync">@L["RegistryAdmin.AddCategory"]</button>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(ItemManagementMessage))
{
<div class="alert alert-warning mt-3 mb-0" role="alert">@ItemManagementMessage</div>
}
<p class="text-muted mt-3 mb-2">@L["RegistryAdmin.DragHint"]</p>
<div class="category-groups mt-3">
@foreach (var category in ItemCategories)
{
<section id="category-card-@category.Id"
class="card category-group @GetCategoryDropClass(category.Id)"
draggable="true"
@ondragstart="() => OnCategoryDragStart(category.Id)"
@ondragend="OnCategoryDragEnd"
@ondragover="@(args => OnCategoryDragOverAsync(category.Id, args))"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnCategoryDropAsync(category.Id)">
<div class="card-header d-flex justify-content-between align-items-center">
@if (EditingCategoryId == category.Id)
{
<div class="d-flex gap-2 align-items-center w-100">
<InputText class="form-control form-control-sm" @bind-Value="CategoryRenameName" />
<button type="button" class="btn btn-sm btn-primary" @onclick="() => SaveCategoryRenameAsync(category.Id)">@L["Common.Save"]</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelCategoryRename">@L["Common.Cancel"]</button>
</div>
}
else
{
<strong>@category.Name</strong>
<div class="d-flex gap-2">
<button type="button"
class="btn btn-sm btn-outline-secondary"
@onclick="() => StartCategoryRename(category)">
@L["RegistryAdmin.Rename"]
</button>
<button type="button"
class="btn btn-sm btn-outline-danger"
@onclick="() => RemoveCategoryAsync(category.Id)"
disabled="@(ItemCategories.Count <= 1)">
@L["Common.Remove"]
</button>
</div>
}
</div>
<div class="card-body p-0 @GetCategoryItemsDropClass(category.Id)"
@ondragover="() => OnCategoryItemsDragOver(category.Id)"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnCategoryItemsDropAsync(category.Id)">
@if (category.Items.Count == 0)
{
<div class="p-3 text-muted">@L["RegistryAdmin.DropItemsHere"]</div>
}
else
{
<table class="table table-striped mb-0">
<thead>
<tr>
<th>@L["RegistryAdmin.Header.Name"]</th>
<th>@L["RegistryAdmin.Header.DesiredQty"]</th>
<th>@L["RegistryAdmin.Header.Condition"]</th>
<th>@L["RegistryAdmin.Header.Participation"]</th>
<th>@L["RegistryAdmin.Header.PurchasedContributedBy"]</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in category.Items)
{
<tr id="item-row-@item.Id"
class="@GetItemDropClass(category.Id, item.Id!.Value)"
draggable="true"
@ondragstart:stopPropagation="true"
@ondragstart="() => OnItemDragStart(item.Id!.Value)"
@ondragend="OnItemDragEnd"
@ondragover:stopPropagation="true"
@ondragover="@(args => OnItemDragOverAsync(category.Id, item.Id!.Value, args))"
@ondragover:preventDefault="true"
@ondrop:stopPropagation="true"
@ondrop:preventDefault="true"
@ondrop="() => OnItemDropAsync(category.Id, item.Id!.Value)">
<td>@item.Name</td>
<td>@item.DesiredQuantity</td>
<td>
@if (item.PreferSecondHand == true)
{
<span class="badge bg-success">@L["RegistryAdmin.PreferSecondHand"]</span>
}
else if (item.PreferSecondHand == false)
{
<span class="badge bg-primary">@L["RegistryAdmin.NewOnly"]</span>
}
else
{
<span class="badge bg-secondary">@L["RegistryAdmin.SecondHandOptional"]</span>
}
</td>
<td>@(item.ParticipationAllowed ? L["Common.Yes"].Value : L["Common.No"].Value)</td>
<td>
@if (item.Purchasers.Count > 0)
{
<div class="mb-1">
<small><strong>@L["RegistryAdmin.Purchased"]</strong></small>
<div class="small">
@foreach (var purchaser in item.Purchasers)
{
<div>@purchaser.DisplayName (@purchaser.Quantity)</div>
}
</div>
</div>
}
@if (item.Contributors.Count > 0)
{
<div>
<small><strong>@L["RegistryAdmin.Contributed"]</strong></small>
<div class="small">
@foreach (var contributor in item.Contributors)
{
<div>@contributor.DisplayName (@contributor.Amount.ToString("0.00"))</div>
}
</div>
</div>
}
</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">@L["Common.Edit"]</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">@L["Common.Remove"]</button>
</td>
</tr>
}
</tbody>
</table>
}
</div>
</section>
}
<div class="category-list-end-drop-zone @GetCategoryListEndDropClass()"
@ondragover="OnCategoryListEndDragOver"
@ondragover:preventDefault="true"
@ondrop:preventDefault="true"
@ondrop="OnCategoryListEndDropAsync">
</div>
</div>
</section>
</div>
<!-- Settings Tab -->
<div class="tab-pane fade @(GetTabPaneClass("settings"))" id="settings-content" role="tabpanel" aria-labelledby="settings-tab">
<section class="mb-4">
<h2>Registry Settings</h2>
<h2>@L["RegistryAdmin.RegistrySettings"]</h2>
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Baby name</label>
<label class="form-label">@L["RegistryAdmin.BabyName"]</label>
<InputText class="form-control" @bind-Value="SettingsModel.BabyName" />
</div>
<div class="col-md-3">
<label class="form-label">Birth date</label>
<label class="form-label">@L["RegistryAdmin.BirthDate"]</label>
<InputDate class="form-control" @bind-Value="SettingsModel.BirthDate" />
</div>
<div class="col-md-2">
<label class="form-label">Currency</label>
<label class="form-label">@L["RegistryAdmin.CurrencySymbol"]</label>
<InputText class="form-control" @bind-Value="SettingsModel.CurrencyCode" />
</div>
<div class="col-md-3">
<label class="form-label">Theme</label>
<label class="form-label">@L["RegistryAdmin.Theme"]</label>
<InputSelect class="form-select" @bind-Value="SettingsModel.ThemeKey">
<option value="default">Default</option>
<option value="soft">Soft</option>
<option value="modern">Modern</option>
<option value="default">@L["RegistryAdmin.Theme.Default"]</option>
<option value="soft">@L["RegistryAdmin.Theme.Soft"]</option>
<option value="modern">@L["RegistryAdmin.Theme.Modern"]</option>
</InputSelect>
</div>
</div>
<div class="mt-3">
<label class="form-label">Shipping address</label>
<label class="form-label">@L["RegistryAdmin.ShippingAddress"]</label>
<InputTextArea class="form-control" @bind-Value="SettingsModel.ShippingAddress" rows="4" />
<small class="form-text text-muted">Line breaks will be preserved</small>
<small class="form-text text-muted">@L["RegistryAdmin.LineBreaksPreserved"]</small>
</div>
<div class="mt-3">
<label class="form-label">Top content</label>
<div class="editor-wrapper">
<BlazoredTextEditor @ref="TextEditor" Placeholder="Welcome text" Theme="snow" ToolbarContent="@ToolbarContent" />
<label class="form-label">@L["RegistryAdmin.HeroImage"]</label>
@if (!string.IsNullOrWhiteSpace(SettingsModel.HeroImagePath) || !string.IsNullOrWhiteSpace(SettingsModel.HeroImageBase64))
{
<div class="mb-2">
<label class="form-label small">@L["RegistryAdmin.CurrentHeroImage"]</label>
<div>
<img src="@(string.IsNullOrWhiteSpace(SettingsModel.HeroImagePath) ? $"data:{SettingsModel.HeroImageContentType};base64,{SettingsModel.HeroImageBase64}" : SettingsModel.HeroImagePath)" alt="Hero" class="img-thumbnail" style="max-height: 200px;" />
</div>
<button type="button" class="btn btn-sm btn-outline-danger mt-2" @onclick="RemoveHeroImageAsync">@L["RegistryAdmin.RemoveHeroImage"]</button>
</div>
}
<div>
<label class="form-label small">@L["RegistryAdmin.UploadImage"]</label>
<InputFile class="form-control" OnChange="OnHeroImageSelectedAsync" accept="image/*" />
@if (!string.IsNullOrWhiteSpace(HeroImageUploadMessage))
{
<small class="text-muted">@HeroImageUploadMessage</small>
}
</div>
<div class="form-check mt-2">
<InputCheckbox class="form-check-input" @bind-Value="SettingsModel.HideHeaderName" id="hideHeaderName" />
<label class="form-check-label" for="hideHeaderName">
@L["RegistryAdmin.HideHeaderName"]
</label>
</div>
</div>
<div class="mt-3">
<h3>Bank Account Settings</h3>
<label class="form-label">@L["RegistryAdmin.TopContent"]</label>
<div class="editor-wrapper">
<BlazoredTextEditor @ref="TextEditor" Placeholder="@L["RegistryAdmin.WelcomeText"]" Theme="snow" ToolbarContent="@ToolbarContent" />
</div>
</div>
<div class="mt-3">
<h3>@L["RegistryAdmin.BankAccountSettings"]</h3>
<div class="row g-3 mt-2">
<div class="col-md-4">
<label class="form-label">Bank account name</label>
<label class="form-label">@L["RegistryAdmin.BankAccountName"]</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountDisplayName" />
</div>
<div class="col-md-4">
<label class="form-label">IBAN</label>
<label class="form-label">@L["RegistryPublic.IBAN"]</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountIban" />
</div>
<div class="col-md-4">
<label class="form-label">BIC</label>
<label class="form-label">@L["RegistryPublic.BIC"]</label>
<InputText class="form-control" @bind-Value="SettingsModel.BankAccountBic" />
</div>
</div>
@@ -241,31 +402,31 @@ else
<div class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="SettingsModel.ShowBankAccountName" id="showBankName" />
<label class="form-check-label" for="showBankName">
Display bank account name
@L["RegistryAdmin.DisplayBankAccountName"]
</label>
</div>
</div>
</div>
<div class="mt-3">
<h3>Contribution Payment Options</h3>
<h3>@L["RegistryAdmin.ContributionPaymentOptions"]</h3>
<div class="row g-3 mt-2">
<div class="col-md-12">
<label class="form-label">Single QR code URL</label>
<label class="form-label">@L["RegistryAdmin.SingleQrCodeUrl"]</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>
<small class="form-text text-muted">@L["RegistryAdmin.SingleQrHelp"]</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>
<h4 class="mb-0">@L["RegistryAdmin.AmountSpecificQrCodes"]</h4>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="AddContributionAmountQrCode">@L["RegistryAdmin.AddQrAmount"]</button>
</div>
@if (SettingsModel.ContributionAmountQrCodes.Count == 0)
{
<p class="text-muted mt-2 mb-0">No amount-specific QR codes configured.</p>
<p class="text-muted mt-2 mb-0">@L["RegistryAdmin.NoAmountSpecificQrCodes"]</p>
}
else
{
@@ -274,15 +435,15 @@ else
{
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label">Amount</label>
<label class="form-label">@L["Common.Amount"]</label>
<InputNumber class="form-control" @bind-Value="amountQr.Amount" />
</div>
<div class="col-md-7">
<label class="form-label">QR code URL</label>
<label class="form-label">@L["RegistryAdmin.QrCodeUrl"]</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>
<button type="button" class="btn btn-outline-danger w-100" @onclick="() => RemoveContributionAmountQrCode(amountQr)">@L["Common.Remove"]</button>
</div>
</div>
}
@@ -291,25 +452,58 @@ else
</div>
</div>
<button class="btn btn-primary mt-4" type="submit">Save settings</button>
<button class="btn btn-primary mt-4" type="submit">@L["RegistryAdmin.SaveSettings"]</button>
</EditForm>
</section>
</div>
<!-- Admins Tab -->
<div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab">
<!-- Addresses Tab -->
<div class="tab-pane fade @(GetTabPaneClass("addresses"))" id="addresses-content" role="tabpanel" aria-labelledby="addresses-tab">
<section class="mb-4">
<h2>Current administrators</h2>
@if (Admins.Count == 0)
<h2>@L["RegistryAdmin.UserAddresses"]</h2>
@if (AccessibleUserAddresses.Count == 0)
{
<p class="text-muted">No admins assigned yet.</p>
<p class="text-muted">@L["RegistryAdmin.NoUsersFound"]</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>Email / Name</th>
<th>@L["RegistryAdmin.Name"]</th>
<th>@L["RegistryAdmin.Email"]</th>
<th>@L["RegistryAdmin.Address"]</th>
</tr>
</thead>
<tbody>
@foreach (var user in AccessibleUserAddresses)
{
<tr>
<td>@user.DisplayName</td>
<td>@(string.IsNullOrWhiteSpace(user.Email) ? "-" : user.Email)</td>
<td>@(string.IsNullOrWhiteSpace(user.Address) ? "-" : user.Address)</td>
</tr>
}
</tbody>
</table>
}
</section>
</div>
<!-- Admins Tab -->
<div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab">
<section class="mb-4">
<h2>@L["RegistryAdmin.CurrentAdministrators"]</h2>
@if (Admins.Count == 0)
{
<p class="text-muted">@L["RegistryAdmin.NoAdmins"]</p>
}
else
{
<table class="table table-striped">
<thead>
<tr>
<th>@L["RegistryAdmin.EmailOrName"]</th>
<th></th>
</tr>
</thead>
@@ -319,7 +513,7 @@ else
<tr>
<td>@admin.DisplayName</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAdminAsync(admin.UserId)">Remove</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteAdminAsync(admin.UserId)">@L["Common.Remove"]</button>
</td>
</tr>
}
@@ -329,19 +523,19 @@ else
</section>
<section>
<h2>Invite administrator</h2>
<h2>@L["RegistryAdmin.InviteAdministrator"]</h2>
<div class="row g-2">
<div class="col-md-8">
<InputText class="form-control" @bind-Value="InviteEmail" placeholder="optional email" />
<InputText class="form-control" @bind-Value="InviteEmail" placeholder="@L["RegistryAdmin.OptionalEmail"]" />
</div>
<div class="col-md-4">
<button class="btn btn-outline-primary w-100" @onclick="CreateInviteAsync">Create invite</button>
<button class="btn btn-outline-primary w-100" @onclick="CreateInviteAsync">@L["RegistryAdmin.CreateInvite"]</button>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(InviteLink))
{
<p class="mt-3">
<strong>Invite link:</strong>
<strong>@L["RegistryAdmin.InviteLink"]</strong>
<br />
<a href="@InviteLink" target="_blank">@InviteLink</a>
</p>
@@ -1,9 +1,13 @@
using BirthList.Domain.Entities;
using BirthList.Web.Authorization;
using BirthList.Web.Features.Registries;
using BirthList.Web.Services;
using Blazored.TextEditor;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
namespace BirthList.Web.Components.Pages;
@@ -20,27 +24,62 @@ public partial class RegistryAdmin : ComponentBase
[Inject] private NavigationManager NavigationManager { get; set; } = null!;
[Inject] private SmtpEmailSender EmailSender { get; set; } = null!;
[Inject] private SmtpConfigurationStatusService SmtpConfigurationStatusService { get; set; } = null!;
[Inject] private IWebHostEnvironment WebHostEnvironment { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
protected RegistrySettingsEditModel SettingsModel { get; } = new();
protected RegistryItemEditModel ItemModel { get; private set; } = new();
protected string PreferSecondHandString
{
get => ItemModel.PreferSecondHand switch
{
null => "",
true => "true",
false => "false"
};
set => ItemModel.PreferSecondHand = value switch
{
"true" => true,
"false" => false,
_ => null
};
}
protected IReadOnlyList<RegistryItemEditModel> Items { get; private set; } = [];
protected IReadOnlyList<RegistryItemCategoryEditModel> ItemCategories { get; private set; } = [];
protected string? NewCategoryName { get; set; }
protected string? CategoryRenameName { get; set; }
protected Guid? EditingCategoryId { get; set; }
protected string? ItemManagementMessage { get; private set; }
protected IReadOnlyList<RegistryAdminDisplayModel> Admins { get; private set; } = [];
protected IReadOnlyList<RegistryAccessibleUserAddressViewModel> AccessibleUserAddresses { get; private set; } = [];
protected bool IsAuthorized { get; private set; }
protected bool IsSmtpConfigured { get; private set; }
protected string? PublicLinkCode { get; private set; }
protected string? InviteEmail { get; set; }
protected string? InviteLink { get; private set; }
protected BlazoredTextEditor? TextEditor { get; set; }
protected string ActiveTab { get; set; } = "items";
protected string? HeroImageUploadMessage { get; set; }
protected RenderFragment ToolbarContent => builder =>
{
builder.AddMarkupContent(0, "<span class='ql-formats'><select class='ql-header'><option selected></option><option value='1'></option><option value='2'></option></select></span>");
builder.AddMarkupContent(1, "<span class='ql-formats'><button class='ql-bold'></button><button class='ql-italic'></button><button class='ql-underline'></button></span>");
builder.AddMarkupContent(2, "<span class='ql-formats'><button class='ql-list' value='ordered'></button><button class='ql-list' value='bullet'></button></span>");
builder.AddMarkupContent(3, "<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>");
builder.AddMarkupContent(0, @"<span class='ql-formats'><select class='ql-size'><option value='small'></option><option selected></option><option value='large'></option><option value='huge'></option></select><select class='ql-header'><option selected></option><option value='1'></option><option value='2'></option><option value='3'></option><option value='4'></option><option value='5'></option></select></span>");
builder.AddMarkupContent(1, @"<span class='ql-formats'><button class='ql-bold'></button><button class='ql-italic'></button><button class='ql-underline'></button><button class='ql-strike'></button></span>");
builder.AddMarkupContent(2, @"<span class='ql-formats'><select class=""ql-color""></select><select class=""ql-background""></select></span>");
builder.AddMarkupContent(3, @"<span class='ql-formats'><button class='ql-list' value='ordered'></button><button class='ql-list' value='bullet'></button></span>");
builder.AddMarkupContent(4, @"<span class='ql-formats'><button class='ql-link'></button><button class='ql-clean'></button></span>");
};
private bool _pendingEditorLoad;
private string? _lastLoadedHeaderContentHtml;
private Guid? _draggedItemId;
private Guid? _draggedCategoryId;
private Guid? _itemDropCategoryId;
private Guid? _itemDropTargetItemId;
private bool _itemDropAfterTarget;
private Guid? _categoryDropTargetId;
private bool _categoryDropAfterTarget;
private bool _categoryDropAtEnd;
private bool _dropOperationInProgress;
protected override async Task OnParametersSetAsync()
{
@@ -122,13 +161,22 @@ public partial class RegistryAdmin : ComponentBase
protected async Task SaveItemAsync()
{
ItemManagementMessage = null;
if (!ItemModel.CategoryId.HasValue && ItemCategories.Count > 0)
{
ItemModel.CategoryId = ItemCategories[0].Id;
}
await RegistryService.UpsertRegistryItemAsync(RegistryId, ItemModel, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
ItemModel = new RegistryItemEditModel
{
CurrencyCode = SettingsModel.CurrencyCode,
DesiredQuantity = 1
DesiredQuantity = 1,
CategoryId = ItemCategories.FirstOrDefault()?.Id
};
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
protected async Task DeleteItemAsync(Guid? itemId)
@@ -138,15 +186,19 @@ public partial class RegistryAdmin : ComponentBase
return;
}
ItemManagementMessage = null;
await RegistryService.DeleteRegistryItemAsync(RegistryId, itemId.Value, CancellationToken.None).ConfigureAwait(false);
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
}
protected void EditItem(RegistryItemEditModel model)
{
ItemManagementMessage = null;
ItemModel = new RegistryItemEditModel
{
Id = model.Id,
CategoryId = model.CategoryId,
Name = model.Name,
PictureUrl = model.PictureUrl,
ProductUrl = model.ProductUrl,
@@ -161,6 +213,391 @@ public partial class RegistryAdmin : ComponentBase
};
}
protected async Task AddCategoryAsync()
{
ItemManagementMessage = null;
if (string.IsNullOrWhiteSpace(NewCategoryName))
{
ItemManagementMessage = "Category name is required.";
return;
}
try
{
await RegistryService.AddRegistryItemCategoryAsync(RegistryId, NewCategoryName, CancellationToken.None).ConfigureAwait(false);
NewCategoryName = null;
await ReloadItemManagementAsync().ConfigureAwait(false);
ItemModel.CategoryId ??= ItemCategories.LastOrDefault()?.Id;
}
catch (InvalidOperationException ex)
{
ItemManagementMessage = ex.Message;
}
}
protected void StartCategoryRename(RegistryItemCategoryEditModel category)
{
ArgumentNullException.ThrowIfNull(category);
ItemManagementMessage = null;
EditingCategoryId = category.Id;
CategoryRenameName = category.Name;
}
protected void CancelCategoryRename()
{
EditingCategoryId = null;
CategoryRenameName = null;
}
protected async Task SaveCategoryRenameAsync(Guid categoryId)
{
ItemManagementMessage = null;
if (string.IsNullOrWhiteSpace(CategoryRenameName))
{
ItemManagementMessage = "Category name is required.";
return;
}
try
{
await RegistryService.RenameRegistryItemCategoryAsync(RegistryId, categoryId, CategoryRenameName, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
EditingCategoryId = null;
CategoryRenameName = null;
}
catch (InvalidOperationException ex)
{
ItemManagementMessage = ex.Message;
}
}
protected async Task RemoveCategoryAsync(Guid categoryId)
{
ItemManagementMessage = null;
if (ItemCategories.Count <= 1)
{
ItemManagementMessage = "At least one category is required.";
return;
}
try
{
await RegistryService.RemoveRegistryItemCategoryAsync(RegistryId, categoryId, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
if (ItemModel.CategoryId == categoryId)
{
ItemModel.CategoryId = ItemCategories.FirstOrDefault()?.Id;
}
}
catch (InvalidOperationException ex)
{
ItemManagementMessage = ex.Message;
}
}
protected void OnItemDragStart(Guid itemId)
{
_draggedItemId = itemId;
_draggedCategoryId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
protected void OnItemDragEnd()
{
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
}
protected void OnCategoryDragStart(Guid categoryId)
{
_draggedCategoryId = categoryId;
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
protected void OnCategoryDragEnd()
{
_draggedCategoryId = null;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
protected static void OnDragOver(DragEventArgs _)
{
}
protected async Task OnItemDragOverAsync(Guid categoryId, Guid targetItemId, DragEventArgs args)
{
if (!_draggedItemId.HasValue)
{
return;
}
_itemDropCategoryId = categoryId;
_itemDropTargetItemId = targetItemId;
var isBottomHalf = await IsPointerInBottomHalfAsync($"item-row-{targetItemId}", args.ClientY).ConfigureAwait(false);
_itemDropAfterTarget = isBottomHalf;
await InvokeAsync(StateHasChanged);
}
protected void OnCategoryItemsDragOver(Guid categoryId)
{
if (!_draggedItemId.HasValue)
{
return;
}
var category = ItemCategories.FirstOrDefault(x => x.Id == categoryId);
if (category is null || category.Items.Count > 0)
{
return;
}
_itemDropCategoryId = categoryId;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
}
protected async Task OnCategoryDragOverAsync(Guid categoryId, DragEventArgs args)
{
if (!_draggedCategoryId.HasValue)
{
return;
}
_categoryDropTargetId = categoryId;
_categoryDropAfterTarget = await IsPointerInBottomHalfAsync($"category-card-{categoryId}", args.ClientY).ConfigureAwait(false);
_categoryDropAtEnd = false;
await InvokeAsync(StateHasChanged);
}
protected void OnCategoryListEndDragOver()
{
if (!_draggedCategoryId.HasValue)
{
return;
}
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = true;
}
protected string GetItemDropClass(Guid categoryId, Guid itemId)
{
if (_itemDropCategoryId != categoryId || _itemDropTargetItemId != itemId)
{
return string.Empty;
}
return _itemDropAfterTarget ? "drop-target-item-after" : "drop-target-item-before";
}
protected string GetCategoryItemsDropClass(Guid categoryId)
{
return _itemDropCategoryId == categoryId && !_itemDropTargetItemId.HasValue
? "drop-target-item-end"
: string.Empty;
}
protected string GetCategoryDropClass(Guid categoryId)
{
if (_categoryDropTargetId != categoryId)
{
return string.Empty;
}
return _categoryDropAfterTarget ? "drop-target-category-after" : "drop-target-category-before";
}
protected string GetCategoryListEndDropClass()
{
return _categoryDropAtEnd ? "drop-target-category-list-end" : string.Empty;
}
protected async Task OnItemDropAsync(Guid categoryId, Guid targetItemId)
{
if (!_draggedItemId.HasValue || _dropOperationInProgress)
{
return;
}
_dropOperationInProgress = true;
try
{
var draggedItem = Items.FirstOrDefault(x => x.Id == _draggedItemId.Value);
var targetCategory = ItemCategories.FirstOrDefault(x => x.Id == categoryId);
if (draggedItem is null || targetCategory is null)
{
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
return;
}
var targetItems = targetCategory.Items.Where(x => x.Id.HasValue).ToList();
var targetIndex = targetItems.FindIndex(x => x.Id == targetItemId);
if (targetIndex < 0)
{
targetIndex = targetItems.Count;
}
var placeAfter = _itemDropTargetItemId == targetItemId && _itemDropAfterTarget;
if (placeAfter)
{
targetIndex++;
}
if (draggedItem.CategoryId == categoryId)
{
var currentIndex = targetItems.FindIndex(x => x.Id == _draggedItemId.Value);
if (currentIndex >= 0 && currentIndex < targetIndex)
{
targetIndex--;
}
}
await RegistryService.MoveRegistryItemAsync(RegistryId, _draggedItemId.Value, categoryId, targetIndex, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
}
finally
{
_dropOperationInProgress = false;
}
}
protected async Task OnCategoryItemsDropAsync(Guid categoryId)
{
if (!_draggedItemId.HasValue || _dropOperationInProgress)
{
return;
}
_dropOperationInProgress = true;
try
{
var targetCategory = ItemCategories.FirstOrDefault(x => x.Id == categoryId);
if (targetCategory is null)
{
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
return;
}
var targetIndex = targetCategory.Items.Count;
await RegistryService.MoveRegistryItemAsync(RegistryId, _draggedItemId.Value, categoryId, targetIndex, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
_draggedItemId = null;
_itemDropCategoryId = null;
_itemDropTargetItemId = null;
_itemDropAfterTarget = false;
}
finally
{
_dropOperationInProgress = false;
}
}
protected async Task OnCategoryDropAsync(Guid targetCategoryId)
{
if (!_draggedCategoryId.HasValue || _draggedCategoryId == targetCategoryId || _dropOperationInProgress)
{
return;
}
_dropOperationInProgress = true;
try
{
var targetIndex = ItemCategories.ToList().FindIndex(x => x.Id == targetCategoryId);
if (targetIndex < 0)
{
_draggedCategoryId = null;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
return;
}
var placeAfter = _categoryDropTargetId == targetCategoryId && _categoryDropAfterTarget;
if (placeAfter)
{
targetIndex++;
}
var sourceIndex = ItemCategories.ToList().FindIndex(x => x.Id == _draggedCategoryId.Value);
if (sourceIndex >= 0 && sourceIndex < targetIndex)
{
targetIndex--;
}
await RegistryService.MoveRegistryCategoryAsync(RegistryId, _draggedCategoryId.Value, targetIndex, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
_draggedCategoryId = null;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
finally
{
_dropOperationInProgress = false;
}
}
protected async Task OnCategoryListEndDropAsync()
{
if (!_draggedCategoryId.HasValue || _dropOperationInProgress)
{
return;
}
_dropOperationInProgress = true;
try
{
await RegistryService.MoveRegistryCategoryAsync(RegistryId, _draggedCategoryId.Value, ItemCategories.Count, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
_draggedCategoryId = null;
_categoryDropTargetId = null;
_categoryDropAfterTarget = false;
_categoryDropAtEnd = false;
}
finally
{
_dropOperationInProgress = false;
}
}
protected async Task FetchMetadataAsync()
{
if (string.IsNullOrWhiteSpace(ItemModel.ProductUrl))
@@ -168,41 +605,95 @@ public partial class RegistryAdmin : ComponentBase
return;
}
var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false);
if (metadata is null)
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
var actorUserId = string.IsNullOrWhiteSpace(userId) ? "unknown-user" : userId;
async Task LogMetadataAsync(UserActionType actionType, string details)
{
return;
try
{
await RegistryService.LogUserActionAsync(
RegistryId,
actorUserId,
actionType,
ItemModel.Id,
TruncateLogDetails(details),
CancellationToken.None).ConfigureAwait(false);
}
catch (ArgumentException)
{
// Keep autofetch UX stable even if logging cannot be persisted.
}
}
if (!string.IsNullOrWhiteSpace(metadata.NormalizedUrl))
try
{
ItemModel.ProductUrl = metadata.NormalizedUrl;
var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false);
if (metadata is null)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=no-metadata").ConfigureAwait(false);
return;
}
if (!string.IsNullOrWhiteSpace(metadata.NormalizedUrl))
{
ItemModel.ProductUrl = metadata.NormalizedUrl;
}
if ((string.IsNullOrWhiteSpace(ItemModel.Name) || string.Equals(ItemModel.Name, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Title))
{
ItemModel.Name = metadata.Title;
}
if ((string.IsNullOrWhiteSpace(ItemModel.Description) || string.Equals(ItemModel.Description, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Description))
{
ItemModel.Description = metadata.Description;
}
if (string.IsNullOrWhiteSpace(ItemModel.PictureUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl))
{
ItemModel.PictureUrl = metadata.ImageUrl;
}
if (!ItemModel.PriceAmount.HasValue && metadata.PriceAmount.HasValue)
{
ItemModel.PriceAmount = metadata.PriceAmount;
}
if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode))
{
ItemModel.CurrencyCode = metadata.CurrencyCode;
}
var summary = $"metadata-fetch success; url={ItemModel.ProductUrl}; title={(string.IsNullOrWhiteSpace(metadata.Title) ? "-" : metadata.Title)}; image={(string.IsNullOrWhiteSpace(metadata.ImageUrl) ? "no" : "yes")}; price={(metadata.PriceAmount.HasValue ? metadata.PriceAmount.Value.ToString("0.00") : "-")}; currency={(string.IsNullOrWhiteSpace(metadata.CurrencyCode) ? "-" : metadata.CurrencyCode)}";
await LogMetadataAsync(UserActionType.MetadataFetchSucceeded, summary).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=http; message={ex.Message}").ConfigureAwait(false);
}
catch (TaskCanceledException ex)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=timeout-or-cancel; message={ex.Message}").ConfigureAwait(false);
}
catch (InvalidOperationException ex)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=invalid-operation; message={ex.Message}").ConfigureAwait(false);
}
catch (Exception ex)
{
await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=unexpected-{ex.GetType().Name}; message={ex.Message}").ConfigureAwait(false);
}
}
private static string TruncateLogDetails(string details)
{
if (string.IsNullOrWhiteSpace(details))
{
return string.Empty;
}
if ((string.IsNullOrWhiteSpace(ItemModel.Name) || string.Equals(ItemModel.Name, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Title))
{
ItemModel.Name = metadata.Title;
}
if ((string.IsNullOrWhiteSpace(ItemModel.Description) || string.Equals(ItemModel.Description, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Description))
{
ItemModel.Description = metadata.Description;
}
if (string.IsNullOrWhiteSpace(ItemModel.PictureUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl))
{
ItemModel.PictureUrl = metadata.ImageUrl;
}
if (!ItemModel.PriceAmount.HasValue && metadata.PriceAmount.HasValue)
{
ItemModel.PriceAmount = metadata.PriceAmount;
}
if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode))
{
ItemModel.CurrencyCode = metadata.CurrencyCode;
}
return details.Length <= 500 ? details : details[..500];
}
protected async Task CreateInviteAsync()
@@ -260,19 +751,179 @@ public partial class RegistryAdmin : ComponentBase
SettingsModel.BankAccountIban = settings.BankAccountIban;
SettingsModel.BankAccountBic = settings.BankAccountBic;
SettingsModel.ShowBankAccountName = settings.ShowBankAccountName;
SettingsModel.HideHeaderName = settings.HideHeaderName;
SettingsModel.ContributionQrCodeUrl = settings.ContributionQrCodeUrl;
SettingsModel.ContributionAmountQrCodes = settings.ContributionAmountQrCodes;
PublicLinkCode = settings.PublicLinkCode;
var currentHeaderContent = SettingsModel.HeaderContentHtml ?? string.Empty;
_pendingEditorLoad = currentHeaderContent != _lastLoadedHeaderContentHtml;
}
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
AccessibleUserAddresses = await RegistryService.GetRegistryAccessibleUserAddressesAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
await ReloadItemManagementAsync().ConfigureAwait(false);
ItemModel = new RegistryItemEditModel
{
CurrencyCode = SettingsModel.CurrencyCode,
DesiredQuantity = 1
DesiredQuantity = 1,
CategoryId = ItemCategories.FirstOrDefault()?.Id
};
}
private async Task ReloadItemManagementAsync()
{
var categories = await RegistryService.GetRegistryItemCategoriesAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
var items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
Items = items;
var itemsByCategory = items
.Where(x => x.CategoryId.HasValue)
.GroupBy(x => x.CategoryId!.Value)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RegistryItemEditModel>)g.ToList());
ItemCategories = categories
.OrderBy(x => x.SortOrder)
.Select(category => new RegistryItemCategoryEditModel
{
Id = category.Id,
Name = category.Name,
SortOrder = category.SortOrder,
Items = itemsByCategory.TryGetValue(category.Id, out var categoryItems)
? categoryItems
: []
})
.ToList();
}
private async Task<bool> IsPointerInBottomHalfAsync(string elementId, double clientY)
{
try
{
return await JSRuntime.InvokeAsync<bool>("birthListDrag.isPointerInBottomHalf", elementId, clientY).ConfigureAwait(false);
}
catch (JSException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
protected async Task OnHeroImageSelectedAsync(InputFileChangeEventArgs e)
{
HeroImageUploadMessage = null;
try
{
const long maxOriginalFileSize = 10 * 1024 * 1024; // 10 MB
const long maxProcessedFileSize = 3 * 1024 * 1024; // 3 MB
const int maxHeroImageWidth = 3840;
const int maxHeroImageHeight = 2160;
var file = e.File;
if (file.Size > maxOriginalFileSize)
{
HeroImageUploadMessage = "File size exceeds maximum of 10 MB";
return;
}
if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
{
HeroImageUploadMessage = "Only image files are allowed";
return;
}
var targetContentType = file.ContentType.Equals("image/png", StringComparison.OrdinalIgnoreCase)
? "image/png"
: "image/jpeg";
var processedFile = file.ContentType.Equals("image/svg+xml", StringComparison.OrdinalIgnoreCase)
? file
: await file.RequestImageFileAsync(targetContentType, maxHeroImageWidth, maxHeroImageHeight).ConfigureAwait(false);
if (processedFile.Size > maxProcessedFileSize)
{
HeroImageUploadMessage = "Image is still too large after optimization. Please choose a smaller image.";
return;
}
var webRootPath = WebHostEnvironment.WebRootPath;
if (string.IsNullOrWhiteSpace(webRootPath))
{
HeroImageUploadMessage = "Unable to resolve web root path for image storage.";
return;
}
var uploadsFolder = Path.Combine(webRootPath, "uploads", "registry-hero");
Directory.CreateDirectory(uploadsFolder);
var extension = GetHeroImageExtension(processedFile.ContentType);
var fileName = $"{RegistryId:N}-{Guid.NewGuid():N}{extension}";
var relativePath = $"/uploads/registry-hero/{fileName}";
var fullPath = Path.Combine(uploadsFolder, fileName);
await using (var destinationStream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None))
await using (var sourceStream = processedFile.OpenReadStream(maxProcessedFileSize))
{
await sourceStream.CopyToAsync(destinationStream).ConfigureAwait(false);
}
DeleteManagedHeroImage(SettingsModel.HeroImagePath);
SettingsModel.HeroImagePath = relativePath;
SettingsModel.HeroImageBase64 = null;
SettingsModel.HeroImageContentType = null;
HeroImageUploadMessage = "Image uploaded successfully. Click 'Save Settings' to apply.";
}
catch (Exception ex)
{
HeroImageUploadMessage = $"Error uploading image: {ex.Message}";
}
}
protected Task RemoveHeroImageAsync()
{
DeleteManagedHeroImage(SettingsModel.HeroImagePath);
SettingsModel.HeroImagePath = string.Empty;
SettingsModel.HeroImageBase64 = string.Empty;
SettingsModel.HeroImageContentType = null;
HeroImageUploadMessage = "Image will be removed when you save settings.";
return Task.CompletedTask;
}
private static string GetHeroImageExtension(string contentType)
{
return contentType.ToLowerInvariant() switch
{
"image/png" => ".png",
"image/webp" => ".webp",
"image/svg+xml" => ".svg",
_ => ".jpg"
};
}
private void DeleteManagedHeroImage(string? relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath)
|| !relativePath.StartsWith("/uploads/registry-hero/", StringComparison.OrdinalIgnoreCase)
|| string.IsNullOrWhiteSpace(WebHostEnvironment.WebRootPath))
{
return;
}
var sanitizedPath = relativePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar);
var fullPath = Path.Combine(WebHostEnvironment.WebRootPath, sanitizedPath);
if (File.Exists(fullPath))
{
File.Delete(fullPath);
}
}
}
@@ -158,3 +158,61 @@ h3 {
font-size: 0.8rem;
}
}
/* Category grouping and drag-drop */
.category-groups {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-group {
border: 1px solid #dee2e6;
}
.category-group .card-header {
background-color: #f8f9fa;
}
.category-group [draggable="true"] {
cursor: move;
}
.category-group tbody tr[draggable="true"]:active {
opacity: 0.6;
}
/* Drop indicators */
.category-group.drop-target-category {
border-color: #0d6efd;
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.2);
}
.drop-target-category-before {
box-shadow: inset 0 3px 0 #0d6efd;
}
.drop-target-category-after {
box-shadow: inset 0 -3px 0 #0d6efd;
}
.drop-target-item-end {
border-bottom: 3px solid #0d6efd;
}
.table tbody tr.drop-target-item-before {
box-shadow: inset 0 3px 0 #0d6efd;
}
.table tbody tr.drop-target-item-after {
box-shadow: inset 0 -3px 0 #0d6efd;
}
.category-list-end-drop-zone {
min-height: 18px;
border-radius: 0.375rem;
}
.category-list-end-drop-zone.drop-target-category-list-end {
box-shadow: inset 0 -3px 0 #0d6efd;
}
@@ -3,29 +3,29 @@
@using BirthList.Web.Features.Registries
<PageTitle>Contribution Amount</PageTitle>
<PageTitle>@L["RegistryContributionAmount.PageTitle"]</PageTitle>
@if (Registry is null || Item is null)
{
<p>Item not found.</p>
<p>@L["RegistryContributionAmount.ItemNotFound"]</p>
}
else if (!IsAuthenticated)
{
<p>Please log in first.</p>
<p>@L["RegistryContributionAmount.LoginFirst"]</p>
}
else
{
<div class="registry-shell @RegistryThemeService.GetCssClass(Registry.RegistryType, Registry.ThemeKey)">
<h1>Partially fulfill: @Item.Name</h1>
<h1>@L["RegistryContributionAmount.PartiallyFulfill", 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>
<p class="text-muted mb-0">@L["RegistryContributionAmount.NoRepresentableAmount", GetCurrencySymbol()]</p>
}
else
{
<label class="form-label">Select amount: €@SelectedAmount</label>
<label class="form-label">@L["RegistryContributionAmount.SelectAmount", GetCurrencySymbol(), SelectedAmount]</label>
<input type="range"
class="form-range"
min="0"
@@ -37,15 +37,15 @@ else
@if (Suggestions.Count == 0)
{
<p class="text-muted mt-2">No QR combination available for this amount.</p>
<p class="text-muted mt-2">@L["RegistryContributionAmount.NoQrCombination"]</p>
}
else
{
<p class="mt-3 mb-2"><strong>Suggested QR combination:</strong></p>
<p class="mt-3 mb-2"><strong>@L["RegistryContributionAmount.SuggestedCombination"]</strong></p>
<ul class="mb-3">
@foreach (var suggestion in Suggestions)
{
<li>@suggestion.RepeatCount x @suggestion.Amount</li>
<li>@suggestion.RepeatCount x @GetCurrencySymbol()@suggestion.Amount</li>
}
</ul>
@@ -55,10 +55,10 @@ else
@for (var i = 0; i < suggestion.RepeatCount; i++)
{
<div>
<div class="small mb-1">@suggestion.Amount</div>
<div class="small mb-1">@GetCurrencySymbol()@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>
<a href="@suggestion.QrCodeUrl" target="_blank" rel="noopener noreferrer">@L["RegistryContributionAmount.OpenPaymentLink"]</a>
</div>
</div>
}
@@ -67,8 +67,8 @@ else
}
<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>
<button class="btn btn-success" @onclick="ConfirmAsync" disabled="@(SelectedAmount <= 0)">@L["RegistryContributionAmount.TransferredAmount"]</button>
<button class="btn btn-outline-secondary" @onclick="BackToRegistry">@L["Common.Back"]</button>
</div>
</div>
</div>
@@ -207,4 +207,11 @@ public partial class RegistryContributionAmount : ComponentBase
public string QrCodeUrl { get; init; } = string.Empty;
public int RepeatCount { get; init; }
}
protected string GetCurrencySymbol()
{
return string.IsNullOrWhiteSpace(Registry?.CurrencyCode)
? "€"
: Registry.CurrencyCode;
}
}
@@ -1,19 +1,19 @@
@page "/registry/{RegistryId:guid}/invite/{Token}"
<PageTitle>Admin Invite</PageTitle>
<PageTitle>@L["RegistryInvite.PageTitle"]</PageTitle>
<h1>Admin invitation</h1>
<h1>@L["RegistryInvite.Title"]</h1>
@if (Redeemed is null)
{
<p>Validating invitation...</p>
<p>@L["RegistryInvite.Validating"]</p>
}
else if (Redeemed.Value)
{
<p>Invitation accepted. You are now an admin.</p>
<a class="btn btn-primary" href="/registry/@RegistryId/admin">Go to admin</a>
<p>@L["RegistryInvite.Accepted"]</p>
<a class="btn btn-primary" href="/registry/@RegistryId/admin">@L["RegistryInvite.GoToAdmin"]</a>
}
else
{
<p>The invitation is invalid or already used.</p>
<p>@L["RegistryInvite.Invalid"]</p>
}
@@ -3,16 +3,35 @@
@using BirthList.Web.Features.Registries
<PageTitle>Registry</PageTitle>
<PageTitle>@L["RegistryPublic.PageTitle"]</PageTitle>
@if (Registry is null)
{
<p>Registry not found.</p>
<p>@L["RegistryPublic.NotFound"]</p>
}
else
{
<div class="registry-shell @RegistryThemeService.GetCssClass(Registry.RegistryType, Registry.ThemeKey)">
<h1>@(string.IsNullOrWhiteSpace(Registry.BabyName) ? Registry.Title : Registry.BabyName)</h1>
@if (!string.IsNullOrWhiteSpace(Registry.HeroImagePath) || !string.IsNullOrWhiteSpace(Registry.HeroImageBase64))
{
<div class="hero-image-container mb-4">
<img src="@(string.IsNullOrWhiteSpace(Registry.HeroImagePath) ? $"data:{Registry.HeroImageContentType};base64,{Registry.HeroImageBase64}" : Registry.HeroImagePath)" alt="@Registry.Title" class="hero-image img-fluid w-100" />
</div>
}
@if (!Registry.HideHeaderName)
{
<h1>@(string.IsNullOrWhiteSpace(Registry.BabyName) ? Registry.Title : Registry.BabyName)</h1>
}
@if (Registry.IsAdmin)
{
<div class="mb-3">
<a href="/registry/@Registry.Id/admin" class="btn btn-outline-secondary">
<span class="bi bi-gear"></span> @L["RegistryPublic.GoToAdmin"]
</a>
</div>
}
@if (!string.IsNullOrWhiteSpace(Registry.HeaderContentHtml))
{
@@ -22,7 +41,7 @@ else
@if (!string.IsNullOrWhiteSpace(Registry.ShippingAddress))
{
<div class="alert alert-info">
<strong>Shipping address</strong><br />
<strong>@L["RegistryPublic.ShippingAddress"]</strong><br />
<span class="shipping-address-text">@Registry.ShippingAddress</span>
</div>
}
@@ -30,158 +49,182 @@ else
@if (Registry.ShowBankAccountName && !string.IsNullOrWhiteSpace(Registry.BankAccountIban))
{
<div class="alert alert-secondary">
<strong>Bank transfer participation</strong><br />
<strong>@L["RegistryPublic.BankTransferParticipation"]</strong><br />
@Registry.BankAccountDisplayName<br />
IBAN: @Registry.BankAccountIban
@L["RegistryPublic.IBAN"]: @Registry.BankAccountIban
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic))
{
<span> | BIC: @Registry.BankAccountBic</span>
<span> | @L["RegistryPublic.BIC"]: @Registry.BankAccountBic</span>
}
</div>
}
<div class="item-grid">
@foreach (var item in Registry.Items)
<div class="category-list">
@foreach (var category in Registry.Categories.Where(x => x.Items.Count > 0))
{
<article class="card item-card @(item.IsGiven ? "item-given" : "")">
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
var isCollapsed = IsCategoryCollapsed(category.Id);
<section class="category-section">
<button type="button" class="btn btn-outline-secondary category-toggle" @onclick="() => ToggleCategoryCollapse(category.Id)">
<span class="bi @(isCollapsed ? "bi-chevron-right" : "bi-chevron-down")"></span>
<span>@category.Name</span>
<span class="badge bg-secondary">@category.Items.Count</span>
</button>
@if (!isCollapsed)
{
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
}
<div class="card-body">
<div class="card-header-badges">
<h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true)
<div class="item-grid mt-3">
@foreach (var item in category.Items)
{
<span class="badge bg-info">Second-hand preferred</span>
}
else if (item.PreferSecondHand == null)
{
<span class="badge bg-warning">Second-hand optional</span>
<article class="card item-card @(item.IsGiven ? "item-given" : "")">
@if (!string.IsNullOrWhiteSpace(item.PictureUrl))
{
<img class="card-img-top item-image" src="@item.PictureUrl" alt="@item.Name" />
}
<div class="card-body">
<div class="card-header-badges">
<h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true)
{
<span class="badge bg-info">@L["RegistryPublic.SecondHandPreferred"]</span>
}
else if (item.PreferSecondHand == null)
{
<span class="badge bg-warning">@L["RegistryPublic.SecondHandOptional"]</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text item-description-text">@item.Description</p>
}
@if (item.DesiredQuantity > 1)
{
<p class="mb-1"><strong>@L["RegistryPublic.Qty"]</strong> @item.PurchasedQuantity/@item.DesiredQuantity @L["RegistryPublic.PurchasedSuffix"]</p>
}
@if (item.PriceAmount.HasValue)
{
<p class="mb-1"><strong>@L["RegistryPublic.Price"]</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue)
{
<p class="mb-2">
<strong>@L["RegistryPublic.Participation"]</strong>
@item.CurrencyCode@item.MoneyFulfilledAmount.ToString("0.00")
@if (GetParticipationTotalAmount(item)!.Value > 0)
{
<span> @L["RegistryPublic.OutOfFulfilled", item.CurrencyCode, GetParticipationTotalAmount(item)!.Value.ToString("0.00")]</span>
}
else
{
<span> @L["RegistryPublic.FulfilledOnly"]</span>
}
</p>
}
@if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
{
<div class="contributors-section mt-2 mb-2">
@if (item.Purchasers.Count > 0)
{
<div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin)
{
<strong class="text-sm">@L["RegistryPublic.PurchasedBy"]</strong>
<div class="contributor-list">
@foreach (var purchaser in item.Purchasers)
{
<span class="contributor-badge">@purchaser.DisplayName (@purchaser.Quantity)</span>
}
</div>
}
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{
<strong class="text-sm">@L["RegistryPublic.PurchasedBy"]</strong>
<div class="contributor-list">
<span class="contributor-badge">@L["RegistryPublic.YouQuantity", item.CurrentUserPurchasedQuantity]</span>
@if (item.Purchasers.Count > 1)
{
var otherCount = item.Purchasers.Count - 1;
<span class="contributor-badge">@L["RegistryPublic.AndOtherPeople", otherCount, otherCount == 1 ? L["RegistryPublic.Person"].Value : L["RegistryPublic.People"].Value]</span>
}
</div>
}
else
{
<span class="text-muted small">@L["RegistryPublic.Purchased"]</span>
}
</div>
}
@if (item.Contributors.Count > 0)
{
<div class="mb-2">
@if (Registry.IsAdmin)
{
<strong class="text-sm">@L["RegistryPublic.ContributedBy"]</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)
{
<strong class="text-sm">@L["RegistryPublic.ContributedBy"]</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">@L["RegistryPublic.AndOthers"]</span>
}
</div>
}
else
{
<span class="text-muted small">@L["RegistryPublic.Contributed"]</span>
}
}
else
{
<span class="text-muted small">@L["RegistryPublic.Contributed"]</span>
}
</div>
}
</div>
}
@if (!IsAuthenticated)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">@L["RegistryPublic.LoginToPurchase"]</button>
}
else
{
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">@L["RegistryPublic.Purchase"]</button>
}
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchaseManagementPromptAsync(item.Id)">
@(Registry.IsAdmin ? L["RegistryPublic.ManagePurchases"].Value : (item.CurrentUserPurchasedQuantity > 0 ? L["RegistryPublic.EditPurchase"].Value : L["RegistryPublic.MarkPurchased"].Value))
</button>
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionActionAsync(item.Id, item.CurrentUserContributionAmount)">
@(Registry.IsAdmin ? L["RegistryPublic.ManageParticipations"].Value : (item.CurrentUserContributionAmount > 0 ? L["RegistryPublic.EditParticipation"].Value : L["RegistryPublic.PartiallyFulfill"].Value))
</button>
}
</div>
}
</div>
</article>
}
</div>
@if (!string.IsNullOrWhiteSpace(item.Description))
{
<p class="card-text item-description-text">@item.Description</p>
}
@if (item.DesiredQuantity > 1)
{
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p>
}
@if (item.PriceAmount.HasValue)
{
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
}
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue)
{
<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)
{
<div class="contributors-section mt-2 mb-2">
@if (item.Purchasers.Count > 0)
{
<div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
@foreach (var purchaser in item.Purchasers)
{
<span class="contributor-badge">@purchaser.DisplayName (@purchaser.Quantity)</span>
}
</div>
}
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{
<strong class="text-sm">Purchased by:</strong>
<div class="contributor-list">
<span class="contributor-badge">You (@item.CurrentUserPurchasedQuantity)</span>
@if (item.Purchasers.Count > 1)
{
<span class="contributor-badge">and @(item.Purchasers.Count - 1) other @(item.Purchasers.Count - 1 == 1 ? "person" : "people")</span>
}
</div>
}
else
{
<span class="text-muted small">Purchased</span>
}
</div>
}
@if (item.Contributors.Count > 0)
{
<div class="mb-2">
@if (Registry.IsAdmin)
{
<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)
{
<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>
}
else
{
<span class="text-muted small">Contributed</span>
}
}
else
{
<span class="text-muted small">Contributed</span>
}
</div>
}
</div>
}
@if (!IsAuthenticated)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button>
}
else
{
<div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button>
}
@if (item.CurrentUserPurchasedQuantity == 0)
{
<button class="btn btn-success btn-sm" @onclick="() => OpenPurchasePrompt(item.Id)">Mark purchased</button>
}
@if (item.CurrentUserPurchasedQuantity > 0 || (Registry.IsAdmin && item.Purchasers.Count > 0))
{
@if (item.CurrentUserPurchasedQuantity > 0)
{
<span class="badge bg-success me-2">You purchased @item.CurrentUserPurchasedQuantity</span>
}
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaseAsync(item.Id)">Unmark purchase</button>
}
@if (item.ParticipationAllowed)
{
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionPrompt(item.Id)">Partially fulfill</button>
}
</div>
}
</div>
</article>
}
</section>
}
</div>
</div>
@@ -191,36 +234,20 @@ else
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Mark as purchased</h3>
<p>How many units did you purchase?</p>
<h3>@L["RegistryPublic.MarkAsPurchased"]</h3>
<p>@L["RegistryPublic.HowManyUnitsPurchased"]</p>
<InputNumber class="form-control" @bind-Value="PurchasedQuantity" />
@if (!string.IsNullOrWhiteSpace(PurchaseItemUrl))
{
<div class="mt-3">
<a href="@PurchaseItemUrl" target="_blank" class="btn btn-outline-primary btn-sm w-100">Open product link</a>
<a href="@PurchaseItemUrl" target="_blank" class="btn btn-outline-primary btn-sm w-100">@L["RegistryPublic.OpenProductLink"]</a>
</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">Confirm purchase</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchasePrompt">Cancel</button>
</div>
</div>
</div>
}
@if (ShowContributionPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Log contribution</h3>
<p>Transferred amount</p>
<InputNumber class="form-control" @bind-Value="ContributionAmount" />
<p class="mt-2">Message: @ContributionMessage</p>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="ConfirmContributionAsync">Confirm</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button>
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">@L["RegistryPublic.ConfirmPurchase"]</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchasePrompt">@L["Common.Cancel"]</button>
</div>
</div>
</div>
@@ -230,20 +257,20 @@ else
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Select purchaser to unmark</h3>
<p>Multiple users have purchased this item. Choose which purchase to unmark:</p>
<h3>@L["RegistryPublic.SelectPurchaserToUnmark"]</h3>
<p>@L["RegistryPublic.MultipleUsersPurchased"]</p>
<div class="purchaser-list">
@foreach (var purchaser in PurchasersToUnmark)
{
<div class="purchaser-item">
<span>@purchaser.DisplayName (@purchaser.Quantity)</span>
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaserAsync(purchaser.UserId)">Unmark</button>
<button class="btn btn-warning btn-sm" @onclick="() => UnmarkPurchaserAsync(purchaser.UserId)">@L["RegistryPublic.Unmark"]</button>
</div>
}
</div>
<div class="d-flex gap-2 mt-3">
<button class="btn btn-danger" @onclick="UnmarkAllPurchasersAsync">Unmark all</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaserSelectionPrompt">Cancel</button>
<button class="btn btn-danger" @onclick="UnmarkAllPurchasersAsync">@L["RegistryPublic.UnmarkAll"]</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaserSelectionPrompt">@L["Common.Cancel"]</button>
</div>
</div>
</div>
@@ -253,29 +280,29 @@ else
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>Partially fulfill item</h3>
<h3>@L["RegistryPublic.PartiallyFulfillItem"]</h3>
@if (PartialFulfillStep == 1)
{
<p>Select how you want to donate:</p>
<p>@L["RegistryPublic.SelectDonateMethod"]</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
@L["RegistryPublic.IbanTransfer"]
</button>
}
@if (HasSingleQrPaymentOption())
{
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.SingleQrCode)">
Single QR code
@L["RegistryPublic.SingleQrCode"]
</button>
}
@if (HasAmountSpecificQrPaymentOption())
{
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.AmountSpecificQrCode)">
QR code per amount
@L["RegistryPublic.QrCodePerAmount"]
</button>
}
</div>
@@ -283,10 +310,10 @@ else
@if (SelectedPaymentMethod == ContributionPaymentMethodType.Iban && !string.IsNullOrWhiteSpace(Registry?.BankAccountIban))
{
<div class="alert alert-secondary mt-3 mb-0">
<strong>IBAN:</strong> @Registry.BankAccountIban
<strong>@L["RegistryPublic.IBAN"]:</strong> @Registry.BankAccountIban
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic))
{
<span> | <strong>BIC:</strong> @Registry.BankAccountBic</span>
<span> | <strong>@L["RegistryPublic.BIC"]:</strong> @Registry.BankAccountBic</span>
}
</div>
}
@@ -296,25 +323,25 @@ else
<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>
<a href="@SelectedPaymentQrCodeUrl" target="_blank" rel="noopener noreferrer">@L["RegistryPublic.OpenPaymentLink"]</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>
<button class="btn btn-primary" @onclick="ContinueContributionWizard" disabled="@(SelectedPaymentMethod is null)">@L["Common.Next"]</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">@L["Common.Cancel"]</button>
</div>
}
else if (PartialFulfillStep == 2)
{
<p>How much did you add for this item?</p>
<p>@L["RegistryPublic.HowMuchAdded"]</p>
<InputNumber class="form-control" @bind-Value="ContributionAmount" @bind-Value:after="OnContributionAmountChanged" />
<p class="mt-2">Message: @ContributionMessage</p>
<p class="mt-2">@L["Common.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>
<p class="text-muted mt-2 mb-0">@L["RegistryPublic.NoQrForAmount"]</p>
}
@if (!string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl))
@@ -322,17 +349,82 @@ else
<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>
<a href="@SelectedPaymentQrCodeUrl" target="_blank" rel="noopener noreferrer">@L["RegistryPublic.OpenPaymentLink"]</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>
<button class="btn btn-success" @onclick="ConfirmContributionAsync">@L["RegistryPublic.Confirm"]</button>
<button class="btn btn-outline-secondary" @onclick="BackContributionWizard">@L["Common.Back"]</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">@L["Common.Cancel"]</button>
</div>
}
</div>
</div>
}
@if (ShowPurchaseManagementPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>@L["RegistryPublic.ManagePurchase"]</h3>
@if (Registry?.IsAdmin == true)
{
<label class="form-label">@L["RegistryPublic.User"]</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="@L["Common.SearchUser"]" />
<select class="form-select mb-3" value="@SelectedPurchaseUserId" @onchange="OnPurchaseManagedUserChanged">
<option value="">@L["Common.SelectUser"]</option>
@foreach (var user in FilteredSelectableUsers)
{
<option value="@user.UserId">@user.DisplayName</option>
}
</select>
}
<label class="form-label">@L["Common.Quantity"]</label>
<InputNumber class="form-control" @bind-Value="ManagedPurchaseQuantity" />
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SavePurchaseManagementAsync">@L["Common.Save"]</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedPurchaseAsync">@L["Common.Remove"]</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaseManagementPrompt">@L["Common.Cancel"]</button>
</div>
</div>
</div>
}
@if (ShowContributionManagementPrompt)
{
<div class="prompt-overlay">
<div class="prompt-card">
<h3>@L["RegistryPublic.ManageParticipation"]</h3>
@if (Registry?.IsAdmin == true)
{
<label class="form-label">@L["RegistryPublic.User"]</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="@L["Common.SearchUser"]" />
<select class="form-select mb-3" value="@SelectedContributionUserId" @onchange="OnContributionManagedUserChanged">
<option value="">@L["Common.SelectUser"]</option>
@foreach (var user in FilteredSelectableUsers)
{
<option value="@user.UserId">@user.DisplayName</option>
}
</select>
}
<label class="form-label">@L["Common.Amount"]</label>
<InputNumber class="form-control" @bind-Value="ManagedContributionAmount" />
<label class="form-label mt-2">@L["Common.Message"]</label>
<InputText class="form-control" @bind-Value="ManagedContributionMessage" />
<div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SaveContributionManagementAsync">@L["Common.Save"]</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedContributionAsync">@L["Common.Remove"]</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionManagementPrompt">@L["Common.Cancel"]</button>
</div>
</div>
</div>
}
@@ -32,12 +32,30 @@ public partial class RegistryPublic : ComponentBase
protected ContributionPaymentMethodType? SelectedPaymentMethod { get; set; }
protected string? SelectedPaymentQrCodeUrl { get; private set; }
protected bool ShowPurchaseManagementPrompt { get; private set; }
protected bool ShowContributionManagementPrompt { get; private set; }
protected int ManagedPurchaseQuantity { get; set; } = 1;
protected decimal ManagedContributionAmount { get; set; }
protected string ManagedContributionMessage { get; set; } = string.Empty;
protected string? SelectedPurchaseUserId { get; set; }
protected string? SelectedContributionUserId { get; set; }
protected string UserFilterText { get; set; } = string.Empty;
protected IReadOnlyList<RegistrySelectableUserViewModel> SelectableUsers { get; private set; } = [];
private readonly HashSet<Guid> _collapsedCategoryIds = [];
protected override async Task OnParametersSetAsync()
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
IsAuthenticated = !string.IsNullOrWhiteSpace(userId);
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
if (Registry is not null)
{
var categoryIds = Registry.Categories.Select(x => x.Id).ToHashSet();
_collapsedCategoryIds.RemoveWhere(id => !categoryIds.Contains(id));
}
if (Registry is not null && !string.IsNullOrWhiteSpace(userId))
{
await RegistryService.LogUserActionAsync(Registry.Id, userId, UserActionType.RegistryLinkOpened, null, null, CancellationToken.None).ConfigureAwait(false);
@@ -266,7 +284,7 @@ public partial class RegistryPublic : ComponentBase
return;
}
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None).ConfigureAwait(false);
await RegistryService.AddContributionAsync(ActiveItemId, userId, ContributionAmount, ContributionMessage, CancellationToken.None). ConfigureAwait(false);
ShowPartialFulfillWizard = false;
PartialFulfillStep = 1;
SelectedPaymentMethod = null;
@@ -274,6 +292,23 @@ public partial class RegistryPublic : ComponentBase
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
}
protected void ToggleCategoryCollapse(Guid categoryId)
{
if (_collapsedCategoryIds.Contains(categoryId))
{
_collapsedCategoryIds.Remove(categoryId);
}
else
{
_collapsedCategoryIds.Add(categoryId);
}
}
protected bool IsCategoryCollapsed(Guid categoryId)
{
return _collapsedCategoryIds.Contains(categoryId);
}
protected static decimal? GetParticipationTotalAmount(RegistryPublicItemViewModel item)
{
ArgumentNullException.ThrowIfNull(item);
@@ -285,4 +320,291 @@ public partial class RegistryPublic : ComponentBase
ArgumentException.ThrowIfNullOrWhiteSpace(paymentLink);
return $"https://api.qrserver.com/v1/create-qr-code/?size=320x320&data={Uri.EscapeDataString(paymentLink)}";
}
protected IEnumerable<RegistrySelectableUserViewModel> FilteredSelectableUsers =>
string.IsNullOrWhiteSpace(UserFilterText)
? SelectableUsers
: SelectableUsers.Where(x => x.DisplayName.Contains(UserFilterText, StringComparison.OrdinalIgnoreCase));
protected async Task OpenPurchaseManagementPromptAsync(Guid itemId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
ActiveItemId = itemId;
ShowPurchaseManagementPrompt = true;
ManagedPurchaseQuantity = item.CurrentUserPurchasedQuantity > 0 ? item.CurrentUserPurchasedQuantity : 1;
UserFilterText = string.Empty;
if (Registry?.IsAdmin == true)
{
SelectableUsers = await RegistryService.GetRegistrySelectableUsersAsync(Registry.Id, CancellationToken.None).ConfigureAwait(false);
SelectedPurchaseUserId = item.Purchasers.FirstOrDefault()?.UserId ?? userId;
if (!SelectableUsers.Any(x => x.UserId == SelectedPurchaseUserId) && !string.IsNullOrWhiteSpace(SelectedPurchaseUserId))
{
SelectableUsers =
[
.. SelectableUsers,
new RegistrySelectableUserViewModel { UserId = SelectedPurchaseUserId, DisplayName = SelectedPurchaseUserId }
];
}
}
else
{
SelectedPurchaseUserId = userId;
}
}
protected void ClosePurchaseManagementPrompt()
{
ShowPurchaseManagementPrompt = false;
SelectedPurchaseUserId = null;
UserFilterText = string.Empty;
SelectableUsers = [];
ActiveItemId = Guid.Empty;
}
protected async Task SavePurchaseManagementAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedPurchaseUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
if (item is null)
{
return;
}
var existingPurchase = item.Purchasers.FirstOrDefault(x => x.UserId == targetUserId);
if (existingPurchase is null)
{
await RegistryService.AddPurchaseAsync(ActiveItemId, targetUserId, ManagedPurchaseQuantity, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UpdatePurchaseAsync(ActiveItemId, targetUserId, ManagedPurchaseQuantity, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
ClosePurchaseManagementPrompt();
}
protected async Task RemoveManagedPurchaseAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedPurchaseUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
if (Registry?.IsAdmin == true)
{
await RegistryService.UnmarkPurchaseByAdminAsync(ActiveItemId, targetUserId, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UnmarkPurchaseAsync(ActiveItemId, targetUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
ClosePurchaseManagementPrompt();
}
protected async Task OpenContributionManagementPromptAsync(Guid itemId)
{
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == itemId);
if (item is null)
{
return;
}
ActiveItemId = itemId;
ShowContributionManagementPrompt = true;
UserFilterText = string.Empty;
if (Registry?.IsAdmin == true)
{
SelectableUsers = await RegistryService.GetRegistrySelectableUsersAsync(Registry.Id, CancellationToken.None).ConfigureAwait(false);
SelectedContributionUserId = item.Contributors.FirstOrDefault()?.UserId ?? userId;
}
else
{
SelectedContributionUserId = userId;
}
var currentContribution = item.Contributors.FirstOrDefault(x => x.UserId == SelectedContributionUserId);
ManagedContributionAmount = currentContribution?.Amount ?? item.CurrentUserContributionAmount;
ManagedContributionMessage = $"Participation for item: {item.Name}";
}
protected void CloseContributionManagementPrompt()
{
ShowContributionManagementPrompt = false;
SelectedContributionUserId = null;
UserFilterText = string.Empty;
SelectableUsers = [];
ManagedContributionAmount = 0;
ManagedContributionMessage = string.Empty;
ActiveItemId = Guid.Empty;
}
protected async Task SaveContributionManagementAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty || ManagedContributionAmount <= 0)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedContributionUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
if (item is null)
{
return;
}
var existingContribution = item.Contributors.FirstOrDefault(x => x.UserId == targetUserId);
if (existingContribution is null)
{
await RegistryService.AddContributionAsync(ActiveItemId, targetUserId, ManagedContributionAmount, ManagedContributionMessage, CancellationToken.None).ConfigureAwait(false);
}
else
{
await RegistryService.UpdateContributionAsync(ActiveItemId, targetUserId, ManagedContributionAmount, ManagedContributionMessage, actorUserId, CancellationToken.None).ConfigureAwait(false);
}
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
CloseContributionManagementPrompt();
}
protected async Task RemoveManagedContributionAsync()
{
var actorUserId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(actorUserId) || ActiveItemId == Guid.Empty)
{
return;
}
var targetUserId = Registry?.IsAdmin == true
? SelectedContributionUserId
: actorUserId;
if (string.IsNullOrWhiteSpace(targetUserId))
{
return;
}
await RegistryService.RemoveContributionAsync(ActiveItemId, targetUserId, actorUserId, CancellationToken.None).ConfigureAwait(false);
await ReloadRegistryAsync(actorUserId).ConfigureAwait(false);
CloseContributionManagementPrompt();
}
protected void SelectManagedPurchaseUser(string userId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
SelectedPurchaseUserId = userId;
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
var existing = item?.Purchasers.FirstOrDefault(x => x.UserId == userId);
ManagedPurchaseQuantity = existing?.Quantity ?? 1;
}
protected void SelectManagedContributionUser(string userId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userId);
SelectedContributionUserId = userId;
var item = Registry?.Items.FirstOrDefault(x => x.Id == ActiveItemId);
var existing = item?.Contributors.FirstOrDefault(x => x.UserId == userId);
ManagedContributionAmount = existing?.Amount ?? 0;
}
protected void OnPurchaseManagedUserChanged(ChangeEventArgs args)
{
var userId = args.Value?.ToString();
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
SelectManagedPurchaseUser(userId);
}
protected void OnContributionManagedUserChanged(ChangeEventArgs args)
{
var userId = args.Value?.ToString();
if (string.IsNullOrWhiteSpace(userId))
{
return;
}
SelectManagedContributionUser(userId);
}
private async Task ReloadRegistryAsync(string userId)
{
Registry = await RegistryService.GetPublicRegistryByCodeAsync(Code, userId, CancellationToken.None).ConfigureAwait(false);
if (Registry is not null)
{
var categoryIds = Registry.Categories.Select(x => x.Id).ToHashSet();
_collapsedCategoryIds.RemoveWhere(id => !categoryIds.Contains(id));
}
}
protected Task OpenContributionActionAsync(Guid itemId, decimal currentUserContributionAmount)
{
if (Registry?.IsAdmin == true || currentUserContributionAmount > 0)
{
return OpenContributionManagementPromptAsync(itemId);
}
OpenContributionPrompt(itemId);
return Task.CompletedTask;
}
}
@@ -4,6 +4,20 @@
gap: 1rem;
}
.hero-image-container {
margin: 0 -1rem;
max-height: 400px;
overflow: hidden;
border-radius: 8px;
}
.hero-image {
object-fit: cover;
object-position: center;
max-height: 400px;
border-radius: 8px;
}
.item-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -141,3 +155,43 @@
.purchaser-item .btn {
margin-left: 0.5rem;
}
.category-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.category-section {
border: 1px solid #dee2e6;
border-radius: 0.5rem;
padding: 0.75rem;
background-color: #fff;
}
.category-toggle {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
font-weight: 600;
text-align: left;
}
.category-toggle span.bi {
margin-right: 0.25rem;
}
/* Render Quill text-size classes in public header content */
.header-content ::deep .ql-size-small {
font-size: 0.75em;
}
.header-content ::deep .ql-size-large {
font-size: 1.5em;
}
.header-content ::deep .ql-size-huge {
font-size: 2.5em;
}
@@ -4,8 +4,11 @@
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Localization
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using BirthList.Web
@using BirthList.Web.Components
@inject IStringLocalizer<SharedResources> L
@@ -0,0 +1,41 @@
namespace BirthList.Web.Configuration;
internal sealed class AmazonMetadataOptions
{
/// <summary>
/// Amazon PA API access key. When set together with <see cref="SecretKey"/> and <see cref="AssociateTag"/>,
/// the PA API is used as the primary metadata source for Amazon URLs.
/// </summary>
public string? AccessKey { get; set; }
/// <summary>
/// Amazon PA API secret key.
/// </summary>
public string? SecretKey { get; set; }
/// <summary>
/// Amazon Associates tag (e.g. "yourstore-21").
/// </summary>
public string? AssociateTag { get; set; }
/// <summary>
/// Amazon PA API marketplace host, e.g. "webservices.amazon.com" or "webservices.amazon.com.be".
/// Defaults to "webservices.amazon.com".
/// </summary>
public string PaApiHost { get; set; } = "webservices.amazon.com";
/// <summary>
/// RapidAPI key for the "Real-Time Amazon Data" API.
/// When set, this is used as a fallback when the direct scrape returns no metadata.
/// </summary>
public string? RapidApiKey { get; set; }
/// <summary>Returns true when PA API credentials are fully configured.</summary>
public bool IsPaApiConfigured =>
!string.IsNullOrWhiteSpace(AccessKey) &&
!string.IsNullOrWhiteSpace(SecretKey) &&
!string.IsNullOrWhiteSpace(AssociateTag);
/// <summary>Returns true when the RapidAPI fallback is configured.</summary>
public bool IsRapidApiConfigured => !string.IsNullOrWhiteSpace(RapidApiKey);
}
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Identity;
namespace BirthList.Web.Data;
@@ -8,5 +9,8 @@ public class ApplicationUser : IdentityUser
public string? FirstName { get; set; }
public string? LastName { get; set; }
public string? Address { get; set; }
[MaxLength(10)]
public string? PreferredCulture { get; set; }
}
@@ -0,0 +1,292 @@
// <auto-generated />
using System;
using BirthList.Web.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace BirthList.Web.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260519000100_AddPreferredCultureToApplicationUser")]
partial class AddPreferredCultureToApplicationUser
{
/// <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.Web.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("Address")
.HasColumnType("nvarchar(max)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<string>("FirstName")
.HasColumnType("nvarchar(max)");
b.Property<string>("LastName")
.HasColumnType("nvarchar(max)");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("PreferredCulture")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("BirthList.Web.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BirthList.Web.Migrations
{
/// <inheritdoc />
public partial class AddPreferredCultureToApplicationUser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PreferredCulture",
table: "AspNetUsers",
type: "nvarchar(10)",
maxLength: 10,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PreferredCulture",
table: "AspNetUsers");
}
}
}
@@ -73,6 +73,10 @@ namespace BirthList.Web.Migrations
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("PreferredCulture")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
@@ -0,0 +1,55 @@
using BirthList.Web.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Localization;
namespace BirthList.Web.Features.Localization;
internal sealed class LocalizationService(UserManager<ApplicationUser> userManager)
{
private static readonly HashSet<string> SupportedCultureNames =
[
"nl-BE",
"en",
"fr-FR",
"qps-Ploc"
];
public static IReadOnlyList<string> GetSupportedCultures()
{
return SupportedCultureNames.OrderBy(x => x).ToList();
}
public bool IsSupportedCulture(string? culture)
{
return !string.IsNullOrWhiteSpace(culture) && SupportedCultureNames.Contains(culture);
}
public async Task SetPreferredCultureAsync(ApplicationUser user, string culture, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(user);
if (!IsSupportedCulture(culture))
{
throw new ArgumentException("Unsupported culture.", nameof(culture));
}
if (string.Equals(user.PreferredCulture, culture, StringComparison.OrdinalIgnoreCase))
{
return;
}
user.PreferredCulture = culture;
var result = await userManager.UpdateAsync(user).ConfigureAwait(false);
if (!result.Succeeded)
{
var error = result.Errors.FirstOrDefault()?.Description ?? "Could not persist preferred culture.";
throw new InvalidOperationException(error);
}
}
public static string BuildCultureCookieValue(string culture)
{
ArgumentException.ThrowIfNullOrWhiteSpace(culture);
return CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture));
}
}
@@ -0,0 +1,241 @@
using System.Globalization;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using BirthList.Web.Configuration;
using Microsoft.Extensions.Options;
namespace BirthList.Web.Features.Registries;
/// <summary>
/// Calls the Amazon Product Advertising API 5.0 to retrieve product metadata.
/// Implements AWS Signature Version 4 signing without external SDK dependencies.
/// </summary>
internal sealed class AmazonPaApiClient(IHttpClientFactory httpClientFactory, IOptions<AmazonMetadataOptions> options)
{
private const string Service = "ProductAdvertisingAPI";
private const string Region = "us-east-1";
private const string Operation = "GetItems";
private readonly AmazonMetadataOptions _options = options.Value;
/// <summary>
/// Fetches product metadata for the given ASIN. Returns null when the item is not found or credentials are not configured.
/// </summary>
public async Task<UrlMetadataResult?> GetItemAsync(string asin, string normalizedUrl, CancellationToken cancellationToken)
{
if (!_options.IsPaApiConfigured)
{
return null;
}
var payload = BuildPayload(asin);
var payloadBytes = Encoding.UTF8.GetBytes(payload);
var payloadHash = ComputeSha256Hex(payloadBytes);
var now = DateTimeOffset.UtcNow;
var dateStamp = now.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
var amzDate = now.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture);
var host = _options.PaApiHost;
var path = "/paapi5/getitems";
var headers = new SortedDictionary<string, string>(StringComparer.Ordinal)
{
["content-encoding"] = "amz-1.0",
["content-type"] = "application/json; charset=utf-8",
["host"] = host,
["x-amz-date"] = amzDate,
["x-amz-target"] = $"com.amazon.paapi5.v1.ProductAdvertisingAPIv1.{Operation}"
};
var signedHeaderNames = string.Join(";", headers.Keys);
var canonicalHeaders = string.Concat(headers.Select(h => $"{h.Key}:{h.Value}\n"));
var canonicalRequest = string.Join("\n",
"POST",
path,
string.Empty,
canonicalHeaders,
signedHeaderNames,
payloadHash);
var credentialScope = $"{dateStamp}/{Region}/{Service}/aws4_request";
var stringToSign = string.Join("\n",
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
ComputeSha256Hex(Encoding.UTF8.GetBytes(canonicalRequest)));
var signingKey = GetSigningKey(_options.SecretKey!, dateStamp);
var signature = ComputeHmacHex(signingKey, stringToSign);
var authorization =
$"AWS4-HMAC-SHA256 Credential={_options.AccessKey}/{credentialScope}, " +
$"SignedHeaders={signedHeaderNames}, Signature={signature}";
var client = httpClientFactory.CreateClient("AmazonPaApi");
using var request = new HttpRequestMessage(HttpMethod.Post, $"https://{host}{path}");
request.Content = new ByteArrayContent(payloadBytes);
foreach (var (key, value) in headers)
{
if (key is "host" or "content-type")
{
continue;
}
request.Headers.TryAddWithoutValidation(key, value);
}
request.Content.Headers.TryAddWithoutValidation("Content-Type", "application/json; charset=utf-8");
request.Content.Headers.TryAddWithoutValidation("Content-Encoding", "amz-1.0");
request.Headers.TryAddWithoutValidation("Authorization", authorization);
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
var paResponse = await response.Content.ReadFromJsonAsync<PaApiResponse>(cancellationToken: cancellationToken).ConfigureAwait(false);
var item = paResponse?.ItemsResult?.Items?.FirstOrDefault();
if (item is null)
{
return null;
}
var title = item.ItemInfo?.Title?.DisplayValue;
var imageUrl = item.Images?.Primary?.Large?.Url ?? item.Images?.Primary?.Medium?.Url;
var priceAmount = item.Offers?.Listings?.FirstOrDefault()?.Price?.Amount;
var currency = item.Offers?.Listings?.FirstOrDefault()?.Price?.Currency;
return new UrlMetadataResult
{
NormalizedUrl = normalizedUrl,
Title = title,
Description = null,
ImageUrl = imageUrl,
PriceAmount = priceAmount,
CurrencyCode = string.IsNullOrWhiteSpace(currency) ? null : currency.ToUpperInvariant()
};
}
private string BuildPayload(string asin)
{
var doc = new
{
ItemIds = new[] { asin },
Resources = new[]
{
"ItemInfo.Title",
"Images.Primary.Large",
"Images.Primary.Medium",
"Offers.Listings.Price"
},
PartnerTag = _options.AssociateTag,
PartnerType = "Associates",
Marketplace = $"www.{_options.PaApiHost.Replace("webservices.", string.Empty, StringComparison.OrdinalIgnoreCase)}"
};
return JsonSerializer.Serialize(doc);
}
private static byte[] GetSigningKey(string secretKey, string dateStamp)
{
var kDate = ComputeHmac(Encoding.UTF8.GetBytes($"AWS4{secretKey}"), dateStamp);
var kRegion = ComputeHmac(kDate, Region);
var kService = ComputeHmac(kRegion, Service);
return ComputeHmac(kService, "aws4_request");
}
private static byte[] ComputeHmac(byte[] key, string data) =>
HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(data));
private static string ComputeHmacHex(byte[] key, string data) =>
Convert.ToHexString(ComputeHmac(key, data)).ToLowerInvariant();
private static string ComputeSha256Hex(byte[] data) =>
Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
// Minimal deserialization models for PA API response
private sealed class PaApiResponse
{
[JsonPropertyName("ItemsResult")]
public PaItemsResult? ItemsResult { get; init; }
}
private sealed class PaItemsResult
{
[JsonPropertyName("Items")]
public List<PaItem>? Items { get; init; }
}
private sealed class PaItem
{
[JsonPropertyName("ItemInfo")]
public PaItemInfo? ItemInfo { get; init; }
[JsonPropertyName("Images")]
public PaImages? Images { get; init; }
[JsonPropertyName("Offers")]
public PaOffers? Offers { get; init; }
}
private sealed class PaItemInfo
{
[JsonPropertyName("Title")]
public PaDisplayValue? Title { get; init; }
}
private sealed class PaDisplayValue
{
[JsonPropertyName("DisplayValue")]
public string? DisplayValue { get; init; }
}
private sealed class PaImages
{
[JsonPropertyName("Primary")]
public PaImageSet? Primary { get; init; }
}
private sealed class PaImageSet
{
[JsonPropertyName("Large")]
public PaImageVariant? Large { get; init; }
[JsonPropertyName("Medium")]
public PaImageVariant? Medium { get; init; }
}
private sealed class PaImageVariant
{
[JsonPropertyName("URL")]
public string? Url { get; init; }
}
private sealed class PaOffers
{
[JsonPropertyName("Listings")]
public List<PaListing>? Listings { get; init; }
}
private sealed class PaListing
{
[JsonPropertyName("Price")]
public PaPrice? Price { get; init; }
}
private sealed class PaPrice
{
[JsonPropertyName("Amount")]
public decimal? Amount { get; init; }
[JsonPropertyName("Currency")]
public string? Currency { get; init; }
}
}
@@ -0,0 +1,126 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using BirthList.Web.Configuration;
using Microsoft.Extensions.Options;
namespace BirthList.Web.Features.Registries;
/// <summary>
/// Calls the "Real-Time Amazon Data" API on RapidAPI as a metadata fallback.
/// https://rapidapi.com/letscrape-6bRBa3QguO5/api/real-time-amazon-data
/// </summary>
internal sealed class RapidApiMetadataClient(IHttpClientFactory httpClientFactory, IOptions<AmazonMetadataOptions> options)
{
private const string Host = "real-time-amazon-data.p.rapidapi.com";
private readonly AmazonMetadataOptions _options = options.Value;
/// <summary>
/// Fetches product metadata for the given ASIN and Amazon country code (e.g. "BE", "US", "DE").
/// Returns null when the API key is not configured or the item is not found.
/// </summary>
public async Task<UrlMetadataResult?> GetItemAsync(string asin, string countryCode, string normalizedUrl, CancellationToken cancellationToken)
{
if (!_options.IsRapidApiConfigured)
{
return null;
}
var client = httpClientFactory.CreateClient("RapidApi");
using var request = new HttpRequestMessage(
HttpMethod.Get,
$"https://{Host}/product-details?asin={Uri.EscapeDataString(asin)}&country={Uri.EscapeDataString(countryCode)}");
request.Headers.TryAddWithoutValidation("x-rapidapi-host", Host);
request.Headers.TryAddWithoutValidation("x-rapidapi-key", _options.RapidApiKey);
using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
return null;
}
var result = await response.Content.ReadFromJsonAsync<RapidApiProductResponse>(cancellationToken: cancellationToken).ConfigureAwait(false);
if (result?.Data is null)
{
return null;
}
var data = result.Data;
var imageUrl = data.ProductMainImageUrl
?? data.ProductPhotos?.FirstOrDefault();
decimal? price = null;
string? currency = null;
var priceEntry = data.ProductPrice ?? data.ProductOriginalPrice;
if (!string.IsNullOrWhiteSpace(priceEntry))
{
(price, currency) = ParseRapidApiPrice(priceEntry);
}
return new UrlMetadataResult
{
NormalizedUrl = normalizedUrl,
Title = data.ProductTitle,
Description = data.ProductDescription,
ImageUrl = imageUrl,
PriceAmount = price,
CurrencyCode = currency
};
}
private static (decimal? amount, string? currency) ParseRapidApiPrice(string raw)
{
// RapidAPI returns prices like "€29,99" or "$19.99" or "29.99 EUR"
string? currencyCode = null;
if (raw.Contains('€')) currencyCode = "EUR";
else if (raw.Contains('$')) currencyCode = "USD";
else if (raw.Contains('£')) currencyCode = "GBP";
else
{
var match = System.Text.RegularExpressions.Regex.Match(raw, @"\b([A-Z]{3})\b");
if (match.Success) currencyCode = match.Value;
}
var cleaned = System.Text.RegularExpressions.Regex.Replace(raw, "[^0-9.,]", string.Empty);
cleaned = cleaned.Replace(",", ".");
if (decimal.TryParse(cleaned, System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture, out var amount))
{
return (amount, currencyCode);
}
return (null, currencyCode);
}
// Minimal deserialization models
private sealed class RapidApiProductResponse
{
[JsonPropertyName("data")]
public RapidApiProductData? Data { get; init; }
}
private sealed class RapidApiProductData
{
[JsonPropertyName("product_title")]
public string? ProductTitle { get; init; }
[JsonPropertyName("product_description")]
public string? ProductDescription { get; init; }
[JsonPropertyName("product_main_image_url")]
public string? ProductMainImageUrl { get; init; }
[JsonPropertyName("product_photos")]
public List<string>? ProductPhotos { get; init; }
[JsonPropertyName("product_price")]
public string? ProductPrice { get; init; }
[JsonPropertyName("product_original_price")]
public string? ProductOriginalPrice { get; init; }
}
}
@@ -3,10 +3,14 @@ using System.Text.RegularExpressions;
namespace BirthList.Web.Features.Registries;
internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFactory)
internal sealed class RegistryMetadataService(
IHttpClientFactory httpClientFactory,
AmazonPaApiClient paApiClient,
RapidApiMetadataClient rapidApiClient)
{
private static readonly Regex MetaTagRegex = new("<meta\\b[^>]*>", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private static readonly Regex AttributeRegex = new("(name|property|content)\\s*=\\s*['\"]([^'\"]*)['\"]", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private static readonly Regex AsinRegex = new(@"/dp/(?<asin>[A-Z0-9]{10})(?:/|$)", RegexOptions.IgnoreCase | RegexOptions.Compiled, TimeSpan.FromSeconds(1));
public async Task<UrlMetadataResult?> FetchAsync(string url, CancellationToken cancellationToken)
{
@@ -16,22 +20,83 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
}
var normalizedUri = NormalizeProductUri(sourceUri);
var isAmazon = normalizedUri.Host.Contains("amazon", StringComparison.OrdinalIgnoreCase);
var client = httpClientFactory.CreateClient("RegistryMetadata");
using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUri);
request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (compatible; BirthListBot/1.0)");
request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
// Strategy 1: PA API (Amazon only, when credentials are configured)
if (isAmazon)
{
return null;
var asin = ExtractAsin(normalizedUri);
if (!string.IsNullOrWhiteSpace(asin))
{
var paResult = await paApiClient.GetItemAsync(asin, normalizedUri.AbsoluteUri, cancellationToken).ConfigureAwait(false);
if (paResult is not null && HasUsefulMetadata(paResult))
{
return paResult;
}
}
}
// Strategy 2: Direct HTML scrape
var scrapeResult = await ScrapeAsync(normalizedUri, cancellationToken).ConfigureAwait(false);
if (scrapeResult is not null && HasUsefulMetadata(scrapeResult))
{
return scrapeResult;
}
// Strategy 3: RapidAPI fallback (Amazon only)
if (isAmazon)
{
var asin = ExtractAsin(normalizedUri);
if (!string.IsNullOrWhiteSpace(asin))
{
var countryCode = ExtractAmazonCountryCode(normalizedUri);
var rapidResult = await rapidApiClient.GetItemAsync(asin, countryCode, normalizedUri.AbsoluteUri, cancellationToken).ConfigureAwait(false);
if (rapidResult is not null)
{
return rapidResult;
}
}
}
// Return whatever the scrape got (may be partial or null) rather than hard-failing
return scrapeResult;
}
private async Task<UrlMetadataResult?> ScrapeAsync(Uri normalizedUri, CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient("RegistryMetadata");
using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUri);
request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8");
request.Headers.TryAddWithoutValidation("Accept-Encoding", "gzip, deflate, br");
request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
request.Headers.TryAddWithoutValidation("Upgrade-Insecure-Requests", "1");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Dest", "document");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Mode", "navigate");
request.Headers.TryAddWithoutValidation("Sec-Fetch-Site", "none");
request.Headers.TryAddWithoutValidation("Sec-Fetch-User", "?1");
request.Headers.TryAddWithoutValidation("Cache-Control", "max-age=0");
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
string? html = null;
try
{
html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException)
{
// Body unreadable; fall through with null html.
}
var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(html))
{
return null;
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Request failed with status {(int)response.StatusCode} {response.ReasonPhrase} and no response body.");
}
throw new InvalidOperationException("Metadata response content was empty.");
}
var meta = ParseMeta(html);
@@ -114,6 +179,15 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
}
}
var hasAnyMetadata = !string.IsNullOrWhiteSpace(title)
|| !string.IsNullOrWhiteSpace(image)
|| price.HasValue;
if (!hasAnyMetadata && !response.IsSuccessStatusCode)
{
throw new HttpRequestException($"Request failed with status {(int)response.StatusCode} {response.ReasonPhrase} and no metadata could be parsed from the response.");
}
return new UrlMetadataResult
{
NormalizedUrl = normalizedUri.AbsoluteUri,
@@ -125,6 +199,34 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
};
}
private static bool HasUsefulMetadata(UrlMetadataResult result) =>
!string.IsNullOrWhiteSpace(result.Title) ||
!string.IsNullOrWhiteSpace(result.ImageUrl) ||
result.PriceAmount.HasValue;
private static string? ExtractAsin(Uri uri)
{
var match = AsinRegex.Match(uri.AbsolutePath);
return match.Success ? match.Groups["asin"].Value.ToUpperInvariant() : null;
}
private static string ExtractAmazonCountryCode(Uri uri)
{
// amazon.com.be -> BE, amazon.de -> DE, amazon.co.uk -> GB, amazon.com -> US
var host = uri.Host.ToLowerInvariant();
if (host.EndsWith(".com.be", StringComparison.Ordinal)) return "BE";
if (host.EndsWith(".de", StringComparison.Ordinal)) return "DE";
if (host.EndsWith(".fr", StringComparison.Ordinal)) return "FR";
if (host.EndsWith(".nl", StringComparison.Ordinal)) return "NL";
if (host.EndsWith(".co.uk", StringComparison.Ordinal)) return "GB";
if (host.EndsWith(".es", StringComparison.Ordinal)) return "ES";
if (host.EndsWith(".it", StringComparison.Ordinal)) return "IT";
if (host.EndsWith(".ca", StringComparison.Ordinal)) return "CA";
if (host.EndsWith(".com.au", StringComparison.Ordinal)) return "AU";
if (host.EndsWith(".co.jp", StringComparison.Ordinal)) return "JP";
return "US";
}
private static bool IsGenericAmazonImage(string image)
{
return image.Contains("amazon.png", StringComparison.OrdinalIgnoreCase)
@@ -25,12 +25,13 @@ public sealed class RegistryCreateModel
public sealed class RegistryItemEditModel
{
public Guid? Id { get; set; }
public Guid? CategoryId { get; set; }
public string Name { get; set; } = string.Empty;
public string? PictureUrl { get; set; }
public string? ProductUrl { get; set; }
public string? Description { get; set; }
public decimal? PriceAmount { get; set; }
public string CurrencyCode { get; set; } = "EUR";
public string CurrencyCode { get; set; } = "";
public int DesiredQuantity { get; set; } = 1;
public bool ParticipationAllowed { get; set; }
public decimal? ParticipationTargetAmount { get; set; }
@@ -52,16 +53,21 @@ public sealed class RegistrySettingsEditModel
{
public string? BabyName { get; set; }
public DateOnly? BirthDate { get; set; }
public string? HeroImageBase64 { get; set; }
public string? HeroImageContentType { get; set; }
public string? HeroImagePath { get; set; }
public string? HeaderContentHtml { get; set; }
public string? ShippingAddress { get; set; }
public string CurrencyCode { get; set; } = "EUR";
public string CurrencyCode { get; set; } = "";
public string ThemeKey { get; set; } = "default";
public string? BankAccountIban { get; set; }
public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; }
public bool HideHeaderName { get; set; }
public string? ContributionQrCodeUrl { get; set; }
public List<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; set; } = [];
public string PublicLinkCode { get; set; } = string.Empty;
}
public sealed class RegistrySummaryViewModel
@@ -79,31 +85,48 @@ public sealed class RegistryPublicViewModel
public string Title { get; init; } = string.Empty;
public string PublicLinkCode { get; init; } = string.Empty;
public string? BabyName { get; init; }
public string? HeroImageBase64 { get; init; }
public string? HeroImageContentType { get; init; }
public string? HeroImagePath { get; init; }
public string? HeaderContentHtml { get; init; }
public string? ShippingAddress { get; init; }
public string CurrencyCode { get; init; } = "EUR";
public string CurrencyCode { get; init; } = "";
public string ThemeKey { get; init; } = "default";
public RegistryType RegistryType { get; init; }
public string? BankAccountIban { get; init; }
public string? BankAccountBic { get; init; }
public string? BankAccountDisplayName { get; init; }
public bool ShowBankAccountName { get; init; }
public bool HideHeaderName { get; init; }
public string? ContributionQrCodeUrl { get; init; }
public IReadOnlyList<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; init; } = [];
public string? CurrentUserId { get; init; }
public bool IsAdmin { get; init; }
public IReadOnlyList<RegistryPublicCategoryViewModel> Categories { get; init; } = [];
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
}
public sealed class RegistryPublicCategoryViewModel
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public int SortOrder { get; init; }
public IReadOnlyList<RegistryPublicItemViewModel> Items { get; init; } = [];
}
public sealed class RegistryPublicItemViewModel
{
public Guid Id { get; init; }
public Guid CategoryId { get; init; }
public string CategoryName { get; init; } = string.Empty;
public int CategorySortOrder { get; init; }
public int SortOrder { get; init; }
public string Name { get; init; } = string.Empty;
public string? PictureUrl { get; init; }
public string? ProductUrl { get; init; }
public string? Description { get; init; }
public decimal? PriceAmount { get; init; }
public string CurrencyCode { get; init; } = "EUR";
public string CurrencyCode { get; init; } = "";
public int DesiredQuantity { get; init; }
public int PurchasedQuantity { get; init; }
public bool ParticipationAllowed { get; init; }
@@ -114,6 +137,7 @@ public sealed class RegistryPublicItemViewModel
public IReadOnlyList<ItemContributorViewModel> Purchasers { get; init; } = [];
public IReadOnlyList<ItemContributorViewModel> Contributors { get; init; } = [];
public int CurrentUserPurchasedQuantity { get; init; }
public decimal CurrentUserContributionAmount { get; init; }
public bool CanViewPurchasers { get; init; }
}
@@ -123,6 +147,14 @@ public sealed class RegistryAdminDisplayModel
public string DisplayName { get; init; } = string.Empty;
}
public sealed class RegistryAccessibleUserAddressViewModel
{
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
public string? Email { get; init; }
public string? Address { get; init; }
}
public sealed class UrlMetadataResult
{
public string? NormalizedUrl { get; init; }
@@ -144,3 +176,17 @@ public sealed class RegistryActionLogViewModel
public string? Details { get; init; }
public DateTimeOffset CreatedAtUtc { get; init; }
}
public sealed class RegistryItemCategoryEditModel
{
public Guid Id { get; init; }
public string Name { get; init; } = string.Empty;
public int SortOrder { get; init; }
public IReadOnlyList<RegistryItemEditModel> Items { get; init; } = [];
}
public sealed class RegistrySelectableUserViewModel
{
public string UserId { get; init; } = string.Empty;
public string DisplayName { get; init; } = string.Empty;
}
File diff suppressed because it is too large Load Diff
+86
View File
@@ -1,15 +1,19 @@
using System.Data;
using System.Globalization;
using BirthList.Infrastructure.Persistence;
using BirthList.Web.Authorization;
using BirthList.Web.Components;
using BirthList.Web.Components.Account;
using BirthList.Web.Configuration;
using BirthList.Web.Data;
using BirthList.Web.Features.Localization;
using BirthList.Web.Features.Registries;
using BirthList.Web.Services;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
@@ -24,7 +28,16 @@ builder.Services.AddHttpClient("RegistryMetadata", client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddHttpClient("AmazonPaApi", client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddHttpClient("RapidApi", client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<AmazonMetadataOptions>(builder.Configuration.GetSection("AmazonMetadata"));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
@@ -41,9 +54,12 @@ builder.Services.AddScoped<RegistryAuthorizationService>();
builder.Services.AddScoped<OwnerBootstrapService>();
builder.Services.AddScoped<RegistryService>();
builder.Services.AddScoped<RegistryMetadataService>();
builder.Services.AddScoped<AmazonPaApiClient>();
builder.Services.AddScoped<RapidApiMetadataClient>();
builder.Services.AddScoped<RegistryThemeService>();
builder.Services.AddScoped<RegistryUserContext>();
builder.Services.AddScoped<SmtpConfigurationStatusService>();
builder.Services.AddScoped<LocalizationService>();
var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"];
@@ -126,6 +142,35 @@ builder.Services.AddScoped<SmtpEmailSender>();
builder.Services.AddScoped<IEmailSender<ApplicationUser>>(serviceProvider => serviceProvider.GetRequiredService<SmtpEmailSender>());
builder.Services.AddScoped<ProfileCompletionService>();
builder.Services.AddLocalization(options =>
{
options.ResourcesPath = "Resources";
});
var supportedCultures = new List<CultureInfo>
{
new("nl-BE"),
new("en"),
new("fr-FR")
};
if (builder.Environment.IsDevelopment())
{
supportedCultures.Add(new CultureInfo("qps-Ploc"));
}
builder.Services.Configure<RequestLocalizationOptions>(options =>
{
options.DefaultRequestCulture = new RequestCulture("nl-BE");
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
options.RequestCultureProviders =
[
new CookieRequestCultureProvider(),
new AcceptLanguageHeaderRequestCultureProvider()
];
});
var app = builder.Build();
using (var scope = app.Services.CreateScope())
@@ -190,9 +235,50 @@ app.UseAuthorization();
app.UseHttpsRedirection();
app.UseRequestLocalization();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapPost("/set-language", async (
HttpContext context,
UserManager<ApplicationUser> userManager,
LocalizationService localizationService,
[FromForm] string culture,
[FromForm] string? returnUrl) =>
{
if (!localizationService.IsSupportedCulture(culture))
{
return Results.BadRequest("Unsupported culture.");
}
context.Response.Cookies.Append(
CookieRequestCultureProvider.DefaultCookieName,
LocalizationService.BuildCultureCookieValue(culture),
new CookieOptions
{
IsEssential = true,
Expires = DateTimeOffset.UtcNow.AddYears(1)
});
if (context.User.Identity?.IsAuthenticated == true)
{
var user = await userManager.GetUserAsync(context.User).ConfigureAwait(false);
if (user is not null)
{
await localizationService.SetPreferredCultureAsync(user, culture, context.RequestAborted).ConfigureAwait(false);
}
}
var targetUrl = string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl;
if (!Uri.TryCreate(targetUrl, UriKind.Relative, out _))
{
targetUrl = "/";
}
return Results.LocalRedirect(targetUrl);
});
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
@@ -0,0 +1,249 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, ...</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, ...</value></resheader>
<data name="Common.Yes" xml:space="preserve"><value>Oui</value></data>
<data name="Common.No" xml:space="preserve"><value>Non</value></data>
<data name="Common.Save" xml:space="preserve"><value>Enregistrer</value></data>
<data name="Common.Cancel" xml:space="preserve"><value>Annuler</value></data>
<data name="Common.Remove" xml:space="preserve"><value>Supprimer</value></data>
<data name="Common.Edit" xml:space="preserve"><value>Modifier</value></data>
<data name="Common.Back" xml:space="preserve"><value>Retour</value></data>
<data name="Common.Next" xml:space="preserve"><value>Suivant</value></data>
<data name="Common.Loading" xml:space="preserve"><value>Chargement...</value></data>
<data name="Common.AccessDenied" xml:space="preserve"><value>Accès refusé.</value></data>
<data name="Common.SelectUser" xml:space="preserve"><value>Sélectionner un utilisateur</value></data>
<data name="Common.SearchUser" xml:space="preserve"><value>Rechercher un utilisateur</value></data>
<data name="Common.Quantity" xml:space="preserve"><value>Quantité</value></data>
<data name="Common.Amount" xml:space="preserve"><value>Montant</value></data>
<data name="Common.Message" xml:space="preserve"><value>Message</value></data>
<data name="TopBar.Language" xml:space="preserve"><value>Langue</value></data>
<data name="TopBar.Brand" xml:space="preserve"><value>Gift List</value></data>
<data name="TopBar.ProfilePrompt" xml:space="preserve"><value>Veuillez compléter votre profil (prénom, nom et adresse).</value></data>
<data name="TopBar.CompleteProfile" xml:space="preserve"><value>Compléter le profil</value></data>
<data name="TopBar.AccountSettings" xml:space="preserve"><value>Paramètres du compte</value></data>
<data name="TopBar.SignIn" xml:space="preserve"><value>Se connecter</value></data>
<data name="TopBar.SignOut" xml:space="preserve"><value>Se déconnecter</value></data>
<data name="Home.PageTitle" xml:space="preserve"><value>Liste de naissance</value></data>
<data name="Home.Welcome" xml:space="preserve"><value>Bienvenue sur Gift List</value></data>
<data name="Home.ManagedRegistries" xml:space="preserve"><value>Listes que vous gérez</value></data>
<data name="Home.CreateNew" xml:space="preserve"><value>Créer</value></data>
<data name="Home.NoRegistries" xml:space="preserve"><value>Aucune liste pour le moment.</value></data>
<data name="Home.View" xml:space="preserve"><value>Voir</value></data>
<data name="Home.Manage" xml:space="preserve"><value>Gérer</value></data>
<data name="Home.VisitedRegistries" xml:space="preserve"><value>Listes visitées</value></data>
<data name="Home.NoVisitedRegistries" xml:space="preserve"><value>Aucune liste visitée pour le moment.</value></data>
<data name="Home.CreateRegistryTitle" xml:space="preserve"><value>Créer une nouvelle liste</value></data>
<data name="Home.Title" xml:space="preserve"><value>Titre</value></data>
<data name="Home.Type" xml:space="preserve"><value>Type</value></data>
<data name="Home.Theme" xml:space="preserve"><value>Thème</value></data>
<data name="Home.Theme.Default" xml:space="preserve"><value>Par défaut</value></data>
<data name="Home.Theme.Soft" xml:space="preserve"><value>Doux</value></data>
<data name="Home.Theme.Modern" xml:space="preserve"><value>Moderne</value></data>
<data name="Home.RegistryType.Birth" xml:space="preserve"><value>Naissance</value></data>
<data name="Home.RegistryType.Wedding" xml:space="preserve"><value>Mariage</value></data>
<data name="Home.RegistryType.Birthday" xml:space="preserve"><value>Anniversaire</value></data>
<data name="Home.MustBeLoggedIn" xml:space="preserve"><value>Vous devez être connecté pour créer une liste.</value></data>
<data name="Home.TitleRequired" xml:space="preserve"><value>Le titre est obligatoire.</value></data>
<data name="Home.LoginPrompt" xml:space="preserve"><value>Veuillez vous connecter pour créer et gérer des listes.</value></data>
<data name="Home.LoginPromptPrefix" xml:space="preserve"><value>Veuillez </value></data>
<data name="Home.LoginPromptLinkText" xml:space="preserve"><value>vous connecter</value></data>
<data name="Home.LoginPromptSuffix" xml:space="preserve"><value> pour créer et gérer des listes.</value></data>
<data name="RegistryPublic.PageTitle" xml:space="preserve"><value>Liste</value></data>
<data name="RegistryPublic.NotFound" xml:space="preserve"><value>Liste introuvable.</value></data>
<data name="RegistryPublic.GoToAdmin" xml:space="preserve"><value>Aller à l'admin</value></data>
<data name="RegistryPublic.ShippingAddress" xml:space="preserve"><value>Adresse de livraison</value></data>
<data name="RegistryPublic.BankTransferParticipation" xml:space="preserve"><value>Participation par virement</value></data>
<data name="RegistryPublic.IBAN" xml:space="preserve"><value>IBAN</value></data>
<data name="RegistryPublic.BIC" xml:space="preserve"><value>BIC</value></data>
<data name="RegistryPublic.SecondHandPreferred" xml:space="preserve"><value>Seconde main privilégiée</value></data>
<data name="RegistryPublic.SecondHandOptional" xml:space="preserve"><value>Seconde main possible</value></data>
<data name="RegistryPublic.Qty" xml:space="preserve"><value>Qté :</value></data>
<data name="RegistryPublic.PurchasedSuffix" xml:space="preserve"><value>achetés</value></data>
<data name="RegistryPublic.Price" xml:space="preserve"><value>Prix :</value></data>
<data name="RegistryPublic.Participation" xml:space="preserve"><value>Participation :</value></data>
<data name="RegistryPublic.OutOfFulfilled" xml:space="preserve"><value>sur {0}{1} atteints</value></data>
<data name="RegistryPublic.FulfilledOnly" xml:space="preserve"><value>atteints</value></data>
<data name="RegistryPublic.PurchasedBy" xml:space="preserve"><value>Acheté par :</value></data>
<data name="RegistryPublic.ContributedBy" xml:space="preserve"><value>Contribué par :</value></data>
<data name="RegistryPublic.YouQuantity" xml:space="preserve"><value>Vous ({0})</value></data>
<data name="RegistryPublic.AndOtherPeople" xml:space="preserve"><value>et {0} autre{1}</value></data>
<data name="RegistryPublic.Person" xml:space="preserve"><value> personne</value></data>
<data name="RegistryPublic.People" xml:space="preserve"><value>s personnes</value></data>
<data name="RegistryPublic.AndOthers" xml:space="preserve"><value>et d'autres</value></data>
<data name="RegistryPublic.Purchased" xml:space="preserve"><value>Acheté</value></data>
<data name="RegistryPublic.Contributed" xml:space="preserve"><value>Contribué</value></data>
<data name="RegistryPublic.LoginToPurchase" xml:space="preserve"><value>Connectez-vous pour acheter</value></data>
<data name="RegistryPublic.Purchase" xml:space="preserve"><value>Acheter</value></data>
<data name="RegistryPublic.ManagePurchases" xml:space="preserve"><value>Gérer les achats</value></data>
<data name="RegistryPublic.EditPurchase" xml:space="preserve"><value>Modifier l'achat</value></data>
<data name="RegistryPublic.MarkPurchased" xml:space="preserve"><value>Marquer comme acheté</value></data>
<data name="RegistryPublic.ManageParticipations" xml:space="preserve"><value>Gérer les participations</value></data>
<data name="RegistryPublic.EditParticipation" xml:space="preserve"><value>Modifier la participation</value></data>
<data name="RegistryPublic.PartiallyFulfill" xml:space="preserve"><value>Participer partiellement</value></data>
<data name="RegistryPublic.MarkAsPurchased" xml:space="preserve"><value>Marquer comme acheté</value></data>
<data name="RegistryPublic.HowManyUnitsPurchased" xml:space="preserve"><value>Combien d'unités avez-vous achetées ?</value></data>
<data name="RegistryPublic.OpenProductLink" xml:space="preserve"><value>Ouvrir le lien du produit</value></data>
<data name="RegistryPublic.LogContribution" xml:space="preserve"><value>Enregistrer la contribution</value></data>
<data name="RegistryPublic.TransferredAmount" xml:space="preserve"><value>Montant transféré</value></data>
<data name="RegistryPublic.Confirm" xml:space="preserve"><value>Confirmer</value></data>
<data name="RegistryPublic.ConfirmPurchase" xml:space="preserve"><value>Confirmer l'achat</value></data>
<data name="RegistryPublic.SelectPurchaserToUnmark" xml:space="preserve"><value>Sélectionner l'acheteur à annuler</value></data>
<data name="RegistryPublic.MultipleUsersPurchased" xml:space="preserve"><value>Plusieurs utilisateurs ont acheté cet article. Choisissez l'achat à annuler :</value></data>
<data name="RegistryPublic.Unmark" xml:space="preserve"><value>Annuler</value></data>
<data name="RegistryPublic.UnmarkAll" xml:space="preserve"><value>Tout annuler</value></data>
<data name="RegistryPublic.PartiallyFulfillItem" xml:space="preserve"><value>Participer partiellement à l'article</value></data>
<data name="RegistryPublic.SelectDonateMethod" xml:space="preserve"><value>Choisissez votre mode de participation :</value></data>
<data name="RegistryPublic.IbanTransfer" xml:space="preserve"><value>Virement IBAN</value></data>
<data name="RegistryPublic.SingleQrCode" xml:space="preserve"><value>QR code unique</value></data>
<data name="RegistryPublic.QrCodePerAmount" xml:space="preserve"><value>QR code par montant</value></data>
<data name="RegistryPublic.OpenPaymentLink" xml:space="preserve"><value>Ouvrir le lien de paiement</value></data>
<data name="RegistryPublic.HowMuchAdded" xml:space="preserve"><value>Quel montant avez-vous ajouté pour cet article ?</value></data>
<data name="RegistryPublic.NoQrForAmount" xml:space="preserve"><value>Aucun QR code configuré pour ce montant exact.</value></data>
<data name="RegistryPublic.ManagePurchase" xml:space="preserve"><value>Gérer l'achat</value></data>
<data name="RegistryPublic.ManageParticipation" xml:space="preserve"><value>Gérer la participation</value></data>
<data name="RegistryPublic.User" xml:space="preserve"><value>Utilisateur</value></data>
<data name="RegistryContributionAmount.PageTitle" xml:space="preserve"><value>Montant de participation</value></data>
<data name="RegistryContributionAmount.ItemNotFound" xml:space="preserve"><value>Article introuvable.</value></data>
<data name="RegistryContributionAmount.LoginFirst" xml:space="preserve"><value>Veuillez d'abord vous connecter.</value></data>
<data name="RegistryContributionAmount.PartiallyFulfill" xml:space="preserve"><value>Participation partielle : {0}</value></data>
<data name="RegistryContributionAmount.NoRepresentableAmount" xml:space="preserve"><value>Aucun montant représentable ne peut être formé avec les QR codes configurés jusqu'à {0}200.</value></data>
<data name="RegistryContributionAmount.SelectAmount" xml:space="preserve"><value>Sélectionnez un montant : {0}{1}</value></data>
<data name="RegistryContributionAmount.NoQrCombination" xml:space="preserve"><value>Aucune combinaison QR disponible pour ce montant.</value></data>
<data name="RegistryContributionAmount.SuggestedCombination" xml:space="preserve"><value>Combinaison QR suggérée :</value></data>
<data name="RegistryContributionAmount.OpenPaymentLink" xml:space="preserve"><value>Ouvrir le lien de paiement</value></data>
<data name="RegistryContributionAmount.TransferredAmount" xml:space="preserve"><value>J'ai transféré ce montant</value></data>
<data name="RegistryInvite.PageTitle" xml:space="preserve"><value>Invitation admin</value></data>
<data name="RegistryInvite.Title" xml:space="preserve"><value>Invitation administrateur</value></data>
<data name="RegistryInvite.Validating" xml:space="preserve"><value>Validation de l'invitation...</value></data>
<data name="RegistryInvite.Accepted" xml:space="preserve"><value>Invitation acceptée. Vous êtes maintenant administrateur.</value></data>
<data name="RegistryInvite.GoToAdmin" xml:space="preserve"><value>Aller à l'administration</value></data>
<data name="RegistryInvite.Invalid" xml:space="preserve"><value>L'invitation est invalide ou déjà utilisée.</value></data>
<data name="RegistryAdmin.PageTitle" xml:space="preserve"><value>Administration de la liste</value></data>
<data name="RegistryAdmin.Title" xml:space="preserve"><value>Administration de la liste</value></data>
<data name="RegistryAdmin.SmtpNotConfigured" xml:space="preserve"><value>SMTP n'est pas configuré. Les fonctionnalités e-mail (Identity et invitations admin) sont désactivées. Configurez la section Smtp dans appsettings ou user secrets.</value></data>
<data name="RegistryAdmin.Tab.Items" xml:space="preserve"><value>Articles</value></data>
<data name="RegistryAdmin.Tab.Settings" xml:space="preserve"><value>Paramètres</value></data>
<data name="RegistryAdmin.Tab.Administrators" xml:space="preserve"><value>Administrateurs</value></data>
<data name="RegistryAdmin.Tab.Addresses" xml:space="preserve"><value>Adresses</value></data>
<data name="RegistryAdmin.Tab.ActionLog" xml:space="preserve"><value>Journal d'actions</value></data>
<data name="RegistryAdmin.ViewPublicList" xml:space="preserve"><value>Voir la liste publique</value></data>
<data name="RegistryAdmin.AddOrEditItem" xml:space="preserve"><value>Ajouter ou modifier un article</value></data>
<data name="RegistryAdmin.Name" xml:space="preserve"><value>Nom</value></data>
<data name="RegistryAdmin.ProductUrl" xml:space="preserve"><value>URL du produit</value></data>
<data name="RegistryAdmin.AutoFetch" xml:space="preserve"><value>Remplissage auto</value></data>
<data name="RegistryAdmin.PictureUrl" xml:space="preserve"><value>URL de l'image</value></data>
<data name="RegistryAdmin.Description" xml:space="preserve"><value>Description</value></data>
<data name="RegistryAdmin.Price" xml:space="preserve"><value>Prix</value></data>
<data name="RegistryAdmin.CurrencySymbol" xml:space="preserve"><value>Symbole monétaire</value></data>
<data name="RegistryAdmin.DesiredQty" xml:space="preserve"><value>Quantité souhaitée</value></data>
<data name="RegistryAdmin.Participation" xml:space="preserve"><value>Participation</value></data>
<data name="RegistryAdmin.ParticipationTarget" xml:space="preserve"><value>Objectif de participation</value></data>
<data name="RegistryAdmin.SecondHandPreference" xml:space="preserve"><value>Préférence seconde main</value></data>
<data name="RegistryAdmin.SecondHandOptional" xml:space="preserve"><value>Seconde main possible</value></data>
<data name="RegistryAdmin.PreferSecondHand" xml:space="preserve"><value>Préférer la seconde main</value></data>
<data name="RegistryAdmin.NewOnly" xml:space="preserve"><value>Neuf uniquement</value></data>
<data name="RegistryAdmin.Given" xml:space="preserve"><value>Donné</value></data>
<data name="RegistryAdmin.Category" xml:space="preserve"><value>Catégorie</value></data>
<data name="RegistryAdmin.SaveItem" xml:space="preserve"><value>Enregistrer l'article</value></data>
<data name="RegistryAdmin.CategoriesAndItems" xml:space="preserve"><value>Catégories et articles</value></data>
<data name="RegistryAdmin.NewCategory" xml:space="preserve"><value>Nouvelle catégorie</value></data>
<data name="RegistryAdmin.AddCategory" xml:space="preserve"><value>Ajouter une catégorie</value></data>
<data name="RegistryAdmin.DragHint" xml:space="preserve"><value>Faites glisser les catégories ou les articles pour réorganiser. Déposez les articles dans une autre catégorie pour les regrouper.</value></data>
<data name="RegistryAdmin.Rename" xml:space="preserve"><value>Renommer</value></data>
<data name="RegistryAdmin.DropItemsHere" xml:space="preserve"><value>Déposez des articles ici.</value></data>
<data name="RegistryAdmin.Header.Name" xml:space="preserve"><value>Nom</value></data>
<data name="RegistryAdmin.Header.DesiredQty" xml:space="preserve"><value>Qté souhaitée</value></data>
<data name="RegistryAdmin.Header.Condition" xml:space="preserve"><value>État</value></data>
<data name="RegistryAdmin.Header.Participation" xml:space="preserve"><value>Participation</value></data>
<data name="RegistryAdmin.Header.PurchasedContributedBy" xml:space="preserve"><value>Acheté par / Contribué par</value></data>
<data name="RegistryAdmin.Purchased" xml:space="preserve"><value>Acheté :</value></data>
<data name="RegistryAdmin.Contributed" xml:space="preserve"><value>Contribué :</value></data>
<data name="RegistryAdmin.RegistrySettings" xml:space="preserve"><value>Paramètres de la liste</value></data>
<data name="RegistryAdmin.BabyName" xml:space="preserve"><value>Prénom du bébé</value></data>
<data name="RegistryAdmin.BirthDate" xml:space="preserve"><value>Date de naissance</value></data>
<data name="RegistryAdmin.Theme" xml:space="preserve"><value>Thème</value></data>
<data name="RegistryAdmin.Theme.Default" xml:space="preserve"><value>Par défaut</value></data>
<data name="RegistryAdmin.Theme.Soft" xml:space="preserve"><value>Doux</value></data>
<data name="RegistryAdmin.Theme.Modern" xml:space="preserve"><value>Moderne</value></data>
<data name="RegistryAdmin.ShippingAddress" xml:space="preserve"><value>Adresse de livraison</value></data>
<data name="RegistryAdmin.LineBreaksPreserved" xml:space="preserve"><value>Les sauts de ligne seront conservés</value></data>
<data name="RegistryAdmin.HeroImage" xml:space="preserve"><value>Image héros</value></data>
<data name="RegistryAdmin.HideHeaderName" xml:space="preserve"><value>Masquer le titre du nom sur la page publique</value></data>
<data name="RegistryAdmin.UploadImage" xml:space="preserve"><value>Télécharger une image</value></data>
<data name="RegistryAdmin.RemoveHeroImage" xml:space="preserve"><value>Supprimer l'image héros</value></data>
<data name="RegistryAdmin.CurrentHeroImage" xml:space="preserve"><value>Image héros actuelle</value></data>
<data name="RegistryAdmin.TopContent" xml:space="preserve"><value>Contenu d'en-tête</value></data>
<data name="RegistryAdmin.WelcomeText" xml:space="preserve"><value>Texte d'accueil</value></data>
<data name="RegistryAdmin.BankAccountSettings" xml:space="preserve"><value>Paramètres du compte bancaire</value></data>
<data name="RegistryAdmin.BankAccountName" xml:space="preserve"><value>Nom du compte bancaire</value></data>
<data name="RegistryAdmin.DisplayBankAccountName" xml:space="preserve"><value>Afficher le nom du compte bancaire</value></data>
<data name="RegistryAdmin.ContributionPaymentOptions" xml:space="preserve"><value>Options de paiement des participations</value></data>
<data name="RegistryAdmin.SingleQrCodeUrl" xml:space="preserve"><value>URL du QR code unique</value></data>
<data name="RegistryAdmin.SingleQrHelp" xml:space="preserve"><value>Optionnel : un QR code unique que les donateurs peuvent scanner pour n'importe quel montant.</value></data>
<data name="RegistryAdmin.AmountSpecificQrCodes" xml:space="preserve"><value>QR codes par montant</value></data>
<data name="RegistryAdmin.AddQrAmount" xml:space="preserve"><value>Ajouter un montant QR</value></data>
<data name="RegistryAdmin.NoAmountSpecificQrCodes" xml:space="preserve"><value>Aucun QR code par montant configuré.</value></data>
<data name="RegistryAdmin.QrCodeUrl" xml:space="preserve"><value>URL du QR code</value></data>
<data name="RegistryAdmin.SaveSettings" xml:space="preserve"><value>Enregistrer les paramètres</value></data>
<data name="RegistryAdmin.UserAddresses" xml:space="preserve"><value>Adresses des utilisateurs</value></data>
<data name="RegistryAdmin.NoUsersFound" xml:space="preserve"><value>Aucun utilisateur trouvé pour le moment.</value></data>
<data name="RegistryAdmin.Email" xml:space="preserve"><value>E-mail</value></data>
<data name="RegistryAdmin.Address" xml:space="preserve"><value>Adresse</value></data>
<data name="RegistryAdmin.CurrentAdministrators" xml:space="preserve"><value>Administrateurs actuels</value></data>
<data name="RegistryAdmin.NoAdmins" xml:space="preserve"><value>Aucun administrateur assigné pour le moment.</value></data>
<data name="RegistryAdmin.EmailOrName" xml:space="preserve"><value>E-mail / Nom</value></data>
<data name="RegistryAdmin.InviteAdministrator" xml:space="preserve"><value>Inviter un administrateur</value></data>
<data name="RegistryAdmin.OptionalEmail" xml:space="preserve"><value>e-mail optionnel</value></data>
<data name="RegistryAdmin.CreateInvite" xml:space="preserve"><value>Créer une invitation</value></data>
<data name="RegistryAdmin.InviteLink" xml:space="preserve"><value>Lien d'invitation :</value></data>
<data name="RegistryActionLog.PageTitle" xml:space="preserve"><value>Journal d'actions - Admin liste</value></data>
<data name="RegistryActionLog.Title" xml:space="preserve"><value>Journal d'actions de la liste</value></data>
<data name="RegistryActionLog.Description" xml:space="preserve"><value>Ce journal montre toutes les actions des utilisateurs sur cette liste : achats, participations et autres interactions.</value></data>
<data name="RegistryActionLog.NoActions" xml:space="preserve"><value>Aucune action enregistrée pour le moment.</value></data>
<data name="RegistryActionLog.DateTime" xml:space="preserve"><value>Date/Heure</value></data>
<data name="RegistryActionLog.User" xml:space="preserve"><value>Utilisateur</value></data>
<data name="RegistryActionLog.Action" xml:space="preserve"><value>Action</value></data>
<data name="RegistryActionLog.Item" xml:space="preserve"><value>Article</value></data>
<data name="RegistryActionLog.Quantity" xml:space="preserve"><value>Quantité</value></data>
<data name="RegistryActionLog.Amount" xml:space="preserve"><value>Montant</value></data>
<data name="RegistryActionLog.Details" xml:space="preserve"><value>Détails</value></data>
<data name="RegistryActionLog.Badge.RegistryOpened" xml:space="preserve"><value>Liste ouverte</value></data>
<data name="RegistryActionLog.Badge.ItemLinkOpened" xml:space="preserve"><value>Lien article ouvert</value></data>
<data name="RegistryActionLog.Badge.PurchaseMarked" xml:space="preserve"><value>Achat marqué</value></data>
<data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Achat annulé</value></data>
<data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Achat partiel</value></data>
<data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Participation enregistrée</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchSucceeded" xml:space="preserve"><value>Remplissage auto réussi</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchFailed" xml:space="preserve"><value>Remplissage auto échoué</value></data>
<data name="Error.PageTitle" xml:space="preserve"><value>Erreur</value></data>
<data name="Error.Title" xml:space="preserve"><value>Erreur.</value></data>
<data name="Error.Subtitle" xml:space="preserve"><value>Une erreur est survenue lors du traitement de votre demande.</value></data>
<data name="Error.RequestId" xml:space="preserve"><value>ID de requête :</value></data>
<data name="Error.DevMode" xml:space="preserve"><value>Mode Développement</value></data>
<data name="Error.DevHint1" xml:space="preserve"><value>Passer en environnement Development affichera des informations plus détaillées sur l'erreur.</value></data>
<data name="Error.DevHint2" xml:space="preserve"><value>L'environnement Development ne devrait pas être activé sur une application déployée.</value></data>
<data name="Error.DevHint3" xml:space="preserve"><value>Cela peut afficher des informations sensibles issues des exceptions aux utilisateurs finaux.</value></data>
<data name="Error.DevHint4" xml:space="preserve"><value>Pour le débogage local, activez l'environnement Development en définissant ASPNETCORE_ENVIRONMENT à Development puis redémarrez l'application.</value></data>
<data name="MainLayout.UnhandledError" xml:space="preserve"><value>Une erreur non gérée est survenue.</value></data>
<data name="MainLayout.Reload" xml:space="preserve"><value>Recharger</value></data>
<data name="NavMenu.Brand" xml:space="preserve"><value>BirthList</value></data>
<data name="NavMenu.NavigationMenu" xml:space="preserve"><value>Menu de navigation</value></data>
<data name="NavMenu.Home" xml:space="preserve"><value>Accueil</value></data>
<data name="NavMenu.Logout" xml:space="preserve"><value>Se déconnecter</value></data>
<data name="NavMenu.Register" xml:space="preserve"><value>S'inscrire</value></data>
<data name="NavMenu.Login" xml:space="preserve"><value>Se connecter</value></data>
</root>
@@ -0,0 +1,249 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, ...</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, ...</value></resheader>
<data name="Common.Yes" xml:space="preserve"><value>Ja</value></data>
<data name="Common.No" xml:space="preserve"><value>Nee</value></data>
<data name="Common.Save" xml:space="preserve"><value>Opslaan</value></data>
<data name="Common.Cancel" xml:space="preserve"><value>Annuleren</value></data>
<data name="Common.Remove" xml:space="preserve"><value>Verwijderen</value></data>
<data name="Common.Edit" xml:space="preserve"><value>Bewerken</value></data>
<data name="Common.Back" xml:space="preserve"><value>Terug</value></data>
<data name="Common.Next" xml:space="preserve"><value>Volgende</value></data>
<data name="Common.Loading" xml:space="preserve"><value>Laden...</value></data>
<data name="Common.AccessDenied" xml:space="preserve"><value>Toegang geweigerd.</value></data>
<data name="Common.SelectUser" xml:space="preserve"><value>Selecteer gebruiker</value></data>
<data name="Common.SearchUser" xml:space="preserve"><value>Zoek gebruiker</value></data>
<data name="Common.Quantity" xml:space="preserve"><value>Aantal</value></data>
<data name="Common.Amount" xml:space="preserve"><value>Bedrag</value></data>
<data name="Common.Message" xml:space="preserve"><value>Bericht</value></data>
<data name="TopBar.Language" xml:space="preserve"><value>Taal</value></data>
<data name="TopBar.Brand" xml:space="preserve"><value>Gift List</value></data>
<data name="TopBar.ProfilePrompt" xml:space="preserve"><value>Vul je profiel aan (voornaam, achternaam en adres).</value></data>
<data name="TopBar.CompleteProfile" xml:space="preserve"><value>Profiel aanvullen</value></data>
<data name="TopBar.AccountSettings" xml:space="preserve"><value>Accountinstellingen</value></data>
<data name="TopBar.SignIn" xml:space="preserve"><value>Aanmelden</value></data>
<data name="TopBar.SignOut" xml:space="preserve"><value>Afmelden</value></data>
<data name="Home.PageTitle" xml:space="preserve"><value>Geboortelijst</value></data>
<data name="Home.Welcome" xml:space="preserve"><value>Welkom bij Gift List</value></data>
<data name="Home.ManagedRegistries" xml:space="preserve"><value>Lijsten die jij beheert</value></data>
<data name="Home.CreateNew" xml:space="preserve"><value>Nieuwe maken</value></data>
<data name="Home.NoRegistries" xml:space="preserve"><value>Nog geen lijsten.</value></data>
<data name="Home.View" xml:space="preserve"><value>Bekijken</value></data>
<data name="Home.Manage" xml:space="preserve"><value>Beheren</value></data>
<data name="Home.VisitedRegistries" xml:space="preserve"><value>Bezochte lijsten</value></data>
<data name="Home.NoVisitedRegistries" xml:space="preserve"><value>Nog geen bezochte lijsten.</value></data>
<data name="Home.CreateRegistryTitle" xml:space="preserve"><value>Nieuwe lijst aanmaken</value></data>
<data name="Home.Title" xml:space="preserve"><value>Titel</value></data>
<data name="Home.Type" xml:space="preserve"><value>Type</value></data>
<data name="Home.Theme" xml:space="preserve"><value>Thema</value></data>
<data name="Home.Theme.Default" xml:space="preserve"><value>Standaard</value></data>
<data name="Home.Theme.Soft" xml:space="preserve"><value>Zacht</value></data>
<data name="Home.Theme.Modern" xml:space="preserve"><value>Modern</value></data>
<data name="Home.RegistryType.Birth" xml:space="preserve"><value>Geboorte</value></data>
<data name="Home.RegistryType.Wedding" xml:space="preserve"><value>Huwelijk</value></data>
<data name="Home.RegistryType.Birthday" xml:space="preserve"><value>Verjaardag</value></data>
<data name="Home.MustBeLoggedIn" xml:space="preserve"><value>Je moet aangemeld zijn om een lijst aan te maken.</value></data>
<data name="Home.TitleRequired" xml:space="preserve"><value>Titel is verplicht.</value></data>
<data name="Home.LoginPrompt" xml:space="preserve"><value>Meld je aan om lijsten aan te maken en te beheren.</value></data>
<data name="Home.LoginPromptPrefix" xml:space="preserve"><value>Gelieve </value></data>
<data name="Home.LoginPromptLinkText" xml:space="preserve"><value>aan te melden</value></data>
<data name="Home.LoginPromptSuffix" xml:space="preserve"><value> om lijsten aan te maken en te beheren.</value></data>
<data name="RegistryPublic.PageTitle" xml:space="preserve"><value>Lijst</value></data>
<data name="RegistryPublic.NotFound" xml:space="preserve"><value>Lijst niet gevonden.</value></data>
<data name="RegistryPublic.GoToAdmin" xml:space="preserve"><value>Naar beheer</value></data>
<data name="RegistryPublic.ShippingAddress" xml:space="preserve"><value>Leveringsadres</value></data>
<data name="RegistryPublic.BankTransferParticipation" xml:space="preserve"><value>Deelname via overschrijving</value></data>
<data name="RegistryPublic.IBAN" xml:space="preserve"><value>IBAN</value></data>
<data name="RegistryPublic.BIC" xml:space="preserve"><value>BIC</value></data>
<data name="RegistryPublic.SecondHandPreferred" xml:space="preserve"><value>Tweedehands heeft de voorkeur</value></data>
<data name="RegistryPublic.SecondHandOptional" xml:space="preserve"><value>Tweedehands is mogelijk</value></data>
<data name="RegistryPublic.Qty" xml:space="preserve"><value>Aantal:</value></data>
<data name="RegistryPublic.PurchasedSuffix" xml:space="preserve"><value>gekocht</value></data>
<data name="RegistryPublic.Price" xml:space="preserve"><value>Prijs:</value></data>
<data name="RegistryPublic.Participation" xml:space="preserve"><value>Deelname:</value></data>
<data name="RegistryPublic.OutOfFulfilled" xml:space="preserve"><value>van {0}{1} behaald</value></data>
<data name="RegistryPublic.FulfilledOnly" xml:space="preserve"><value>behaald</value></data>
<data name="RegistryPublic.PurchasedBy" xml:space="preserve"><value>Gekocht door:</value></data>
<data name="RegistryPublic.ContributedBy" xml:space="preserve"><value>Bijgedragen door:</value></data>
<data name="RegistryPublic.YouQuantity" xml:space="preserve"><value>Jij ({0})</value></data>
<data name="RegistryPublic.AndOtherPeople" xml:space="preserve"><value>en {0} andere {1}</value></data>
<data name="RegistryPublic.Person" xml:space="preserve"><value>persoon</value></data>
<data name="RegistryPublic.People" xml:space="preserve"><value>personen</value></data>
<data name="RegistryPublic.AndOthers" xml:space="preserve"><value>en anderen</value></data>
<data name="RegistryPublic.Purchased" xml:space="preserve"><value>Gekocht</value></data>
<data name="RegistryPublic.Contributed" xml:space="preserve"><value>Bijgedragen</value></data>
<data name="RegistryPublic.LoginToPurchase" xml:space="preserve"><value>Meld je aan om te kopen</value></data>
<data name="RegistryPublic.Purchase" xml:space="preserve"><value>Kopen</value></data>
<data name="RegistryPublic.ManagePurchases" xml:space="preserve"><value>Aankopen beheren</value></data>
<data name="RegistryPublic.EditPurchase" xml:space="preserve"><value>Aankoop bewerken</value></data>
<data name="RegistryPublic.MarkPurchased" xml:space="preserve"><value>Markeer als gekocht</value></data>
<data name="RegistryPublic.ManageParticipations" xml:space="preserve"><value>Deelnames beheren</value></data>
<data name="RegistryPublic.EditParticipation" xml:space="preserve"><value>Deelname bewerken</value></data>
<data name="RegistryPublic.PartiallyFulfill" xml:space="preserve"><value>Gedeeltelijk vervullen</value></data>
<data name="RegistryPublic.MarkAsPurchased" xml:space="preserve"><value>Markeer als gekocht</value></data>
<data name="RegistryPublic.HowManyUnitsPurchased" xml:space="preserve"><value>Hoeveel stuks heb je gekocht?</value></data>
<data name="RegistryPublic.OpenProductLink" xml:space="preserve"><value>Open productlink</value></data>
<data name="RegistryPublic.LogContribution" xml:space="preserve"><value>Bijdrage registreren</value></data>
<data name="RegistryPublic.TransferredAmount" xml:space="preserve"><value>Overgeschreven bedrag</value></data>
<data name="RegistryPublic.Confirm" xml:space="preserve"><value>Bevestigen</value></data>
<data name="RegistryPublic.ConfirmPurchase" xml:space="preserve"><value>Aankoop bevestigen</value></data>
<data name="RegistryPublic.SelectPurchaserToUnmark" xml:space="preserve"><value>Selecteer koper om te verwijderen</value></data>
<data name="RegistryPublic.MultipleUsersPurchased" xml:space="preserve"><value>Meerdere gebruikers hebben dit item gekocht. Kies welke aankoop je wil verwijderen:</value></data>
<data name="RegistryPublic.Unmark" xml:space="preserve"><value>Verwijderen</value></data>
<data name="RegistryPublic.UnmarkAll" xml:space="preserve"><value>Alles verwijderen</value></data>
<data name="RegistryPublic.PartiallyFulfillItem" xml:space="preserve"><value>Item gedeeltelijk vervullen</value></data>
<data name="RegistryPublic.SelectDonateMethod" xml:space="preserve"><value>Kies hoe je wil bijdragen:</value></data>
<data name="RegistryPublic.IbanTransfer" xml:space="preserve"><value>IBAN-overschrijving</value></data>
<data name="RegistryPublic.SingleQrCode" xml:space="preserve"><value>Enkele QR-code</value></data>
<data name="RegistryPublic.QrCodePerAmount" xml:space="preserve"><value>QR-code per bedrag</value></data>
<data name="RegistryPublic.OpenPaymentLink" xml:space="preserve"><value>Open betaallink</value></data>
<data name="RegistryPublic.HowMuchAdded" xml:space="preserve"><value>Hoeveel heb je toegevoegd voor dit item?</value></data>
<data name="RegistryPublic.NoQrForAmount" xml:space="preserve"><value>Geen QR-code ingesteld voor dit exacte bedrag.</value></data>
<data name="RegistryPublic.ManagePurchase" xml:space="preserve"><value>Aankoop beheren</value></data>
<data name="RegistryPublic.ManageParticipation" xml:space="preserve"><value>Deelname beheren</value></data>
<data name="RegistryPublic.User" xml:space="preserve"><value>Gebruiker</value></data>
<data name="RegistryContributionAmount.PageTitle" xml:space="preserve"><value>Deelnamebedrag</value></data>
<data name="RegistryContributionAmount.ItemNotFound" xml:space="preserve"><value>Item niet gevonden.</value></data>
<data name="RegistryContributionAmount.LoginFirst" xml:space="preserve"><value>Meld je eerst aan.</value></data>
<data name="RegistryContributionAmount.PartiallyFulfill" xml:space="preserve"><value>Gedeeltelijk vervullen: {0}</value></data>
<data name="RegistryContributionAmount.NoRepresentableAmount" xml:space="preserve"><value>Er kan geen voorstelbaar bedrag gevormd worden met ingestelde QR-codes tot {0}200.</value></data>
<data name="RegistryContributionAmount.SelectAmount" xml:space="preserve"><value>Selecteer bedrag: {0}{1}</value></data>
<data name="RegistryContributionAmount.NoQrCombination" xml:space="preserve"><value>Geen QR-combinatie beschikbaar voor dit bedrag.</value></data>
<data name="RegistryContributionAmount.SuggestedCombination" xml:space="preserve"><value>Voorgestelde QR-combinatie:</value></data>
<data name="RegistryContributionAmount.OpenPaymentLink" xml:space="preserve"><value>Open betaallink</value></data>
<data name="RegistryContributionAmount.TransferredAmount" xml:space="preserve"><value>Ik heb dit bedrag overgeschreven</value></data>
<data name="RegistryInvite.PageTitle" xml:space="preserve"><value>Admin-uitnodiging</value></data>
<data name="RegistryInvite.Title" xml:space="preserve"><value>Uitnodiging voor beheerder</value></data>
<data name="RegistryInvite.Validating" xml:space="preserve"><value>Uitnodiging valideren...</value></data>
<data name="RegistryInvite.Accepted" xml:space="preserve"><value>Uitnodiging geaccepteerd. Je bent nu beheerder.</value></data>
<data name="RegistryInvite.GoToAdmin" xml:space="preserve"><value>Ga naar beheer</value></data>
<data name="RegistryInvite.Invalid" xml:space="preserve"><value>De uitnodiging is ongeldig of al gebruikt.</value></data>
<data name="RegistryAdmin.PageTitle" xml:space="preserve"><value>Lijstbeheer</value></data>
<data name="RegistryAdmin.Title" xml:space="preserve"><value>Lijstbeheer</value></data>
<data name="RegistryAdmin.SmtpNotConfigured" xml:space="preserve"><value>SMTP is niet geconfigureerd. E-mailfuncties (Identity-mails en admin-uitnodigingen) zijn uitgeschakeld. Configureer de sectie Smtp in appsettings of user secrets.</value></data>
<data name="RegistryAdmin.Tab.Items" xml:space="preserve"><value>Items</value></data>
<data name="RegistryAdmin.Tab.Settings" xml:space="preserve"><value>Instellingen</value></data>
<data name="RegistryAdmin.Tab.Administrators" xml:space="preserve"><value>Beheerders</value></data>
<data name="RegistryAdmin.Tab.Addresses" xml:space="preserve"><value>Adressen</value></data>
<data name="RegistryAdmin.Tab.ActionLog" xml:space="preserve"><value>Actielog</value></data>
<data name="RegistryAdmin.ViewPublicList" xml:space="preserve"><value>Openbare lijst bekijken</value></data>
<data name="RegistryAdmin.AddOrEditItem" xml:space="preserve"><value>Item toevoegen of bewerken</value></data>
<data name="RegistryAdmin.Name" xml:space="preserve"><value>Naam</value></data>
<data name="RegistryAdmin.ProductUrl" xml:space="preserve"><value>Product-URL</value></data>
<data name="RegistryAdmin.AutoFetch" xml:space="preserve"><value>Automatisch ophalen</value></data>
<data name="RegistryAdmin.PictureUrl" xml:space="preserve"><value>Afbeeldings-URL</value></data>
<data name="RegistryAdmin.Description" xml:space="preserve"><value>Beschrijving</value></data>
<data name="RegistryAdmin.Price" xml:space="preserve"><value>Prijs</value></data>
<data name="RegistryAdmin.CurrencySymbol" xml:space="preserve"><value>Valutasymbool</value></data>
<data name="RegistryAdmin.DesiredQty" xml:space="preserve"><value>Gewenst aantal</value></data>
<data name="RegistryAdmin.Participation" xml:space="preserve"><value>Deelname</value></data>
<data name="RegistryAdmin.ParticipationTarget" xml:space="preserve"><value>Doelbedrag deelname</value></data>
<data name="RegistryAdmin.SecondHandPreference" xml:space="preserve"><value>Voorkeur tweedehands</value></data>
<data name="RegistryAdmin.SecondHandOptional" xml:space="preserve"><value>Tweedehands mogelijk</value></data>
<data name="RegistryAdmin.PreferSecondHand" xml:space="preserve"><value>Tweedehands verkiezen</value></data>
<data name="RegistryAdmin.NewOnly" xml:space="preserve"><value>Enkel nieuw</value></data>
<data name="RegistryAdmin.Given" xml:space="preserve"><value>Gegeven</value></data>
<data name="RegistryAdmin.Category" xml:space="preserve"><value>Categorie</value></data>
<data name="RegistryAdmin.SaveItem" xml:space="preserve"><value>Item opslaan</value></data>
<data name="RegistryAdmin.CategoriesAndItems" xml:space="preserve"><value>Categorieën en items</value></data>
<data name="RegistryAdmin.NewCategory" xml:space="preserve"><value>Nieuwe categorie</value></data>
<data name="RegistryAdmin.AddCategory" xml:space="preserve"><value>Categorie toevoegen</value></data>
<data name="RegistryAdmin.DragHint" xml:space="preserve"><value>Sleep categorieën of items om te herschikken. Zet items in een andere categorie om ze te groeperen.</value></data>
<data name="RegistryAdmin.Rename" xml:space="preserve"><value>Hernoemen</value></data>
<data name="RegistryAdmin.DropItemsHere" xml:space="preserve"><value>Sleep items hierheen.</value></data>
<data name="RegistryAdmin.Header.Name" xml:space="preserve"><value>Naam</value></data>
<data name="RegistryAdmin.Header.DesiredQty" xml:space="preserve"><value>Gewenst aantal</value></data>
<data name="RegistryAdmin.Header.Condition" xml:space="preserve"><value>Staat</value></data>
<data name="RegistryAdmin.Header.Participation" xml:space="preserve"><value>Deelname</value></data>
<data name="RegistryAdmin.Header.PurchasedContributedBy" xml:space="preserve"><value>Gekocht door / Bijgedragen door</value></data>
<data name="RegistryAdmin.Purchased" xml:space="preserve"><value>Gekocht:</value></data>
<data name="RegistryAdmin.Contributed" xml:space="preserve"><value>Bijgedragen:</value></data>
<data name="RegistryAdmin.RegistrySettings" xml:space="preserve"><value>Lijstinstellingen</value></data>
<data name="RegistryAdmin.BabyName" xml:space="preserve"><value>Naam baby</value></data>
<data name="RegistryAdmin.BirthDate" xml:space="preserve"><value>Geboortedatum</value></data>
<data name="RegistryAdmin.Theme" xml:space="preserve"><value>Thema</value></data>
<data name="RegistryAdmin.Theme.Default" xml:space="preserve"><value>Standaard</value></data>
<data name="RegistryAdmin.Theme.Soft" xml:space="preserve"><value>Zacht</value></data>
<data name="RegistryAdmin.Theme.Modern" xml:space="preserve"><value>Modern</value></data>
<data name="RegistryAdmin.ShippingAddress" xml:space="preserve"><value>Leveringsadres</value></data>
<data name="RegistryAdmin.LineBreaksPreserved" xml:space="preserve"><value>Regeleinden blijven behouden</value></data>
<data name="RegistryAdmin.HeroImage" xml:space="preserve"><value>Hero-afbeelding</value></data>
<data name="RegistryAdmin.HideHeaderName" xml:space="preserve"><value>Naamkop op openbare pagina verbergen</value></data>
<data name="RegistryAdmin.UploadImage" xml:space="preserve"><value>Afbeelding uploaden</value></data>
<data name="RegistryAdmin.RemoveHeroImage" xml:space="preserve"><value>Hero-afbeelding verwijderen</value></data>
<data name="RegistryAdmin.CurrentHeroImage" xml:space="preserve"><value>Huidige hero-afbeelding</value></data>
<data name="RegistryAdmin.TopContent" xml:space="preserve"><value>Bovenste inhoud</value></data>
<data name="RegistryAdmin.WelcomeText" xml:space="preserve"><value>Welkomsttekst</value></data>
<data name="RegistryAdmin.BankAccountSettings" xml:space="preserve"><value>Bankrekeninginstellingen</value></data>
<data name="RegistryAdmin.BankAccountName" xml:space="preserve"><value>Naam bankrekening</value></data>
<data name="RegistryAdmin.DisplayBankAccountName" xml:space="preserve"><value>Naam bankrekening tonen</value></data>
<data name="RegistryAdmin.ContributionPaymentOptions" xml:space="preserve"><value>Betaalopties voor deelname</value></data>
<data name="RegistryAdmin.SingleQrCodeUrl" xml:space="preserve"><value>URL van enkele QR-code</value></data>
<data name="RegistryAdmin.SingleQrHelp" xml:space="preserve"><value>Optioneel: één QR-code die schenkers voor eender welk bedrag kunnen scannen.</value></data>
<data name="RegistryAdmin.AmountSpecificQrCodes" xml:space="preserve"><value>Bedragsspecifieke QR-codes</value></data>
<data name="RegistryAdmin.AddQrAmount" xml:space="preserve"><value>QR-bedrag toevoegen</value></data>
<data name="RegistryAdmin.NoAmountSpecificQrCodes" xml:space="preserve"><value>Geen bedragsspecifieke QR-codes ingesteld.</value></data>
<data name="RegistryAdmin.QrCodeUrl" xml:space="preserve"><value>QR-code-URL</value></data>
<data name="RegistryAdmin.SaveSettings" xml:space="preserve"><value>Instellingen opslaan</value></data>
<data name="RegistryAdmin.UserAddresses" xml:space="preserve"><value>Gebruikersadressen</value></data>
<data name="RegistryAdmin.NoUsersFound" xml:space="preserve"><value>Nog geen gebruikers gevonden.</value></data>
<data name="RegistryAdmin.Email" xml:space="preserve"><value>E-mail</value></data>
<data name="RegistryAdmin.Address" xml:space="preserve"><value>Adres</value></data>
<data name="RegistryAdmin.CurrentAdministrators" xml:space="preserve"><value>Huidige beheerders</value></data>
<data name="RegistryAdmin.NoAdmins" xml:space="preserve"><value>Nog geen beheerders toegewezen.</value></data>
<data name="RegistryAdmin.EmailOrName" xml:space="preserve"><value>E-mail / Naam</value></data>
<data name="RegistryAdmin.InviteAdministrator" xml:space="preserve"><value>Beheerder uitnodigen</value></data>
<data name="RegistryAdmin.OptionalEmail" xml:space="preserve"><value>optionele e-mail</value></data>
<data name="RegistryAdmin.CreateInvite" xml:space="preserve"><value>Uitnodiging aanmaken</value></data>
<data name="RegistryAdmin.InviteLink" xml:space="preserve"><value>Uitnodigingslink:</value></data>
<data name="RegistryActionLog.PageTitle" xml:space="preserve"><value>Actielog - Lijstbeheer</value></data>
<data name="RegistryActionLog.Title" xml:space="preserve"><value>Actielog van de lijst</value></data>
<data name="RegistryActionLog.Description" xml:space="preserve"><value>Dit log toont alle gebruikersacties op deze lijst: aankopen, deelnames en andere interacties.</value></data>
<data name="RegistryActionLog.NoActions" xml:space="preserve"><value>Nog geen acties geregistreerd.</value></data>
<data name="RegistryActionLog.DateTime" xml:space="preserve"><value>Datum/tijd</value></data>
<data name="RegistryActionLog.User" xml:space="preserve"><value>Gebruiker</value></data>
<data name="RegistryActionLog.Action" xml:space="preserve"><value>Actie</value></data>
<data name="RegistryActionLog.Item" xml:space="preserve"><value>Item</value></data>
<data name="RegistryActionLog.Quantity" xml:space="preserve"><value>Aantal</value></data>
<data name="RegistryActionLog.Amount" xml:space="preserve"><value>Bedrag</value></data>
<data name="RegistryActionLog.Details" xml:space="preserve"><value>Details</value></data>
<data name="RegistryActionLog.Badge.RegistryOpened" xml:space="preserve"><value>Lijst geopend</value></data>
<data name="RegistryActionLog.Badge.ItemLinkOpened" xml:space="preserve"><value>Itemlink geopend</value></data>
<data name="RegistryActionLog.Badge.PurchaseMarked" xml:space="preserve"><value>Aankoop gemarkeerd</value></data>
<data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Aankoop verwijderd</value></data>
<data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Gedeeltelijke aankoop</value></data>
<data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Deelname geregistreerd</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchSucceeded" xml:space="preserve"><value>Automatisch ophalen gelukt</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchFailed" xml:space="preserve"><value>Automatisch ophalen mislukt</value></data>
<data name="Error.PageTitle" xml:space="preserve"><value>Fout</value></data>
<data name="Error.Title" xml:space="preserve"><value>Fout.</value></data>
<data name="Error.Subtitle" xml:space="preserve"><value>Er is een fout opgetreden bij het verwerken van je aanvraag.</value></data>
<data name="Error.RequestId" xml:space="preserve"><value>Aanvraag-ID:</value></data>
<data name="Error.DevMode" xml:space="preserve"><value>Ontwikkelmodus</value></data>
<data name="Error.DevHint1" xml:space="preserve"><value>Wanneer je naar de Development-omgeving schakelt, zie je meer gedetailleerde foutinformatie.</value></data>
<data name="Error.DevHint2" xml:space="preserve"><value>De Development-omgeving mag niet ingeschakeld zijn op gedeployde toepassingen.</value></data>
<data name="Error.DevHint3" xml:space="preserve"><value>Dat kan gevoelige informatie uit uitzonderingen tonen aan eindgebruikers.</value></data>
<data name="Error.DevHint4" xml:space="preserve"><value>Voor lokaal debuggen: zet ASPNETCORE_ENVIRONMENT op Development en herstart de app.</value></data>
<data name="MainLayout.UnhandledError" xml:space="preserve"><value>Er is een onverwachte fout opgetreden.</value></data>
<data name="MainLayout.Reload" xml:space="preserve"><value>Herladen</value></data>
<data name="NavMenu.Brand" xml:space="preserve"><value>BirthList</value></data>
<data name="NavMenu.NavigationMenu" xml:space="preserve"><value>Navigatiemenu</value></data>
<data name="NavMenu.Home" xml:space="preserve"><value>Start</value></data>
<data name="NavMenu.Logout" xml:space="preserve"><value>Afmelden</value></data>
<data name="NavMenu.Register" xml:space="preserve"><value>Registreren</value></data>
<data name="NavMenu.Login" xml:space="preserve"><value>Aanmelden</value></data>
</root>
@@ -0,0 +1,705 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, ...</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, ...</value>
</resheader>
<data name="Common.Yes" xml:space="preserve">
<value>[[Yes]]</value>
</data>
<data name="Common.No" xml:space="preserve">
<value>[[No]]</value>
</data>
<data name="Common.Save" xml:space="preserve">
<value>[[Save]]</value>
</data>
<data name="Common.Cancel" xml:space="preserve">
<value>[[Cancel]]</value>
</data>
<data name="Common.Remove" xml:space="preserve">
<value>[[Remove]]</value>
</data>
<data name="Common.Edit" xml:space="preserve">
<value>[[Edit]]</value>
</data>
<data name="Common.Back" xml:space="preserve">
<value>[[Back]]</value>
</data>
<data name="Common.Next" xml:space="preserve">
<value>[[Next]]</value>
</data>
<data name="Common.Loading" xml:space="preserve">
<value>[[Loading...]]</value>
</data>
<data name="Common.AccessDenied" xml:space="preserve">
<value>[[Access denied.]]</value>
</data>
<data name="Common.SelectUser" xml:space="preserve">
<value>[[Select user]]</value>
</data>
<data name="Common.SearchUser" xml:space="preserve">
<value>[[Search user]]</value>
</data>
<data name="Common.Quantity" xml:space="preserve">
<value>[[Quantity]]</value>
</data>
<data name="Common.Amount" xml:space="preserve">
<value>[[Amount]]</value>
</data>
<data name="Common.Message" xml:space="preserve">
<value>[[Message]]</value>
</data>
<data name="TopBar.Language" xml:space="preserve">
<value>[[Language]]</value>
</data>
<data name="TopBar.Brand" xml:space="preserve">
<value>[[Gift List]]</value>
</data>
<data name="TopBar.ProfilePrompt" xml:space="preserve">
<value>[[Please complete your profile (first name, last name, and address).]]</value>
</data>
<data name="TopBar.CompleteProfile" xml:space="preserve">
<value>[[Complete profile]]</value>
</data>
<data name="TopBar.AccountSettings" xml:space="preserve">
<value>[[Account settings]]</value>
</data>
<data name="TopBar.SignIn" xml:space="preserve">
<value>[[Sign in]]</value>
</data>
<data name="TopBar.SignOut" xml:space="preserve">
<value>[[Sign out]]</value>
</data>
<data name="Home.PageTitle" xml:space="preserve">
<value>[[Birth Registry]]</value>
</data>
<data name="Home.Welcome" xml:space="preserve">
<value>[[Welcome to Gift List]]</value>
</data>
<data name="Home.ManagedRegistries" xml:space="preserve">
<value>[[Registries you manage]]</value>
</data>
<data name="Home.CreateNew" xml:space="preserve">
<value>[[Create new]]</value>
</data>
<data name="Home.NoRegistries" xml:space="preserve">
<value>[[No registries yet.]]</value>
</data>
<data name="Home.View" xml:space="preserve">
<value>[[View]]</value>
</data>
<data name="Home.Manage" xml:space="preserve">
<value>[[Manage]]</value>
</data>
<data name="Home.VisitedRegistries" xml:space="preserve">
<value>[[Visited registries]]</value>
</data>
<data name="Home.NoVisitedRegistries" xml:space="preserve">
<value>[[No visited registries yet.]]</value>
</data>
<data name="Home.CreateRegistryTitle" xml:space="preserve">
<value>[[Create new registry]]</value>
</data>
<data name="Home.Title" xml:space="preserve">
<value>[[Title]]</value>
</data>
<data name="Home.Type" xml:space="preserve">
<value>[[Type]]</value>
</data>
<data name="Home.Theme" xml:space="preserve">
<value>[[Theme]]</value>
</data>
<data name="Home.Theme.Default" xml:space="preserve">
<value>[[Default]]</value>
</data>
<data name="Home.Theme.Soft" xml:space="preserve">
<value>[[Soft]]</value>
</data>
<data name="Home.Theme.Modern" xml:space="preserve">
<value>[[Modern]]</value>
</data>
<data name="Home.RegistryType.Birth" xml:space="preserve">
<value>[[Birth]]</value>
</data>
<data name="Home.RegistryType.Wedding" xml:space="preserve">
<value>[[Wedding]]</value>
</data>
<data name="Home.RegistryType.Birthday" xml:space="preserve">
<value>[[Birthday]]</value>
</data>
<data name="Home.MustBeLoggedIn" xml:space="preserve">
<value>[[You must be logged in to create a registry.]]</value>
</data>
<data name="Home.TitleRequired" xml:space="preserve">
<value>[[Title is required.]]</value>
</data>
<data name="Home.LoginPromptPrefix" xml:space="preserve">
<value>[[Please ]]</value>
</data>
<data name="Home.LoginPromptLinkText" xml:space="preserve">
<value>[[log in]]</value>
</data>
<data name="Home.LoginPromptSuffix" xml:space="preserve">
<value>[[ to create and manage registries.]]</value>
</data>
<data name="RegistryPublic.PageTitle" xml:space="preserve">
<value>[[Registry]]</value>
</data>
<data name="RegistryPublic.NotFound" xml:space="preserve">
<value>[[Registry not found.]]</value>
</data>
<data name="RegistryPublic.GoToAdmin" xml:space="preserve">
<value>[[Go to Admin]]</value>
</data>
<data name="RegistryPublic.ShippingAddress" xml:space="preserve">
<value>[[Shipping address]]</value>
</data>
<data name="RegistryPublic.BankTransferParticipation" xml:space="preserve">
<value>[[Bank transfer participation]]</value>
</data>
<data name="RegistryPublic.IBAN" xml:space="preserve">
<value>[[IBAN]]</value>
</data>
<data name="RegistryPublic.BIC" xml:space="preserve">
<value>[[BIC]]</value>
</data>
<data name="RegistryPublic.SecondHandPreferred" xml:space="preserve">
<value>[[Second-hand preferred]]</value>
</data>
<data name="RegistryPublic.SecondHandOptional" xml:space="preserve">
<value>[[Second-hand optional]]</value>
</data>
<data name="RegistryPublic.Qty" xml:space="preserve">
<value>[[Qty:]]</value>
</data>
<data name="RegistryPublic.PurchasedSuffix" xml:space="preserve">
<value>[[purchased]]</value>
</data>
<data name="RegistryPublic.Price" xml:space="preserve">
<value>[[Price:]]</value>
</data>
<data name="RegistryPublic.Participation" xml:space="preserve">
<value>[[Participation:]]</value>
</data>
<data name="RegistryPublic.OutOfFulfilled" xml:space="preserve">
<value>[[out of {0}{1} fulfilled]]</value>
</data>
<data name="RegistryPublic.FulfilledOnly" xml:space="preserve">
<value>[[fulfilled]]</value>
</data>
<data name="RegistryPublic.PurchasedBy" xml:space="preserve">
<value>[[Purchased by:]]</value>
</data>
<data name="RegistryPublic.ContributedBy" xml:space="preserve">
<value>[[Contributed by:]]</value>
</data>
<data name="RegistryPublic.YouQuantity" xml:space="preserve">
<value>[[You ({0})]]</value>
</data>
<data name="RegistryPublic.AndOtherPeople" xml:space="preserve">
<value>[[and {0} other {1}]]</value>
</data>
<data name="RegistryPublic.Person" xml:space="preserve">
<value>[[person]]</value>
</data>
<data name="RegistryPublic.People" xml:space="preserve">
<value>[[people]]</value>
</data>
<data name="RegistryPublic.AndOthers" xml:space="preserve">
<value>[[and others]]</value>
</data>
<data name="RegistryPublic.Purchased" xml:space="preserve">
<value>[[Purchased]]</value>
</data>
<data name="RegistryPublic.Contributed" xml:space="preserve">
<value>[[Contributed]]</value>
</data>
<data name="RegistryPublic.LoginToPurchase" xml:space="preserve">
<value>[[Login to purchase]]</value>
</data>
<data name="RegistryPublic.Purchase" xml:space="preserve">
<value>[[Purchase]]</value>
</data>
<data name="RegistryPublic.ManagePurchases" xml:space="preserve">
<value>[[Manage purchases]]</value>
</data>
<data name="RegistryPublic.EditPurchase" xml:space="preserve">
<value>[[Edit purchase]]</value>
</data>
<data name="RegistryPublic.MarkPurchased" xml:space="preserve">
<value>[[Mark purchased]]</value>
</data>
<data name="RegistryPublic.ManageParticipations" xml:space="preserve">
<value>[[Manage participations]]</value>
</data>
<data name="RegistryPublic.EditParticipation" xml:space="preserve">
<value>[[Edit participation]]</value>
</data>
<data name="RegistryPublic.PartiallyFulfill" xml:space="preserve">
<value>[[Partially fulfill]]</value>
</data>
<data name="RegistryPublic.MarkAsPurchased" xml:space="preserve">
<value>[[Mark as purchased]]</value>
</data>
<data name="RegistryPublic.HowManyUnitsPurchased" xml:space="preserve">
<value>[[How many units did you purchase?]]</value>
</data>
<data name="RegistryPublic.OpenProductLink" xml:space="preserve">
<value>[[Open product link]]</value>
</data>
<data name="RegistryPublic.ConfirmPurchase" xml:space="preserve">
<value>[[Confirm purchase]]</value>
</data>
<data name="RegistryPublic.LogContribution" xml:space="preserve">
<value>[[Log contribution]]</value>
</data>
<data name="RegistryPublic.TransferredAmount" xml:space="preserve">
<value>[[Transferred amount]]</value>
</data>
<data name="RegistryPublic.Confirm" xml:space="preserve">
<value>[[Confirm]]</value>
</data>
<data name="RegistryPublic.SelectPurchaserToUnmark" xml:space="preserve">
<value>[[Select purchaser to unmark]]</value>
</data>
<data name="RegistryPublic.MultipleUsersPurchased" xml:space="preserve">
<value>[[Multiple users have purchased this item. Choose which purchase to unmark:]]</value>
</data>
<data name="RegistryPublic.Unmark" xml:space="preserve">
<value>[[Unmark]]</value>
</data>
<data name="RegistryPublic.UnmarkAll" xml:space="preserve">
<value>[[Unmark all]]</value>
</data>
<data name="RegistryPublic.PartiallyFulfillItem" xml:space="preserve">
<value>[[Partially fulfill item]]</value>
</data>
<data name="RegistryPublic.SelectDonateMethod" xml:space="preserve">
<value>[[Select how you want to donate:]]</value>
</data>
<data name="RegistryPublic.IbanTransfer" xml:space="preserve">
<value>[[IBAN transfer]]</value>
</data>
<data name="RegistryPublic.SingleQrCode" xml:space="preserve">
<value>[[Single QR code]]</value>
</data>
<data name="RegistryPublic.QrCodePerAmount" xml:space="preserve">
<value>[[QR code per amount]]</value>
</data>
<data name="RegistryPublic.OpenPaymentLink" xml:space="preserve">
<value>[[Open payment link]]</value>
</data>
<data name="RegistryPublic.HowMuchAdded" xml:space="preserve">
<value>[[How much did you add for this item?]]</value>
</data>
<data name="RegistryPublic.NoQrForAmount" xml:space="preserve">
<value>[[No QR code configured for this exact amount.]]</value>
</data>
<data name="RegistryPublic.ManagePurchase" xml:space="preserve">
<value>[[Manage purchase]]</value>
</data>
<data name="RegistryPublic.ManageParticipation" xml:space="preserve">
<value>[[Manage participation]]</value>
</data>
<data name="RegistryPublic.User" xml:space="preserve">
<value>[[User]]</value>
</data>
<data name="RegistryContributionAmount.PageTitle" xml:space="preserve">
<value>[[Contribution Amount]]</value>
</data>
<data name="RegistryContributionAmount.ItemNotFound" xml:space="preserve">
<value>[[Item not found.]]</value>
</data>
<data name="RegistryContributionAmount.LoginFirst" xml:space="preserve">
<value>[[Please log in first.]]</value>
</data>
<data name="RegistryContributionAmount.PartiallyFulfill" xml:space="preserve">
<value>[[Partially fulfill: {0}]]</value>
</data>
<data name="RegistryContributionAmount.NoRepresentableAmount" xml:space="preserve">
<value>[[No representable amount can be formed from configured QR codes up to {0}200.]]</value>
</data>
<data name="RegistryContributionAmount.SelectAmount" xml:space="preserve">
<value>[[Select amount: {0}{1}]]</value>
</data>
<data name="RegistryContributionAmount.NoQrCombination" xml:space="preserve">
<value>[[No QR combination available for this amount.]]</value>
</data>
<data name="RegistryContributionAmount.SuggestedCombination" xml:space="preserve">
<value>[[Suggested QR combination:]]</value>
</data>
<data name="RegistryContributionAmount.OpenPaymentLink" xml:space="preserve">
<value>[[Open payment link]]</value>
</data>
<data name="RegistryContributionAmount.TransferredAmount" xml:space="preserve">
<value>[[I transferred this amount]]</value>
</data>
<data name="RegistryInvite.PageTitle" xml:space="preserve">
<value>[[Admin Invite]]</value>
</data>
<data name="RegistryInvite.Title" xml:space="preserve">
<value>[[Admin invitation]]</value>
</data>
<data name="RegistryInvite.Validating" xml:space="preserve">
<value>[[Validating invitation...]]</value>
</data>
<data name="RegistryInvite.Accepted" xml:space="preserve">
<value>[[Invitation accepted. You are now an admin.]]</value>
</data>
<data name="RegistryInvite.GoToAdmin" xml:space="preserve">
<value>[[Go to admin]]</value>
</data>
<data name="RegistryInvite.Invalid" xml:space="preserve">
<value>[[The invitation is invalid or already used.]]</value>
</data>
<data name="RegistryAdmin.PageTitle" xml:space="preserve">
<value>[[Registry Admin]]</value>
</data>
<data name="RegistryAdmin.Title" xml:space="preserve">
<value>[[Registry Admin]]</value>
</data>
<data name="RegistryAdmin.SmtpNotConfigured" xml:space="preserve">
<value>[[SMTP is not configured. Email features (identity emails and admin invite emails) are disabled. Configure the Smtp section in appsettings or user secrets.]]</value>
</data>
<data name="RegistryAdmin.Tab.Items" xml:space="preserve">
<value>[[Items]]</value>
</data>
<data name="RegistryAdmin.Tab.Settings" xml:space="preserve">
<value>[[Settings]]</value>
</data>
<data name="RegistryAdmin.Tab.Administrators" xml:space="preserve">
<value>[[Administrators]]</value>
</data>
<data name="RegistryAdmin.Tab.Addresses" xml:space="preserve">
<value>[[Addresses]]</value>
</data>
<data name="RegistryAdmin.Tab.ActionLog" xml:space="preserve">
<value>[[Action Log]]</value>
</data>
<data name="RegistryAdmin.ViewPublicList" xml:space="preserve">
<value>[[View Public List]]</value>
</data>
<data name="RegistryAdmin.AddOrEditItem" xml:space="preserve">
<value>[[Add or edit item]]</value>
</data>
<data name="RegistryAdmin.Name" xml:space="preserve">
<value>[[Name]]</value>
</data>
<data name="RegistryAdmin.ProductUrl" xml:space="preserve">
<value>[[Product URL]]</value>
</data>
<data name="RegistryAdmin.AutoFetch" xml:space="preserve">
<value>[[Auto fetch]]</value>
</data>
<data name="RegistryAdmin.PictureUrl" xml:space="preserve">
<value>[[Picture URL]]</value>
</data>
<data name="RegistryAdmin.Description" xml:space="preserve">
<value>[[Description]]</value>
</data>
<data name="RegistryAdmin.Price" xml:space="preserve">
<value>[[Price]]</value>
</data>
<data name="RegistryAdmin.CurrencySymbol" xml:space="preserve">
<value>[[Currency symbol]]</value>
</data>
<data name="RegistryAdmin.DesiredQty" xml:space="preserve">
<value>[[Desired qty]]</value>
</data>
<data name="RegistryAdmin.Participation" xml:space="preserve">
<value>[[Participation]]</value>
</data>
<data name="RegistryAdmin.ParticipationTarget" xml:space="preserve">
<value>[[Participation target]]</value>
</data>
<data name="RegistryAdmin.SecondHandPreference" xml:space="preserve">
<value>[[Second hand preference]]</value>
</data>
<data name="RegistryAdmin.SecondHandOptional" xml:space="preserve">
<value>[[Second hand optional]]</value>
</data>
<data name="RegistryAdmin.PreferSecondHand" xml:space="preserve">
<value>[[Prefer second hand]]</value>
</data>
<data name="RegistryAdmin.NewOnly" xml:space="preserve">
<value>[[New only]]</value>
</data>
<data name="RegistryAdmin.Given" xml:space="preserve">
<value>[[Given]]</value>
</data>
<data name="RegistryAdmin.Category" xml:space="preserve">
<value>[[Category]]</value>
</data>
<data name="RegistryAdmin.SaveItem" xml:space="preserve">
<value>[[Save item]]</value>
</data>
<data name="RegistryAdmin.CategoriesAndItems" xml:space="preserve">
<value>[[Categories and items]]</value>
</data>
<data name="RegistryAdmin.NewCategory" xml:space="preserve">
<value>[[New category]]</value>
</data>
<data name="RegistryAdmin.AddCategory" xml:space="preserve">
<value>[[Add category]]</value>
</data>
<data name="RegistryAdmin.DragHint" xml:space="preserve">
<value>[[Drag categories or items to reorder. Drop items into another category to regroup them.]]</value>
</data>
<data name="RegistryAdmin.Rename" xml:space="preserve">
<value>[[Rename]]</value>
</data>
<data name="RegistryAdmin.DropItemsHere" xml:space="preserve">
<value>[[Drop items here.]]</value>
</data>
<data name="RegistryAdmin.Header.Name" xml:space="preserve">
<value>[[Name]]</value>
</data>
<data name="RegistryAdmin.Header.DesiredQty" xml:space="preserve">
<value>[[Desired Qty]]</value>
</data>
<data name="RegistryAdmin.Header.Condition" xml:space="preserve">
<value>[[Condition]]</value>
</data>
<data name="RegistryAdmin.Header.Participation" xml:space="preserve">
<value>[[Participation]]</value>
</data>
<data name="RegistryAdmin.Header.PurchasedContributedBy" xml:space="preserve">
<value>[[Purchased by / Contributed by]]</value>
</data>
<data name="RegistryAdmin.Purchased" xml:space="preserve">
<value>[[Purchased:]]</value>
</data>
<data name="RegistryAdmin.Contributed" xml:space="preserve">
<value>[[Contributed:]]</value>
</data>
<data name="RegistryAdmin.RegistrySettings" xml:space="preserve">
<value>[[Registry Settings]]</value>
</data>
<data name="RegistryAdmin.BabyName" xml:space="preserve">
<value>[[Baby name]]</value>
</data>
<data name="RegistryAdmin.BirthDate" xml:space="preserve">
<value>[[Birth date]]</value>
</data>
<data name="RegistryAdmin.Theme" xml:space="preserve">
<value>[[Theme]]</value>
</data>
<data name="RegistryAdmin.Theme.Default" xml:space="preserve">
<value>[[Default]]</value>
</data>
<data name="RegistryAdmin.Theme.Soft" xml:space="preserve">
<value>[[Soft]]</value>
</data>
<data name="RegistryAdmin.Theme.Modern" xml:space="preserve">
<value>[[Modern]]</value>
</data>
<data name="RegistryAdmin.ShippingAddress" xml:space="preserve">
<value>[[Shipping address]]</value>
</data>
<data name="RegistryAdmin.LineBreaksPreserved" xml:space="preserve">
<value>[[Line breaks will be preserved]]</value>
</data>
<data name="RegistryAdmin.HeroImage" xml:space="preserve">
<value>[[Hero image]]</value>
</data>
<data name="RegistryAdmin.HideHeaderName" xml:space="preserve">
<value>[[Hide name heading on public page]]</value>
</data>
<data name="RegistryAdmin.UploadImage" xml:space="preserve">
<value>[[Upload image]]</value>
</data>
<data name="RegistryAdmin.RemoveHeroImage" xml:space="preserve">
<value>[[Remove hero image]]</value>
</data>
<data name="RegistryAdmin.CurrentHeroImage" xml:space="preserve">
<value>[[Current hero image]]</value>
</data>
<data name="RegistryAdmin.TopContent" xml:space="preserve">
<value>[[Top content]]</value>
</data>
<data name="RegistryAdmin.WelcomeText" xml:space="preserve">
<value>[[Welcome text]]</value>
</data>
<data name="RegistryAdmin.BankAccountSettings" xml:space="preserve">
<value>[[Bank Account Settings]]</value>
</data>
<data name="RegistryAdmin.BankAccountName" xml:space="preserve">
<value>[[Bank account name]]</value>
</data>
<data name="RegistryAdmin.DisplayBankAccountName" xml:space="preserve">
<value>[[Display bank account name]]</value>
</data>
<data name="RegistryAdmin.ContributionPaymentOptions" xml:space="preserve">
<value>[[Contribution Payment Options]]</value>
</data>
<data name="RegistryAdmin.SingleQrCodeUrl" xml:space="preserve">
<value>[[Single QR code URL]]</value>
</data>
<data name="RegistryAdmin.SingleQrHelp" xml:space="preserve">
<value>[[Optional: one QR code that donors can scan for any amount.]]</value>
</data>
<data name="RegistryAdmin.AmountSpecificQrCodes" xml:space="preserve">
<value>[[Amount-specific QR codes]]</value>
</data>
<data name="RegistryAdmin.AddQrAmount" xml:space="preserve">
<value>[[Add QR amount]]</value>
</data>
<data name="RegistryAdmin.NoAmountSpecificQrCodes" xml:space="preserve">
<value>[[No amount-specific QR codes configured.]]</value>
</data>
<data name="RegistryAdmin.QrCodeUrl" xml:space="preserve">
<value>[[QR code URL]]</value>
</data>
<data name="RegistryAdmin.SaveSettings" xml:space="preserve">
<value>[[Save settings]]</value>
</data>
<data name="RegistryAdmin.UserAddresses" xml:space="preserve">
<value>[[User addresses]]</value>
</data>
<data name="RegistryAdmin.NoUsersFound" xml:space="preserve">
<value>[[No users found yet.]]</value>
</data>
<data name="RegistryAdmin.Email" xml:space="preserve">
<value>[[Email]]</value>
</data>
<data name="RegistryAdmin.Address" xml:space="preserve">
<value>[[Address]]</value>
</data>
<data name="RegistryAdmin.CurrentAdministrators" xml:space="preserve">
<value>[[Current administrators]]</value>
</data>
<data name="RegistryAdmin.NoAdmins" xml:space="preserve">
<value>[[No admins assigned yet.]]</value>
</data>
<data name="RegistryAdmin.EmailOrName" xml:space="preserve">
<value>[[Email / Name]]</value>
</data>
<data name="RegistryAdmin.InviteAdministrator" xml:space="preserve">
<value>[[Invite administrator]]</value>
</data>
<data name="RegistryAdmin.OptionalEmail" xml:space="preserve">
<value>[[optional email]]</value>
</data>
<data name="RegistryAdmin.CreateInvite" xml:space="preserve">
<value>[[Create invite]]</value>
</data>
<data name="RegistryAdmin.InviteLink" xml:space="preserve">
<value>[[Invite link:]]</value>
</data>
<data name="RegistryActionLog.PageTitle" xml:space="preserve">
<value>[[Action Log - Registry Admin]]</value>
</data>
<data name="RegistryActionLog.Title" xml:space="preserve">
<value>[[Registry Action Log]]</value>
</data>
<data name="RegistryActionLog.Description" xml:space="preserve">
<value>[[This log shows all user actions on this registry: purchases, contributions, and other interactions.]]</value>
</data>
<data name="RegistryActionLog.NoActions" xml:space="preserve">
<value>[[No actions recorded yet.]]</value>
</data>
<data name="RegistryActionLog.DateTime" xml:space="preserve">
<value>[[Date/Time]]</value>
</data>
<data name="RegistryActionLog.User" xml:space="preserve">
<value>[[User]]</value>
</data>
<data name="RegistryActionLog.Action" xml:space="preserve">
<value>[[Action]]</value>
</data>
<data name="RegistryActionLog.Item" xml:space="preserve">
<value>[[Item]]</value>
</data>
<data name="RegistryActionLog.Quantity" xml:space="preserve">
<value>[[Quantity]]</value>
</data>
<data name="RegistryActionLog.Amount" xml:space="preserve">
<value>[[Amount]]</value>
</data>
<data name="RegistryActionLog.Details" xml:space="preserve">
<value>[[Details]]</value>
</data>
<data name="RegistryActionLog.Badge.RegistryOpened" xml:space="preserve">
<value>[[Registry opened]]</value>
</data>
<data name="RegistryActionLog.Badge.ItemLinkOpened" xml:space="preserve">
<value>[[Item link opened]]</value>
</data>
<data name="RegistryActionLog.Badge.PurchaseMarked" xml:space="preserve">
<value>[[Purchase marked]]</value>
</data>
<data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve">
<value>[[Purchase unmarked]]</value>
</data>
<data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve">
<value>[[Partial purchase]]</value>
</data>
<data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve">
<value>[[Contribution logged]]</value>
</data>
<data name="RegistryActionLog.Badge.MetadataFetchSucceeded" xml:space="preserve">
<value>[[Auto fetch succeeded]]</value>
</data>
<data name="RegistryActionLog.Badge.MetadataFetchFailed" xml:space="preserve">
<value>[[Auto fetch failed]]</value>
</data>
<data name="Error.PageTitle" xml:space="preserve">
<value>[[Error]]</value>
</data>
<data name="Error.Title" xml:space="preserve">
<value>[[Error.]]</value>
</data>
<data name="Error.Subtitle" xml:space="preserve">
<value>[[An error occurred while processing your request.]]</value>
</data>
<data name="Error.RequestId" xml:space="preserve">
<value>[[Request ID:]]</value>
</data>
<data name="Error.DevMode" xml:space="preserve">
<value>[[Development Mode]]</value>
</data>
<data name="Error.DevHint1" xml:space="preserve">
<value>[[Swapping to Development environment will display more detailed information about the error that occurred.]]</value>
</data>
<data name="Error.DevHint2" xml:space="preserve">
<value>[[The Development environment shouldn't be enabled for deployed applications.]]</value>
</data>
<data name="Error.DevHint3" xml:space="preserve">
<value>[[It can result in displaying sensitive information from exceptions to end users.]]</value>
</data>
<data name="Error.DevHint4" xml:space="preserve">
<value>[[For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development and restarting the app.]]</value>
</data>
<data name="MainLayout.UnhandledError" xml:space="preserve">
<value>[[An unhandled error has occurred.]]</value>
</data>
<data name="MainLayout.Reload" xml:space="preserve">
<value>[[Reload]]</value>
</data>
<data name="NavMenu.Brand" xml:space="preserve">
<value>[[BirthList]]</value>
</data>
<data name="NavMenu.NavigationMenu" xml:space="preserve">
<value>[[Navigation menu]]</value>
</data>
<data name="NavMenu.Home" xml:space="preserve">
<value>[[Home]]</value>
</data>
<data name="NavMenu.Logout" xml:space="preserve">
<value>[[Logout]]</value>
</data>
<data name="NavMenu.Register" xml:space="preserve">
<value>[[Register]]</value>
</data>
<data name="NavMenu.Login" xml:space="preserve">
<value>[[Login]]</value>
</data>
</root>
@@ -0,0 +1,257 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, ...</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, ...</value>
</resheader>
<data name="Common.Yes" xml:space="preserve"><value>Yes</value></data>
<data name="Common.No" xml:space="preserve"><value>No</value></data>
<data name="Common.Save" xml:space="preserve"><value>Save</value></data>
<data name="Common.Cancel" xml:space="preserve"><value>Cancel</value></data>
<data name="Common.Remove" xml:space="preserve"><value>Remove</value></data>
<data name="Common.Edit" xml:space="preserve"><value>Edit</value></data>
<data name="Common.Back" xml:space="preserve"><value>Back</value></data>
<data name="Common.Next" xml:space="preserve"><value>Next</value></data>
<data name="Common.Loading" xml:space="preserve"><value>Loading...</value></data>
<data name="Common.AccessDenied" xml:space="preserve"><value>Access denied.</value></data>
<data name="Common.SelectUser" xml:space="preserve"><value>Select user</value></data>
<data name="Common.SearchUser" xml:space="preserve"><value>Search user</value></data>
<data name="Common.Quantity" xml:space="preserve"><value>Quantity</value></data>
<data name="Common.Amount" xml:space="preserve"><value>Amount</value></data>
<data name="Common.Message" xml:space="preserve"><value>Message</value></data>
<data name="TopBar.Language" xml:space="preserve"><value>Language</value></data>
<data name="TopBar.Brand" xml:space="preserve"><value>Gift List</value></data>
<data name="TopBar.ProfilePrompt" xml:space="preserve"><value>Please complete your profile (first name, last name, and address).</value></data>
<data name="TopBar.CompleteProfile" xml:space="preserve"><value>Complete profile</value></data>
<data name="TopBar.AccountSettings" xml:space="preserve"><value>Account settings</value></data>
<data name="TopBar.SignIn" xml:space="preserve"><value>Sign in</value></data>
<data name="TopBar.SignOut" xml:space="preserve"><value>Sign out</value></data>
<data name="Home.PageTitle" xml:space="preserve"><value>Birth Registry</value></data>
<data name="Home.Welcome" xml:space="preserve"><value>Welcome to Gift List</value></data>
<data name="Home.ManagedRegistries" xml:space="preserve"><value>Registries you manage</value></data>
<data name="Home.CreateNew" xml:space="preserve"><value>Create new</value></data>
<data name="Home.NoRegistries" xml:space="preserve"><value>No registries yet.</value></data>
<data name="Home.View" xml:space="preserve"><value>View</value></data>
<data name="Home.Manage" xml:space="preserve"><value>Manage</value></data>
<data name="Home.VisitedRegistries" xml:space="preserve"><value>Visited registries</value></data>
<data name="Home.NoVisitedRegistries" xml:space="preserve"><value>No visited registries yet.</value></data>
<data name="Home.CreateRegistryTitle" xml:space="preserve"><value>Create new registry</value></data>
<data name="Home.Title" xml:space="preserve"><value>Title</value></data>
<data name="Home.Type" xml:space="preserve"><value>Type</value></data>
<data name="Home.Theme" xml:space="preserve"><value>Theme</value></data>
<data name="Home.Theme.Default" xml:space="preserve"><value>Default</value></data>
<data name="Home.Theme.Soft" xml:space="preserve"><value>Soft</value></data>
<data name="Home.Theme.Modern" xml:space="preserve"><value>Modern</value></data>
<data name="Home.RegistryType.Birth" xml:space="preserve"><value>Birth</value></data>
<data name="Home.RegistryType.Wedding" xml:space="preserve"><value>Wedding</value></data>
<data name="Home.RegistryType.Birthday" xml:space="preserve"><value>Birthday</value></data>
<data name="Home.MustBeLoggedIn" xml:space="preserve"><value>You must be logged in to create a registry.</value></data>
<data name="Home.TitleRequired" xml:space="preserve"><value>Title is required.</value></data>
<data name="Home.LoginPromptPrefix" xml:space="preserve"><value>Please </value></data>
<data name="Home.LoginPromptLinkText" xml:space="preserve"><value>log in</value></data>
<data name="Home.LoginPromptSuffix" xml:space="preserve"><value> to create and manage registries.</value></data>
<data name="RegistryPublic.PageTitle" xml:space="preserve"><value>Registry</value></data>
<data name="RegistryPublic.NotFound" xml:space="preserve"><value>Registry not found.</value></data>
<data name="RegistryPublic.GoToAdmin" xml:space="preserve"><value>Go to Admin</value></data>
<data name="RegistryPublic.ShippingAddress" xml:space="preserve"><value>Shipping address</value></data>
<data name="RegistryPublic.BankTransferParticipation" xml:space="preserve"><value>Bank transfer participation</value></data>
<data name="RegistryPublic.IBAN" xml:space="preserve"><value>IBAN</value></data>
<data name="RegistryPublic.BIC" xml:space="preserve"><value>BIC</value></data>
<data name="RegistryPublic.SecondHandPreferred" xml:space="preserve"><value>Second-hand preferred</value></data>
<data name="RegistryPublic.SecondHandOptional" xml:space="preserve"><value>Second-hand optional</value></data>
<data name="RegistryPublic.Qty" xml:space="preserve"><value>Qty:</value></data>
<data name="RegistryPublic.PurchasedSuffix" xml:space="preserve"><value>purchased</value></data>
<data name="RegistryPublic.Price" xml:space="preserve"><value>Price:</value></data>
<data name="RegistryPublic.Participation" xml:space="preserve"><value>Participation:</value></data>
<data name="RegistryPublic.OutOfFulfilled" xml:space="preserve"><value>out of {0}{1} fulfilled</value></data>
<data name="RegistryPublic.FulfilledOnly" xml:space="preserve"><value>fulfilled</value></data>
<data name="RegistryPublic.PurchasedBy" xml:space="preserve"><value>Purchased by:</value></data>
<data name="RegistryPublic.ContributedBy" xml:space="preserve"><value>Contributed by:</value></data>
<data name="RegistryPublic.YouQuantity" xml:space="preserve"><value>You ({0})</value></data>
<data name="RegistryPublic.AndOtherPeople" xml:space="preserve"><value>and {0} other {1}</value></data>
<data name="RegistryPublic.Person" xml:space="preserve"><value>person</value></data>
<data name="RegistryPublic.People" xml:space="preserve"><value>people</value></data>
<data name="RegistryPublic.AndOthers" xml:space="preserve"><value>and others</value></data>
<data name="RegistryPublic.Purchased" xml:space="preserve"><value>Purchased</value></data>
<data name="RegistryPublic.Contributed" xml:space="preserve"><value>Contributed</value></data>
<data name="RegistryPublic.LoginToPurchase" xml:space="preserve"><value>Login to purchase</value></data>
<data name="RegistryPublic.Purchase" xml:space="preserve"><value>Purchase</value></data>
<data name="RegistryPublic.ManagePurchases" xml:space="preserve"><value>Manage purchases</value></data>
<data name="RegistryPublic.EditPurchase" xml:space="preserve"><value>Edit purchase</value></data>
<data name="RegistryPublic.MarkPurchased" xml:space="preserve"><value>Mark purchased</value></data>
<data name="RegistryPublic.ManageParticipations" xml:space="preserve"><value>Manage participations</value></data>
<data name="RegistryPublic.EditParticipation" xml:space="preserve"><value>Edit participation</value></data>
<data name="RegistryPublic.PartiallyFulfill" xml:space="preserve"><value>Partially fulfill</value></data>
<data name="RegistryPublic.MarkAsPurchased" xml:space="preserve"><value>Mark as purchased</value></data>
<data name="RegistryPublic.HowManyUnitsPurchased" xml:space="preserve"><value>How many units did you purchase?</value></data>
<data name="RegistryPublic.OpenProductLink" xml:space="preserve"><value>Open product link</value></data>
<data name="RegistryPublic.ConfirmPurchase" xml:space="preserve"><value>Confirm purchase</value></data>
<data name="RegistryPublic.LogContribution" xml:space="preserve"><value>Log contribution</value></data>
<data name="RegistryPublic.TransferredAmount" xml:space="preserve"><value>Transferred amount</value></data>
<data name="RegistryPublic.Confirm" xml:space="preserve"><value>Confirm</value></data>
<data name="RegistryPublic.SelectPurchaserToUnmark" xml:space="preserve"><value>Select purchaser to unmark</value></data>
<data name="RegistryPublic.MultipleUsersPurchased" xml:space="preserve"><value>Multiple users have purchased this item. Choose which purchase to unmark:</value></data>
<data name="RegistryPublic.Unmark" xml:space="preserve"><value>Unmark</value></data>
<data name="RegistryPublic.UnmarkAll" xml:space="preserve"><value>Unmark all</value></data>
<data name="RegistryPublic.PartiallyFulfillItem" xml:space="preserve"><value>Partially fulfill item</value></data>
<data name="RegistryPublic.SelectDonateMethod" xml:space="preserve"><value>Select how you want to donate:</value></data>
<data name="RegistryPublic.IbanTransfer" xml:space="preserve"><value>IBAN transfer</value></data>
<data name="RegistryPublic.SingleQrCode" xml:space="preserve"><value>Single QR code</value></data>
<data name="RegistryPublic.QrCodePerAmount" xml:space="preserve"><value>QR code per amount</value></data>
<data name="RegistryPublic.OpenPaymentLink" xml:space="preserve"><value>Open payment link</value></data>
<data name="RegistryPublic.HowMuchAdded" xml:space="preserve"><value>How much did you add for this item?</value></data>
<data name="RegistryPublic.NoQrForAmount" xml:space="preserve"><value>No QR code configured for this exact amount.</value></data>
<data name="RegistryPublic.ManagePurchase" xml:space="preserve"><value>Manage purchase</value></data>
<data name="RegistryPublic.ManageParticipation" xml:space="preserve"><value>Manage participation</value></data>
<data name="RegistryPublic.User" xml:space="preserve"><value>User</value></data>
<data name="RegistryContributionAmount.PageTitle" xml:space="preserve"><value>Contribution Amount</value></data>
<data name="RegistryContributionAmount.ItemNotFound" xml:space="preserve"><value>Item not found.</value></data>
<data name="RegistryContributionAmount.LoginFirst" xml:space="preserve"><value>Please log in first.</value></data>
<data name="RegistryContributionAmount.PartiallyFulfill" xml:space="preserve"><value>Partially fulfill: {0}</value></data>
<data name="RegistryContributionAmount.NoRepresentableAmount" xml:space="preserve"><value>No representable amount can be formed from configured QR codes up to {0}200.</value></data>
<data name="RegistryContributionAmount.SelectAmount" xml:space="preserve"><value>Select amount: {0}{1}</value></data>
<data name="RegistryContributionAmount.NoQrCombination" xml:space="preserve"><value>No QR combination available for this amount.</value></data>
<data name="RegistryContributionAmount.SuggestedCombination" xml:space="preserve"><value>Suggested QR combination:</value></data>
<data name="RegistryContributionAmount.OpenPaymentLink" xml:space="preserve"><value>Open payment link</value></data>
<data name="RegistryContributionAmount.TransferredAmount" xml:space="preserve"><value>I transferred this amount</value></data>
<data name="RegistryInvite.PageTitle" xml:space="preserve"><value>Admin Invite</value></data>
<data name="RegistryInvite.Title" xml:space="preserve"><value>Admin invitation</value></data>
<data name="RegistryInvite.Validating" xml:space="preserve"><value>Validating invitation...</value></data>
<data name="RegistryInvite.Accepted" xml:space="preserve"><value>Invitation accepted. You are now an admin.</value></data>
<data name="RegistryInvite.GoToAdmin" xml:space="preserve"><value>Go to admin</value></data>
<data name="RegistryInvite.Invalid" xml:space="preserve"><value>The invitation is invalid or already used.</value></data>
<data name="RegistryAdmin.PageTitle" xml:space="preserve"><value>Registry Admin</value></data>
<data name="RegistryAdmin.Title" xml:space="preserve"><value>Registry Admin</value></data>
<data name="RegistryAdmin.SmtpNotConfigured" xml:space="preserve"><value>SMTP is not configured. Email features (identity emails and admin invite emails) are disabled. Configure the Smtp section in appsettings or user secrets.</value></data>
<data name="RegistryAdmin.Tab.Items" xml:space="preserve"><value>Items</value></data>
<data name="RegistryAdmin.Tab.Settings" xml:space="preserve"><value>Settings</value></data>
<data name="RegistryAdmin.Tab.Administrators" xml:space="preserve"><value>Administrators</value></data>
<data name="RegistryAdmin.Tab.Addresses" xml:space="preserve"><value>Addresses</value></data>
<data name="RegistryAdmin.Tab.ActionLog" xml:space="preserve"><value>Action Log</value></data>
<data name="RegistryAdmin.ViewPublicList" xml:space="preserve"><value>View Public List</value></data>
<data name="RegistryAdmin.AddOrEditItem" xml:space="preserve"><value>Add or edit item</value></data>
<data name="RegistryAdmin.Name" xml:space="preserve"><value>Name</value></data>
<data name="RegistryAdmin.ProductUrl" xml:space="preserve"><value>Product URL</value></data>
<data name="RegistryAdmin.AutoFetch" xml:space="preserve"><value>Auto fetch</value></data>
<data name="RegistryAdmin.PictureUrl" xml:space="preserve"><value>Picture URL</value></data>
<data name="RegistryAdmin.Description" xml:space="preserve"><value>Description</value></data>
<data name="RegistryAdmin.Price" xml:space="preserve"><value>Price</value></data>
<data name="RegistryAdmin.CurrencySymbol" xml:space="preserve"><value>Currency symbol</value></data>
<data name="RegistryAdmin.DesiredQty" xml:space="preserve"><value>Desired qty</value></data>
<data name="RegistryAdmin.Participation" xml:space="preserve"><value>Participation</value></data>
<data name="RegistryAdmin.ParticipationTarget" xml:space="preserve"><value>Participation target</value></data>
<data name="RegistryAdmin.SecondHandPreference" xml:space="preserve"><value>Second hand preference</value></data>
<data name="RegistryAdmin.SecondHandOptional" xml:space="preserve"><value>Second hand optional</value></data>
<data name="RegistryAdmin.PreferSecondHand" xml:space="preserve"><value>Prefer second hand</value></data>
<data name="RegistryAdmin.NewOnly" xml:space="preserve"><value>New only</value></data>
<data name="RegistryAdmin.Given" xml:space="preserve"><value>Given</value></data>
<data name="RegistryAdmin.Category" xml:space="preserve"><value>Category</value></data>
<data name="RegistryAdmin.SaveItem" xml:space="preserve"><value>Save item</value></data>
<data name="RegistryAdmin.CategoriesAndItems" xml:space="preserve"><value>Categories and items</value></data>
<data name="RegistryAdmin.NewCategory" xml:space="preserve"><value>New category</value></data>
<data name="RegistryAdmin.AddCategory" xml:space="preserve"><value>Add category</value></data>
<data name="RegistryAdmin.DragHint" xml:space="preserve"><value>Drag categories or items to reorder. Drop items into another category to regroup them.</value></data>
<data name="RegistryAdmin.Rename" xml:space="preserve"><value>Rename</value></data>
<data name="RegistryAdmin.DropItemsHere" xml:space="preserve"><value>Drop items here.</value></data>
<data name="RegistryAdmin.Header.Name" xml:space="preserve"><value>Name</value></data>
<data name="RegistryAdmin.Header.DesiredQty" xml:space="preserve"><value>Desired Qty</value></data>
<data name="RegistryAdmin.Header.Condition" xml:space="preserve"><value>Condition</value></data>
<data name="RegistryAdmin.Header.Participation" xml:space="preserve"><value>Participation</value></data>
<data name="RegistryAdmin.Header.PurchasedContributedBy" xml:space="preserve"><value>Purchased by / Contributed by</value></data>
<data name="RegistryAdmin.Purchased" xml:space="preserve"><value>Purchased:</value></data>
<data name="RegistryAdmin.Contributed" xml:space="preserve"><value>Contributed:</value></data>
<data name="RegistryAdmin.RegistrySettings" xml:space="preserve"><value>Registry Settings</value></data>
<data name="RegistryAdmin.BabyName" xml:space="preserve"><value>Baby name</value></data>
<data name="RegistryAdmin.BirthDate" xml:space="preserve"><value>Birth date</value></data>
<data name="RegistryAdmin.Theme" xml:space="preserve"><value>Theme</value></data>
<data name="RegistryAdmin.Theme.Default" xml:space="preserve"><value>Default</value></data>
<data name="RegistryAdmin.Theme.Soft" xml:space="preserve"><value>Soft</value></data>
<data name="RegistryAdmin.Theme.Modern" xml:space="preserve"><value>Modern</value></data>
<data name="RegistryAdmin.ShippingAddress" xml:space="preserve"><value>Shipping address</value></data>
<data name="RegistryAdmin.LineBreaksPreserved" xml:space="preserve"><value>Line breaks will be preserved</value></data>
<data name="RegistryAdmin.HeroImage" xml:space="preserve"><value>Hero image</value></data>
<data name="RegistryAdmin.HideHeaderName" xml:space="preserve"><value>Hide name heading on public page</value></data>
<data name="RegistryAdmin.UploadImage" xml:space="preserve"><value>Upload image</value></data>
<data name="RegistryAdmin.RemoveHeroImage" xml:space="preserve"><value>Remove hero image</value></data>
<data name="RegistryAdmin.CurrentHeroImage" xml:space="preserve"><value>Current hero image</value></data>
<data name="RegistryAdmin.TopContent" xml:space="preserve"><value>Top content</value></data>
<data name="RegistryAdmin.WelcomeText" xml:space="preserve"><value>Welcome text</value></data>
<data name="RegistryAdmin.BankAccountSettings" xml:space="preserve"><value>Bank Account Settings</value></data>
<data name="RegistryAdmin.BankAccountName" xml:space="preserve"><value>Bank account name</value></data>
<data name="RegistryAdmin.DisplayBankAccountName" xml:space="preserve"><value>Display bank account name</value></data>
<data name="RegistryAdmin.ContributionPaymentOptions" xml:space="preserve"><value>Contribution Payment Options</value></data>
<data name="RegistryAdmin.SingleQrCodeUrl" xml:space="preserve"><value>Single QR code URL</value></data>
<data name="RegistryAdmin.SingleQrHelp" xml:space="preserve"><value>Optional: one QR code that donors can scan for any amount.</value></data>
<data name="RegistryAdmin.AmountSpecificQrCodes" xml:space="preserve"><value>Amount-specific QR codes</value></data>
<data name="RegistryAdmin.AddQrAmount" xml:space="preserve"><value>Add QR amount</value></data>
<data name="RegistryAdmin.NoAmountSpecificQrCodes" xml:space="preserve"><value>No amount-specific QR codes configured.</value></data>
<data name="RegistryAdmin.QrCodeUrl" xml:space="preserve"><value>QR code URL</value></data>
<data name="RegistryAdmin.SaveSettings" xml:space="preserve"><value>Save settings</value></data>
<data name="RegistryAdmin.UserAddresses" xml:space="preserve"><value>User addresses</value></data>
<data name="RegistryAdmin.NoUsersFound" xml:space="preserve"><value>No users found yet.</value></data>
<data name="RegistryAdmin.Email" xml:space="preserve"><value>Email</value></data>
<data name="RegistryAdmin.Address" xml:space="preserve"><value>Address</value></data>
<data name="RegistryAdmin.CurrentAdministrators" xml:space="preserve"><value>Current administrators</value></data>
<data name="RegistryAdmin.NoAdmins" xml:space="preserve"><value>No admins assigned yet.</value></data>
<data name="RegistryAdmin.EmailOrName" xml:space="preserve"><value>Email / Name</value></data>
<data name="RegistryAdmin.InviteAdministrator" xml:space="preserve"><value>Invite administrator</value></data>
<data name="RegistryAdmin.OptionalEmail" xml:space="preserve"><value>optional email</value></data>
<data name="RegistryAdmin.CreateInvite" xml:space="preserve"><value>Create invite</value></data>
<data name="RegistryAdmin.InviteLink" xml:space="preserve"><value>Invite link:</value></data>
<data name="RegistryActionLog.PageTitle" xml:space="preserve"><value>Action Log - Registry Admin</value></data>
<data name="RegistryActionLog.Title" xml:space="preserve"><value>Registry Action Log</value></data>
<data name="RegistryActionLog.Description" xml:space="preserve"><value>This log shows all user actions on this registry: purchases, contributions, and other interactions.</value></data>
<data name="RegistryActionLog.NoActions" xml:space="preserve"><value>No actions recorded yet.</value></data>
<data name="RegistryActionLog.DateTime" xml:space="preserve"><value>Date/Time</value></data>
<data name="RegistryActionLog.User" xml:space="preserve"><value>User</value></data>
<data name="RegistryActionLog.Action" xml:space="preserve"><value>Action</value></data>
<data name="RegistryActionLog.Item" xml:space="preserve"><value>Item</value></data>
<data name="RegistryActionLog.Quantity" xml:space="preserve"><value>Quantity</value></data>
<data name="RegistryActionLog.Amount" xml:space="preserve"><value>Amount</value></data>
<data name="RegistryActionLog.Details" xml:space="preserve"><value>Details</value></data>
<data name="RegistryActionLog.Badge.RegistryOpened" xml:space="preserve"><value>Registry opened</value></data>
<data name="RegistryActionLog.Badge.ItemLinkOpened" xml:space="preserve"><value>Item link opened</value></data>
<data name="RegistryActionLog.Badge.PurchaseMarked" xml:space="preserve"><value>Purchase marked</value></data>
<data name="RegistryActionLog.Badge.PurchaseUnmarked" xml:space="preserve"><value>Purchase unmarked</value></data>
<data name="RegistryActionLog.Badge.PartialPurchase" xml:space="preserve"><value>Partial purchase</value></data>
<data name="RegistryActionLog.Badge.ContributionLogged" xml:space="preserve"><value>Contribution logged</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchSucceeded" xml:space="preserve"><value>Auto fetch succeeded</value></data>
<data name="RegistryActionLog.Badge.MetadataFetchFailed" xml:space="preserve"><value>Auto fetch failed</value></data>
<data name="Error.PageTitle" xml:space="preserve"><value>Error</value></data>
<data name="Error.Title" xml:space="preserve"><value>Error.</value></data>
<data name="Error.Subtitle" xml:space="preserve"><value>An error occurred while processing your request.</value></data>
<data name="Error.RequestId" xml:space="preserve"><value>Request ID:</value></data>
<data name="Error.DevMode" xml:space="preserve"><value>Development Mode</value></data>
<data name="Error.DevHint1" xml:space="preserve"><value>Swapping to Development environment will display more detailed information about the error that occurred.</value></data>
<data name="Error.DevHint2" xml:space="preserve"><value>The Development environment shouldn't be enabled for deployed applications.</value></data>
<data name="Error.DevHint3" xml:space="preserve"><value>It can result in displaying sensitive information from exceptions to end users.</value></data>
<data name="Error.DevHint4" xml:space="preserve"><value>For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development and restarting the app.</value></data>
<data name="MainLayout.UnhandledError" xml:space="preserve"><value>An unhandled error has occurred.</value></data>
<data name="MainLayout.Reload" xml:space="preserve"><value>Reload</value></data>
<data name="NavMenu.Brand" xml:space="preserve"><value>BirthList</value></data>
<data name="NavMenu.NavigationMenu" xml:space="preserve"><value>Navigation menu</value></data>
<data name="NavMenu.Home" xml:space="preserve"><value>Home</value></data>
<data name="NavMenu.Logout" xml:space="preserve"><value>Logout</value></data>
<data name="NavMenu.Register" xml:space="preserve"><value>Register</value></data>
<data name="NavMenu.Login" xml:space="preserve"><value>Login</value></data>
</root>
+5
View File
@@ -0,0 +1,5 @@
namespace BirthList.Web;
public sealed class SharedResources
{
}
+14 -1
View File
@@ -39,5 +39,18 @@
}
},
"AllowedHosts": "*",
"PublicUrl": ""
"PublicUrl": "",
"AmazonMetadata": {
// Amazon Product Advertising API 5.0 credentials.
// Get these from your Amazon Associates account at https://affiliate-program.amazon.com
// When AccessKey, SecretKey and AssociateTag are all set, the PA API is used as primary metadata source.
"AccessKey": "",
"SecretKey": "",
"AssociateTag": "",
// PA API marketplace host, e.g. "webservices.amazon.com" (US/default) or "webservices.amazon.com.be" (BE)
"PaApiHost": "webservices.amazon.com",
// RapidAPI key for the "Real-Time Amazon Data" API (https://rapidapi.com/letscrape-6bRBa3QguO5/api/real-time-amazon-data)
// Used as fallback when direct scraping returns no metadata.
"RapidApiKey": ""
}
}
+21
View File
@@ -0,0 +1,21 @@
window.birthListDrag = window.birthListDrag || {
isPointerInBottomHalf: function (elementId, clientY) {
if (!elementId) {
return false;
}
const element = document.getElementById(elementId);
if (!element) {
return false;
}
const rect = element.getBoundingClientRect();
if (!rect || rect.height <= 0) {
return false;
}
const relativeY = clientY - rect.top;
const ratio = relativeY / rect.height;
return ratio >= 0.5;
}
};