From 2a90aa92f39aa3e7367c606ff369d298c763467e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Tue, 23 Dec 2025 09:19:41 +0100 Subject: [PATCH 1/7] add the map search feature --- src/shared/Jordnaer.Shared/FeatureFlags.cs | 1 + src/web/Jordnaer/appsettings.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/shared/Jordnaer.Shared/FeatureFlags.cs b/src/shared/Jordnaer.Shared/FeatureFlags.cs index 320ddf02..0c9fe25f 100644 --- a/src/shared/Jordnaer.Shared/FeatureFlags.cs +++ b/src/shared/Jordnaer.Shared/FeatureFlags.cs @@ -7,4 +7,5 @@ public static class FeatureFlags public const string Posts = "Posts"; public const string AccountSettings = "AccountSettings"; public const string NotificationSettings = "NotificationSettings"; + public const string MapSearch = "MapSearch"; } diff --git a/src/web/Jordnaer/appsettings.json b/src/web/Jordnaer/appsettings.json index 4f84454e..1d7dce8c 100644 --- a/src/web/Jordnaer/appsettings.json +++ b/src/web/Jordnaer/appsettings.json @@ -8,7 +8,8 @@ "Events": false, "Posts": false, "AccountSettings": false, - "NotificationSettings": false + "NotificationSettings": false, + "MapSearch": false }, "Serilog": { "MinimumLevel": { From c4fba98423e8d4c3ccd6c2c80d5e439c8b4074fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Tue, 23 Dec 2025 10:21:48 +0100 Subject: [PATCH 2/7] add leaflet --- src/web/Jordnaer/Components/App.razor | 11 + .../Jordnaer/Features/Map/LeafletMap.razor | 119 +++++++++ .../Features/Map/LeafletMapInterop.cs | 106 ++++++++ src/web/Jordnaer/Program.cs | 3 + .../Jordnaer/wwwroot/js/leaflet-interop.js | 248 ++++++++++++++++++ 5 files changed, 487 insertions(+) create mode 100644 src/web/Jordnaer/Features/Map/LeafletMap.razor create mode 100644 src/web/Jordnaer/Features/Map/LeafletMapInterop.cs create mode 100644 src/web/Jordnaer/wwwroot/js/leaflet-interop.js diff --git a/src/web/Jordnaer/Components/App.razor b/src/web/Jordnaer/Components/App.razor index 28c28837..89ddaa71 100644 --- a/src/web/Jordnaer/Components/App.razor +++ b/src/web/Jordnaer/Components/App.razor @@ -47,6 +47,11 @@ + @* Leaflet.js for map search functionality *@ + + @@ -71,6 +76,12 @@ + + @* Leaflet.js for map search functionality *@ + + diff --git a/src/web/Jordnaer/Features/Map/LeafletMap.razor b/src/web/Jordnaer/Features/Map/LeafletMap.razor new file mode 100644 index 00000000..cc75f066 --- /dev/null +++ b/src/web/Jordnaer/Features/Map/LeafletMap.razor @@ -0,0 +1,119 @@ +@using Microsoft.JSInterop +@implements IAsyncDisposable +@inject ILeafletMapInterop LeafletMapInterop + +
+ +@code { + private DotNetObjectReference? _dotNetHelper; + private bool _isInitialized; + + [Parameter] + public string MapId { get; set; } = $"map-{Guid.NewGuid()}"; + + [Parameter] + public double InitialLatitude { get; set; } = 56.0; // Center of Denmark + + [Parameter] + public double InitialLongitude { get; set; } = 10.0; // Center of Denmark + + [Parameter] + public int InitialZoom { get; set; } = 7; + + [Parameter] + public string MapStyle { get; set; } = "height: 400px; width: 100%; border-radius: 8px;"; + + [Parameter] + public EventCallback<(double Latitude, double Longitude)> OnLocationSelected { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _dotNetHelper = DotNetObjectReference.Create(this); + + // Initialize the map + var success = await LeafletMapInterop.InitializeMapAsync( + MapId, + InitialLatitude, + InitialLongitude, + InitialZoom); + + if (success) + { + // Setup click handler + await LeafletMapInterop.SetupClickHandlerAsync(MapId, _dotNetHelper!); + _isInitialized = true; + } + } + } + + /// + /// Called from JavaScript when the map is clicked + /// + [JSInvokable] + public async Task OnMapClicked(double lat, double lng) + { + await OnLocationSelected.InvokeAsync((lat, lng)); + } + + /// + /// Updates the search radius visualization on the map + /// + public async Task UpdateSearchRadiusAsync(double lat, double lng, int radiusKm) + { + if (!_isInitialized) return; + + await LeafletMapInterop.UpdateSearchRadiusAsync(MapId, lat, lng, radiusKm); + } + + /// + /// Centers the map on a specific location + /// + public async Task CenterMapAsync(double lat, double lng, int? zoom = null) + { + if (!_isInitialized) return; + + await LeafletMapInterop.CenterMapAsync(MapId, lat, lng, zoom); + } + + /// + /// Updates the marker position + /// + public async Task UpdateMarkerAsync(double lat, double lng) + { + if (!_isInitialized) return; + + await LeafletMapInterop.UpdateMarkerAsync(MapId, lat, lng); + } + + /// + /// Removes the marker from the map + /// + public async Task RemoveMarkerAsync() + { + if (!_isInitialized) return; + + await LeafletMapInterop.RemoveMarkerAsync(MapId); + } + + /// + /// Removes the search radius circle from the map + /// + public async Task RemoveSearchRadiusAsync() + { + if (!_isInitialized) return; + + await LeafletMapInterop.RemoveSearchRadiusAsync(MapId); + } + + public async ValueTask DisposeAsync() + { + if (_isInitialized) + { + await LeafletMapInterop.DisposeMapAsync(MapId); + } + + _dotNetHelper?.Dispose(); + } +} diff --git a/src/web/Jordnaer/Features/Map/LeafletMapInterop.cs b/src/web/Jordnaer/Features/Map/LeafletMapInterop.cs new file mode 100644 index 00000000..50182998 --- /dev/null +++ b/src/web/Jordnaer/Features/Map/LeafletMapInterop.cs @@ -0,0 +1,106 @@ +using Microsoft.JSInterop; +using MudBlazor; + +namespace Jordnaer.Features.Map; + +/// +/// Service for interacting with Leaflet.js maps via JavaScript interop +/// +public interface ILeafletMapInterop +{ + /// + /// Initializes a Leaflet map instance + /// + Task InitializeMapAsync(string mapId, double lat, double lng, int zoom); + + /// + /// Sets up a click handler on the map that calls back to C# + /// + Task SetupClickHandlerAsync(string mapId, DotNetObjectReference dotNetHelper) where T : class; + + /// + /// Updates or creates a circle to show the search radius + /// + Task UpdateSearchRadiusAsync(string mapId, double lat, double lng, int radiusKm); + + /// + /// Centers the map on a specific location + /// + Task CenterMapAsync(string mapId, double lat, double lng, int? zoom = null); + + /// + /// Adds or updates a marker at the search location + /// + Task UpdateMarkerAsync(string mapId, double lat, double lng); + + /// + /// Removes the search marker + /// + Task RemoveMarkerAsync(string mapId); + + /// + /// Removes the search radius circle + /// + Task RemoveSearchRadiusAsync(string mapId); + + /// + /// Disposes of a map instance + /// + Task DisposeMapAsync(string mapId); +} + +public class LeafletMapInterop(IJSRuntime jsRuntime) : ILeafletMapInterop +{ + private readonly IJSRuntime _jsRuntime = jsRuntime; + + public async Task InitializeMapAsync(string mapId, double lat, double lng, int zoom) + { + return await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.initializeMap", mapId, lat, lng, zoom); + } + + public async Task SetupClickHandlerAsync(string mapId, DotNetObjectReference dotNetHelper) where T : class + { + return await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.setupClickHandler", mapId, dotNetHelper); + } + + public async Task UpdateSearchRadiusAsync(string mapId, double lat, double lng, int radiusKm) + { + return await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.updateSearchRadius", mapId, lat, lng, radiusKm); + } + + public async Task CenterMapAsync(string mapId, double lat, double lng, int? zoom = null) + { + return zoom.HasValue + ? await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.centerMap", mapId, lat, lng, zoom.Value) + : await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.centerMap", mapId, lat, lng, null); + } + + public async Task UpdateMarkerAsync(string mapId, double lat, double lng) + { + return await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.updateMarker", mapId, lat, lng); + } + + public async Task RemoveMarkerAsync(string mapId) + { + return await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.removeMarker", mapId); + } + + public async Task RemoveSearchRadiusAsync(string mapId) + { + return await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.removeSearchRadius", mapId); + } + + public async Task DisposeMapAsync(string mapId) + { + return await _jsRuntime.InvokeVoidAsyncWithErrorHandling( + "leafletInterop.disposeMap", mapId); + } +} diff --git a/src/web/Jordnaer/Program.cs b/src/web/Jordnaer/Program.cs index 166b50ec..29f6b7bf 100644 --- a/src/web/Jordnaer/Program.cs +++ b/src/web/Jordnaer/Program.cs @@ -16,6 +16,7 @@ using Jordnaer.Features.GroupPosts; using Jordnaer.Features.Posts; using Jordnaer.Features.PostSearch; +using Jordnaer.Features.Map; using Jordnaer.Features.Profile; using Jordnaer.Features.Search; using Jordnaer.Features.UserSearch; @@ -91,6 +92,8 @@ builder.Services.AddDataForsyningenClient(); +builder.Services.AddScoped(); + builder.Services.AddHealthChecks().AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]); builder.AddOpenTelemetry(); diff --git a/src/web/Jordnaer/wwwroot/js/leaflet-interop.js b/src/web/Jordnaer/wwwroot/js/leaflet-interop.js new file mode 100644 index 00000000..b556662e --- /dev/null +++ b/src/web/Jordnaer/wwwroot/js/leaflet-interop.js @@ -0,0 +1,248 @@ +/** + * Leaflet.js interop for Blazor map search functionality + * Provides map initialization, interaction, and radius visualization + */ + +window.leafletInterop = { + maps: {}, + + /** + * Initializes a Leaflet map instance + * @param {string} mapId - The HTML element ID for the map container + * @param {number} lat - Initial latitude + * @param {number} lng - Initial longitude + * @param {number} zoom - Initial zoom level + * @returns {boolean} Success status + */ + initializeMap: function (mapId, lat, lng, zoom) { + try { + if (this.maps[mapId]) { + this.maps[mapId].map.remove(); + } + + const map = L.map(mapId).setView([lat, lng], zoom); + + // Add OpenStreetMap tile layer (free, no API key required) + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 19 + }).addTo(map); + + this.maps[mapId] = { + map: map, + circle: null, + marker: null + }; + + return true; + } catch (error) { + console.error('Error initializing map:', error); + return false; + } + }, + + /** + * Sets up click handler for map that calls back to C# + * @param {string} mapId - The map instance ID + * @param {object} dotNetHelper - DotNetObjectReference for callbacks + * @returns {boolean} Success status + */ + setupClickHandler: function (mapId, dotNetHelper) { + try { + const mapInstance = this.maps[mapId]; + if (!mapInstance) { + console.error('Map not found:', mapId); + return false; + } + + mapInstance.map.on('click', function (e) { + dotNetHelper.invokeMethodAsync('OnMapClicked', e.latlng.lat, e.latlng.lng); + }); + + return true; + } catch (error) { + console.error('Error setting up click handler:', error); + return false; + } + }, + + /** + * Updates or creates a circle to show search radius + * @param {string} mapId - The map instance ID + * @param {number} lat - Center latitude + * @param {number} lng - Center longitude + * @param {number} radiusKm - Radius in kilometers + * @returns {boolean} Success status + */ + updateSearchRadius: function (mapId, lat, lng, radiusKm) { + try { + const mapInstance = this.maps[mapId]; + if (!mapInstance) { + console.error('Map not found:', mapId); + return false; + } + + // Remove existing circle if any + if (mapInstance.circle) { + mapInstance.circle.remove(); + } + + // Create new circle (radius in meters) + const radiusMeters = radiusKm * 1000; + mapInstance.circle = L.circle([lat, lng], { + color: '#594F8D', // Primary color from Jordnaer theme + fillColor: '#594F8D', + fillOpacity: 0.15, + radius: radiusMeters + }).addTo(mapInstance.map); + + // Fit map bounds to show the entire circle + const bounds = mapInstance.circle.getBounds(); + mapInstance.map.fitBounds(bounds, { padding: [50, 50] }); + + return true; + } catch (error) { + console.error('Error updating search radius:', error); + return false; + } + }, + + /** + * Centers the map on a specific location + * @param {string} mapId - The map instance ID + * @param {number} lat - Latitude + * @param {number} lng - Longitude + * @param {number} zoom - Zoom level (optional, uses current if not provided) + * @returns {boolean} Success status + */ + centerMap: function (mapId, lat, lng, zoom) { + try { + const mapInstance = this.maps[mapId]; + if (!mapInstance) { + console.error('Map not found:', mapId); + return false; + } + + if (zoom !== undefined && zoom !== null) { + mapInstance.map.setView([lat, lng], zoom); + } else { + mapInstance.map.panTo([lat, lng]); + } + + return true; + } catch (error) { + console.error('Error centering map:', error); + return false; + } + }, + + /** + * Adds or updates a marker at the search location + * @param {string} mapId - The map instance ID + * @param {number} lat - Latitude + * @param {number} lng - Longitude + * @returns {boolean} Success status + */ + updateMarker: function (mapId, lat, lng) { + try { + const mapInstance = this.maps[mapId]; + if (!mapInstance) { + console.error('Map not found:', mapId); + return false; + } + + // Remove existing marker if any + if (mapInstance.marker) { + mapInstance.marker.remove(); + } + + // Add new marker + mapInstance.marker = L.marker([lat, lng]).addTo(mapInstance.map); + + return true; + } catch (error) { + console.error('Error updating marker:', error); + return false; + } + }, + + /** + * Removes the search marker + * @param {string} mapId - The map instance ID + * @returns {boolean} Success status + */ + removeMarker: function (mapId) { + try { + const mapInstance = this.maps[mapId]; + if (!mapInstance) { + console.error('Map not found:', mapId); + return false; + } + + if (mapInstance.marker) { + mapInstance.marker.remove(); + mapInstance.marker = null; + } + + return true; + } catch (error) { + console.error('Error removing marker:', error); + return false; + } + }, + + /** + * Removes the search radius circle + * @param {string} mapId - The map instance ID + * @returns {boolean} Success status + */ + removeSearchRadius: function (mapId) { + try { + const mapInstance = this.maps[mapId]; + if (!mapInstance) { + console.error('Map not found:', mapId); + return false; + } + + if (mapInstance.circle) { + mapInstance.circle.remove(); + mapInstance.circle = null; + } + + return true; + } catch (error) { + console.error('Error removing search radius:', error); + return false; + } + }, + + /** + * Disposes of a map instance + * @param {string} mapId - The map instance ID + * @returns {boolean} Success status + */ + disposeMap: function (mapId) { + try { + const mapInstance = this.maps[mapId]; + if (!mapInstance) { + return true; // Already disposed + } + + if (mapInstance.circle) { + mapInstance.circle.remove(); + } + if (mapInstance.marker) { + mapInstance.marker.remove(); + } + if (mapInstance.map) { + mapInstance.map.remove(); + } + + delete this.maps[mapId]; + return true; + } catch (error) { + console.error('Error disposing map:', error); + return false; + } + } +}; From f99d1d0125c0f56e793acadee8629b6702551efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Tue, 23 Dec 2025 10:32:27 +0100 Subject: [PATCH 3/7] finish map search feature --- .../Groups/GroupSearchFilter.cs | 31 ++- .../Jordnaer.Shared/Posts/PostSearchFilter.cs | 24 +- .../UserSearch/UserSearchFilter.cs | 25 +- .../GroupSearch/GroupSearchForm.razor | 94 ++++++- .../GroupSearch/GroupSearchService.cs | 26 +- .../Features/Map/MapSearchFilter.razor | 174 ++++++++++++ .../Features/PostSearch/PostSearchService.cs | 26 +- .../Features/Posts/PostSearchForm.razor | 105 +++++-- .../Profile/LocationMigrationService.cs | 257 ------------------ .../WebApplicationBuilderExtensions.cs | 3 - .../QueryableUserProfileExtensions.cs | 28 +- .../Features/UserSearch/UserSearchForm.razor | 178 +++++++++--- .../Pages/GroupSearch/GroupSearch.razor | 8 + src/web/Jordnaer/Pages/Posts/Posts.razor | 76 ++++++ .../Pages/UserSearch/UserSearch.razor | 8 + src/web/Jordnaer/appsettings.json | 2 +- 16 files changed, 710 insertions(+), 355 deletions(-) create mode 100644 src/web/Jordnaer/Features/Map/MapSearchFilter.razor delete mode 100644 src/web/Jordnaer/Features/Profile/LocationMigrationService.cs diff --git a/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs b/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs index f2e3adb9..ed7c2859 100644 --- a/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs +++ b/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs @@ -18,6 +18,18 @@ public record GroupSearchFilter [RadiusRequired] public string? Location { get; set; } + /// + /// Latitude coordinate for map-based location search. + /// When set (along with Longitude), takes precedence over Location string. + /// + public double? Latitude { get; set; } + + /// + /// Longitude coordinate for map-based location search. + /// When set (along with Latitude), takes precedence over Location string. + /// + public double? Longitude { get; set; } + public int PageNumber { get; set; } = 1; public int PageSize { get; set; } = 10; @@ -32,6 +44,8 @@ public override int GetHashCode() hash = hash * 23 + (Categories != null ? Categories.Aggregate(0, (current, category) => current + category.GetHashCode()) : 0); hash = hash * 23 + WithinRadiusKilometers.GetHashCode(); hash = hash * 23 + (Location?.GetHashCode() ?? 0); + hash = hash * 23 + Latitude.GetHashCode(); + hash = hash * 23 + Longitude.GetHashCode(); return hash; } @@ -44,7 +58,9 @@ public virtual bool Equals(UserSearchFilter? other) ((Categories == null && other.Categories == null) || (Categories != null && other.Categories != null && Categories.SequenceEqual(other.Categories))) && WithinRadiusKilometers == other.WithinRadiusKilometers && - Location == other.Location; + Location == other.Location && + Latitude == other.Latitude && + Longitude == other.Longitude; } } @@ -68,14 +84,21 @@ protected override ValidationResult IsValid(object? value, ValidationContext val { protected override ValidationResult IsValid(object? value, ValidationContext validationContext) { - var userSearchFilter = (GroupSearchFilter)validationContext.ObjectInstance; + var groupSearchFilter = (GroupSearchFilter)validationContext.ObjectInstance; - if (userSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(userSearchFilter.Location)) + if (groupSearchFilter.WithinRadiusKilometers is null && + string.IsNullOrEmpty(groupSearchFilter.Location) && + !groupSearchFilter.Latitude.HasValue && + !groupSearchFilter.Longitude.HasValue) { return ValidationResult.Success!; } - return string.IsNullOrEmpty(userSearchFilter.Location) + // Valid if either Location string is set OR lat/long coordinates are set + var hasLocation = !string.IsNullOrEmpty(groupSearchFilter.Location) || + (groupSearchFilter.Latitude.HasValue && groupSearchFilter.Longitude.HasValue); + + return !hasLocation ? new ValidationResult("Område skal vælges når en radius er valgt.") : ValidationResult.Success!; } diff --git a/src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs b/src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs index 736f85ed..3564e9d1 100644 --- a/src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs +++ b/src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs @@ -17,6 +17,18 @@ public class PostSearchFilter [RadiusRequired] public string? Location { get; set; } + /// + /// Latitude coordinate for map-based location search. + /// When set (along with Longitude), takes precedence over Location string. + /// + public double? Latitude { get; set; } + + /// + /// Longitude coordinate for map-based location search. + /// When set (along with Latitude), takes precedence over Location string. + /// + public double? Longitude { get; set; } + public int PageNumber { get; set; } = 1; public int PageSize { get; set; } = 10; } @@ -44,13 +56,19 @@ protected override ValidationResult IsValid(object? value, ValidationContext val { var postSearchFilter = (PostSearchFilter)validationContext.ObjectInstance; - if (postSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(postSearchFilter.Location)) + if (postSearchFilter.WithinRadiusKilometers is null && + string.IsNullOrEmpty(postSearchFilter.Location) && + !postSearchFilter.Latitude.HasValue && + !postSearchFilter.Longitude.HasValue) { return ValidationResult.Success!; - } - return string.IsNullOrEmpty(postSearchFilter.Location) + // Valid if either Location string is set OR lat/long coordinates are set + var hasLocation = !string.IsNullOrEmpty(postSearchFilter.Location) || + (postSearchFilter.Latitude.HasValue && postSearchFilter.Longitude.HasValue); + + return !hasLocation ? new ValidationResult("Område skal vælges når en radius er valgt.") : ValidationResult.Success!; } diff --git a/src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs b/src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs index 79f14ae4..fc6ccb23 100644 --- a/src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs +++ b/src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs @@ -17,6 +17,18 @@ public record UserSearchFilter [RadiusRequired] public string? Location { get; set; } + /// + /// Latitude coordinate for map-based location search. + /// When set (along with Longitude), takes precedence over Location string. + /// + public double? Latitude { get; set; } + + /// + /// Longitude coordinate for map-based location search. + /// When set (along with Latitude), takes precedence over Location string. + /// + public double? Longitude { get; set; } + [Range(0, 18, ErrorMessage = "Skal være mellem 0 og 18 år")] public int? MinimumChildAge { get; set; } [Range(0, 18, ErrorMessage = "Skal være mellem 0 og 18 år")] @@ -37,6 +49,8 @@ public override int GetHashCode() hash = hash * 23 + (Categories != null ? Categories.Aggregate(0, (current, category) => current + category.GetHashCode()) : 0); hash = hash * 23 + WithinRadiusKilometers.GetHashCode(); hash = hash * 23 + (Location?.GetHashCode() ?? 0); + hash = hash * 23 + Latitude.GetHashCode(); + hash = hash * 23 + Longitude.GetHashCode(); hash = hash * 23 + MinimumChildAge.GetHashCode(); hash = hash * 23 + MaximumChildAge.GetHashCode(); hash = hash * 23 + ChildGender.GetHashCode(); @@ -53,6 +67,8 @@ public virtual bool Equals(UserSearchFilter? other) (Categories != null && other.Categories != null && Categories.SequenceEqual(other.Categories))) && WithinRadiusKilometers == other.WithinRadiusKilometers && Location == other.Location && + Latitude == other.Latitude && + Longitude == other.Longitude && MinimumChildAge == other.MinimumChildAge && MaximumChildAge == other.MaximumChildAge && ChildGender == other.ChildGender; @@ -81,13 +97,16 @@ protected override ValidationResult IsValid(object? value, ValidationContext val { var userSearchFilter = (UserSearchFilter)validationContext.ObjectInstance; - if (userSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(userSearchFilter.Location)) + if (userSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(userSearchFilter.Location) && !userSearchFilter.Latitude.HasValue && !userSearchFilter.Longitude.HasValue) { return ValidationResult.Success!; - } - return string.IsNullOrEmpty(userSearchFilter.Location) + // Valid if either Location string is set OR lat/long coordinates are set + var hasLocation = !string.IsNullOrEmpty(userSearchFilter.Location) || + (userSearchFilter.Latitude.HasValue && userSearchFilter.Longitude.HasValue); + + return !hasLocation ? new ValidationResult("Område skal vælges når en radius er valgt.") : ValidationResult.Success!; } diff --git a/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor b/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor index 2baf5d12..c85be2b0 100644 --- a/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor +++ b/src/web/Jordnaer/Features/GroupSearch/GroupSearchForm.razor @@ -1,5 +1,10 @@ +@using Jordnaer.Features.Map +@using Microsoft.FeatureManagement +@using MudBlazor +@using Variant = MudBlazor.Variant @inject NavigationManager Navigation @inject IJSRuntime JsRuntime +@inject IFeatureManager FeatureManager @@ -24,19 +29,33 @@ - - - - - - - + @if (_mapSearchEnabled) + { + @* New map-based search experience *@ + + + + } + else + { + @* Existing zip code search *@ + + + + + + + + } @@ -71,6 +90,9 @@ @code { + private MapSearchFilter? _mapSearchFilter; + private bool _mapSearchEnabled = false; + [Parameter] public required GroupSearchFilter Filter { get; set; } @@ -85,6 +107,52 @@ private bool _recentlyClearedForm = false; private bool _disableSmartCompletionForZipCode => _recentlyClearedForm || Filter != DefaultFilter; + protected override async Task OnInitializedAsync() + { + _mapSearchEnabled = await FeatureManager.IsEnabledAsync(FeatureFlags.MapSearch); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && _mapSearchEnabled && _mapSearchFilter is not null) + { + // If we have lat/long in the filter from query string, set the map location + if (Filter.Latitude.HasValue && Filter.Longitude.HasValue && Filter.WithinRadiusKilometers.HasValue) + { + await _mapSearchFilter.SetLocationAsync( + Filter.Latitude.Value, + Filter.Longitude.Value, + 13); + } + } + } + + private async Task OnLocationSearchChanged((double Latitude, double Longitude, int RadiusKm, string? Address) locationSearch) + { + Filter.Latitude = locationSearch.Latitude; + Filter.Longitude = locationSearch.Longitude; + Filter.WithinRadiusKilometers = locationSearch.RadiusKm; + Filter.Location = locationSearch.Address; + + await FilterChanged.InvokeAsync(Filter); + } + + private async Task OnRadiusChanged(int newRadius) + { + Filter.WithinRadiusKilometers = newRadius; + + // Update the map if we have a location + if (_mapSearchFilter is not null && Filter.Latitude.HasValue && Filter.Longitude.HasValue) + { + await _mapSearchFilter.UpdateSearchRadiusAsync( + Filter.Latitude.Value, + Filter.Longitude.Value, + newRadius); + } + + await FilterChanged.InvokeAsync(Filter); + } + private async Task ClearFilter() { Filter = new GroupSearchFilter(); diff --git a/src/web/Jordnaer/Features/GroupSearch/GroupSearchService.cs b/src/web/Jordnaer/Features/GroupSearch/GroupSearchService.cs index f419ce7b..317d53c1 100644 --- a/src/web/Jordnaer/Features/GroupSearch/GroupSearchService.cs +++ b/src/web/Jordnaer/Features/GroupSearch/GroupSearchService.cs @@ -3,6 +3,7 @@ using Jordnaer.Features.Profile; using Jordnaer.Shared; using Microsoft.EntityFrameworkCore; +using NetTopologySuite.Geometries; namespace Jordnaer.Features.GroupSearch; @@ -16,6 +17,7 @@ public class GroupSearchService( ILocationService locationService) : IGroupSearchService { + private static readonly GeometryFactory GeometryFactory = new(new PrecisionModel(), 4326); public async Task GetGroupsAsync(GroupSearchFilter filter, CancellationToken cancellationToken = default) { @@ -63,20 +65,34 @@ public async Task GetGroupsAsync(GroupSearchFilter filter, GroupSearchFilter filter, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(filter.Location) || filter.WithinRadiusKilometers is null) + if (filter.WithinRadiusKilometers is null) { return (groups, false); } - // Get location from the search location - var searchLocation = await locationService.GetLocationFromZipCodeAsync(filter.Location, cancellationToken); + Point? location = null; - if (searchLocation is null) + // Prefer lat/long from map search if available + if (filter.Latitude.HasValue && filter.Longitude.HasValue) + { + // NetTopologySuite Point uses (longitude, latitude) order + location = GeometryFactory.CreatePoint(new Coordinate(filter.Longitude.Value, filter.Latitude.Value)); + } + // Fall back to zip code/location string lookup for backward compatibility + else if (!string.IsNullOrEmpty(filter.Location)) + { + var searchLocation = await locationService.GetLocationFromZipCodeAsync(filter.Location, cancellationToken); + if (searchLocation is null) + { + return (groups, false); + } + location = searchLocation.Location; + } + else { return (groups, false); } - var location = searchLocation.Location; var radiusMeters = filter.WithinRadiusKilometers.Value * 1000; // Use SQL Server's built-in distance calculation with geography type diff --git a/src/web/Jordnaer/Features/Map/MapSearchFilter.razor b/src/web/Jordnaer/Features/Map/MapSearchFilter.razor new file mode 100644 index 00000000..7e5c2b0b --- /dev/null +++ b/src/web/Jordnaer/Features/Map/MapSearchFilter.razor @@ -0,0 +1,174 @@ +@using Jordnaer.Shared +@using Jordnaer.Features.Profile +@inject IDataForsyningenClient DataForsyningenClient +@inject ILocationService LocationService + + + @* Address search with autocomplete *@ + + + @* Radius slider with value display *@ + + Radius: + + + @RadiusKm km + + + + @* Map *@ + + + @* Instructions *@ + + Klik på kortet eller søg efter en adresse for at vælge et søgeområde + + + +@code { + private LeafletMap? _mapComponent; + private string? _addressText; + + [Parameter] + public string MapId { get; set; } = $"map-search-{Guid.NewGuid()}"; + + [Parameter] + public double InitialLatitude { get; set; } = 56.0; // Center of Denmark + + [Parameter] + public double InitialLongitude { get; set; } = 10.0; // Center of Denmark + + [Parameter] + public int InitialZoom { get; set; } = 7; + + [Parameter] + public int RadiusKm { get; set; } = 10; + + [Parameter] + public EventCallback RadiusKmChanged { get; set; } + + [Parameter] + public EventCallback<(double Latitude, double Longitude, int RadiusKm, string? Address)> OnLocationSearchChanged + { + get; + set; + } + + /// + /// Searches for Danish addresses using DataForsyningen API + /// + private async Task> SearchForAddresses(string value, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(value) || value.Length < 3) + { + return []; + } + + var searchResponse = await DataForsyningenClient.GetAddressesWithAutoComplete(value, cancellationToken); + + return searchResponse.IsSuccessful && searchResponse.Content is not null + ? searchResponse.Content.Select(response => response.ToString()) + : []; + } + + /// + /// Called when an address is selected from autocomplete + /// + private async Task OnAddressSelected(string? address) + { + if (string.IsNullOrWhiteSpace(address)) + { + return; + } + + _addressText = address; + + // Get coordinates for the selected address + var locationResult = await LocationService.GetLocationFromAddressAsync(address); + + if (locationResult is null) + { + return; + } + + var lat = locationResult.Location.Y; + var lng = locationResult.Location.X; + + // Update map + if (_mapComponent is not null) + { + await _mapComponent.CenterMapAsync(lat, lng, 13); + await _mapComponent.UpdateMarkerAsync(lat, lng); + await _mapComponent.UpdateSearchRadiusAsync(lat, lng, RadiusKm); + } + + // Notify parent component + await OnLocationSearchChanged.InvokeAsync((lat, lng, RadiusKm, address)); + } + + /// + /// Called when the map is clicked + /// + private async Task OnMapClicked((double Latitude, double Longitude) location) + { + var (lat, lng) = location; + + // Clear the address text since we're selecting a new location on the map + _addressText = null; + + // Update marker and radius on map + if (_mapComponent is not null) + { + await _mapComponent.UpdateMarkerAsync(lat, lng); + await _mapComponent.UpdateSearchRadiusAsync(lat, lng, RadiusKm); + } + + // Pass null for address when clicking on map (not from address search) + await OnLocationSearchChanged.InvokeAsync((lat, lng, RadiusKm, null)); + } + + /// + /// Called when the radius slider changes + /// + private async Task OnRadiusChanged(int newRadius) + { + RadiusKm = newRadius; + await RadiusKmChanged.InvokeAsync(newRadius); + + // The parent will handle updating the map through the public method + } + + /// + /// Public method to update the search radius visualization + /// Call this from parent when radius changes + /// + public async Task UpdateSearchRadiusAsync(double lat, double lng, int radiusKm) + { + if (_mapComponent is not null) + { + await _mapComponent.UpdateSearchRadiusAsync(lat, lng, radiusKm); + } + } + + /// + /// Public method to update the map based on lat/long + /// + public async Task SetLocationAsync(double lat, double lng, int? zoom = null) + { + if (_mapComponent is not null) + { + await _mapComponent.CenterMapAsync(lat, lng, zoom); + await _mapComponent.UpdateMarkerAsync(lat, lng); + if (RadiusKm > 0) + { + await _mapComponent.UpdateSearchRadiusAsync(lat, lng, RadiusKm); + } + } + } +} \ No newline at end of file diff --git a/src/web/Jordnaer/Features/PostSearch/PostSearchService.cs b/src/web/Jordnaer/Features/PostSearch/PostSearchService.cs index a02a5979..abbd4e59 100644 --- a/src/web/Jordnaer/Features/PostSearch/PostSearchService.cs +++ b/src/web/Jordnaer/Features/PostSearch/PostSearchService.cs @@ -4,6 +4,7 @@ using Jordnaer.Models; using Jordnaer.Shared; using Microsoft.EntityFrameworkCore; +using NetTopologySuite.Geometries; using OneOf; using OneOf.Types; @@ -14,6 +15,7 @@ public class PostSearchService( ILocationService locationService, ILogger logger) { + private static readonly GeometryFactory GeometryFactory = new(new PrecisionModel(), 4326); public async Task>> GetPostsAsync(PostSearchFilter filter, CancellationToken cancellationToken = default) { @@ -65,20 +67,34 @@ internal async Task> ApplyLocationFilterAsync( IQueryable posts, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(filter.Location) || filter.WithinRadiusKilometers is null) + if (filter.WithinRadiusKilometers is null) { return posts; } - // Get location from the search location - var searchLocation = await locationService.GetLocationFromZipCodeAsync(filter.Location, cancellationToken); + Point? location = null; - if (searchLocation is null) + // Prefer lat/long from map search if available + if (filter.Latitude.HasValue && filter.Longitude.HasValue) + { + // NetTopologySuite Point uses (longitude, latitude) order + location = GeometryFactory.CreatePoint(new Coordinate(filter.Longitude.Value, filter.Latitude.Value)); + } + // Fall back to zip code/location string lookup for backward compatibility + else if (!string.IsNullOrEmpty(filter.Location)) + { + var searchLocation = await locationService.GetLocationFromZipCodeAsync(filter.Location, cancellationToken); + if (searchLocation is null) + { + return posts; + } + location = searchLocation.Location; + } + else { return posts; } - var location = searchLocation.Location; var radiusMeters = filter.WithinRadiusKilometers.Value * 1000; // Use SQL Server's built-in distance calculation with geography type diff --git a/src/web/Jordnaer/Features/Posts/PostSearchForm.razor b/src/web/Jordnaer/Features/Posts/PostSearchForm.razor index c7c24956..61ce3e5e 100644 --- a/src/web/Jordnaer/Features/Posts/PostSearchForm.razor +++ b/src/web/Jordnaer/Features/Posts/PostSearchForm.razor @@ -1,4 +1,12 @@ - +@using Jordnaer.Features.Map +@using Microsoft.FeatureManagement +@using MudBlazor +@using Variant = MudBlazor.Variant +@inject NavigationManager Navigation +@inject IJSRuntime JsRuntime +@inject IFeatureManager FeatureManager + +

@@ -11,21 +19,35 @@ - - - - - - - - + + @if (_mapSearchEnabled) + { + @* New map-based search experience *@ + + + + } + else + { + @* Existing zip code search *@ + + + + + + + + } @@ -59,6 +81,9 @@ @code { + private MapSearchFilter? _mapSearchFilter; + private bool _mapSearchEnabled = false; + [Parameter, EditorRequired] public required PostSearchFilter Filter { get; set; } @@ -70,6 +95,52 @@ private bool _recentlyClearedForm = false; + protected override async Task OnInitializedAsync() + { + _mapSearchEnabled = await FeatureManager.IsEnabledAsync(FeatureFlags.MapSearch); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && _mapSearchEnabled && _mapSearchFilter is not null) + { + // If we have lat/long in the filter from query string, set the map location + if (Filter.Latitude.HasValue && Filter.Longitude.HasValue && Filter.WithinRadiusKilometers.HasValue) + { + await _mapSearchFilter.SetLocationAsync( + Filter.Latitude.Value, + Filter.Longitude.Value, + 13); + } + } + } + + private async Task OnLocationSearchChanged((double Latitude, double Longitude, int RadiusKm, string? Address) locationSearch) + { + Filter.Latitude = locationSearch.Latitude; + Filter.Longitude = locationSearch.Longitude; + Filter.WithinRadiusKilometers = locationSearch.RadiusKm; + Filter.Location = locationSearch.Address; + + await FilterChanged.InvokeAsync(Filter); + } + + private async Task OnRadiusChanged(int newRadius) + { + Filter.WithinRadiusKilometers = newRadius; + + // Update the map if we have a location + if (_mapSearchFilter is not null && Filter.Latitude.HasValue && Filter.Longitude.HasValue) + { + await _mapSearchFilter.UpdateSearchRadiusAsync( + Filter.Latitude.Value, + Filter.Longitude.Value, + newRadius); + } + + await FilterChanged.InvokeAsync(Filter); + } + private async Task ClearFilter() { Filter = new PostSearchFilter(); @@ -77,7 +148,7 @@ _recentlyClearedForm = true; } - + private void LocationChanged(string location) { Filter.Location = location; diff --git a/src/web/Jordnaer/Features/Profile/LocationMigrationService.cs b/src/web/Jordnaer/Features/Profile/LocationMigrationService.cs deleted file mode 100644 index 44db697e..00000000 --- a/src/web/Jordnaer/Features/Profile/LocationMigrationService.cs +++ /dev/null @@ -1,257 +0,0 @@ -using Jordnaer.Database; -using Jordnaer.Shared; -using Microsoft.EntityFrameworkCore; - -namespace Jordnaer.Features.Profile; - -///

-/// One-time migration service that fetches location data from DataForsyningen for existing users with zip codes. -/// Converts ZipCode + City to Location Point geometry by calling the external API. -/// Runs once on application startup and marks completion to avoid re-running. -/// -public class LocationMigrationService( - IServiceScopeFactory serviceScopeFactory, - ILogger logger) : BackgroundService -{ - private const int BatchSize = 50; // Process in batches to avoid overloading DataForsyningen API - private const int DelayBetweenBatchesMs = 1000; // 1 second delay between batches - - private ILocationService locationService = null!; - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - await using var scope = serviceScopeFactory.CreateAsyncScope(); - - var contextFactory = scope.ServiceProvider.GetRequiredService>(); - locationService = scope.ServiceProvider.GetRequiredService(); - await using var context = await contextFactory.CreateDbContextAsync(stoppingToken); - - logger.LogInformation("Starting location migration from ZipCode to Point geometry"); - - // Migrate UserProfiles - var userProfilesUpdated = await MigrateUserProfiles(context, stoppingToken); - logger.LogInformation("Migrated {Count} user profiles", userProfilesUpdated); - - // Migrate Groups - var groupsUpdated = await MigrateGroups(context, stoppingToken); - logger.LogInformation("Migrated {Count} groups", groupsUpdated); - - // Migrate Posts - var postsUpdated = await MigratePosts(context, stoppingToken); - logger.LogInformation("Migrated {Count} posts", postsUpdated); - - logger.LogInformation( - "Location migration completed successfully. Total: {UserProfiles} users, {Groups} groups, {Posts} posts", - userProfilesUpdated, groupsUpdated, postsUpdated); - } - catch (Exception ex) - { - logger.LogError(ex, "Location migration failed. This is non-critical and the application will continue running."); - } - } - - private async Task MigrateUserProfiles(JordnaerDbContext context, CancellationToken cancellationToken) - { - try - { - // Get all users with ZipCode and City but no Location - var usersToMigrate = await context.UserProfiles - .Where(u => u.ZipCode != null && u.City != null && u.Location == null) - .Select(u => new { u.Id, u.ZipCode, u.City }) - .ToListAsync(cancellationToken); - - if (usersToMigrate.Count == 0) - { - logger.LogInformation("No user profiles need migration"); - return 0; - } - - logger.LogInformation("Found {Count} user profiles to migrate", usersToMigrate.Count); - - var successCount = 0; - var batches = usersToMigrate.Chunk(BatchSize); - - foreach (var batch in batches) - { - foreach (var user in batch) - { - if (cancellationToken.IsCancellationRequested) - break; - - try - { - var zipCodeText = $"{user.ZipCode} {user.City}"; - var locationResult = await locationService.GetLocationFromZipCodeAsync(zipCodeText, cancellationToken); - - if (locationResult != null) - { - await context.Database.ExecuteSqlRawAsync( - "UPDATE UserProfiles SET Location = {0} WHERE Id = {1}", - locationResult.Location, - user.Id); - - successCount++; - } - else - { - logger.LogWarning("Failed to get location for user {UserId} with ZipCode {ZipCode}", user.Id, zipCodeText); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Error migrating user {UserId}", user.Id); - } - } - - // Delay between batches to avoid rate limiting - if (cancellationToken.IsCancellationRequested) - break; - - await Task.Delay(DelayBetweenBatchesMs, cancellationToken); - } - - return successCount; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to migrate user profiles"); - return 0; - } - } - - private async Task MigrateGroups(JordnaerDbContext context, CancellationToken cancellationToken) - { - try - { - var groupsToMigrate = await context.Groups - .Where(g => g.ZipCode != null && g.City != null && g.Location == null) - .Select(g => new { g.Id, g.ZipCode, g.City }) - .ToListAsync(cancellationToken); - - if (groupsToMigrate.Count == 0) - { - logger.LogInformation("No groups need migration"); - return 0; - } - - logger.LogInformation("Found {Count} groups to migrate", groupsToMigrate.Count); - - var successCount = 0; - var batches = groupsToMigrate.Chunk(BatchSize); - - foreach (var batch in batches) - { - foreach (var group in batch) - { - if (cancellationToken.IsCancellationRequested) - break; - - try - { - var zipCodeText = $"{group.ZipCode} {group.City}"; - var locationResult = await locationService.GetLocationFromZipCodeAsync(zipCodeText, cancellationToken); - - if (locationResult != null) - { - await context.Database.ExecuteSqlRawAsync( - "UPDATE Groups SET Location = {0} WHERE Id = {1}", - locationResult.Location, - group.Id); - - successCount++; - } - else - { - logger.LogWarning("Failed to get location for group {GroupId} with ZipCode {ZipCode}", group.Id, zipCodeText); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Error migrating group {GroupId}", group.Id); - } - } - - if (cancellationToken.IsCancellationRequested) - break; - - await Task.Delay(DelayBetweenBatchesMs, cancellationToken); - } - - return successCount; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to migrate groups"); - return 0; - } - } - - private async Task MigratePosts(JordnaerDbContext context, CancellationToken cancellationToken) - { - try - { - var postsToMigrate = await context.Posts - .Where(p => p.ZipCode != null && p.Location == null) - .Select(p => new { p.Id, p.ZipCode }) - .ToListAsync(cancellationToken); - - if (postsToMigrate.Count == 0) - { - logger.LogInformation("No posts need migration"); - return 0; - } - - logger.LogInformation("Found {Count} posts to migrate", postsToMigrate.Count); - - var successCount = 0; - var batches = postsToMigrate.Chunk(BatchSize); - - foreach (var batch in batches) - { - foreach (var post in batch) - { - if (cancellationToken.IsCancellationRequested) - break; - - try - { - var zipCodeText = post.ZipCode.ToString()!; - var locationResult = await locationService.GetLocationFromZipCodeAsync(zipCodeText, cancellationToken); - - if (locationResult != null) - { - await context.Database.ExecuteSqlRawAsync( - "UPDATE Posts SET Location = {0} WHERE Id = {1}", - locationResult.Location, - post.Id); - - successCount++; - } - else - { - logger.LogWarning("Failed to get location for post {PostId} with ZipCode {ZipCode}", post.Id, zipCodeText); - } - } - catch (Exception ex) - { - logger.LogWarning(ex, "Error migrating post {PostId}", post.Id); - } - } - - if (cancellationToken.IsCancellationRequested) - break; - - await Task.Delay(DelayBetweenBatchesMs, cancellationToken); - } - - return successCount; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Failed to migrate posts"); - return 0; - } - } -} diff --git a/src/web/Jordnaer/Features/Profile/WebApplicationBuilderExtensions.cs b/src/web/Jordnaer/Features/Profile/WebApplicationBuilderExtensions.cs index a8e236cc..bf03da11 100644 --- a/src/web/Jordnaer/Features/Profile/WebApplicationBuilderExtensions.cs +++ b/src/web/Jordnaer/Features/Profile/WebApplicationBuilderExtensions.cs @@ -15,9 +15,6 @@ public static WebApplicationBuilder AddProfileServices(this WebApplicationBuilde builder.Services.AddScoped(); builder.Services.AddScoped(); - // One-time migration service to convert Latitude/Longitude to Location Point geometry - builder.Services.AddHostedService(); - return builder; } } diff --git a/src/web/Jordnaer/Features/UserSearch/QueryableUserProfileExtensions.cs b/src/web/Jordnaer/Features/UserSearch/QueryableUserProfileExtensions.cs index 66e17c22..44b61c0f 100644 --- a/src/web/Jordnaer/Features/UserSearch/QueryableUserProfileExtensions.cs +++ b/src/web/Jordnaer/Features/UserSearch/QueryableUserProfileExtensions.cs @@ -1,11 +1,14 @@ using Jordnaer.Features.Profile; using Jordnaer.Shared; using Microsoft.EntityFrameworkCore; +using NetTopologySuite.Geometries; namespace Jordnaer.Features.UserSearch; internal static class QueryableUserProfileExtensions { + private static readonly GeometryFactory GeometryFactory = new(new PrecisionModel(), 4326); + // TODO: We can make this generic for posts, groups and users internal static async Task<(IQueryable UserProfiles, bool AppliedOrdering)> ApplyLocationFilter( this IQueryable users, @@ -13,21 +16,34 @@ internal static class QueryableUserProfileExtensions ILocationService locationService, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(filter.Location) || filter.WithinRadiusKilometers is null) + if (filter.WithinRadiusKilometers is null) { return (users, false); } - // Get location from the search location - // TODO: When we get a map in our user, group and post search filters, we should use that location instead and remove async - var searchLocation = await locationService.GetLocationFromZipCodeAsync(filter.Location, cancellationToken); + Point? location = null; - if (searchLocation is null) + // Prefer lat/long from map search if available + if (filter.Latitude.HasValue && filter.Longitude.HasValue) + { + // NetTopologySuite Point uses (longitude, latitude) order + location = GeometryFactory.CreatePoint(new Coordinate(filter.Longitude.Value, filter.Latitude.Value)); + } + // Fall back to zip code/location string lookup for backward compatibility + else if (!string.IsNullOrEmpty(filter.Location)) + { + var searchLocation = await locationService.GetLocationFromZipCodeAsync(filter.Location, cancellationToken); + if (searchLocation is null) + { + return (users, false); + } + location = searchLocation.Location; + } + else { return (users, false); } - var location = searchLocation.Location; var radiusMeters = filter.WithinRadiusKilometers.Value * 1000; // Use SQL Server's built-in distance calculation with geography type diff --git a/src/web/Jordnaer/Features/UserSearch/UserSearchForm.razor b/src/web/Jordnaer/Features/UserSearch/UserSearchForm.razor index 9de5fa8f..a79fd36f 100644 --- a/src/web/Jordnaer/Features/UserSearch/UserSearchForm.razor +++ b/src/web/Jordnaer/Features/UserSearch/UserSearchForm.razor @@ -1,5 +1,10 @@ +@using Jordnaer.Features.Map +@using Microsoft.FeatureManagement +@using MudBlazor +@using Variant = MudBlazor.Variant @inject NavigationManager Navigation @inject IJSRuntime JsRuntime +@inject IFeatureManager FeatureManager @@ -14,55 +19,103 @@ - - - - - - - + @if (_mapSearchEnabled) + { + @* New map-based search experience *@ + + + + } + else + { + @* Existing zip code search *@ + + + + + + + + } - - + + @(ExpandAdvancedSearch ? "Luk udvidet søgning" : "Udvidet søgning") - - - - Børn - - - - - - - - - - - - - - @foreach (var gender in Enum.GetValues()) - { - @gender.GetDisplayName() - } - - + + + @* Name search *@ + + + @* Children filters section *@ + + + Børn + + + + + + + + + + + + + @foreach (var gender in Enum.GetValues()) + { + @gender.GetDisplayName() + } + + + @@ -88,6 +141,9 @@ @code { + private MapSearchFilter? _mapSearchFilter; + private bool _mapSearchEnabled = false; + [Parameter] public required UserSearchFilter Filter { get; set; } @@ -105,6 +161,52 @@ private bool _recentlyClearedForm = false; private bool _disableSmartCompletionForZipCode => _recentlyClearedForm || Filter != DefaultFilter; + protected override async Task OnInitializedAsync() + { + _mapSearchEnabled = await FeatureManager.IsEnabledAsync(FeatureFlags.MapSearch); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender && _mapSearchEnabled && _mapSearchFilter is not null) + { + // If we have lat/long in the filter from query string, set the map location + if (Filter.Latitude.HasValue && Filter.Longitude.HasValue && Filter.WithinRadiusKilometers.HasValue) + { + await _mapSearchFilter.SetLocationAsync( + Filter.Latitude.Value, + Filter.Longitude.Value, + 13); + } + } + } + + private async Task OnLocationSearchChanged((double Latitude, double Longitude, int RadiusKm, string? Address) locationSearch) + { + Filter.Latitude = locationSearch.Latitude; + Filter.Longitude = locationSearch.Longitude; + Filter.WithinRadiusKilometers = locationSearch.RadiusKm; + Filter.Location = locationSearch.Address; + + await FilterChanged.InvokeAsync(Filter); + } + + private async Task OnRadiusChanged(int newRadius) + { + Filter.WithinRadiusKilometers = newRadius; + + // Update the map if we have a location + if (_mapSearchFilter is not null && Filter.Latitude.HasValue && Filter.Longitude.HasValue) + { + await _mapSearchFilter.UpdateSearchRadiusAsync( + Filter.Latitude.Value, + Filter.Longitude.Value, + newRadius); + } + + await FilterChanged.InvokeAsync(Filter); + } + private async Task ClearFilter() { Filter = new UserSearchFilter(); diff --git a/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor b/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor index 4f3392a1..4dd2b347 100644 --- a/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor +++ b/src/web/Jordnaer/Pages/GroupSearch/GroupSearch.razor @@ -51,6 +51,10 @@ [SupplyParameterFromQuery] public string? Location { get; set; } [SupplyParameterFromQuery] + public double? Latitude { get; set; } + [SupplyParameterFromQuery] + public double? Longitude { get; set; } + [SupplyParameterFromQuery] public int? PageNumber { get; set; } [SupplyParameterFromQuery] public int? PageSize { get; set; } @@ -114,6 +118,8 @@ _queryStrings[nameof(_filter.Categories).ToLower()] = _filter.Categories; _queryStrings[nameof(_filter.WithinRadiusKilometers).ToLower()] = _filter.WithinRadiusKilometers; _queryStrings[nameof(_filter.Location).ToLower()] = _filter.Location; + _queryStrings[nameof(_filter.Latitude).ToLower()] = _filter.Latitude; + _queryStrings[nameof(_filter.Longitude).ToLower()] = _filter.Longitude; _queryStrings[nameof(_filter.PageSize).ToLower()] = _filter.PageSize; _queryStrings[nameof(_filter.PageNumber).ToLower()] = _filter.PageNumber; @@ -136,6 +142,8 @@ Categories = Categories, WithinRadiusKilometers = WithinRadiusKilometers, Location = Location, + Latitude = Latitude, + Longitude = Longitude, }; if (PageNumber is not null) diff --git a/src/web/Jordnaer/Pages/Posts/Posts.razor b/src/web/Jordnaer/Pages/Posts/Posts.razor index 56d5e449..2837307d 100644 --- a/src/web/Jordnaer/Pages/Posts/Posts.razor +++ b/src/web/Jordnaer/Pages/Posts/Posts.razor @@ -3,6 +3,8 @@ @inject PostSearchService PostSearchService @inject ISnackbar Snackbar @inject ILogger Logger +@inject NavigationManager Navigation +@inject IJSRuntime JsRuntime @attribute [Sitemap] @@ -43,12 +45,34 @@ @code { + [SupplyParameterFromQuery] + public string? Contents { get; set; } + [SupplyParameterFromQuery] + public string[]? Categories { get; set; } = []; + [SupplyParameterFromQuery] + public int? WithinRadiusKilometers { get; set; } + [SupplyParameterFromQuery] + public string? Location { get; set; } + [SupplyParameterFromQuery] + public double? Latitude { get; set; } + [SupplyParameterFromQuery] + public double? Longitude { get; set; } + [SupplyParameterFromQuery] + public int? PageNumber { get; set; } + [SupplyParameterFromQuery] + public int? PageSize { get; set; } + private PostSearchFilter _filter = new(); private PostSearchResult _searchResult = new(); private bool _isSearching = false; private bool _hasSearched = false; + protected override async Task OnInitializedAsync() + { + await LoadFromQueryString(); + } + private async Task Search() { _isSearching = true; @@ -56,6 +80,8 @@ { var result = await PostSearchService.GetPostsAsync(_filter); + await UpdateQueryString(); + Snackbar.Clear(); result.Switch( @@ -80,6 +106,56 @@ } } + private static readonly Dictionary _queryStrings = []; + private async Task UpdateQueryString() + { + _queryStrings[nameof(_filter.Contents).ToLower()] = _filter.Contents; + _queryStrings[nameof(_filter.Categories).ToLower()] = _filter.Categories; + _queryStrings[nameof(_filter.WithinRadiusKilometers).ToLower()] = _filter.WithinRadiusKilometers; + _queryStrings[nameof(_filter.Location).ToLower()] = _filter.Location; + _queryStrings[nameof(_filter.Latitude).ToLower()] = _filter.Latitude; + _queryStrings[nameof(_filter.Longitude).ToLower()] = _filter.Longitude; + _queryStrings[nameof(_filter.PageSize).ToLower()] = _filter.PageSize; + _queryStrings[nameof(_filter.PageNumber).ToLower()] = _filter.PageNumber; + + var newUrl = Navigation.GetUriWithQueryParameters(_queryStrings); + + await JsRuntime.NavigateTo(newUrl); + } + + private async ValueTask LoadFromQueryString() + { + var queryStrings = new Uri(Navigation.Uri).Query; + if (string.IsNullOrEmpty(queryStrings)) + { + return; + } + + var filter = new PostSearchFilter + { + Contents = Contents, + Categories = Categories, + WithinRadiusKilometers = WithinRadiusKilometers, + Location = Location, + Latitude = Latitude, + Longitude = Longitude, + }; + + if (PageNumber is not null) + { + filter.PageNumber = PageNumber.Value; + } + + if (PageSize is not null) + { + filter.PageSize = PageSize.Value; + } + + _filter = filter; + + await Search(); + } + private async Task OnSelectedPageChanged(int selectedPage) { _filter.PageNumber = selectedPage; diff --git a/src/web/Jordnaer/Pages/UserSearch/UserSearch.razor b/src/web/Jordnaer/Pages/UserSearch/UserSearch.razor index 73698e81..bb59aa25 100644 --- a/src/web/Jordnaer/Pages/UserSearch/UserSearch.razor +++ b/src/web/Jordnaer/Pages/UserSearch/UserSearch.razor @@ -50,6 +50,10 @@ [SupplyParameterFromQuery] public string? Location { get; set; } [SupplyParameterFromQuery] + public double? Latitude { get; set; } + [SupplyParameterFromQuery] + public double? Longitude { get; set; } + [SupplyParameterFromQuery] public int? MinimumChildAge { get; set; } [SupplyParameterFromQuery] public int? MaximumChildAge { get; set; } @@ -119,6 +123,8 @@ _queryStrings[nameof(_filter.Categories).ToLower()] = _filter.Categories; _queryStrings[nameof(_filter.WithinRadiusKilometers).ToLower()] = _filter.WithinRadiusKilometers; _queryStrings[nameof(_filter.Location).ToLower()] = _filter.Location; + _queryStrings[nameof(_filter.Latitude).ToLower()] = _filter.Latitude; + _queryStrings[nameof(_filter.Longitude).ToLower()] = _filter.Longitude; _queryStrings[nameof(_filter.MinimumChildAge).ToLower()] = _filter.MinimumChildAge; _queryStrings[nameof(_filter.MaximumChildAge).ToLower()] = _filter.MaximumChildAge; _queryStrings[nameof(_filter.ChildGender).ToLower()] = _filter.ChildGender.HasValue ? _filter.ChildGender.ToString() : null; @@ -144,6 +150,8 @@ Categories = Categories, WithinRadiusKilometers = WithinRadiusKilometers, Location = Location, + Latitude = Latitude, + Longitude = Longitude, MinimumChildAge = MinimumChildAge, MaximumChildAge = MaximumChildAge }; diff --git a/src/web/Jordnaer/appsettings.json b/src/web/Jordnaer/appsettings.json index 1d7dce8c..681b1f7b 100644 --- a/src/web/Jordnaer/appsettings.json +++ b/src/web/Jordnaer/appsettings.json @@ -9,7 +9,7 @@ "Posts": false, "AccountSettings": false, "NotificationSettings": false, - "MapSearch": false + "MapSearch": true }, "Serilog": { "MinimumLevel": { From face8f118bd9e163cae6119ae0f626e681aea591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Tue, 23 Dec 2025 10:36:30 +0100 Subject: [PATCH 4/7] Delete 05-map-search.md --- tasks/05-map-search.md | 216 ----------------------------------------- 1 file changed, 216 deletions(-) delete mode 100644 tasks/05-map-search.md diff --git a/tasks/05-map-search.md b/tasks/05-map-search.md deleted file mode 100644 index 2df2da88..00000000 --- a/tasks/05-map-search.md +++ /dev/null @@ -1,216 +0,0 @@ -# Task 07: Add Map to Search Pages (Users, then Groups, then Posts) - -## Context - -**App:** Jordnaer (.NET Blazor Server) -**Area:** Search/Location -**Priority:** Medium -**Related:** Task 08 (NetTopologySuite) - provides distance calculation engine for radius-based search - -## ⚠️ Feature Flag Requirement - -**CRITICAL:** This feature MUST be implemented behind a feature flag to allow easy rollback to existing working behavior. - -```csharp -// appsettings.json -{ - "FeatureFlags": { - "EnableNewSearchExperience": false // Controls map search for posts, groups, and users - } -} -``` - -**Implementation Notes:** - -- When `false`: Use existing search UI/behavior (fallback) -- When `true`: Enable new map-based search experience -- This flag will also control Task 01 (User Search Display improvements) - both are part of the "new search experience" -- Allows A/B testing, gradual rollout, and instant rollback if issues arise - -## Objective - -Integrate a map component into search pages (**users, groups, AND posts**) to allow users to visually search for content near a specific location by selecting a point on the map and defining a search radius. - -**Implementation Order:** -1. Start with [UserSearch.razor](src/web/Jordnaer/Pages/UserSearch/UserSearch.razor) - refine until we're happy -2. Then extend to Groups search -3. Then extend to Posts search - -## Current State - -Search pages lack visual location-based search capabilities. Users cannot easily search "near this location" using a map interface for any content type (posts, groups, or users). - -## How It Works (Integration with Task 08) - -1. **User Interaction (Two Methods):** - - **Method A: Click on Map** - - - User clicks/taps on map to select a location - - User sets search radius (e.g., 5km, 10km, 25km, 50km) - - **Method B: Search by Address** ⭐ (See uploaded image example) - - - User types address in search box (e.g., "nordlyvej 20") - - DataForsyningen autocomplete suggests addresses - - User selects address from dropdown - - Map centers on selected address with radius circle - - User can adjust radius with slider - -2. **Backend Processing:** - - - Map selection or address search produces lat/long coordinates - - Coordinates + radius passed to search backend - - NetTopologySuite calculates distances from selected point - - Returns users/groups/posts within radius - -3. **Visual Feedback:** - - Map displays search radius as a circle - - **NO markers/pins for individual results** (privacy) - - List view updates with distance from selected point - -## Requirements - -### Core Feature (All Search Types) - -1. **Feature flag implementation** - `EnableNewSearchExperience` controls all new search UI -2. Integrate interactive map component starting with **Users search**, then Groups, then Posts -3. **Reuse existing [AddressAutoComplete.razor](src/web/Jordnaer/Features/Profile/AddressAutoComplete.razor) component** with DataForsyningen -4. Allow users to click map to select search center point -5. Provide radius selector (slider or dropdown) -6. When address selected, center map on that location -7. Extract lat/long from map selection or address lookup -8. Pass lat/long + radius to search backend (uses NetTopologySuite from Task 08) -9. **Display ONLY search radius circle on map** - NO result markers (privacy) -10. Show distance from selected point for each result in list view -11. Ensure responsive design and good performance - -### Generic Implementation Strategy - -**Code Reusability:** - -- Create generic/reusable map search component that works with any filter type -- Add interfaces or base class to [UserSearchFilter.cs](src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs) -- Extend to GroupSearchFilter and PostSearchFilter to implement same interface/base -- Avoid maintaining 3 separate search implementations - -### Search Type Specific Requirements - -**Users Search:** (implement first) - -- Distance from selected point displayed in list -- **NO map markers** - only search radius circle shown - -**Groups Search:** (implement second) - -- Distance from selected point displayed in list -- **NO map markers** - only search radius circle shown - -**Posts Search:** (implement third) - -- Distance from selected point displayed in list -- **NO map markers** - only search radius circle shown - -## Acceptance Criteria - -### Feature Flag - -- [ ] `EnableNewSearchExperience` feature flag implemented in appsettings.json -- [ ] When flag is `false`, existing search UI displays (no map) -- [ ] When flag is `true`, new map-based search displays -- [ ] Easy toggle between old/new behavior without code changes - -### Map Integration (Phased Implementation) - -**Phase 1: Users Search** -- [ ] Map component integrated for Users search (recommend Leaflet.js - free, lightweight, Blazor-compatible) -- [ ] **AddressAutoComplete.razor component integrated into user search form** -- [ ] **Selecting address from autocomplete centers map on that location** -- [ ] **Radius circle updates when address is selected** -- [ ] Users can click/tap map to select search location (alternative to address search) -- [ ] Radius selector implemented as slider (like image) or dropdown -- [ ] Search circle/radius displayed on map -- [ ] **NO result markers** - only radius circle shown -- [ ] Lat/long extracted from map selection or address lookup -- [ ] Backend search uses lat/long + radius (integrates with Task 08) -- [ ] Refine and validate with user search until happy with implementation - -**Phase 2: Generic Implementation** -- [ ] Create interface/base class for search filters (ILocationSearchFilter or LocationSearchFilterBase) -- [ ] Refactor UserSearchFilter to implement/inherit from base -- [ ] Create generic map search component that works with any filter implementing interface - -**Phase 3: Groups & Posts** -- [ ] Extend GroupSearchFilter to implement same interface/base -- [ ] Integrate generic map component into Groups search -- [ ] Extend PostSearchFilter to implement same interface/base -- [ ] Integrate generic map component into Posts search - -### Results Display (All Search Types) - -- [ ] **NO map markers for results** - only search radius circle displayed -- [ ] List view shows distance from selected point (e.g., "3.2 km away") for all types -- [ ] Responsive design (mobile & desktop) -- [ ] Good performance (lazy loading, efficient rendering) -- [ ] Accessible controls for map interaction -- [ ] User's current location can be used as starting point (with permission) - -## Technical Considerations - -- **Map Library:** Recommend Leaflet.js (free, no API costs, Blazor-compatible via JS interop) -- **Alternative:** Google Maps (requires API key and billing) -- **Address Search:** Use DataForsyningen autocomplete API (same as EditProfile) -- **Geolocation:** Request user permission to use current location as default -- **Mobile:** Optimize for touch interactions and data usage -- **Performance:** Lazy load map library, cluster markers if many results -- **Coordinate Format:** Ensure lat/long matches format expected by NetTopologySuite (WGS84) - -## UI Reference - -address search box ("nordlyvej 20"), map with radius circle, and distance slider (3 km) - -## Implementation Suggestions - -**Phase 1: User Search (Refine First)** -1. Add Leaflet.js via CDN or npm -2. Create Blazor map component wrapper with JS interop -3. Integrate existing [AddressAutoComplete.razor](src/web/Jordnaer/Features/Profile/AddressAutoComplete.razor) into [UserSearch.razor](src/web/Jordnaer/Pages/UserSearch/UserSearch.razor) -4. Wire up address selection to center map and update lat/long in filter -5. Add click-on-map handler to set location as alternative to address search -6. Add radius selector UI (slider recommended) -7. Display search radius circle on map (no result markers) -8. Update [UserSearchFilter.cs](src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs) to include lat/long -9. Backend already supports lat/long + radius via NetTopologySuite -10. Test and refine until satisfied with UX - -**Phase 2: Make It Generic** -11. Extract common location search logic into interface/base class -12. Create generic map component that accepts any filter implementing interface -13. Refactor user search to use generic component - -**Phase 3: Extend to Groups & Posts** -14. Apply same pattern to GroupSearchFilter and PostSearchFilter -15. Integrate generic map component into Groups and Posts search pages - -## Files to Investigate - -### Phase 1: User Search -- [UserSearch.razor](src/web/Jordnaer/Pages/UserSearch/UserSearch.razor) - main search page -- [UserSearchFilter.cs](src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs) - add lat/long properties -- [AddressAutoComplete.razor](src/web/Jordnaer/Features/Profile/AddressAutoComplete.razor) - reuse this component -- UserSearchService - verify it accepts lat/long + radius - -### Phase 2: Generic Implementation -- Create `ILocationSearchFilter` interface or `LocationSearchFilterBase` class -- Create generic `LocationMapSearch.razor` component - -### Phase 3: Groups & Posts -- GroupSearchFilter - extend with interface/base -- PostSearchFilter - extend with interface/base -- Groups search page -- Posts search page - -### Feature Flag Implementation -- appsettings.json (add `EnableNewSearchExperience` flag) -- Configuration service/interface for feature flags -- Search page conditional rendering based on flag From 26bf5b6d240b461bcf867e7d5dce4ce35f1e4a8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Tue, 23 Dec 2025 16:50:46 +0100 Subject: [PATCH 5/7] small fixes --- .../Jordnaer.Shared/Groups/GroupSearchFilter.cs | 15 ++++++++++----- .../Jordnaer.Shared/Posts/PostSearchFilter.cs | 5 ++++- .../UserSearch/UserSearchFilter.cs | 5 ++++- src/web/Jordnaer/Components/App.razor | 2 +- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs b/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs index ed7c2859..e3620497 100644 --- a/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs +++ b/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs @@ -51,7 +51,7 @@ public override int GetHashCode() } } - public virtual bool Equals(UserSearchFilter? other) + public virtual bool Equals(GroupSearchFilter? other) { return other is not null && Name == other.Name && @@ -60,7 +60,9 @@ public virtual bool Equals(UserSearchFilter? other) WithinRadiusKilometers == other.WithinRadiusKilometers && Location == other.Location && Latitude == other.Latitude && - Longitude == other.Longitude; + Longitude == other.Longitude && + PageNumber == other.PageNumber && + PageSize == other.PageSize; } } @@ -68,14 +70,17 @@ public virtual bool Equals(UserSearchFilter? other) { protected override ValidationResult IsValid(object? value, ValidationContext validationContext) { - var userSearchFilter = (GroupSearchFilter)validationContext.ObjectInstance; + var groupSearchFilter = (GroupSearchFilter)validationContext.ObjectInstance; - if (userSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(userSearchFilter.Location)) + if (groupSearchFilter.WithinRadiusKilometers is null && + string.IsNullOrEmpty(groupSearchFilter.Location) && + !groupSearchFilter.Latitude.HasValue && + !groupSearchFilter.Longitude.HasValue) { return ValidationResult.Success!; } - return userSearchFilter.WithinRadiusKilometers is null + return groupSearchFilter.WithinRadiusKilometers is null ? new ValidationResult("Radius skal vælges når et område er valgt.") : ValidationResult.Success!; } diff --git a/src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs b/src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs index 3564e9d1..601bbc7d 100644 --- a/src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs +++ b/src/shared/Jordnaer.Shared/Posts/PostSearchFilter.cs @@ -39,7 +39,10 @@ protected override ValidationResult IsValid(object? value, ValidationContext val { var postSearchFilter = (PostSearchFilter)validationContext.ObjectInstance; - if (postSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(postSearchFilter.Location)) + if (postSearchFilter.WithinRadiusKilometers is null && + string.IsNullOrEmpty(postSearchFilter.Location) && + !postSearchFilter.Latitude.HasValue && + !postSearchFilter.Longitude.HasValue) { return ValidationResult.Success!; } diff --git a/src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs b/src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs index fc6ccb23..78b087f0 100644 --- a/src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs +++ b/src/shared/Jordnaer.Shared/UserSearch/UserSearchFilter.cs @@ -81,7 +81,10 @@ protected override ValidationResult IsValid(object? value, ValidationContext val { var userSearchFilter = (UserSearchFilter)validationContext.ObjectInstance; - if (userSearchFilter.WithinRadiusKilometers is null && string.IsNullOrEmpty(userSearchFilter.Location)) + if (userSearchFilter.WithinRadiusKilometers is null && + string.IsNullOrEmpty(userSearchFilter.Location) && + !userSearchFilter.Latitude.HasValue && + !userSearchFilter.Longitude.HasValue) { return ValidationResult.Success!; } diff --git a/src/web/Jordnaer/Components/App.razor b/src/web/Jordnaer/Components/App.razor index 89ddaa71..c3519d82 100644 --- a/src/web/Jordnaer/Components/App.razor +++ b/src/web/Jordnaer/Components/App.razor @@ -81,7 +81,7 @@ - + From 9ad6665c683cb5362f0e02347322aa692048f9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Tue, 23 Dec 2025 16:51:08 +0100 Subject: [PATCH 6/7] Update 06-improve-user-search-display.md --- tasks/06-improve-user-search-display.md | 100 ++++++++++++++---------- 1 file changed, 59 insertions(+), 41 deletions(-) diff --git a/tasks/06-improve-user-search-display.md b/tasks/06-improve-user-search-display.md index c5b8ea5c..a664f6f5 100644 --- a/tasks/06-improve-user-search-display.md +++ b/tasks/06-improve-user-search-display.md @@ -3,69 +3,87 @@ ## Context **App:** Jordnaer (.NET Blazor Server) -**Area:** User Search +**Area:** User Search Results Display **Priority:** High -**Related:** Task 07 (Map Search) - both are part of the "new search experience" - -## ⚠️ Feature Flag Requirement -**CRITICAL:** This feature MUST be implemented behind the same feature flag as Task 07 (Map Search). - -```csharp -// appsettings.json -{ - "FeatureFlags": { - "EnableNewSearchExperience": false // Controls BOTH map search AND improved display - } -} -``` - -**Implementation Notes:** -- When `false`: Use existing search results display -- When `true`: Enable new card-based display with distance info (from map search) -- Single flag controls entire new search experience (map + improved UI) -- Design this UI with map integration in mind (distance display, etc.) +**Note:** Map search (Task 07) is now enabled by default. This task focuses on improving the visual presentation of search results. ## Objective -Enhance the visual presentation and layout of user search results to make them more scannable and informative, with integration for map-based distance display from Task 07. +Enhance the visual presentation and layout of user search results to make them more scannable and informative. Since map search is now enabled by default, this task focuses on creating a modern card-based UI that displays distance information and works seamlessly with the existing map functionality. ## Current State -User search results display needs improvement in terms of visual hierarchy and information presentation. Does not show distance from selected location or integrate with map view. +User search results display needs improvement in terms of visual hierarchy and information presentation. ## Requirements -1. **Feature flag implementation** - Use same `EnableNewSearchExperience` flag as Task 07 -2. Display user information in a clear, card-based layout -3. Ensure responsive design across mobile and desktop -4. Add user avatars and key profile details -5. Implement visual hierarchy for better scannability -6. **Integrate with map search** - Show distance from selected location when map search is used -7. Support both list view and map marker view (from Task 07) +1. Display user information in a clear, card-based layout +2. Ensure responsive design across mobile and desktop +3. Add user avatars and key profile details +4. Implement visual hierarchy for better scannability +5. **Display distance information** - Show distance from map center/selected location (e.g., "3.2 km away") +6. **Map interaction** - Clicking a result card highlights the corresponding map marker +7. **Ad integration** - Design a non-invasive way to intertwine ads with search results (preparing for multiple ads in the future) ## Acceptance Criteria -### Feature Flag -- [ ] Uses `EnableNewSearchExperience` feature flag (shared with Task 07) -- [ ] When flag is `false`, existing search display is used -- [ ] When flag is `true`, new card-based display with distance info is used - ### Visual Design + - [ ] Search results use a modern card-based layout - [ ] Each result card shows: avatar, name, location, and key profile info -- [ ] **Distance from selected point displayed** (e.g., "3.2 km away") when using map search +- [ ] **Distance from map center displayed** (e.g., "3.2 km away") - [ ] Layout is fully responsive (mobile & desktop) - [ ] Visual hierarchy makes results easy to scan - [ ] Consistent with JordnaerPalette design system (Task 03) -### Integration with Map Search (Task 07) -- [ ] Cards can display distance information from map-based search -- [ ] Clicking card can highlight corresponding map marker (if map visible) +### Map Integration + +- [ ] Distance calculation works with current map center (from existing map search feature) +- [ ] Clicking a result card highlights the corresponding map marker - [ ] Design works well in split view (map + list) and list-only view +- [ ] Results update when map is panned/zoomed (leveraging existing map search functionality) + +### Ad Integration + +- [ ] Ads are naturally intertwined with search results (not in a separate section) +- [ ] Ad placement is non-invasive and doesn't disrupt scanning +- [ ] Design scales to handle multiple ads in the future +- [ ] Ads are clearly distinguishable from regular results but blend visually + +## Complexity Reduction & UX Improvements + +**Simplify search experience:** +- Remove query string integration (no URL parameter syncing) +- Remove auto-search on navigation (only search on explicit user action) +- Use component-level state instead of URL state +- Simplify [UserSearchResultCache.cs](src/web/Jordnaer/Features/UserSearch/UserSearchResultCache.cs) usage + +**Seamless navigation:** +- Improve scroll position restoration when navigating from search → profile/group → back to search +- Current implementation uses [scroll.js](src/web/Jordnaer/wwwroot/js/scroll.js) with 50ms setTimeout - this is janky +- Replace with proper scroll restoration: + - Use Blazor's NavigationManager.LocationChanged event + - Wait for OnAfterRenderAsync to ensure DOM is ready + - Use IntersectionObserver API to restore to visible element instead of pixel position + - Store both scroll position AND the visible item ID/index + - More reliable restoration on different screen sizes +- Maintain search results in cache during navigation (avoid re-querying) +- Restore filter state when returning to search page + +**Browser geolocation:** +- Use browser's Geolocation API to get user's current location as default search center +- Request location permission on search page load +- Set map center to user's coordinates if permission granted +- Fallback to Denmark center if permission denied or unavailable +- Show loading indicator while fetching location ## Files to Investigate -- User search components (likely in `src/web/Jordnaer/Features/`) +- User search components: [UserSearch.razor](src/web/Jordnaer/Pages/UserSearch/UserSearch.razor) +- Search result component: [UserSearchResultComponent.razor](src/web/Jordnaer/Features/UserSearch/UserSearchResultComponent.razor) +- Search cache: [UserSearchResultCache.cs](src/web/Jordnaer/Features/UserSearch/UserSearchResultCache.cs) +- Scroll utilities: [scroll.js](src/web/Jordnaer/wwwroot/js/scroll.js) +- Location service: [LocationService.cs](src/web/Jordnaer/Features/Profile/LocationService.cs) +- Map component: [MapSearchFilter.razor](src/web/Jordnaer/Features/Map/MapSearchFilter.razor) - Existing card components for reference (e.g., `GroupCard.razor`, `ChildProfileCard.razor`) -- Feature flag configuration service -- Distance display formatting utilities (from Task 08) +- Distance display formatting utilities From 695f81f120eb52e50ad603e15401be55ce893aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Pilgaard=20Gr=C3=B8ndahl?= Date: Tue, 23 Dec 2025 20:23:47 +0100 Subject: [PATCH 7/7] Update GroupSearchFilter.cs --- src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs b/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs index e3620497..151968ed 100644 --- a/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs +++ b/src/shared/Jordnaer.Shared/Groups/GroupSearchFilter.cs @@ -60,9 +60,7 @@ public virtual bool Equals(GroupSearchFilter? other) WithinRadiusKilometers == other.WithinRadiusKilometers && Location == other.Location && Latitude == other.Latitude && - Longitude == other.Longitude && - PageNumber == other.PageNumber && - PageSize == other.PageSize; + Longitude == other.Longitude; } }