diff --git a/.gitignore b/.gitignore
index 51596e9..c51c70a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -50,3 +50,4 @@ Thumbs.db
# Docker overrides
docker-compose.override.yml
+/src/BirthList.Web/appsettings.Development.json
diff --git a/deploy/portainer-stack.yml b/deploy/portainer-stack.yml
index 1b01324..a108b5c 100644
--- a/deploy/portainer-stack.yml
+++ b/deploy/portainer-stack.yml
@@ -29,7 +29,7 @@ services:
- caddy-net
restart: unless-stopped
healthcheck:
- test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/"]
+ test: ["CMD-SHELL", "pidof dotnet > /dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
@@ -52,7 +52,7 @@ services:
- birthlist-network
restart: unless-stopped
healthcheck:
- test: ["CMD", "/opt/mssql-tools/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "${SA_PASSWORD}", "-Q", "SELECT 1"]
+ test: ["CMD-SHELL", "pidof sqlservr > /dev/null || exit 1"]
interval: 10s
timeout: 3s
retries: 10
diff --git a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor
index c0aaf36..dea6fa9 100644
--- a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor
+++ b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor
@@ -20,6 +20,23 @@ else
}
+
+ Current admins
+ @if (Admins.Count == 0)
+ {
+ No admins assigned yet.
+ }
+ else
+ {
+
+ @foreach (var admin in Admins)
+ {
+ - @admin.DisplayName
+ }
+
+ }
+
+
Settings
diff --git a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs
index afd2ead..c99f2e4 100644
--- a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs
+++ b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs
@@ -4,6 +4,7 @@ using BirthList.Web.Services;
using Blazored.TextEditor;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
+using Microsoft.JSInterop;
namespace BirthList.Web.Components.Pages;
@@ -23,6 +24,7 @@ public partial class RegistryAdmin : ComponentBase
protected RegistrySettingsEditModel SettingsModel { get; } = new();
protected RegistryItemEditModel ItemModel { get; private set; } = new();
protected IReadOnlyList Items { get; private set; } = [];
+ protected IReadOnlyList Admins { get; private set; } = [];
protected bool IsAuthorized { get; private set; }
protected bool IsSmtpConfigured { get; private set; }
protected string? InviteEmail { get; set; }
@@ -50,22 +52,41 @@ public partial class RegistryAdmin : ComponentBase
protected override async Task OnAfterRenderAsync(bool firstRender)
{
- if (!firstRender || TextEditor is null)
+ if (!firstRender || TextEditor is null || string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml))
{
return;
}
- if (!string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml))
+ try
{
await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml).ConfigureAwait(false);
}
+ catch (JSException)
+ {
+ // Editor scripts may still be initializing on first render in some environments.
+ }
+ catch (InvalidOperationException)
+ {
+ // Ignore transient prerender/circuit timing issues.
+ }
}
protected async Task SaveSettingsAsync()
{
if (TextEditor is not null)
{
- SettingsModel.HeaderContentHtml = await TextEditor.GetHTML().ConfigureAwait(false);
+ try
+ {
+ SettingsModel.HeaderContentHtml = await TextEditor.GetHTML().ConfigureAwait(false);
+ }
+ catch (JSException)
+ {
+ // Preserve existing content when editor JS isn't ready yet.
+ }
+ catch (InvalidOperationException)
+ {
+ // Preserve existing content when interop isn't available.
+ }
}
await RegistryService.UpdateRegistrySettingsAsync(RegistryId, SettingsModel, CancellationToken.None).ConfigureAwait(false);
@@ -125,12 +146,17 @@ public partial class RegistryAdmin : ComponentBase
return;
}
- if (string.IsNullOrWhiteSpace(ItemModel.Name) && !string.IsNullOrWhiteSpace(metadata.Title))
+ 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.IsNullOrWhiteSpace(metadata.Description))
+ if ((string.IsNullOrWhiteSpace(ItemModel.Description) || string.Equals(ItemModel.Description, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Description))
{
ItemModel.Description = metadata.Description;
}
@@ -145,7 +171,7 @@ public partial class RegistryAdmin : ComponentBase
ItemModel.PriceAmount = metadata.PriceAmount;
}
- if (string.IsNullOrWhiteSpace(ItemModel.CurrencyCode) && !string.IsNullOrWhiteSpace(metadata.CurrencyCode))
+ if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode))
{
ItemModel.CurrencyCode = metadata.CurrencyCode;
}
@@ -165,6 +191,8 @@ public partial class RegistryAdmin : ComponentBase
{
await EmailSender.SendInviteAsync(InviteEmail, InviteLink).ConfigureAwait(false);
}
+
+ Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
}
private async Task LoadAsync()
@@ -183,6 +211,7 @@ public partial class RegistryAdmin : ComponentBase
SettingsModel.BankAccountBic = settings.BankAccountBic;
}
+ Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
ItemModel = new RegistryItemEditModel
{
diff --git a/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs b/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs
index 7438695..da98676 100644
--- a/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs
+++ b/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs
@@ -10,14 +10,17 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
public async Task FetchAsync(string url, CancellationToken cancellationToken)
{
- if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var sourceUri))
{
return null;
}
+ var normalizedUri = NormalizeProductUri(sourceUri);
+
var client = httpClientFactory.CreateClient("RegistryMetadata");
- using var request = new HttpRequestMessage(HttpMethod.Get, uri);
- request.Headers.UserAgent.ParseAdd("BirthListBot/1.0");
+ using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUri);
+ request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (compatible; BirthListBot/1.0)");
+ request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
@@ -32,25 +35,234 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
}
var meta = ParseMeta(html);
- meta.TryGetValue("product:price:amount", out var priceRaw);
- meta.TryGetValue("product:price:currency", out var currency);
+
+ var title = First(meta,
+ "product:title",
+ "og:title",
+ "twitter:title",
+ "title");
+
+ if (string.Equals(title, "Amazon", StringComparison.OrdinalIgnoreCase) || string.Equals(title, "Amazon.com", StringComparison.OrdinalIgnoreCase))
+ {
+ title = First(meta,
+ "title",
+ "twitter:title",
+ "og:title");
+ }
+
+ var description = First(meta,
+ "og:description",
+ "twitter:description",
+ "description");
+
+ if (string.Equals(description, "Amazon", StringComparison.OrdinalIgnoreCase))
+ {
+ description = null;
+ }
+
+ var image = First(meta,
+ "twitter:image",
+ "og:image",
+ "image");
+
+ var priceRaw = First(meta,
+ "product:price:amount",
+ "og:price:amount",
+ "price",
+ "twitter:data1");
+
+ var currency = First(meta,
+ "product:price:currency",
+ "og:price:currency",
+ "currency");
decimal? price = null;
- if (!string.IsNullOrWhiteSpace(priceRaw) && decimal.TryParse(priceRaw, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsedPrice))
+ if (!string.IsNullOrWhiteSpace(priceRaw))
{
- price = parsedPrice;
+ var cleanedPrice = Regex.Replace(priceRaw, "[^0-9.,]", string.Empty);
+ cleanedPrice = cleanedPrice.Replace(",", ".");
+
+ if (decimal.TryParse(cleanedPrice, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsedPrice))
+ {
+ price = parsedPrice;
+ }
+ }
+
+ if (normalizedUri.Host.Contains("amazon", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.IsNullOrWhiteSpace(image) || IsGenericAmazonImage(image))
+ {
+ image = ExtractAmazonImage(html) ?? image;
+ }
+
+ if (!price.HasValue)
+ {
+ price = ExtractAmazonPrice(html);
+ }
+
+ if (string.IsNullOrWhiteSpace(currency))
+ {
+ currency = "EUR";
+ }
}
return new UrlMetadataResult
{
- Title = First(meta, "og:title", "twitter:title", "title"),
- Description = First(meta, "og:description", "twitter:description", "description"),
- ImageUrl = First(meta, "og:image", "twitter:image"),
+ NormalizedUrl = normalizedUri.AbsoluteUri,
+ Title = title,
+ Description = description,
+ ImageUrl = image,
PriceAmount = price,
CurrencyCode = string.IsNullOrWhiteSpace(currency) ? null : currency.ToUpperInvariant()
};
}
+ private static bool IsGenericAmazonImage(string image)
+ {
+ return image.Contains("amazon.png", StringComparison.OrdinalIgnoreCase)
+ || image.Contains("/images/G/", StringComparison.OrdinalIgnoreCase)
+ || image.Contains("share-icons", StringComparison.OrdinalIgnoreCase)
+ || image.Contains("logo", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string? ExtractAmazonImage(string html)
+ {
+ var patterns = new []
+ {
+ "\\\"landingImageUrl\\\"\\s*:\\s*\\\"(?https?:\\\\/\\\\/[^\\\"]+)\\\"",
+ "\\\"hiRes\\\"\\s*:\\s*\\\"(?https?:\\\\/\\\\/[^\\\"]+)\\\"",
+ "
]+id=['\"]landingImage['\"][^>]+src=['\"](?[^'\"]+)['\"]"
+ };
+
+ foreach (var pattern in patterns)
+ {
+ var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ var value = match.Groups["url"].Value;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ continue;
+ }
+
+ value = value.Replace("\\/", "/").Replace("\\u0026", "&");
+ if (Uri.TryCreate(value, UriKind.Absolute, out _))
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
+
+ private static decimal? ExtractAmazonPrice(string html)
+ {
+ var directPatterns = new []
+ {
+ "\\\"priceAmount\\\"\\s*:\\s*\\\"?(?[0-9]+(?:[.,][0-9]{2})?)",
+ "id=['\"]priceblock_ourprice['\"][^>]*>\\s*(?[^<]+)<",
+ "id=['\"]priceblock_dealprice['\"][^>]*>\\s*(?[^<]+)<",
+ "id=['\"]price_inside_buybox['\"][^>]*>\\s*(?[^<]+)<"
+ };
+
+ foreach (var pattern in directPatterns)
+ {
+ var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
+ if (!match.Success)
+ {
+ continue;
+ }
+
+ var parsed = ParsePrice(match.Groups["price"].Value);
+ if (parsed.HasValue)
+ {
+ return parsed.Value;
+ }
+ }
+
+ var whole = Regex.Match(html, "a-price-whole[^>]*>\\s*(?[0-9.,]+)\\s*<", RegexOptions.IgnoreCase);
+ var fraction = Regex.Match(html, "a-price-fraction[^>]*>\\s*(?[0-9]{2})\\s*<", RegexOptions.IgnoreCase);
+
+ if (whole.Success)
+ {
+ var combined = whole.Groups["whole"].Value + (fraction.Success ? "." + fraction.Groups["fraction"].Value : string.Empty);
+ return ParsePrice(combined);
+ }
+
+ return null;
+ }
+
+ private static decimal? ParsePrice(string raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ var cleaned = Regex.Replace(raw, "[^0-9.,]", string.Empty);
+ if (string.IsNullOrWhiteSpace(cleaned))
+ {
+ return null;
+ }
+
+ if (cleaned.Count(c => c == ',') > 1 && cleaned.Contains('.'))
+ {
+ cleaned = cleaned.Replace(",", string.Empty);
+ }
+
+ if (cleaned.Contains(',') && cleaned.Contains('.'))
+ {
+ if (cleaned.LastIndexOf(',') > cleaned.LastIndexOf('.'))
+ {
+ cleaned = cleaned.Replace(".", string.Empty).Replace(',', '.');
+ }
+ else
+ {
+ cleaned = cleaned.Replace(",", string.Empty);
+ }
+ }
+ else
+ {
+ cleaned = cleaned.Replace(',', '.');
+ }
+
+ if (decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var price))
+ {
+ return price;
+ }
+
+ return null;
+ }
+
+ private static Uri NormalizeProductUri(Uri uri)
+ {
+ if (!uri.Host.Contains("amazon", StringComparison.OrdinalIgnoreCase))
+ {
+ return uri;
+ }
+
+ var path = uri.AbsolutePath;
+ var match = Regex.Match(path, @"(?/.*?/dp/(?[A-Z0-9]{10})/?)", RegexOptions.IgnoreCase);
+
+ if (!match.Success)
+ {
+ return new UriBuilder(uri)
+ {
+ Query = string.Empty
+ }.Uri;
+ }
+
+ var normalizedPath = match.Groups["prefix"].Value;
+ return new UriBuilder(uri)
+ {
+ Path = normalizedPath,
+ Query = string.Empty
+ }.Uri;
+ }
+
private static Dictionary ParseMeta(string html)
{
var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
@@ -89,7 +301,10 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
var title = html.Substring(titleStart + 7, titleEnd - (titleStart + 7)).Trim();
if (!string.IsNullOrWhiteSpace(title))
{
- map["title"] = title;
+ var cleanedTitle = title.Replace("- Amazon.com.be", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Replace("- Amazon", string.Empty, StringComparison.OrdinalIgnoreCase)
+ .Trim();
+ map["title"] = string.IsNullOrWhiteSpace(cleanedTitle) ? title : cleanedTitle;
}
}
diff --git a/src/BirthList.Web/Features/Registries/RegistryModels.cs b/src/BirthList.Web/Features/Registries/RegistryModels.cs
index 431bf0b..7b72045 100644
--- a/src/BirthList.Web/Features/Registries/RegistryModels.cs
+++ b/src/BirthList.Web/Features/Registries/RegistryModels.cs
@@ -82,8 +82,15 @@ public sealed class RegistryPublicItemViewModel
public bool IsGiven { get; init; }
}
+public sealed class RegistryAdminDisplayModel
+{
+ public string UserId { get; init; } = string.Empty;
+ public string DisplayName { get; init; } = string.Empty;
+}
+
public sealed class UrlMetadataResult
{
+ public string? NormalizedUrl { get; init; }
public string? Title { get; init; }
public string? Description { get; init; }
public string? ImageUrl { get; init; }
diff --git a/src/BirthList.Web/Features/Registries/RegistryService.cs b/src/BirthList.Web/Features/Registries/RegistryService.cs
index ebecd60..d823385 100644
--- a/src/BirthList.Web/Features/Registries/RegistryService.cs
+++ b/src/BirthList.Web/Features/Registries/RegistryService.cs
@@ -84,6 +84,20 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
.ConfigureAwait(false);
}
+ public async Task> GetRegistryAdminsAsync(Guid registryId, CancellationToken cancellationToken)
+ {
+ return await registryDbContext.RegistryAdmins
+ .Where(x => x.RegistryId == registryId)
+ .OrderBy(x => x.AddedAtUtc)
+ .Select(x => new RegistryAdminDisplayModel
+ {
+ UserId = x.UserId,
+ DisplayName = x.UserId
+ })
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+
public async Task GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(code))
diff --git a/src/BirthList.Web/appsettings.Development.json b/src/BirthList.Web/appsettings.Development.json
deleted file mode 100644
index b0915dd..0000000
--- a/src/BirthList.Web/appsettings.Development.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "ConnectionStrings": {
- "DefaultConnection": "Server=.;Database=BirthList;TrustServerCertificate=True;Encrypt=False;Integrated Security=true"
- },
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- },
- "Authentication": {
- "Google": {
- // Google OAuth credentials: create in Google Cloud Console > APIs & Services > Credentials > OAuth 2.0 Client IDs
- // For local dev redirect URI usually: https://localhost:xxxx/signin-google
- "ClientId": "",
- "ClientSecret": ""
- },
- "Microsoft": {
- // Microsoft OAuth credentials: create in Microsoft Entra ID > App registrations > New registration
- // For local dev redirect URI usually: https://localhost:xxxx/signin-microsoft
- "ClientId": "",
- "ClientSecret": ""
- }
- },
- "Smtp": {
- // Gmail SMTP: use smtp.gmail.com + app password (Google account with 2FA enabled)
- "Host": "smtp.gmail.com",
- "Port": 587,
- "EnableSsl": true,
- // UserName is your sending mailbox address, e.g. yourname@gmail.com
- "UserName": "",
- // Password should be an app password, not your normal account password
- "Password": "",
- // FromAddress is the sender email address displayed to recipients
- "FromAddress": "",
- "FromName": "Birth Registry"
- }
-}