diff --git a/src/BirthList.Domain/Entities/UserActionLog.cs b/src/BirthList.Domain/Entities/UserActionLog.cs index 1d81910..a012653 100644 --- a/src/BirthList.Domain/Entities/UserActionLog.cs +++ b/src/BirthList.Domain/Entities/UserActionLog.cs @@ -22,5 +22,7 @@ public enum UserActionType MarkPurchased = 3, UnmarkPurchased = 4, MarkPartialPurchase = 5, - LogContribution = 6 + LogContribution = 6, + MetadataFetchSucceeded = 7, + MetadataFetchFailed = 8 } diff --git a/src/BirthList.Web/Components/Layout/TopBar.razor b/src/BirthList.Web/Components/Layout/TopBar.razor index 6af0ee4..23238f8 100644 --- a/src/BirthList.Web/Components/Layout/TopBar.razor +++ b/src/BirthList.Web/Components/Layout/TopBar.razor @@ -2,12 +2,14 @@ @using BirthList.Web.Features.Localization @using BirthList.Web.Features.Registries @using BirthList.Web.Services +@using Microsoft.Extensions.Hosting @implements IDisposable @inject NavigationManager NavigationManager @inject AuthenticationStateProvider AuthenticationStateProvider @inject RegistryUserContext RegistryUserContext @inject ProfileCompletionService ProfileCompletionService +@inject IHostEnvironment HostEnvironment @if (ShowProfileCompletionPrompt) { @@ -70,12 +72,21 @@ private bool ShowProfileCompletionPrompt { get; set; } private string CurrentCulture { get; set; } = "en"; - private static readonly IReadOnlyList LanguageOptions = + private IReadOnlyList LanguageOptions => HostEnvironment.IsDevelopment() + ? DevelopmentLanguageOptions + : ProductionLanguageOptions; + + private static readonly IReadOnlyList ProductionLanguageOptions = [ new("en", "English"), new("nl-NL", "Nederlands (NL)"), new("nl-BE", "Nederlands (BE)"), - new("fr-FR", "Français"), + new("fr-FR", "Français") + ]; + + private static readonly IReadOnlyList DevelopmentLanguageOptions = + [ + ..ProductionLanguageOptions, new("qps-Ploc", "Pseudo") ]; diff --git a/src/BirthList.Web/Components/Pages/RegistryActionLog.razor b/src/BirthList.Web/Components/Pages/RegistryActionLog.razor index f9fbb24..ca7d7e9 100644 --- a/src/BirthList.Web/Components/Pages/RegistryActionLog.razor +++ b/src/BirthList.Web/Components/Pages/RegistryActionLog.razor @@ -65,6 +65,12 @@ else case "LogContribution": @L["RegistryActionLog.Badge.ContributionLogged"] break; + case "MetadataFetchSucceeded": + @L["RegistryActionLog.Badge.MetadataFetchSucceeded"] + break; + case "MetadataFetchFailed": + @L["RegistryActionLog.Badge.MetadataFetchFailed"] + break; default: @log.ActionType break; diff --git a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs index 8363b00..25e035b 100644 --- a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs +++ b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs @@ -1,3 +1,4 @@ +using BirthList.Domain.Entities; using BirthList.Web.Authorization; using BirthList.Web.Features.Registries; using BirthList.Web.Services; @@ -41,10 +42,11 @@ public partial class RegistryAdmin : ComponentBase protected string ActiveTab { get; set; } = "items"; protected RenderFragment ToolbarContent => builder => { - builder.AddMarkupContent(0, ""); - builder.AddMarkupContent(1, ""); - builder.AddMarkupContent(2, ""); - builder.AddMarkupContent(3, ""); + builder.AddMarkupContent(0, @""); + builder.AddMarkupContent(1, @""); + builder.AddMarkupContent(2, @""); + builder.AddMarkupContent(3, @""); + builder.AddMarkupContent(4, @""); }; private bool _pendingEditorLoad; @@ -583,41 +585,95 @@ public partial class RegistryAdmin : ComponentBase return; } - var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false); - if (metadata is null) + var userId = await RegistryUserContext.GetUserIdAsync(CancellationToken.None).ConfigureAwait(false); + var actorUserId = string.IsNullOrWhiteSpace(userId) ? "unknown-user" : userId; + + async Task LogMetadataAsync(UserActionType actionType, string details) { - return; + try + { + await RegistryService.LogUserActionAsync( + RegistryId, + actorUserId, + actionType, + ItemModel.Id, + TruncateLogDetails(details), + CancellationToken.None).ConfigureAwait(false); + } + catch (ArgumentException) + { + // Keep autofetch UX stable even if logging cannot be persisted. + } } - if (!string.IsNullOrWhiteSpace(metadata.NormalizedUrl)) + try { - ItemModel.ProductUrl = metadata.NormalizedUrl; + var metadata = await RegistryMetadataService.FetchAsync(ItemModel.ProductUrl, CancellationToken.None).ConfigureAwait(false); + if (metadata is null) + { + await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=no-metadata").ConfigureAwait(false); + return; + } + + if (!string.IsNullOrWhiteSpace(metadata.NormalizedUrl)) + { + ItemModel.ProductUrl = metadata.NormalizedUrl; + } + + if ((string.IsNullOrWhiteSpace(ItemModel.Name) || string.Equals(ItemModel.Name, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Title)) + { + ItemModel.Name = metadata.Title; + } + + if ((string.IsNullOrWhiteSpace(ItemModel.Description) || string.Equals(ItemModel.Description, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Description)) + { + ItemModel.Description = metadata.Description; + } + + if (string.IsNullOrWhiteSpace(ItemModel.PictureUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl)) + { + ItemModel.PictureUrl = metadata.ImageUrl; + } + + if (!ItemModel.PriceAmount.HasValue && metadata.PriceAmount.HasValue) + { + ItemModel.PriceAmount = metadata.PriceAmount; + } + + if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode)) + { + ItemModel.CurrencyCode = metadata.CurrencyCode; + } + + var summary = $"metadata-fetch success; url={ItemModel.ProductUrl}; title={(string.IsNullOrWhiteSpace(metadata.Title) ? "-" : metadata.Title)}; image={(string.IsNullOrWhiteSpace(metadata.ImageUrl) ? "no" : "yes")}; price={(metadata.PriceAmount.HasValue ? metadata.PriceAmount.Value.ToString("0.00") : "-")}; currency={(string.IsNullOrWhiteSpace(metadata.CurrencyCode) ? "-" : metadata.CurrencyCode)}"; + await LogMetadataAsync(UserActionType.MetadataFetchSucceeded, summary).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=http; message={ex.Message}").ConfigureAwait(false); + } + catch (TaskCanceledException ex) + { + await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=timeout-or-cancel; message={ex.Message}").ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=invalid-operation; message={ex.Message}").ConfigureAwait(false); + } + catch (Exception ex) + { + await LogMetadataAsync(UserActionType.MetadataFetchFailed, $"metadata-fetch failed; url={ItemModel.ProductUrl}; reason=unexpected-{ex.GetType().Name}; message={ex.Message}").ConfigureAwait(false); + } + } + + private static string TruncateLogDetails(string details) + { + if (string.IsNullOrWhiteSpace(details)) + { + return string.Empty; } - if ((string.IsNullOrWhiteSpace(ItemModel.Name) || string.Equals(ItemModel.Name, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Title)) - { - ItemModel.Name = metadata.Title; - } - - if ((string.IsNullOrWhiteSpace(ItemModel.Description) || string.Equals(ItemModel.Description, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Description)) - { - ItemModel.Description = metadata.Description; - } - - if (string.IsNullOrWhiteSpace(ItemModel.PictureUrl) && !string.IsNullOrWhiteSpace(metadata.ImageUrl)) - { - ItemModel.PictureUrl = metadata.ImageUrl; - } - - if (!ItemModel.PriceAmount.HasValue && metadata.PriceAmount.HasValue) - { - ItemModel.PriceAmount = metadata.PriceAmount; - } - - if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode)) - { - ItemModel.CurrencyCode = metadata.CurrencyCode; - } + return details.Length <= 500 ? details : details[..500]; } protected async Task CreateInviteAsync() diff --git a/src/BirthList.Web/Components/Pages/RegistryPublic.razor.css b/src/BirthList.Web/Components/Pages/RegistryPublic.razor.css index dce5f55..0a91bd5 100644 --- a/src/BirthList.Web/Components/Pages/RegistryPublic.razor.css +++ b/src/BirthList.Web/Components/Pages/RegistryPublic.razor.css @@ -168,3 +168,16 @@ .category-toggle span.bi { margin-right: 0.25rem; } + +/* Render Quill text-size classes in public header content */ +.header-content ::deep .ql-size-small { + font-size: 0.75em; +} + +.header-content ::deep .ql-size-large { + font-size: 1.5em; +} + +.header-content ::deep .ql-size-huge { + font-size: 2.5em; +} diff --git a/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs b/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs index 8f36226..babc5b4 100644 --- a/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs +++ b/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs @@ -23,15 +23,12 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto 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; - } + response.EnsureSuccessStatusCode(); var html = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); if (string.IsNullOrWhiteSpace(html)) { - return null; + throw new InvalidOperationException("Metadata response content was empty."); } var meta = ParseMeta(html); diff --git a/src/BirthList.Web/Program.cs b/src/BirthList.Web/Program.cs index 0991f1b..976d70e 100644 --- a/src/BirthList.Web/Program.cs +++ b/src/BirthList.Web/Program.cs @@ -13,10 +13,10 @@ using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage; -using Microsoft.AspNetCore.Mvc; var builder = WebApplication.CreateBuilder(args); @@ -136,15 +136,18 @@ builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"; }); -var supportedCultures = new[] +var supportedCultures = new List { - new CultureInfo("en"), - new CultureInfo("nl-NL"), - new CultureInfo("nl-BE"), - new CultureInfo("fr-FR"), - new CultureInfo("qps-Ploc") + new("en"), + new("nl-BE"), + new("fr-FR") }; +if (builder.Environment.IsDevelopment()) +{ + supportedCultures.Add(new CultureInfo("qps-Ploc")); +} + builder.Services.Configure(options => { options.DefaultRequestCulture = new RequestCulture("en"); diff --git a/src/BirthList.Web/Resources/SharedResources.fr-FR.resx b/src/BirthList.Web/Resources/SharedResources.fr-FR.resx index 8063576..6b673c2 100644 --- a/src/BirthList.Web/Resources/SharedResources.fr-FR.resx +++ b/src/BirthList.Web/Resources/SharedResources.fr-FR.resx @@ -216,6 +216,8 @@ Achat annulé Achat partiel Participation enregistrée + Remplissage auto réussi + Remplissage auto échoué Erreur Erreur. diff --git a/src/BirthList.Web/Resources/SharedResources.nl-BE.resx b/src/BirthList.Web/Resources/SharedResources.nl-BE.resx index 023606d..b1416e5 100644 --- a/src/BirthList.Web/Resources/SharedResources.nl-BE.resx +++ b/src/BirthList.Web/Resources/SharedResources.nl-BE.resx @@ -216,6 +216,8 @@ Aankoop verwijderd Gedeeltelijke aankoop Deelname geregistreerd + Automatisch ophalen gelukt + Automatisch ophalen mislukt Fout Fout. diff --git a/src/BirthList.Web/Resources/SharedResources.qps-Ploc.resx b/src/BirthList.Web/Resources/SharedResources.qps-Ploc.resx index 2307729..6836ba5 100644 --- a/src/BirthList.Web/Resources/SharedResources.qps-Ploc.resx +++ b/src/BirthList.Web/Resources/SharedResources.qps-Ploc.resx @@ -621,6 +621,12 @@ [[Contribution logged]] + + [[Auto fetch succeeded]] + + + [[Auto fetch failed]] + [[Error]] diff --git a/src/BirthList.Web/Resources/SharedResources.resx b/src/BirthList.Web/Resources/SharedResources.resx index ba4d4a3..bb52b04 100644 --- a/src/BirthList.Web/Resources/SharedResources.resx +++ b/src/BirthList.Web/Resources/SharedResources.resx @@ -223,6 +223,8 @@ Purchase unmarked Partial purchase Contribution logged + Auto fetch succeeded + Auto fetch failed Error Error.