Compare commits

..

11 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
48 changed files with 5766 additions and 375 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. - Authentication must support local accounts plus Microsoft and Google login; admin invite links are single-use and not email-locked.
- Money participation defaults to EUR and tracks fulfilled amount per item via bank-transfer contributions with item-linked messages. - Money participation defaults to EUR and tracks fulfilled amount per item via bank-transfer contributions with item-linked messages.
- Registry visibility is public-by-link initially, but logged-in users who visited a registry should be able to rediscover it in-app. - Registry visibility is public-by-link initially, but logged-in users who visited a registry should be able to rediscover it in-app.
- Identities must be hidden from non-admin users for privacy, only admins and the user who purchased/contributed can see that he did so. Other users may see a generic indication that a contrubution/purchase was made but never a name/email of another user. - 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. - Use SQL Server as the default provider and set `RequireConfirmedAccount=false` for MVP testing; the first registered account should be the owner with full access.
## Technical Specifications ## Technical Specifications
- Use SMTP via Google initially for email functionalities. - Use SMTP via Google initially for email functionalities.
- Support registry type theming from day one to enhance user experience. - Support registry type theming from day one to enhance user experience.
- URL autofetch should utilize OpenGraph/meta tags for better link previews. - 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> <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/"> <Folder Name="/gitea build/">
<File Path=".gitea/workflows/build-and-push.yml" /> <File Path=".gitea/workflows/build-and-push.yml" />
</Folder> </Folder>
+5
View File
@@ -23,6 +23,11 @@ services:
- Smtp__FromAddress=${SMTP_FROM_ADDRESS} - Smtp__FromAddress=${SMTP_FROM_ADDRESS}
- Smtp__FromName=${SMTP_FROM_NAME} - Smtp__FromName=${SMTP_FROM_NAME}
- PublicUrl=${PUBLIC_URL} - 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: depends_on:
- mssql - mssql
networks: networks:
@@ -7,6 +7,9 @@ public class Registry
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public DateOnly? BirthDate { get; set; } public DateOnly? BirthDate { get; set; }
public string? BabyName { 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? HeaderContentHtml { get; set; }
public string? ShippingAddress { get; set; } public string? ShippingAddress { get; set; }
public string CurrencyCode { get; set; } = "EUR"; public string CurrencyCode { get; set; } = "EUR";
@@ -7,6 +7,7 @@ public class RegistrySettings
public string? BankAccountBic { get; set; } public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; } public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; } public bool ShowBankAccountName { get; set; }
public bool HideHeaderName { get; set; }
public string? ContributionQrCodeUrl { get; set; } public string? ContributionQrCodeUrl { get; set; }
public string? ContributionAmountQrCodesJson { get; set; } public string? ContributionAmountQrCodesJson { get; set; }
@@ -22,5 +22,7 @@ public enum UserActionType
MarkPurchased = 3, MarkPurchased = 3,
UnmarkPurchased = 4, UnmarkPurchased = 4,
MarkPartialPurchase = 5, MarkPartialPurchase = 5,
LogContribution = 6 LogContribution = 6,
MetadataFetchSucceeded = 7,
MetadataFetchFailed = 8
} }
@@ -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,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") b.Property<string>("HeaderContentHtml")
.HasColumnType("nvarchar(max)"); .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") b.Property<string>("PublicLinkCode")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -344,6 +353,9 @@ namespace BirthList.Infrastructure.Migrations
.HasMaxLength(2048) .HasMaxLength(2048)
.HasColumnType("nvarchar(2048)"); .HasColumnType("nvarchar(2048)");
b.Property<bool>("HideHeaderName")
.HasColumnType("bit");
b.Property<bool>("ShowBankAccountName") b.Property<bool>("ShowBankAccountName")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -9,7 +9,7 @@
</main> </main>
<div id="blazor-error-ui"> <div id="blazor-error-ui">
An unhandled error has occurred. @L["MainLayout.UnhandledError"]
<a href="" class="reload">Reload</a> <a href="" class="reload">@L["MainLayout.Reload"]</a>
<a class="dismiss">🗙</a> <a class="dismiss">🗙</a>
</div> </div>
@@ -4,17 +4,17 @@
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid"> <div class="container-fluid">
<a class="navbar-brand" href="">BirthList</a> <a class="navbar-brand" href="">@L["NavMenu.Brand"]</a>
</div> </div>
</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()"> <div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="flex-column"> <nav class="flex-column">
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All"> <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> </NavLink>
</div> </div>
@@ -30,7 +30,7 @@
<AntiforgeryToken /> <AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@currentUrl" /> <input type="hidden" name="ReturnUrl" value="@currentUrl" />
<button type="submit" class="nav-link"> <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> </button>
</form> </form>
</div> </div>
@@ -38,12 +38,12 @@
<NotAuthorized> <NotAuthorized>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Register"> <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> </NavLink>
</div> </div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Login"> <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> </NavLink>
</div> </div>
</NotAuthorized> </NotAuthorized>
@@ -1,48 +1,64 @@
@using System.Globalization
@using BirthList.Web.Features.Localization
@using BirthList.Web.Features.Registries @using BirthList.Web.Features.Registries
@using BirthList.Web.Services @using BirthList.Web.Services
@using Microsoft.Extensions.Hosting
@implements IDisposable @implements IDisposable
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject RegistryUserContext RegistryUserContext @inject RegistryUserContext RegistryUserContext
@inject ProfileCompletionService ProfileCompletionService @inject ProfileCompletionService ProfileCompletionService
@inject IHostEnvironment HostEnvironment
@if (ShowProfileCompletionPrompt) @if (ShowProfileCompletionPrompt)
{ {
<div class="profile-completion-banner"> <div class="profile-completion-banner">
<span>Please complete your profile (first name, last name, and address).</span> <span>@L["TopBar.ProfilePrompt"]</span>
<a href="/Account/Manage" class="profile-completion-link">Complete profile</a> <a href="/Account/Manage" class="profile-completion-link">@L["TopBar.CompleteProfile"]</a>
</div> </div>
} }
<nav class="top-bar"> <nav class="top-bar">
<div class="top-bar-left"> <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> <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> </a>
</div> </div>
<div class="top-bar-right"> <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> <AuthorizeView>
<Authorized> <Authorized>
<div class="user-info"> <div class="user-info">
<i class="bi bi-person-circle" aria-hidden="true"></i> <i class="bi bi-person-circle" aria-hidden="true"></i>
<span class="user-email">@context.User.Identity?.Name</span> <span class="user-email">@context.User.Identity?.Name</span>
</div> </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> <i class="bi bi-gear" aria-hidden="true"></i>
</a> </a>
<form action="Account/Logout" method="post" class="logout-form"> <form action="Account/Logout" method="post" class="logout-form">
<AntiforgeryToken /> <AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@currentUrl" /> <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> <i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</button> </button>
</form> </form>
</Authorized> </Authorized>
<NotAuthorized> <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> <i class="bi bi-box-arrow-right" aria-hidden="true"></i>
</a> </a>
</NotAuthorized> </NotAuthorized>
@@ -52,11 +68,33 @@
@code { @code {
private string currentUrl = ""; private string currentUrl = "";
private string currentAbsolutePath = "/";
private bool ShowProfileCompletionPrompt { get; set; } 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() protected override async Task OnInitializedAsync()
{ {
currentUrl = GetRelativePath(NavigationManager.Uri); currentUrl = GetRelativePath(NavigationManager.Uri);
currentAbsolutePath = BuildAbsolutePath(NavigationManager.Uri);
CurrentCulture = CultureInfo.CurrentUICulture.Name;
NavigationManager.LocationChanged += OnLocationChanged; NavigationManager.LocationChanged += OnLocationChanged;
await RefreshProfileCompletionPromptAsync().ConfigureAwait(false); await RefreshProfileCompletionPromptAsync().ConfigureAwait(false);
@@ -65,6 +103,8 @@
private async void OnLocationChanged(object? sender, LocationChangedEventArgs e) private async void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{ {
currentUrl = GetRelativePath(e.Location); currentUrl = GetRelativePath(e.Location);
currentAbsolutePath = BuildAbsolutePath(e.Location);
CurrentCulture = CultureInfo.CurrentUICulture.Name;
await RefreshProfileCompletionPromptAsync().ConfigureAwait(false); await RefreshProfileCompletionPromptAsync().ConfigureAwait(false);
await InvokeAsync(StateHasChanged).ConfigureAwait(false); await InvokeAsync(StateHasChanged).ConfigureAwait(false);
} }
@@ -99,6 +139,19 @@
return relativePath; 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() void IDisposable.Dispose()
{ {
NavigationManager.LocationChanged -= OnLocationChanged; NavigationManager.LocationChanged -= OnLocationChanged;
+9 -10
View File
@@ -1,27 +1,26 @@
@page "/Error" @page "/Error"
@using System.Diagnostics @using System.Diagnostics
<PageTitle>Error</PageTitle> <PageTitle>@L["Error.PageTitle"]</PageTitle>
<h1 class="text-danger">Error.</h1> <h1 class="text-danger">@L["Error.Title"]</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2> <h2 class="text-danger">@L["Error.Subtitle"]</h2>
@if (ShowRequestId) @if (ShowRequestId)
{ {
<p> <p>
<strong>Request ID:</strong> <code>@RequestId</code> <strong>@L["Error.RequestId"]</strong> <code>@RequestId</code>
</p> </p>
} }
<h3>Development Mode</h3> <h3>@L["Error.DevMode"]</h3>
<p> <p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred. @L["Error.DevHint1"]
</p> </p>
<p> <p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong> <strong>@L["Error.DevHint2"]</strong>
It can result in displaying sensitive information from exceptions to end users. @L["Error.DevHint3"]
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong> @L["Error.DevHint4"]
and restarting the app.
</p> </p>
@code{ @code{
+29 -26
View File
@@ -1,24 +1,25 @@
@page "/" @page "/"
@rendermode InteractiveServer
@using BirthList.Web.Features.Registries @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> <AuthorizeView>
<Authorized Context="authState"> <Authorized Context="authState">
<div class="registry-sections"> <div class="registry-sections">
<div class="mb-4"> <div class="mb-4">
<div class="section-header"> <div class="section-header">
<h2>Registries you manage</h2> <h2>@L["Home.ManagedRegistries"]</h2>
<button class="btn btn-primary btn-sm" @onclick="() => ShowCreateForm = !ShowCreateForm"> <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> </button>
</div> </div>
@if (MyRegistries.Count == 0) @if (MyRegistries.Count == 0)
{ {
<p class="text-muted">No registries yet.</p> <p class="text-muted">@L["Home.NoRegistries"]</p>
} }
else else
{ {
@@ -28,8 +29,8 @@
<div class="registry-card"> <div class="registry-card">
<h3>@registry.Title</h3> <h3>@registry.Title</h3>
<div class="registry-actions"> <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>
<a href="/registry/@registry.Id/admin" class="btn btn-outline-secondary btn-sm">Manage</a> <a href="/registry/@registry.Id/admin" class="btn btn-outline-secondary btn-sm">@L["Home.Manage"]</a>
</div> </div>
</div> </div>
} }
@@ -38,10 +39,10 @@
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h2>Visited registries</h2> <h2>@L["Home.VisitedRegistries"]</h2>
@if (VisitedRegistries.Count == 0) @if (VisitedRegistries.Count == 0)
{ {
<p class="text-muted">No visited registries yet.</p> <p class="text-muted">@L["Home.NoVisitedRegistries"]</p>
} }
else else
{ {
@@ -51,7 +52,7 @@
<div class="registry-card"> <div class="registry-card">
<h3>@registry.Title</h3> <h3>@registry.Title</h3>
<div class="registry-actions"> <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>
</div> </div>
} }
@@ -65,30 +66,30 @@
<div class="create-registry-modal-overlay" @onclick="() => ShowCreateForm = false"> <div class="create-registry-modal-overlay" @onclick="() => ShowCreateForm = false">
<div class="create-registry-modal" @onclick:stopPropagation="true"> <div class="create-registry-modal" @onclick:stopPropagation="true">
<div class="modal-header"> <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> <button type="button" class="btn-close" @onclick="() => ShowCreateForm = false"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<EditForm Model="Model" OnValidSubmit="CreateRegistryAsync" Context="formContext" FormName="create-registry-form"> <EditForm Model="Model" OnValidSubmit="CreateRegistryAsync" Context="formContext" FormName="create-registry-form">
<DataAnnotationsValidator /> <DataAnnotationsValidator />
<div class="mb-3"> <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" /> <InputText class="form-control" @bind-Value="Model.Title" />
</div> </div>
<div class="mb-3"> <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"> <InputSelect class="form-select" @bind-Value="Model.RegistryType">
<option value="@BirthList.Domain.Entities.RegistryType.Birth">Birth</option> <option value="@BirthList.Domain.Entities.RegistryType.Birth">@L["Home.RegistryType.Birth"]</option>
<option value="@BirthList.Domain.Entities.RegistryType.Wedding">Wedding</option> <option value="@BirthList.Domain.Entities.RegistryType.Wedding">@L["Home.RegistryType.Wedding"]</option>
<option value="@BirthList.Domain.Entities.RegistryType.Birthday">Birthday</option> <option value="@BirthList.Domain.Entities.RegistryType.Birthday">@L["Home.RegistryType.Birthday"]</option>
</InputSelect> </InputSelect>
</div> </div>
<div class="mb-3"> <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"> <InputSelect class="form-select" @bind-Value="Model.ThemeKey">
<option value="default">Default</option> <option value="default">@L["Home.Theme.Default"]</option>
<option value="soft">Soft</option> <option value="soft">@L["Home.Theme.Soft"]</option>
<option value="modern">Modern</option> <option value="modern">@L["Home.Theme.Modern"]</option>
</InputSelect> </InputSelect>
</div> </div>
@if (!string.IsNullOrWhiteSpace(ErrorMessage)) @if (!string.IsNullOrWhiteSpace(ErrorMessage))
@@ -96,8 +97,8 @@
<div class="alert alert-danger mb-3">@ErrorMessage</div> <div class="alert alert-danger mb-3">@ErrorMessage</div>
} }
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" @onclick="() => ShowCreateForm = false">Cancel</button> <button type="button" class="btn btn-outline-secondary" @onclick="() => ShowCreateForm = false">@L["Common.Cancel"]</button>
<button class="btn btn-primary" type="submit">Create</button> <button class="btn btn-primary" type="submit">@L["Home.CreateNew"]</button>
</div> </div>
</EditForm> </EditForm>
</div> </div>
@@ -107,7 +108,9 @@
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<div class="alert alert-info mt-4"> <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> </div>
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </AuthorizeView>
@@ -119,7 +122,7 @@
protected IReadOnlyList<RegistrySummaryViewModel> MyRegistries { get; private set; } = []; protected IReadOnlyList<RegistrySummaryViewModel> MyRegistries { get; private set; } = [];
protected IReadOnlyList<RegistrySummaryViewModel> VisitedRegistries { get; private set; } = []; protected IReadOnlyList<RegistrySummaryViewModel> VisitedRegistries { get; private set; } = [];
protected string? ErrorMessage { 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 RegistryService RegistryService { get; set; } = null!;
[Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!; [Inject] private RegistryUserContext RegistryUserContext { get; set; } = null!;
@@ -136,13 +139,13 @@
var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(userId)) if (string.IsNullOrWhiteSpace(userId))
{ {
ErrorMessage = "You must be logged in to create a registry."; ErrorMessage = L["Home.MustBeLoggedIn"];
return; return;
} }
if (string.IsNullOrWhiteSpace(Model.Title)) if (string.IsNullOrWhiteSpace(Model.Title))
{ {
ErrorMessage = "Title is required."; ErrorMessage = L["Home.TitleRequired"];
return; return;
} }
@@ -4,24 +4,24 @@
@using BirthList.Web.Features.Registries @using BirthList.Web.Features.Registries
@using BirthList.Web.Authorization @using BirthList.Web.Authorization
<PageTitle>Action Log - Registry Admin</PageTitle> <PageTitle>@L["RegistryActionLog.PageTitle"]</PageTitle>
@if (!IsAuthorized) @if (!IsAuthorized)
{ {
<p>Access denied.</p> <p>@L["Common.AccessDenied"]</p>
} }
else else
{ {
<h1>Registry Action Log</h1> <h1>@L["RegistryActionLog.Title"]</h1>
<p class="text-muted">This log shows all user actions on this registry: purchases, contributions, and other interactions.</p> <p class="text-muted">@L["RegistryActionLog.Description"]</p>
@if (ActionLogs is null) @if (ActionLogs is null)
{ {
<p>Loading...</p> <p>@L["Common.Loading"]</p>
} }
else if (ActionLogs.Count == 0) else if (ActionLogs.Count == 0)
{ {
<p class="text-muted">No actions recorded yet.</p> <p class="text-muted">@L["RegistryActionLog.NoActions"]</p>
} }
else else
{ {
@@ -29,13 +29,13 @@ else
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th>Date/Time</th> <th>@L["RegistryActionLog.DateTime"]</th>
<th>User</th> <th>@L["RegistryActionLog.User"]</th>
<th>Action</th> <th>@L["RegistryActionLog.Action"]</th>
<th>Item</th> <th>@L["RegistryActionLog.Item"]</th>
<th>Quantity</th> <th>@L["RegistryActionLog.Quantity"]</th>
<th>Amount</th> <th>@L["RegistryActionLog.Amount"]</th>
<th>Details</th> <th>@L["RegistryActionLog.Details"]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -48,22 +48,28 @@ else
@switch (log.ActionType) @switch (log.ActionType)
{ {
case "RegistryLinkOpened": case "RegistryLinkOpened":
<span class="badge bg-info">Registry opened</span> <span class="badge bg-info">@L["RegistryActionLog.Badge.RegistryOpened"]</span>
break; break;
case "ItemLinkOpened": case "ItemLinkOpened":
<span class="badge bg-info">Item link opened</span> <span class="badge bg-info">@L["RegistryActionLog.Badge.ItemLinkOpened"]</span>
break; break;
case "MarkPurchased": case "MarkPurchased":
<span class="badge bg-success">Purchase marked</span> <span class="badge bg-success">@L["RegistryActionLog.Badge.PurchaseMarked"]</span>
break; break;
case "UnmarkPurchased": case "UnmarkPurchased":
<span class="badge bg-warning">Purchase unmarked</span> <span class="badge bg-warning">@L["RegistryActionLog.Badge.PurchaseUnmarked"]</span>
break; break;
case "MarkPartialPurchase": case "MarkPartialPurchase":
<span class="badge bg-primary">Partial purchase</span> <span class="badge bg-primary">@L["RegistryActionLog.Badge.PartialPurchase"]</span>
break; break;
case "LogContribution": 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; break;
default: default:
<span class="badge bg-dark">@log.ActionType</span> <span class="badge bg-dark">@log.ActionType</span>
@@ -3,20 +3,29 @@
@using Blazored.TextEditor @using Blazored.TextEditor
<PageTitle>Registry Admin</PageTitle> <PageTitle>@L["RegistryAdmin.PageTitle"]</PageTitle>
@if (!IsAuthorized) @if (!IsAuthorized)
{ {
<p>Access denied.</p> <p>@L["Common.AccessDenied"]</p>
} }
else 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) @if (!IsSmtpConfigured)
{ {
<div class="alert alert-warning" role="alert"> <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> </div>
} }
@@ -29,7 +38,7 @@ else
role="tab" role="tab"
aria-controls="items-content" aria-controls="items-content"
aria-selected="@(ActiveTab == "items" ? "true" : "false")"> 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> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@@ -40,7 +49,7 @@ else
role="tab" role="tab"
aria-controls="settings-content" aria-controls="settings-content"
aria-selected="@(ActiveTab == "settings" ? "true" : "false")"> aria-selected="@(ActiveTab == "settings" ? "true" : "false")">
<span class="bi bi-gear"></span> Settings <span class="bi bi-gear"></span> @L["RegistryAdmin.Tab.Settings"]
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@@ -51,7 +60,7 @@ else
role="tab" role="tab"
aria-controls="admins-content" aria-controls="admins-content"
aria-selected="@(ActiveTab == "admins" ? "true" : "false")"> aria-selected="@(ActiveTab == "admins" ? "true" : "false")">
<span class="bi bi-people"></span> Administrators <span class="bi bi-people"></span> @L["RegistryAdmin.Tab.Administrators"]
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@@ -62,12 +71,12 @@ else
role="tab" role="tab"
aria-controls="addresses-content" aria-controls="addresses-content"
aria-selected="@(ActiveTab == "addresses" ? "true" : "false")"> aria-selected="@(ActiveTab == "addresses" ? "true" : "false")">
<span class="bi bi-house"></span> Addresses <span class="bi bi-house"></span> @L["RegistryAdmin.Tab.Addresses"]
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<a href="/registry/@RegistryId/admin/action-log" class="nav-link"> <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> </a>
</li> </li>
</ul> </ul>
@@ -76,62 +85,62 @@ else
<!-- Items Tab --> <!-- Items Tab -->
<div class="tab-pane fade @(GetTabPaneClass("items"))" id="items-content" role="tabpanel" aria-labelledby="items-tab"> <div class="tab-pane fade @(GetTabPaneClass("items"))" id="items-content" role="tabpanel" aria-labelledby="items-tab">
<section class="mb-4"> <section class="mb-4">
<h2>Add or edit item</h2> <h2>@L["RegistryAdmin.AddOrEditItem"]</h2>
<EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form"> <EditForm Model="ItemModel" OnValidSubmit="SaveItemAsync" FormName="registry-item-form">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <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" /> <InputText class="form-control" @bind-Value="ItemModel.Name" />
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Product URL</label> <label class="form-label">@L["RegistryAdmin.ProductUrl"]</label>
<div class="input-group"> <div class="input-group">
<InputText class="form-control" @bind-Value="ItemModel.ProductUrl" /> <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> </div>
<div class="col-md-6"> <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" /> <InputText class="form-control" @bind-Value="ItemModel.PictureUrl" />
</div> </div>
<div class="col-md-6"> <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" /> <InputTextArea class="form-control" @bind-Value="ItemModel.Description" rows="2" />
</div> </div>
<div class="col-md-2"> <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" /> <InputNumber class="form-control" @bind-Value="ItemModel.PriceAmount" />
</div> </div>
<div class="col-md-2"> <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" /> <InputText class="form-control" @bind-Value="ItemModel.CurrencyCode" />
</div> </div>
<div class="col-md-2"> <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" /> <InputNumber class="form-control" @bind-Value="ItemModel.DesiredQuantity" />
</div> </div>
<div class="col-md-3 d-flex align-items-center gap-2"> <div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" /> <InputCheckbox @bind-Value="ItemModel.ParticipationAllowed" />
<label>Participation</label> <label>@L["RegistryAdmin.Participation"]</label>
</div> </div>
<div class="col-md-3"> <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" /> <InputNumber class="form-control" @bind-Value="ItemModel.ParticipationTargetAmount" />
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Second hand preference</label> <label class="form-label">@L["RegistryAdmin.SecondHandPreference"]</label>
<InputSelect class="form-select" @bind-Value="ItemModel.PreferSecondHand"> <InputSelect class="form-select" @bind-Value="PreferSecondHandString">
<option value="">Second hand optional</option> <option value="">@L["RegistryAdmin.SecondHandOptional"]</option>
<option value="true">Prefer second hand</option> <option value="true">@L["RegistryAdmin.PreferSecondHand"]</option>
<option value="false">New only</option> <option value="false">@L["RegistryAdmin.NewOnly"]</option>
</InputSelect> </InputSelect>
</div> </div>
<div class="col-md-3 d-flex align-items-center gap-2"> <div class="col-md-3 d-flex align-items-center gap-2">
<InputCheckbox @bind-Value="ItemModel.IsGiven" /> <InputCheckbox @bind-Value="ItemModel.IsGiven" />
<label>Given</label> <label>@L["RegistryAdmin.Given"]</label>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label">Category</label> <label class="form-label">@L["RegistryAdmin.Category"]</label>
<InputSelect class="form-select" @bind-Value="ItemModel.CategoryId"> <InputSelect class="form-select" @bind-Value="ItemModel.CategoryId">
@foreach (var category in ItemCategories) @foreach (var category in ItemCategories)
{ {
@@ -140,16 +149,16 @@ else
</InputSelect> </InputSelect>
</div> </div>
</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> </EditForm>
</section> </section>
<section> <section>
<div class="d-flex justify-content-between align-items-end flex-wrap gap-3"> <div class="d-flex justify-content-between align-items-end flex-wrap gap-3">
<h2 class="mb-0">Categories and items</h2> <h2 class="mb-0">@L["RegistryAdmin.CategoriesAndItems"]</h2>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<InputText class="form-control" @bind-Value="NewCategoryName" placeholder="New category" /> <InputText class="form-control" @bind-Value="NewCategoryName" placeholder="@L["RegistryAdmin.NewCategory"]" />
<button type="button" class="btn btn-outline-primary" @onclick="AddCategoryAsync">Add category</button> <button type="button" class="btn btn-outline-primary" @onclick="AddCategoryAsync">@L["RegistryAdmin.AddCategory"]</button>
</div> </div>
</div> </div>
@@ -158,7 +167,7 @@ else
<div class="alert alert-warning mt-3 mb-0" role="alert">@ItemManagementMessage</div> <div class="alert alert-warning mt-3 mb-0" role="alert">@ItemManagementMessage</div>
} }
<p class="text-muted mt-3 mb-2">Drag categories or items to reorder. Drop items into another category to regroup them.</p> <p class="text-muted mt-3 mb-2">@L["RegistryAdmin.DragHint"]</p>
<div class="category-groups mt-3"> <div class="category-groups mt-3">
@foreach (var category in ItemCategories) @foreach (var category in ItemCategories)
@@ -178,8 +187,8 @@ else
{ {
<div class="d-flex gap-2 align-items-center w-100"> <div class="d-flex gap-2 align-items-center w-100">
<InputText class="form-control form-control-sm" @bind-Value="CategoryRenameName" /> <InputText class="form-control form-control-sm" @bind-Value="CategoryRenameName" />
<button type="button" class="btn btn-sm btn-primary" @onclick="() => SaveCategoryRenameAsync(category.Id)">Save</button> <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">Cancel</button> <button type="button" class="btn btn-sm btn-outline-secondary" @onclick="CancelCategoryRename">@L["Common.Cancel"]</button>
</div> </div>
} }
else else
@@ -189,13 +198,13 @@ else
<button type="button" <button type="button"
class="btn btn-sm btn-outline-secondary" class="btn btn-sm btn-outline-secondary"
@onclick="() => StartCategoryRename(category)"> @onclick="() => StartCategoryRename(category)">
Rename @L["RegistryAdmin.Rename"]
</button> </button>
<button type="button" <button type="button"
class="btn btn-sm btn-outline-danger" class="btn btn-sm btn-outline-danger"
@onclick="() => RemoveCategoryAsync(category.Id)" @onclick="() => RemoveCategoryAsync(category.Id)"
disabled="@(ItemCategories.Count <= 1)"> disabled="@(ItemCategories.Count <= 1)">
Remove @L["Common.Remove"]
</button> </button>
</div> </div>
} }
@@ -208,17 +217,18 @@ else
@ondrop="() => OnCategoryItemsDropAsync(category.Id)"> @ondrop="() => OnCategoryItemsDropAsync(category.Id)">
@if (category.Items.Count == 0) @if (category.Items.Count == 0)
{ {
<div class="p-3 text-muted">Drop items here.</div> <div class="p-3 text-muted">@L["RegistryAdmin.DropItemsHere"]</div>
} }
else else
{ {
<table class="table table-striped mb-0"> <table class="table table-striped mb-0">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>@L["RegistryAdmin.Header.Name"]</th>
<th>Desired Qty</th> <th>@L["RegistryAdmin.Header.DesiredQty"]</th>
<th>Participation</th> <th>@L["RegistryAdmin.Header.Condition"]</th>
<th>Purchased by / Contributed by</th> <th>@L["RegistryAdmin.Header.Participation"]</th>
<th>@L["RegistryAdmin.Header.PurchasedContributedBy"]</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -239,12 +249,26 @@ else
@ondrop="() => OnItemDropAsync(category.Id, item.Id!.Value)"> @ondrop="() => OnItemDropAsync(category.Id, item.Id!.Value)">
<td>@item.Name</td> <td>@item.Name</td>
<td>@item.DesiredQuantity</td> <td>@item.DesiredQuantity</td>
<td>@(item.ParticipationAllowed ? "Yes" : "No")</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> <td>
@if (item.Purchasers.Count > 0) @if (item.Purchasers.Count > 0)
{ {
<div class="mb-1"> <div class="mb-1">
<small><strong>Purchased:</strong></small> <small><strong>@L["RegistryAdmin.Purchased"]</strong></small>
<div class="small"> <div class="small">
@foreach (var purchaser in item.Purchasers) @foreach (var purchaser in item.Purchasers)
{ {
@@ -256,7 +280,7 @@ else
@if (item.Contributors.Count > 0) @if (item.Contributors.Count > 0)
{ {
<div> <div>
<small><strong>Contributed:</strong></small> <small><strong>@L["RegistryAdmin.Contributed"]</strong></small>
<div class="small"> <div class="small">
@foreach (var contributor in item.Contributors) @foreach (var contributor in item.Contributors)
{ {
@@ -267,8 +291,8 @@ else
} }
</td> </td>
<td> <td>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="() => EditItem(item)">Edit</button> <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)">Delete</button> <button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteItemAsync(item.Id)">@L["Common.Remove"]</button>
</td> </td>
</tr> </tr>
} }
@@ -292,57 +316,85 @@ else
<!-- Settings Tab --> <!-- Settings Tab -->
<div class="tab-pane fade @(GetTabPaneClass("settings"))" id="settings-content" role="tabpanel" aria-labelledby="settings-tab"> <div class="tab-pane fade @(GetTabPaneClass("settings"))" id="settings-content" role="tabpanel" aria-labelledby="settings-tab">
<section class="mb-4"> <section class="mb-4">
<h2>Registry Settings</h2> <h2>@L["RegistryAdmin.RegistrySettings"]</h2>
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form"> <EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <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" /> <InputText class="form-control" @bind-Value="SettingsModel.BabyName" />
</div> </div>
<div class="col-md-3"> <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" /> <InputDate class="form-control" @bind-Value="SettingsModel.BirthDate" />
</div> </div>
<div class="col-md-2"> <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" /> <InputText class="form-control" @bind-Value="SettingsModel.CurrencyCode" />
</div> </div>
<div class="col-md-3"> <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"> <InputSelect class="form-select" @bind-Value="SettingsModel.ThemeKey">
<option value="default">Default</option> <option value="default">@L["RegistryAdmin.Theme.Default"]</option>
<option value="soft">Soft</option> <option value="soft">@L["RegistryAdmin.Theme.Soft"]</option>
<option value="modern">Modern</option> <option value="modern">@L["RegistryAdmin.Theme.Modern"]</option>
</InputSelect> </InputSelect>
</div> </div>
</div> </div>
<div class="mt-3"> <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" /> <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>
<div class="mt-3"> <div class="mt-3">
<label class="form-label">Top content</label> <label class="form-label">@L["RegistryAdmin.HeroImage"]</label>
<div class="editor-wrapper"> @if (!string.IsNullOrWhiteSpace(SettingsModel.HeroImagePath) || !string.IsNullOrWhiteSpace(SettingsModel.HeroImageBase64))
<BlazoredTextEditor @ref="TextEditor" Placeholder="Welcome text" Theme="snow" ToolbarContent="@ToolbarContent" /> {
<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> </div>
<div class="mt-3"> <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="row g-3 mt-2">
<div class="col-md-4"> <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" /> <InputText class="form-control" @bind-Value="SettingsModel.BankAccountDisplayName" />
</div> </div>
<div class="col-md-4"> <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" /> <InputText class="form-control" @bind-Value="SettingsModel.BankAccountIban" />
</div> </div>
<div class="col-md-4"> <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" /> <InputText class="form-control" @bind-Value="SettingsModel.BankAccountBic" />
</div> </div>
</div> </div>
@@ -350,31 +402,31 @@ else
<div class="form-check"> <div class="form-check">
<InputCheckbox class="form-check-input" @bind-Value="SettingsModel.ShowBankAccountName" id="showBankName" /> <InputCheckbox class="form-check-input" @bind-Value="SettingsModel.ShowBankAccountName" id="showBankName" />
<label class="form-check-label" for="showBankName"> <label class="form-check-label" for="showBankName">
Display bank account name @L["RegistryAdmin.DisplayBankAccountName"]
</label> </label>
</div> </div>
</div> </div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<h3>Contribution Payment Options</h3> <h3>@L["RegistryAdmin.ContributionPaymentOptions"]</h3>
<div class="row g-3 mt-2"> <div class="row g-3 mt-2">
<div class="col-md-12"> <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" /> <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> </div>
<div class="mt-3"> <div class="mt-3">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h4 class="mb-0">Amount-specific QR codes</h4> <h4 class="mb-0">@L["RegistryAdmin.AmountSpecificQrCodes"]</h4>
<button type="button" class="btn btn-outline-secondary btn-sm" @onclick="AddContributionAmountQrCode">Add QR amount</button> <button type="button" class="btn btn-outline-secondary btn-sm" @onclick="AddContributionAmountQrCode">@L["RegistryAdmin.AddQrAmount"]</button>
</div> </div>
@if (SettingsModel.ContributionAmountQrCodes.Count == 0) @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 else
{ {
@@ -383,15 +435,15 @@ else
{ {
<div class="row g-2 align-items-end"> <div class="row g-2 align-items-end">
<div class="col-md-3"> <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" /> <InputNumber class="form-control" @bind-Value="amountQr.Amount" />
</div> </div>
<div class="col-md-7"> <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" /> <InputText class="form-control" @bind-Value="amountQr.QrCodeUrl" />
</div> </div>
<div class="col-md-2"> <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>
</div> </div>
} }
@@ -400,7 +452,7 @@ else
</div> </div>
</div> </div>
<button class="btn btn-primary mt-4" type="submit">Save settings</button> <button class="btn btn-primary mt-4" type="submit">@L["RegistryAdmin.SaveSettings"]</button>
</EditForm> </EditForm>
</section> </section>
</div> </div>
@@ -408,19 +460,19 @@ else
<!-- Addresses Tab --> <!-- Addresses Tab -->
<div class="tab-pane fade @(GetTabPaneClass("addresses"))" id="addresses-content" role="tabpanel" aria-labelledby="addresses-tab"> <div class="tab-pane fade @(GetTabPaneClass("addresses"))" id="addresses-content" role="tabpanel" aria-labelledby="addresses-tab">
<section class="mb-4"> <section class="mb-4">
<h2>User addresses</h2> <h2>@L["RegistryAdmin.UserAddresses"]</h2>
@if (AccessibleUserAddresses.Count == 0) @if (AccessibleUserAddresses.Count == 0)
{ {
<p class="text-muted">No users found yet.</p> <p class="text-muted">@L["RegistryAdmin.NoUsersFound"]</p>
} }
else else
{ {
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>@L["RegistryAdmin.Name"]</th>
<th>Email</th> <th>@L["RegistryAdmin.Email"]</th>
<th>Address</th> <th>@L["RegistryAdmin.Address"]</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -441,17 +493,17 @@ else
<!-- Admins Tab --> <!-- Admins Tab -->
<div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab"> <div class="tab-pane fade @(GetTabPaneClass("admins"))" id="admins-content" role="tabpanel" aria-labelledby="admins-tab">
<section class="mb-4"> <section class="mb-4">
<h2>Current administrators</h2> <h2>@L["RegistryAdmin.CurrentAdministrators"]</h2>
@if (Admins.Count == 0) @if (Admins.Count == 0)
{ {
<p class="text-muted">No admins assigned yet.</p> <p class="text-muted">@L["RegistryAdmin.NoAdmins"]</p>
} }
else else
{ {
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th>Email / Name</th> <th>@L["RegistryAdmin.EmailOrName"]</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -461,7 +513,7 @@ else
<tr> <tr>
<td>@admin.DisplayName</td> <td>@admin.DisplayName</td>
<td class="text-end"> <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> </td>
</tr> </tr>
} }
@@ -471,19 +523,19 @@ else
</section> </section>
<section> <section>
<h2>Invite administrator</h2> <h2>@L["RegistryAdmin.InviteAdministrator"]</h2>
<div class="row g-2"> <div class="row g-2">
<div class="col-md-8"> <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>
<div class="col-md-4"> <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>
</div> </div>
@if (!string.IsNullOrWhiteSpace(InviteLink)) @if (!string.IsNullOrWhiteSpace(InviteLink))
{ {
<p class="mt-3"> <p class="mt-3">
<strong>Invite link:</strong> <strong>@L["RegistryAdmin.InviteLink"]</strong>
<br /> <br />
<a href="@InviteLink" target="_blank">@InviteLink</a> <a href="@InviteLink" target="_blank">@InviteLink</a>
</p> </p>
@@ -1,9 +1,12 @@
using BirthList.Domain.Entities;
using BirthList.Web.Authorization; using BirthList.Web.Authorization;
using BirthList.Web.Features.Registries; using BirthList.Web.Features.Registries;
using BirthList.Web.Services; using BirthList.Web.Services;
using Blazored.TextEditor; using Blazored.TextEditor;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -21,10 +24,26 @@ public partial class RegistryAdmin : ComponentBase
[Inject] private NavigationManager NavigationManager { get; set; } = null!; [Inject] private NavigationManager NavigationManager { get; set; } = null!;
[Inject] private SmtpEmailSender EmailSender { get; set; } = null!; [Inject] private SmtpEmailSender EmailSender { get; set; } = null!;
[Inject] private SmtpConfigurationStatusService SmtpConfigurationStatusService { get; set; } = null!; [Inject] private SmtpConfigurationStatusService SmtpConfigurationStatusService { get; set; } = null!;
[Inject] private IWebHostEnvironment WebHostEnvironment { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!; [Inject] private IJSRuntime JSRuntime { get; set; } = null!;
protected RegistrySettingsEditModel SettingsModel { get; } = new(); protected RegistrySettingsEditModel SettingsModel { get; } = new();
protected RegistryItemEditModel ItemModel { get; private set; } = 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<RegistryItemEditModel> Items { get; private set; } = [];
protected IReadOnlyList<RegistryItemCategoryEditModel> ItemCategories { get; private set; } = []; protected IReadOnlyList<RegistryItemCategoryEditModel> ItemCategories { get; private set; } = [];
protected string? NewCategoryName { get; set; } protected string? NewCategoryName { get; set; }
@@ -35,16 +54,19 @@ public partial class RegistryAdmin : ComponentBase
protected IReadOnlyList<RegistryAccessibleUserAddressViewModel> AccessibleUserAddresses { get; private set; } = []; protected IReadOnlyList<RegistryAccessibleUserAddressViewModel> AccessibleUserAddresses { get; private set; } = [];
protected bool IsAuthorized { get; private set; } protected bool IsAuthorized { get; private set; }
protected bool IsSmtpConfigured { get; private set; } protected bool IsSmtpConfigured { get; private set; }
protected string? PublicLinkCode { get; private set; }
protected string? InviteEmail { get; set; } protected string? InviteEmail { get; set; }
protected string? InviteLink { get; private set; } protected string? InviteLink { get; private set; }
protected BlazoredTextEditor? TextEditor { get; set; } protected BlazoredTextEditor? TextEditor { get; set; }
protected string ActiveTab { get; set; } = "items"; protected string ActiveTab { get; set; } = "items";
protected string? HeroImageUploadMessage { get; set; }
protected RenderFragment ToolbarContent => builder => 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(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></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'><button class='ql-list' value='ordered'></button><button class='ql-list' value='bullet'></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-link'></button><button class='ql-clean'></button></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 bool _pendingEditorLoad;
@@ -583,41 +605,95 @@ public partial class RegistryAdmin : ComponentBase
return; return;
} }
var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false); var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false);
if (metadata is null) 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)) return details.Length <= 500 ? details : details[..500];
{
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;
}
} }
protected async Task CreateInviteAsync() protected async Task CreateInviteAsync()
@@ -675,8 +751,10 @@ public partial class RegistryAdmin : ComponentBase
SettingsModel.BankAccountIban = settings.BankAccountIban; SettingsModel.BankAccountIban = settings.BankAccountIban;
SettingsModel.BankAccountBic = settings.BankAccountBic; SettingsModel.BankAccountBic = settings.BankAccountBic;
SettingsModel.ShowBankAccountName = settings.ShowBankAccountName; SettingsModel.ShowBankAccountName = settings.ShowBankAccountName;
SettingsModel.HideHeaderName = settings.HideHeaderName;
SettingsModel.ContributionQrCodeUrl = settings.ContributionQrCodeUrl; SettingsModel.ContributionQrCodeUrl = settings.ContributionQrCodeUrl;
SettingsModel.ContributionAmountQrCodes = settings.ContributionAmountQrCodes; SettingsModel.ContributionAmountQrCodes = settings.ContributionAmountQrCodes;
PublicLinkCode = settings.PublicLinkCode;
var currentHeaderContent = SettingsModel.HeaderContentHtml ?? string.Empty; var currentHeaderContent = SettingsModel.HeaderContentHtml ?? string.Empty;
_pendingEditorLoad = currentHeaderContent != _lastLoadedHeaderContentHtml; _pendingEditorLoad = currentHeaderContent != _lastLoadedHeaderContentHtml;
@@ -734,4 +812,118 @@ public partial class RegistryAdmin : ComponentBase
return false; 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);
}
}
} }
@@ -3,29 +3,29 @@
@using BirthList.Web.Features.Registries @using BirthList.Web.Features.Registries
<PageTitle>Contribution Amount</PageTitle> <PageTitle>@L["RegistryContributionAmount.PageTitle"]</PageTitle>
@if (Registry is null || Item is null) @if (Registry is null || Item is null)
{ {
<p>Item not found.</p> <p>@L["RegistryContributionAmount.ItemNotFound"]</p>
} }
else if (!IsAuthenticated) else if (!IsAuthenticated)
{ {
<p>Please log in first.</p> <p>@L["RegistryContributionAmount.LoginFirst"]</p>
} }
else else
{ {
<div class="registry-shell @RegistryThemeService.GetCssClass(Registry.RegistryType, Registry.ThemeKey)"> <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"> <div class="card p-3">
@if (RepresentableAmounts.Count == 0) @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 else
{ {
<label class="form-label">Select amount: €@SelectedAmount</label> <label class="form-label">@L["RegistryContributionAmount.SelectAmount", GetCurrencySymbol(), SelectedAmount]</label>
<input type="range" <input type="range"
class="form-range" class="form-range"
min="0" min="0"
@@ -37,15 +37,15 @@ else
@if (Suggestions.Count == 0) @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 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"> <ul class="mb-3">
@foreach (var suggestion in Suggestions) @foreach (var suggestion in Suggestions)
{ {
<li>@suggestion.RepeatCount x @suggestion.Amount</li> <li>@suggestion.RepeatCount x @GetCurrencySymbol()@suggestion.Amount</li>
} }
</ul> </ul>
@@ -55,10 +55,10 @@ else
@for (var i = 0; i < suggestion.RepeatCount; i++) @for (var i = 0; i < suggestion.RepeatCount; i++)
{ {
<div> <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;" /> <img src="@BuildQrImageUrl(suggestion.QrCodeUrl)" alt="QR code @suggestion.Amount" class="img-fluid" style="max-height: 220px;" />
<div class="small text-muted text-break"> <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>
</div> </div>
} }
@@ -67,8 +67,8 @@ else
} }
<div class="d-flex gap-2 mt-3"> <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-success" @onclick="ConfirmAsync" disabled="@(SelectedAmount <= 0)">@L["RegistryContributionAmount.TransferredAmount"]</button>
<button class="btn btn-outline-secondary" @onclick="BackToRegistry">Back</button> <button class="btn btn-outline-secondary" @onclick="BackToRegistry">@L["Common.Back"]</button>
</div> </div>
</div> </div>
</div> </div>
@@ -207,4 +207,11 @@ public partial class RegistryContributionAmount : ComponentBase
public string QrCodeUrl { get; init; } = string.Empty; public string QrCodeUrl { get; init; } = string.Empty;
public int RepeatCount { get; init; } 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}" @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) @if (Redeemed is null)
{ {
<p>Validating invitation...</p> <p>@L["RegistryInvite.Validating"]</p>
} }
else if (Redeemed.Value) else if (Redeemed.Value)
{ {
<p>Invitation accepted. You are now an admin.</p> <p>@L["RegistryInvite.Accepted"]</p>
<a class="btn btn-primary" href="/registry/@RegistryId/admin">Go to admin</a> <a class="btn btn-primary" href="/registry/@RegistryId/admin">@L["RegistryInvite.GoToAdmin"]</a>
} }
else else
{ {
<p>The invitation is invalid or already used.</p> <p>@L["RegistryInvite.Invalid"]</p>
} }
@@ -3,16 +3,35 @@
@using BirthList.Web.Features.Registries @using BirthList.Web.Features.Registries
<PageTitle>Registry</PageTitle> <PageTitle>@L["RegistryPublic.PageTitle"]</PageTitle>
@if (Registry is null) @if (Registry is null)
{ {
<p>Registry not found.</p> <p>@L["RegistryPublic.NotFound"]</p>
} }
else else
{ {
<div class="registry-shell @RegistryThemeService.GetCssClass(Registry.RegistryType, Registry.ThemeKey)"> <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)) @if (!string.IsNullOrWhiteSpace(Registry.HeaderContentHtml))
{ {
@@ -22,7 +41,7 @@ else
@if (!string.IsNullOrWhiteSpace(Registry.ShippingAddress)) @if (!string.IsNullOrWhiteSpace(Registry.ShippingAddress))
{ {
<div class="alert alert-info"> <div class="alert alert-info">
<strong>Shipping address</strong><br /> <strong>@L["RegistryPublic.ShippingAddress"]</strong><br />
<span class="shipping-address-text">@Registry.ShippingAddress</span> <span class="shipping-address-text">@Registry.ShippingAddress</span>
</div> </div>
} }
@@ -30,18 +49,18 @@ else
@if (Registry.ShowBankAccountName && !string.IsNullOrWhiteSpace(Registry.BankAccountIban)) @if (Registry.ShowBankAccountName && !string.IsNullOrWhiteSpace(Registry.BankAccountIban))
{ {
<div class="alert alert-secondary"> <div class="alert alert-secondary">
<strong>Bank transfer participation</strong><br /> <strong>@L["RegistryPublic.BankTransferParticipation"]</strong><br />
@Registry.BankAccountDisplayName<br /> @Registry.BankAccountDisplayName<br />
IBAN: @Registry.BankAccountIban @L["RegistryPublic.IBAN"]: @Registry.BankAccountIban
@if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic)) @if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic))
{ {
<span> | BIC: @Registry.BankAccountBic</span> <span> | @L["RegistryPublic.BIC"]: @Registry.BankAccountBic</span>
} }
</div> </div>
} }
<div class="category-list"> <div class="category-list">
@foreach (var category in Registry.Categories) @foreach (var category in Registry.Categories.Where(x => x.Items.Count > 0))
{ {
var isCollapsed = IsCategoryCollapsed(category.Id); var isCollapsed = IsCategoryCollapsed(category.Id);
<section class="category-section"> <section class="category-section">
@@ -66,11 +85,11 @@ else
<h5 class="card-title">@item.Name</h5> <h5 class="card-title">@item.Name</h5>
@if (item.PreferSecondHand == true) @if (item.PreferSecondHand == true)
{ {
<span class="badge bg-info">Second-hand preferred</span> <span class="badge bg-info">@L["RegistryPublic.SecondHandPreferred"]</span>
} }
else if (item.PreferSecondHand == null) else if (item.PreferSecondHand == null)
{ {
<span class="badge bg-warning">Second-hand optional</span> <span class="badge bg-warning">@L["RegistryPublic.SecondHandOptional"]</span>
} }
</div> </div>
@if (!string.IsNullOrWhiteSpace(item.Description)) @if (!string.IsNullOrWhiteSpace(item.Description))
@@ -79,15 +98,26 @@ else
} }
@if (item.DesiredQuantity > 1) @if (item.DesiredQuantity > 1)
{ {
<p class="mb-1"><strong>Qty:</strong> @item.PurchasedQuantity/@item.DesiredQuantity purchased</p> <p class="mb-1"><strong>@L["RegistryPublic.Qty"]</strong> @item.PurchasedQuantity/@item.DesiredQuantity @L["RegistryPublic.PurchasedSuffix"]</p>
} }
@if (item.PriceAmount.HasValue) @if (item.PriceAmount.HasValue)
{ {
<p class="mb-1"><strong>Price:</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p> <p class="mb-1"><strong>@L["RegistryPublic.Price"]</strong> @item.PriceAmount.Value.ToString("0.00") @item.CurrencyCode</p>
} }
@if (item.ParticipationAllowed && GetParticipationTotalAmount(item).HasValue) @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> <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) @if (item.Purchasers.Count > 0 || item.Contributors.Count > 0)
@@ -98,7 +128,7 @@ else
<div class="mb-2"> <div class="mb-2">
@if (item.CanViewPurchasers && Registry.IsAdmin) @if (item.CanViewPurchasers && Registry.IsAdmin)
{ {
<strong class="text-sm">Purchased by:</strong> <strong class="text-sm">@L["RegistryPublic.PurchasedBy"]</strong>
<div class="contributor-list"> <div class="contributor-list">
@foreach (var purchaser in item.Purchasers) @foreach (var purchaser in item.Purchasers)
{ {
@@ -108,18 +138,19 @@ else
} }
else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0) else if (item.CanViewPurchasers && item.CurrentUserPurchasedQuantity > 0)
{ {
<strong class="text-sm">Purchased by:</strong> <strong class="text-sm">@L["RegistryPublic.PurchasedBy"]</strong>
<div class="contributor-list"> <div class="contributor-list">
<span class="contributor-badge">You (@item.CurrentUserPurchasedQuantity)</span> <span class="contributor-badge">@L["RegistryPublic.YouQuantity", item.CurrentUserPurchasedQuantity]</span>
@if (item.Purchasers.Count > 1) @if (item.Purchasers.Count > 1)
{ {
<span class="contributor-badge">and @(item.Purchasers.Count - 1) other @(item.Purchasers.Count - 1 == 1 ? "person" : "people")</span> 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> </div>
} }
else else
{ {
<span class="text-muted small">Purchased</span> <span class="text-muted small">@L["RegistryPublic.Purchased"]</span>
} }
</div> </div>
} }
@@ -128,7 +159,7 @@ else
<div class="mb-2"> <div class="mb-2">
@if (Registry.IsAdmin) @if (Registry.IsAdmin)
{ {
<strong class="text-sm">Contributed by:</strong> <strong class="text-sm">@L["RegistryPublic.ContributedBy"]</strong>
<div class="contributor-list"> <div class="contributor-list">
@foreach (var contributor in item.Contributors) @foreach (var contributor in item.Contributors)
{ {
@@ -141,23 +172,23 @@ else
var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId); var currentUserContribution = item.Contributors.FirstOrDefault(x => x.UserId == Registry.CurrentUserId);
if (currentUserContribution is not null) if (currentUserContribution is not null)
{ {
<strong class="text-sm">Contributed by:</strong> <strong class="text-sm">@L["RegistryPublic.ContributedBy"]</strong>
<div class="contributor-list"> <div class="contributor-list">
<span class="contributor-badge">@currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode)</span> <span class="contributor-badge">@currentUserContribution.DisplayName (@currentUserContribution.Amount.ToString("0.00") @item.CurrencyCode)</span>
@if (item.Contributors.Count > 1) @if (item.Contributors.Count > 1)
{ {
<span class="contributor-badge">and others</span> <span class="contributor-badge">@L["RegistryPublic.AndOthers"]</span>
} }
</div> </div>
} }
else else
{ {
<span class="text-muted small">Contributed</span> <span class="text-muted small">@L["RegistryPublic.Contributed"]</span>
} }
} }
else else
{ {
<span class="text-muted small">Contributed</span> <span class="text-muted small">@L["RegistryPublic.Contributed"]</span>
} }
</div> </div>
} }
@@ -166,24 +197,24 @@ else
@if (!IsAuthenticated) @if (!IsAuthenticated)
{ {
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">Login to purchase</button> <button class="btn btn-outline-primary btn-sm" @onclick="() => LoginRedirect()">@L["RegistryPublic.LoginToPurchase"]</button>
} }
else else
{ {
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
@if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0) @if (!string.IsNullOrWhiteSpace(item.ProductUrl) && item.CurrentUserPurchasedQuantity == 0)
{ {
<button class="btn btn-primary btn-sm" @onclick="() => OpenPurchasePrompt(item.Id, openTab: true)">Purchase</button> <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)"> <button class="btn btn-success btn-sm" @onclick="() => OpenPurchaseManagementPromptAsync(item.Id)">
@(Registry.IsAdmin ? "Manage purchases" : (item.CurrentUserPurchasedQuantity > 0 ? "Edit purchase" : "Mark purchased")) @(Registry.IsAdmin ? L["RegistryPublic.ManagePurchases"].Value : (item.CurrentUserPurchasedQuantity > 0 ? L["RegistryPublic.EditPurchase"].Value : L["RegistryPublic.MarkPurchased"].Value))
</button> </button>
@if (item.ParticipationAllowed) @if (item.ParticipationAllowed)
{ {
<button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionActionAsync(item.Id, item.CurrentUserContributionAmount)"> <button class="btn btn-secondary btn-sm" @onclick="() => OpenContributionActionAsync(item.Id, item.CurrentUserContributionAmount)">
@(Registry.IsAdmin ? "Manage participations" : (item.CurrentUserContributionAmount > 0 ? "Edit participation" : "Partially fulfill")) @(Registry.IsAdmin ? L["RegistryPublic.ManageParticipations"].Value : (item.CurrentUserContributionAmount > 0 ? L["RegistryPublic.EditParticipation"].Value : L["RegistryPublic.PartiallyFulfill"].Value))
</button> </button>
} }
</div> </div>
@@ -203,36 +234,20 @@ else
{ {
<div class="prompt-overlay"> <div class="prompt-overlay">
<div class="prompt-card"> <div class="prompt-card">
<h3>Mark as purchased</h3> <h3>@L["RegistryPublic.MarkAsPurchased"]</h3>
<p>How many units did you purchase?</p> <p>@L["RegistryPublic.HowManyUnitsPurchased"]</p>
<InputNumber class="form-control" @bind-Value="PurchasedQuantity" /> <InputNumber class="form-control" @bind-Value="PurchasedQuantity" />
@if (!string.IsNullOrWhiteSpace(PurchaseItemUrl)) @if (!string.IsNullOrWhiteSpace(PurchaseItemUrl))
{ {
<div class="mt-3"> <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>
} }
<div class="d-flex gap-2 mt-3"> <div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="ConfirmPurchaseAsync">Confirm purchase</button> <button class="btn btn-success" @onclick="ConfirmPurchaseAsync">@L["RegistryPublic.ConfirmPurchase"]</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchasePrompt">Cancel</button> <button class="btn btn-outline-secondary" @onclick="ClosePurchasePrompt">@L["Common.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>
</div> </div>
</div> </div>
</div> </div>
@@ -242,20 +257,20 @@ else
{ {
<div class="prompt-overlay"> <div class="prompt-overlay">
<div class="prompt-card"> <div class="prompt-card">
<h3>Select purchaser to unmark</h3> <h3>@L["RegistryPublic.SelectPurchaserToUnmark"]</h3>
<p>Multiple users have purchased this item. Choose which purchase to unmark:</p> <p>@L["RegistryPublic.MultipleUsersPurchased"]</p>
<div class="purchaser-list"> <div class="purchaser-list">
@foreach (var purchaser in PurchasersToUnmark) @foreach (var purchaser in PurchasersToUnmark)
{ {
<div class="purchaser-item"> <div class="purchaser-item">
<span>@purchaser.DisplayName (@purchaser.Quantity)</span> <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> </div>
<div class="d-flex gap-2 mt-3"> <div class="d-flex gap-2 mt-3">
<button class="btn btn-danger" @onclick="UnmarkAllPurchasersAsync">Unmark all</button> <button class="btn btn-danger" @onclick="UnmarkAllPurchasersAsync">@L["RegistryPublic.UnmarkAll"]</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaserSelectionPrompt">Cancel</button> <button class="btn btn-outline-secondary" @onclick="ClosePurchaserSelectionPrompt">@L["Common.Cancel"]</button>
</div> </div>
</div> </div>
</div> </div>
@@ -265,29 +280,29 @@ else
{ {
<div class="prompt-overlay"> <div class="prompt-overlay">
<div class="prompt-card"> <div class="prompt-card">
<h3>Partially fulfill item</h3> <h3>@L["RegistryPublic.PartiallyFulfillItem"]</h3>
@if (PartialFulfillStep == 1) @if (PartialFulfillStep == 1)
{ {
<p>Select how you want to donate:</p> <p>@L["RegistryPublic.SelectDonateMethod"]</p>
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column gap-2">
@if (HasIbanPaymentOption()) @if (HasIbanPaymentOption())
{ {
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.Iban)"> <button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.Iban)">
IBAN transfer @L["RegistryPublic.IbanTransfer"]
</button> </button>
} }
@if (HasSingleQrPaymentOption()) @if (HasSingleQrPaymentOption())
{ {
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.SingleQrCode)"> <button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.SingleQrCode)">
Single QR code @L["RegistryPublic.SingleQrCode"]
</button> </button>
} }
@if (HasAmountSpecificQrPaymentOption()) @if (HasAmountSpecificQrPaymentOption())
{ {
<button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.AmountSpecificQrCode)"> <button class="btn btn-outline-primary text-start" @onclick="() => SelectPaymentMethod(ContributionPaymentMethodType.AmountSpecificQrCode)">
QR code per amount @L["RegistryPublic.QrCodePerAmount"]
</button> </button>
} }
</div> </div>
@@ -295,10 +310,10 @@ else
@if (SelectedPaymentMethod == ContributionPaymentMethodType.Iban && !string.IsNullOrWhiteSpace(Registry?.BankAccountIban)) @if (SelectedPaymentMethod == ContributionPaymentMethodType.Iban && !string.IsNullOrWhiteSpace(Registry?.BankAccountIban))
{ {
<div class="alert alert-secondary mt-3 mb-0"> <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)) @if (!string.IsNullOrWhiteSpace(Registry.BankAccountBic))
{ {
<span> | <strong>BIC:</strong> @Registry.BankAccountBic</span> <span> | <strong>@L["RegistryPublic.BIC"]:</strong> @Registry.BankAccountBic</span>
} }
</div> </div>
} }
@@ -308,25 +323,25 @@ else
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<img src="@BuildQrImageUrl(SelectedPaymentQrCodeUrl)" alt="Payment QR code" class="img-fluid" style="max-height: 240px;" /> <img src="@BuildQrImageUrl(SelectedPaymentQrCodeUrl)" alt="Payment QR code" class="img-fluid" style="max-height: 240px;" />
<div class="mt-2"> <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> </div>
} }
<div class="d-flex gap-2 mt-3"> <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-primary" @onclick="ContinueContributionWizard" disabled="@(SelectedPaymentMethod is null)">@L["Common.Next"]</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button> <button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">@L["Common.Cancel"]</button>
</div> </div>
} }
else if (PartialFulfillStep == 2) 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" /> <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)) @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)) @if (!string.IsNullOrWhiteSpace(SelectedPaymentQrCodeUrl))
@@ -334,15 +349,15 @@ else
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<img src="@BuildQrImageUrl(SelectedPaymentQrCodeUrl)" alt="Payment QR code" class="img-fluid" style="max-height: 240px;" /> <img src="@BuildQrImageUrl(SelectedPaymentQrCodeUrl)" alt="Payment QR code" class="img-fluid" style="max-height: 240px;" />
<div class="mt-2"> <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> </div>
} }
<div class="d-flex gap-2 mt-3"> <div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="ConfirmContributionAsync">Confirm</button> <button class="btn btn-success" @onclick="ConfirmContributionAsync">@L["RegistryPublic.Confirm"]</button>
<button class="btn btn-outline-secondary" @onclick="BackContributionWizard">Back</button> <button class="btn btn-outline-secondary" @onclick="BackContributionWizard">@L["Common.Back"]</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">Cancel</button> <button class="btn btn-outline-secondary" @onclick="CloseContributionPrompt">@L["Common.Cancel"]</button>
</div> </div>
} }
</div> </div>
@@ -353,14 +368,14 @@ else
{ {
<div class="prompt-overlay"> <div class="prompt-overlay">
<div class="prompt-card"> <div class="prompt-card">
<h3>Manage purchase</h3> <h3>@L["RegistryPublic.ManagePurchase"]</h3>
@if (Registry?.IsAdmin == true) @if (Registry?.IsAdmin == true)
{ {
<label class="form-label">User</label> <label class="form-label">@L["RegistryPublic.User"]</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="Search user" /> <InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="@L["Common.SearchUser"]" />
<select class="form-select mb-3" value="@SelectedPurchaseUserId" @onchange="OnPurchaseManagedUserChanged"> <select class="form-select mb-3" value="@SelectedPurchaseUserId" @onchange="OnPurchaseManagedUserChanged">
<option value="">Select user</option> <option value="">@L["Common.SelectUser"]</option>
@foreach (var user in FilteredSelectableUsers) @foreach (var user in FilteredSelectableUsers)
{ {
<option value="@user.UserId">@user.DisplayName</option> <option value="@user.UserId">@user.DisplayName</option>
@@ -368,13 +383,13 @@ else
</select> </select>
} }
<label class="form-label">Quantity</label> <label class="form-label">@L["Common.Quantity"]</label>
<InputNumber class="form-control" @bind-Value="ManagedPurchaseQuantity" /> <InputNumber class="form-control" @bind-Value="ManagedPurchaseQuantity" />
<div class="d-flex gap-2 mt-3"> <div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SavePurchaseManagementAsync">Save</button> <button class="btn btn-success" @onclick="SavePurchaseManagementAsync">@L["Common.Save"]</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedPurchaseAsync">Remove</button> <button class="btn btn-outline-danger" @onclick="RemoveManagedPurchaseAsync">@L["Common.Remove"]</button>
<button class="btn btn-outline-secondary" @onclick="ClosePurchaseManagementPrompt">Cancel</button> <button class="btn btn-outline-secondary" @onclick="ClosePurchaseManagementPrompt">@L["Common.Cancel"]</button>
</div> </div>
</div> </div>
</div> </div>
@@ -384,14 +399,14 @@ else
{ {
<div class="prompt-overlay"> <div class="prompt-overlay">
<div class="prompt-card"> <div class="prompt-card">
<h3>Manage participation</h3> <h3>@L["RegistryPublic.ManageParticipation"]</h3>
@if (Registry?.IsAdmin == true) @if (Registry?.IsAdmin == true)
{ {
<label class="form-label">User</label> <label class="form-label">@L["RegistryPublic.User"]</label>
<InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="Search user" /> <InputText class="form-control mb-2" @bind-Value="UserFilterText" placeholder="@L["Common.SearchUser"]" />
<select class="form-select mb-3" value="@SelectedContributionUserId" @onchange="OnContributionManagedUserChanged"> <select class="form-select mb-3" value="@SelectedContributionUserId" @onchange="OnContributionManagedUserChanged">
<option value="">Select user</option> <option value="">@L["Common.SelectUser"]</option>
@foreach (var user in FilteredSelectableUsers) @foreach (var user in FilteredSelectableUsers)
{ {
<option value="@user.UserId">@user.DisplayName</option> <option value="@user.UserId">@user.DisplayName</option>
@@ -399,16 +414,16 @@ else
</select> </select>
} }
<label class="form-label">Amount</label> <label class="form-label">@L["Common.Amount"]</label>
<InputNumber class="form-control" @bind-Value="ManagedContributionAmount" /> <InputNumber class="form-control" @bind-Value="ManagedContributionAmount" />
<label class="form-label mt-2">Message</label> <label class="form-label mt-2">@L["Common.Message"]</label>
<InputText class="form-control" @bind-Value="ManagedContributionMessage" /> <InputText class="form-control" @bind-Value="ManagedContributionMessage" />
<div class="d-flex gap-2 mt-3"> <div class="d-flex gap-2 mt-3">
<button class="btn btn-success" @onclick="SaveContributionManagementAsync">Save</button> <button class="btn btn-success" @onclick="SaveContributionManagementAsync">@L["Common.Save"]</button>
<button class="btn btn-outline-danger" @onclick="RemoveManagedContributionAsync">Remove</button> <button class="btn btn-outline-danger" @onclick="RemoveManagedContributionAsync">@L["Common.Remove"]</button>
<button class="btn btn-outline-secondary" @onclick="CloseContributionManagementPrompt">Cancel</button> <button class="btn btn-outline-secondary" @onclick="CloseContributionManagementPrompt">@L["Common.Cancel"]</button>
</div> </div>
</div> </div>
</div> </div>
@@ -4,6 +4,20 @@
gap: 1rem; 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 { .item-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -168,3 +182,16 @@
.category-toggle span.bi { .category-toggle span.bi {
margin-right: 0.25rem; 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.Forms
@using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web @using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Localization
@using static Microsoft.AspNetCore.Components.Web.RenderMode @using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization @using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using BirthList.Web @using BirthList.Web
@using BirthList.Web.Components @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; using Microsoft.AspNetCore.Identity;
namespace BirthList.Web.Data; namespace BirthList.Web.Data;
@@ -8,5 +9,8 @@ public class ApplicationUser : IdentityUser
public string? FirstName { get; set; } public string? FirstName { get; set; }
public string? LastName { get; set; } public string? LastName { get; set; }
public string? Address { 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") b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<string>("PreferredCulture")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("SecurityStamp") b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)"); .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; 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 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 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) public async Task<UrlMetadataResult?> FetchAsync(string url, CancellationToken cancellationToken)
{ {
@@ -16,22 +20,83 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
} }
var normalizedUri = NormalizeProductUri(sourceUri); var normalizedUri = NormalizeProductUri(sourceUri);
var isAmazon = normalizedUri.Host.Contains("amazon", StringComparison.OrdinalIgnoreCase);
var client = httpClientFactory.CreateClient("RegistryMetadata"); // Strategy 1: PA API (Amazon only, when credentials are configured)
using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUri); if (isAmazon)
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)
{ {
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)) 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); 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 return new UrlMetadataResult
{ {
NormalizedUrl = normalizedUri.AbsoluteUri, 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) private static bool IsGenericAmazonImage(string image)
{ {
return image.Contains("amazon.png", StringComparison.OrdinalIgnoreCase) return image.Contains("amazon.png", StringComparison.OrdinalIgnoreCase)
@@ -31,7 +31,7 @@ public sealed class RegistryItemEditModel
public string? ProductUrl { get; set; } public string? ProductUrl { get; set; }
public string? Description { get; set; } public string? Description { get; set; }
public decimal? PriceAmount { 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 int DesiredQuantity { get; set; } = 1;
public bool ParticipationAllowed { get; set; } public bool ParticipationAllowed { get; set; }
public decimal? ParticipationTargetAmount { get; set; } public decimal? ParticipationTargetAmount { get; set; }
@@ -53,16 +53,21 @@ public sealed class RegistrySettingsEditModel
{ {
public string? BabyName { get; set; } public string? BabyName { get; set; }
public DateOnly? BirthDate { 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? HeaderContentHtml { get; set; }
public string? ShippingAddress { 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 ThemeKey { get; set; } = "default";
public string? BankAccountIban { get; set; } public string? BankAccountIban { get; set; }
public string? BankAccountBic { get; set; } public string? BankAccountBic { get; set; }
public string? BankAccountDisplayName { get; set; } public string? BankAccountDisplayName { get; set; }
public bool ShowBankAccountName { get; set; } public bool ShowBankAccountName { get; set; }
public bool HideHeaderName { get; set; }
public string? ContributionQrCodeUrl { get; set; } public string? ContributionQrCodeUrl { get; set; }
public List<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; set; } = []; public List<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; set; } = [];
public string PublicLinkCode { get; set; } = string.Empty;
} }
public sealed class RegistrySummaryViewModel public sealed class RegistrySummaryViewModel
@@ -80,15 +85,19 @@ public sealed class RegistryPublicViewModel
public string Title { get; init; } = string.Empty; public string Title { get; init; } = string.Empty;
public string PublicLinkCode { get; init; } = string.Empty; public string PublicLinkCode { get; init; } = string.Empty;
public string? BabyName { get; init; } 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? HeaderContentHtml { get; init; }
public string? ShippingAddress { 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 string ThemeKey { get; init; } = "default";
public RegistryType RegistryType { get; init; } public RegistryType RegistryType { get; init; }
public string? BankAccountIban { get; init; } public string? BankAccountIban { get; init; }
public string? BankAccountBic { get; init; } public string? BankAccountBic { get; init; }
public string? BankAccountDisplayName { get; init; } public string? BankAccountDisplayName { get; init; }
public bool ShowBankAccountName { get; init; } public bool ShowBankAccountName { get; init; }
public bool HideHeaderName { get; init; }
public string? ContributionQrCodeUrl { get; init; } public string? ContributionQrCodeUrl { get; init; }
public IReadOnlyList<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; init; } = []; public IReadOnlyList<ContributionAmountQrCodeModel> ContributionAmountQrCodes { get; init; } = [];
public string? CurrentUserId { get; init; } public string? CurrentUserId { get; init; }
@@ -117,7 +126,7 @@ public sealed class RegistryPublicItemViewModel
public string? ProductUrl { get; init; } public string? ProductUrl { get; init; }
public string? Description { get; init; } public string? Description { get; init; }
public decimal? PriceAmount { 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 DesiredQuantity { get; init; }
public int PurchasedQuantity { get; init; } public int PurchasedQuantity { get; init; }
public bool ParticipationAllowed { get; init; } public bool ParticipationAllowed { get; init; }
@@ -11,6 +11,7 @@ namespace BirthList.Web.Features.Registries;
internal sealed class RegistryService(RegistryDbContext registryDbContext, ApplicationDbContext applicationDbContext) internal sealed class RegistryService(RegistryDbContext registryDbContext, ApplicationDbContext applicationDbContext)
{ {
private const string DefaultCategoryName = "General"; private const string DefaultCategoryName = "General";
private const string DefaultCurrencySymbol = "€";
public async Task<Guid> CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken) public async Task<Guid> CreateRegistryAsync(string userId, RegistryCreateModel model, CancellationToken cancellationToken)
{ {
@@ -31,7 +32,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim(), ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim(),
PublicLinkCode = publicCode, PublicLinkCode = publicCode,
CreatedAtUtc = DateTimeOffset.UtcNow, CreatedAtUtc = DateTimeOffset.UtcNow,
CurrencyCode = "EUR" CurrencyCode = DefaultCurrencySymbol
}; };
registryDbContext.Registries.Add(registry); registryDbContext.Registries.Add(registry);
@@ -298,6 +299,9 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
Title = registry.Title, Title = registry.Title,
PublicLinkCode = registry.PublicLinkCode, PublicLinkCode = registry.PublicLinkCode,
BabyName = registry.BabyName, BabyName = registry.BabyName,
HeroImageBase64 = registry.HeroImageData != null ? Convert.ToBase64String(registry.HeroImageData) : null,
HeroImageContentType = registry.HeroImageContentType,
HeroImagePath = registry.HeroImagePath,
HeaderContentHtml = registry.HeaderContentHtml, HeaderContentHtml = registry.HeaderContentHtml,
ShippingAddress = registry.ShippingAddress, ShippingAddress = registry.ShippingAddress,
CurrencyCode = registry.CurrencyCode, CurrencyCode = registry.CurrencyCode,
@@ -307,6 +311,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
BankAccountBic = settings?.BankAccountBic, BankAccountBic = settings?.BankAccountBic,
BankAccountDisplayName = settings?.BankAccountDisplayName, BankAccountDisplayName = settings?.BankAccountDisplayName,
ShowBankAccountName = settings?.ShowBankAccountName ?? false, ShowBankAccountName = settings?.ShowBankAccountName ?? false,
HideHeaderName = settings?.HideHeaderName ?? false,
ContributionQrCodeUrl = settings?.ContributionQrCodeUrl, ContributionQrCodeUrl = settings?.ContributionQrCodeUrl,
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson), ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson),
CurrentUserId = userId, CurrentUserId = userId,
@@ -335,6 +340,9 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
{ {
BabyName = registry.BabyName, BabyName = registry.BabyName,
BirthDate = registry.BirthDate, BirthDate = registry.BirthDate,
HeroImageBase64 = registry.HeroImageData != null ? Convert.ToBase64String(registry.HeroImageData) : null,
HeroImageContentType = registry.HeroImageContentType,
HeroImagePath = registry.HeroImagePath,
HeaderContentHtml = registry.HeaderContentHtml, HeaderContentHtml = registry.HeaderContentHtml,
ShippingAddress = registry.ShippingAddress, ShippingAddress = registry.ShippingAddress,
CurrencyCode = registry.CurrencyCode, CurrencyCode = registry.CurrencyCode,
@@ -343,8 +351,10 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
BankAccountBic = settings?.BankAccountBic, BankAccountBic = settings?.BankAccountBic,
BankAccountDisplayName = settings?.BankAccountDisplayName, BankAccountDisplayName = settings?.BankAccountDisplayName,
ShowBankAccountName = settings?.ShowBankAccountName ?? false, ShowBankAccountName = settings?.ShowBankAccountName ?? false,
HideHeaderName = settings?.HideHeaderName ?? false,
ContributionQrCodeUrl = settings?.ContributionQrCodeUrl, ContributionQrCodeUrl = settings?.ContributionQrCodeUrl,
ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson).ToList() ContributionAmountQrCodes = ParseContributionAmountQrCodes(settings?.ContributionAmountQrCodesJson).ToList(),
PublicLinkCode = registry.PublicLinkCode
}; };
} }
@@ -371,77 +381,43 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
registry.BabyName = string.IsNullOrWhiteSpace(model.BabyName) ? null : model.BabyName.Trim(); registry.BabyName = string.IsNullOrWhiteSpace(model.BabyName) ? null : model.BabyName.Trim();
registry.BirthDate = model.BirthDate; registry.BirthDate = model.BirthDate;
if (!string.IsNullOrWhiteSpace(model.HeroImagePath))
{
registry.HeroImagePath = model.HeroImagePath.Trim();
registry.HeroImageData = null;
registry.HeroImageContentType = null;
}
else if (!string.IsNullOrWhiteSpace(model.HeroImageBase64))
{
registry.HeroImageData = Convert.FromBase64String(model.HeroImageBase64);
registry.HeroImageContentType = model.HeroImageContentType;
registry.HeroImagePath = null;
}
else if ((string.IsNullOrWhiteSpace(model.HeroImageBase64) && model.HeroImageBase64 == string.Empty)
|| (string.IsNullOrWhiteSpace(model.HeroImagePath) && model.HeroImagePath == string.Empty))
{
registry.HeroImageData = null;
registry.HeroImageContentType = null;
registry.HeroImagePath = null;
}
registry.HeaderContentHtml = string.IsNullOrWhiteSpace(model.HeaderContentHtml) ? null : model.HeaderContentHtml; registry.HeaderContentHtml = string.IsNullOrWhiteSpace(model.HeaderContentHtml) ? null : model.HeaderContentHtml;
registry.ShippingAddress = string.IsNullOrWhiteSpace(model.ShippingAddress) ? null : model.ShippingAddress.Trim(); registry.ShippingAddress = string.IsNullOrWhiteSpace(model.ShippingAddress) ? null : model.ShippingAddress.Trim();
registry.CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? "EUR" : model.CurrencyCode.Trim().ToUpperInvariant(); registry.CurrencyCode = NormalizeCurrencySymbol(model.CurrencyCode);
registry.ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim(); registry.ThemeKey = string.IsNullOrWhiteSpace(model.ThemeKey) ? "default" : model.ThemeKey.Trim();
settings.BankAccountIban = string.IsNullOrWhiteSpace(model.BankAccountIban) ? null : model.BankAccountIban.Trim(); settings.BankAccountIban = string.IsNullOrWhiteSpace(model.BankAccountIban) ? null : model.BankAccountIban.Trim();
settings.BankAccountBic = string.IsNullOrWhiteSpace(model.BankAccountBic) ? null : model.BankAccountBic.Trim(); settings.BankAccountBic = string.IsNullOrWhiteSpace(model.BankAccountBic) ? null : model.BankAccountBic.Trim();
settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim(); settings.BankAccountDisplayName = string.IsNullOrWhiteSpace(model.BankAccountDisplayName) ? null : model.BankAccountDisplayName.Trim();
settings.ShowBankAccountName = model.ShowBankAccountName; settings.ShowBankAccountName = model.ShowBankAccountName;
settings.HideHeaderName = model.HideHeaderName;
settings.ContributionQrCodeUrl = string.IsNullOrWhiteSpace(model.ContributionQrCodeUrl) ? null : model.ContributionQrCodeUrl.Trim(); settings.ContributionQrCodeUrl = string.IsNullOrWhiteSpace(model.ContributionQrCodeUrl) ? null : model.ContributionQrCodeUrl.Trim();
settings.ContributionAmountQrCodesJson = SerializeContributionAmountQrCodes(model.ContributionAmountQrCodes); settings.ContributionAmountQrCodesJson = SerializeContributionAmountQrCodes(model.ContributionAmountQrCodes);
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
} }
private static IReadOnlyList<ContributionAmountQrCodeModel> ParseContributionAmountQrCodes(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
var items = JsonSerializer.Deserialize<List<ContributionAmountQrCodeModel>>(json);
if (items is null)
{
return [];
}
return items
.Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl))
.Select(x => new ContributionAmountQrCodeModel
{
Amount = x.Amount,
QrCodeUrl = x.QrCodeUrl.Trim()
})
.OrderBy(x => x.Amount)
.ToList();
}
catch (JsonException)
{
return [];
}
}
private static string? SerializeContributionAmountQrCodes(IEnumerable<ContributionAmountQrCodeModel>? amountQrCodes)
{
if (amountQrCodes is null)
{
return null;
}
var normalized = amountQrCodes
.Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl))
.Select(x => new ContributionAmountQrCodeModel
{
Amount = x.Amount,
QrCodeUrl = x.QrCodeUrl.Trim()
})
.OrderBy(x => x.Amount)
.ToList();
if (normalized.Count == 0)
{
return null;
}
return JsonSerializer.Serialize(normalized);
}
public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken) public async Task<IReadOnlyList<RegistryItemEditModel>> GetRegistryItemsAsync(Guid registryId, CancellationToken cancellationToken)
{ {
var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false); var defaultCategory = await EnsureDefaultCategoryAsync(registryId, cancellationToken).ConfigureAwait(false);
@@ -682,7 +658,7 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
entity.ProductUrl = string.IsNullOrWhiteSpace(model.ProductUrl) ? null : model.ProductUrl.Trim(); entity.ProductUrl = string.IsNullOrWhiteSpace(model.ProductUrl) ? null : model.ProductUrl.Trim();
entity.Description = string.IsNullOrWhiteSpace(model.Description) ? null : model.Description.Trim(); entity.Description = string.IsNullOrWhiteSpace(model.Description) ? null : model.Description.Trim();
entity.PriceAmount = model.PriceAmount; entity.PriceAmount = model.PriceAmount;
entity.CurrencyCode = string.IsNullOrWhiteSpace(model.CurrencyCode) ? "EUR" : model.CurrencyCode.Trim().ToUpperInvariant(); entity.CurrencyCode = NormalizeCurrencySymbol(model.CurrencyCode);
entity.DesiredQuantity = model.DesiredQuantity < 1 ? 1 : model.DesiredQuantity; entity.DesiredQuantity = model.DesiredQuantity < 1 ? 1 : model.DesiredQuantity;
entity.ParticipationAllowed = model.ParticipationAllowed; entity.ParticipationAllowed = model.ParticipationAllowed;
entity.ParticipationTargetAmount = model.ParticipationAllowed ? model.ParticipationTargetAmount : null; entity.ParticipationTargetAmount = model.ParticipationAllowed ? model.ParticipationTargetAmount : null;
@@ -1629,4 +1605,67 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext, Appli
await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await registryDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
} }
} }
private static IReadOnlyList<ContributionAmountQrCodeModel> ParseContributionAmountQrCodes(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
var items = JsonSerializer.Deserialize<List<ContributionAmountQrCodeModel>>(json);
if (items is null)
{
return [];
}
return items
.Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl))
.Select(x => new ContributionAmountQrCodeModel
{
Amount = x.Amount,
QrCodeUrl = x.QrCodeUrl.Trim()
})
.OrderBy(x => x.Amount)
.ToList();
}
catch (JsonException)
{
return [];
}
}
private static string? SerializeContributionAmountQrCodes(IEnumerable<ContributionAmountQrCodeModel>? amountQrCodes)
{
if (amountQrCodes is null)
{
return null;
}
var normalized = amountQrCodes
.Where(x => x.Amount > 0 && !string.IsNullOrWhiteSpace(x.QrCodeUrl))
.Select(x => new ContributionAmountQrCodeModel
{
Amount = x.Amount,
QrCodeUrl = x.QrCodeUrl.Trim()
})
.OrderBy(x => x.Amount)
.ToList();
if (normalized.Count == 0)
{
return null;
}
return JsonSerializer.Serialize(normalized);
}
private static string NormalizeCurrencySymbol(string? currencySymbol)
{
return string.IsNullOrWhiteSpace(currencySymbol)
? DefaultCurrencySymbol
: currencySymbol.Trim();
}
} }
+86
View File
@@ -1,15 +1,19 @@
using System.Data; using System.Data;
using System.Globalization;
using BirthList.Infrastructure.Persistence; using BirthList.Infrastructure.Persistence;
using BirthList.Web.Authorization; using BirthList.Web.Authorization;
using BirthList.Web.Components; using BirthList.Web.Components;
using BirthList.Web.Components.Account; using BirthList.Web.Components.Account;
using BirthList.Web.Configuration; using BirthList.Web.Configuration;
using BirthList.Web.Data; using BirthList.Web.Data;
using BirthList.Web.Features.Localization;
using BirthList.Web.Features.Registries; using BirthList.Web.Features.Registries;
using BirthList.Web.Services; using BirthList.Web.Services;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
@@ -24,7 +28,16 @@ builder.Services.AddHttpClient("RegistryMetadata", client =>
{ {
client.Timeout = TimeSpan.FromSeconds(10); 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<SmtpOptions>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<AmazonMetadataOptions>(builder.Configuration.GetSection("AmazonMetadata"));
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
{ {
@@ -41,9 +54,12 @@ builder.Services.AddScoped<RegistryAuthorizationService>();
builder.Services.AddScoped<OwnerBootstrapService>(); builder.Services.AddScoped<OwnerBootstrapService>();
builder.Services.AddScoped<RegistryService>(); builder.Services.AddScoped<RegistryService>();
builder.Services.AddScoped<RegistryMetadataService>(); builder.Services.AddScoped<RegistryMetadataService>();
builder.Services.AddScoped<AmazonPaApiClient>();
builder.Services.AddScoped<RapidApiMetadataClient>();
builder.Services.AddScoped<RegistryThemeService>(); builder.Services.AddScoped<RegistryThemeService>();
builder.Services.AddScoped<RegistryUserContext>(); builder.Services.AddScoped<RegistryUserContext>();
builder.Services.AddScoped<SmtpConfigurationStatusService>(); builder.Services.AddScoped<SmtpConfigurationStatusService>();
builder.Services.AddScoped<LocalizationService>();
var googleClientId = builder.Configuration["Authentication:Google:ClientId"]; var googleClientId = builder.Configuration["Authentication:Google:ClientId"];
var googleClientSecret = builder.Configuration["Authentication:Google:ClientSecret"]; 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<IEmailSender<ApplicationUser>>(serviceProvider => serviceProvider.GetRequiredService<SmtpEmailSender>());
builder.Services.AddScoped<ProfileCompletionService>(); 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(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
@@ -190,9 +235,50 @@ app.UseAuthorization();
app.UseHttpsRedirection(); app.UseHttpsRedirection();
app.UseRequestLocalization();
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAntiforgery(); 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>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode(); .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": "*", "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": ""
}
} }