diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs new file mode 100644 index 00000000..d4f6f972 --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetAllBreweryLocationsHandlerTests.cs @@ -0,0 +1,54 @@ +using Domain.Entities; +using Features.Breweries.Dtos; +using Features.Breweries.Queries.GetAllBreweryLocations; +using Features.Breweries.Repository; +using FluentAssertions; +using Moq; + +namespace Features.Breweries.Tests.Queries; + +public class GetAllBreweryLocationsHandlerTests +{ + private readonly GetAllBreweryLocationsHandler _handler; + private readonly Mock _repoMock = new(); + + public GetAllBreweryLocationsHandlerTests() + { + _handler = new GetAllBreweryLocationsHandler(_repoMock.Object); + } + + [Fact] + public async Task Handle_ReturnsEmpty_WhenNoBreweriesHaveALocation() + { + _repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty()); + + IEnumerable result = await _handler.Handle( + new GetAllBreweryLocationsQuery(), + CancellationToken.None + ); + + result.Should().BeEmpty(); + _repoMock.Verify(r => r.GetAllLocations(), Times.Once); + } + + [Fact] + public async Task Handle_ReturnsAllLocatedBreweries_FromRepository() + { + BreweryPost[] breweries = + [ + new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "A" }, + new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "B" }, + ]; + _repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(breweries); + + IEnumerable result = await _handler.Handle( + new GetAllBreweryLocationsQuery(), + CancellationToken.None + ); + + result + .Select(b => b.BreweryPostId) + .Should() + .BeEquivalentTo(breweries.Select(b => b.BreweryPostId)); + } +} diff --git a/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs new file mode 100644 index 00000000..71e863c3 --- /dev/null +++ b/web/backend/Features/Features.Breweries.Tests/Queries/GetBreweryLocationsWithinRangeHandlerTests.cs @@ -0,0 +1,70 @@ +using Domain.Entities; +using Features.Breweries.Dtos; +using Features.Breweries.Queries.GetBreweryLocationsWithinRange; +using Features.Breweries.Repository; +using FluentAssertions; +using Moq; + +namespace Features.Breweries.Tests.Queries; + +public class GetBreweryLocationsWithinRangeHandlerTests +{ + private readonly GetBreweryLocationsWithinRangeHandler _handler; + private readonly Mock _repoMock = new(); + + public GetBreweryLocationsWithinRangeHandlerTests() + { + _handler = new GetBreweryLocationsWithinRangeHandler(_repoMock.Object); + } + + [Fact] + public async Task Handle_PassesCoordinatesAndRange_ToRepository() + { + _repoMock + .Setup(r => + r.GetAllLocationsWithinRange( + It.Is(c => c.Latitude == 43.6532 && c.Longitude == -79.3832), + 1000 + ) + ) + .ReturnsAsync(Array.Empty()); + + IEnumerable result = await _handler.Handle( + new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000), + CancellationToken.None + ); + + result.Should().BeEmpty(); + _repoMock.Verify( + r => + r.GetAllLocationsWithinRange( + It.Is(c => c.Latitude == 43.6532 && c.Longitude == -79.3832), + 1000 + ), + Times.Once + ); + } + + [Fact] + public async Task Handle_ReturnsNearbyBreweries_FromRepository() + { + BreweryPost[] breweries = + [ + new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby A" }, + new BreweryPost { BreweryPostId = Guid.NewGuid(), BreweryName = "Nearby B" }, + ]; + _repoMock + .Setup(r => r.GetAllLocationsWithinRange(It.IsAny(), It.IsAny())) + .ReturnsAsync(breweries); + + IEnumerable result = await _handler.Handle( + new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000), + CancellationToken.None + ); + + result + .Select(b => b.BreweryPostId) + .Should() + .BeEquivalentTo(breweries.Select(b => b.BreweryPostId)); + } +} diff --git a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs index d53e4764..5628fcfb 100644 --- a/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs +++ b/web/backend/Features/Features.Breweries/Controllers/BreweryController.cs @@ -3,7 +3,9 @@ using Features.Breweries.Commands.DeleteBrewery; using Features.Breweries.Commands.UpdateBrewery; using Features.Breweries.Dtos; using Features.Breweries.Queries.GetAllBreweries; +using Features.Breweries.Queries.GetAllBreweryLocations; using Features.Breweries.Queries.GetBreweryById; +using Features.Breweries.Queries.GetBreweryLocationsWithinRange; using MediatR; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -64,6 +66,49 @@ public class BreweryController(IMediator mediator) : ControllerBase ); } + /// Retrieves all brewery posts that have a set location. + /// Allows anonymous access. + [AllowAnonymous] + [HttpGet("locations")] + public async Task>>> GetAllLocations() + { + IEnumerable breweries = await mediator.Send( + new GetAllBreweryLocationsQuery() + ); + return Ok( + new ResponseBody> + { + Message = "Brewery locations retrieved successfully.", + Payload = breweries, + } + ); + } + + /// Retrieves brewery posts with a set location within range of the given coordinates, nearest first. + /// The origin latitude, in decimal degrees. + /// The origin longitude, in decimal degrees. + /// The maximum distance, in metres, from the origin coordinates. + /// Allows anonymous access. + [AllowAnonymous] + [HttpGet("locations/nearby")] + public async Task>>> GetLocationsWithinRange( + [FromQuery] double latitude, + [FromQuery] double longitude, + [FromQuery] double rangeInMetres + ) + { + IEnumerable breweries = await mediator.Send( + new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres) + ); + return Ok( + new ResponseBody> + { + Message = "Brewery locations retrieved successfully.", + Payload = breweries, + } + ); + } + /// Creates a new brewery post. /// 201 Created with the newly created brewery. [HttpPost] diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs index 58de1a98..ceaee690 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -32,3 +32,8 @@ public record BreweryDto( byte[]? RowVersion, BreweryLocationDto? Location ); +public record SimplifiedBreweryDto( + Guid BreweryPostId, + string BreweryName, + BreweryLocationDto? Location +); diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs index 4bb74bef..6b3f62c1 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -40,4 +40,35 @@ public static class BreweryDtoMapper ) ); } + + /// + /// Converts a to its simplified DTO representation, used for + /// location-based queries that only need identifying and location details. + /// + public static SimplifiedBreweryDto ToSimplifiedDto(this BreweryPost brewery) + { + return new SimplifiedBreweryDto( + BreweryPostId: brewery.BreweryPostId, + BreweryName: brewery.BreweryName, + Location: brewery.Location is null + ? null + : new BreweryLocationDto( + BreweryPostLocationId: brewery.Location.BreweryPostLocationId, + CityId: brewery.Location.CityId, + CityName: brewery.Location.City?.CityName ?? string.Empty, + StateProvinceName: brewery.Location.City?.StateProvince?.StateProvinceName + ?? string.Empty, + StateProvinceCode: brewery.Location.City?.StateProvince?.Iso31662 + ?? string.Empty, + CountryName: brewery.Location.City?.StateProvince?.Country?.CountryName + ?? string.Empty, + CountryCode: brewery.Location.City?.StateProvince?.Country?.Iso31661 + ?? string.Empty, + AddressLine1: brewery.Location.AddressLine1, + AddressLine2: brewery.Location.AddressLine2, + PostalCode: brewery.Location.PostalCode, + Coordinates: brewery.Location.Coordinates + ) + ); + } } diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs new file mode 100644 index 00000000..d1551303 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsHandler.cs @@ -0,0 +1,21 @@ +using Domain.Entities; +using Features.Breweries.Dtos; +using Features.Breweries.Repository; +using MediatR; + +namespace Features.Breweries.Queries.GetAllBreweryLocations; + +/// Handles by retrieving all located brewery posts. +public class GetAllBreweryLocationsHandler(IBreweryRepository repository) + : IRequestHandler> +{ + /// Retrieves every brewery post that has a set location. + public async Task> Handle( + GetAllBreweryLocationsQuery request, + CancellationToken cancellationToken + ) + { + IEnumerable breweries = await repository.GetAllLocations(); + return breweries.Select(b => b.ToSimplifiedDto()); + } +} diff --git a/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs new file mode 100644 index 00000000..bed55fc5 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetAllBreweryLocations/GetAllBreweryLocationsQuery.cs @@ -0,0 +1,10 @@ +using Features.Breweries.Dtos; +using MediatR; + +namespace Features.Breweries.Queries.GetAllBreweryLocations; + +/// +/// Retrieves all brewery posts that have a set location. Bound directly from +/// GET /api/brewery/locations. +/// +public record GetAllBreweryLocationsQuery : IRequest>; diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs new file mode 100644 index 00000000..51ce4c6a --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeHandler.cs @@ -0,0 +1,26 @@ +using Domain.Entities; +using Features.Breweries.Dtos; +using Features.Breweries.Repository; +using MediatR; + +namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; + +/// +/// Handles by retrieving nearby located brewery posts. +/// +public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository) + : IRequestHandler> +{ + /// Retrieves brewery posts within range of the requested coordinates, nearest first. + public async Task> Handle( + GetBreweryLocationsWithinRangeQuery request, + CancellationToken cancellationToken + ) + { + IEnumerable breweries = await repository.GetAllLocationsWithinRange( + new CoordinateData(request.Latitude, request.Longitude), + request.RangeInMetres + ); + return breweries.Select(b => b.ToSimplifiedDto()); + } +} diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs new file mode 100644 index 00000000..c7a92765 --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeQuery.cs @@ -0,0 +1,15 @@ +using Features.Breweries.Dtos; +using MediatR; + +namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; + +/// +/// Retrieves brewery posts with a set location within of the given +/// coordinates, nearest first. Bound directly from the query string of +/// GET /api/brewery/locations/nearby. +/// +/// The origin latitude, in decimal degrees. +/// The origin longitude, in decimal degrees. +/// The maximum distance, in metres, from the origin coordinates. +public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres) + : IRequest>; diff --git a/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeValidator.cs b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeValidator.cs new file mode 100644 index 00000000..8b2e3fda --- /dev/null +++ b/web/backend/Features/Features.Breweries/Queries/GetBreweryLocationsWithinRange/GetBreweryLocationsWithinRangeValidator.cs @@ -0,0 +1,23 @@ +using FluentValidation; + +namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange; + +/// Validates . +public class GetBreweryLocationsWithinRangeValidator + : AbstractValidator +{ + public GetBreweryLocationsWithinRangeValidator() + { + RuleFor(x => x.Latitude) + .InclusiveBetween(-90, 90) + .WithMessage("Latitude must be between -90 and 90 degrees."); + + RuleFor(x => x.Longitude) + .InclusiveBetween(-180, 180) + .WithMessage("Longitude must be between -180 and 180 degrees."); + + RuleFor(x => x.RangeInMetres) + .GreaterThan(0) + .WithMessage("RangeInMetres must be greater than zero."); + } +} diff --git a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs index 52862175..7fa18d12 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -124,6 +124,99 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) ); } + /// + public async Task> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres) + { + await using DbConnection connection = await CreateConnection(); + return await connection.QueryAsync< + BreweryPost, + BreweryPostLocation, + City, + StateProvince, + Country, + BreweryPost + > ( + """ + DECLARE @Origin geography = geography::Point(@Latitude, @Longitude, 4326); + + SELECT + bp.BreweryPostID, + bp.BreweryName, + bpl.BreweryPostLocationID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, + c.CityID, + c.CityName, + sp.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2 AS Iso31662, + co.CountryID, + co.CountryName, + co.ISO3166_1 AS Iso31661 + FROM dbo.BreweryPost bp + INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + LEFT JOIN dbo.City c ON bpl.CityID = c.CityID + LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID + LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID + WHERE bpl.Coordinates IS NOT NULL + AND bpl.Coordinates.STDistance(@Origin) <= @RangeInMetres + ORDER BY bpl.Coordinates.STDistance(@Origin) ASC + """, + MapBreweryRow, + param: new + { + coords.Latitude, + coords.Longitude, + RangeInMetres = rangeInMetres + }, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + ); + } + + /// + public async Task> GetAllLocations() + { + await using DbConnection connection = await CreateConnection(); + return await connection.QueryAsync< + BreweryPost, + BreweryPostLocation, + City, + StateProvince, + Country, + BreweryPost + > ( + """ + SELECT + bp.BreweryPostID, + bp.BreweryName, + bpl.BreweryPostLocationID, + bpl.AddressLine1, + bpl.AddressLine2, + bpl.PostalCode, + CONVERT(varbinary(max), bpl.Coordinates) AS Coordinates, + c.CityID, + c.CityName, + sp.StateProvinceID, + sp.StateProvinceName, + sp.ISO3166_2 AS Iso31662, + co.CountryID, + co.CountryName, + co.ISO3166_1 AS Iso31661 + FROM dbo.BreweryPost bp + INNER JOIN dbo.BreweryPostLocation bpl ON bp.BreweryPostID = bpl.BreweryPostID + LEFT JOIN dbo.City c ON bpl.CityID = c.CityID + LEFT JOIN dbo.StateProvince sp ON c.StateProvinceID = sp.StateProvinceID + LEFT JOIN dbo.Country co ON sp.CountryID = co.CountryID + WHERE bpl.Coordinates IS NOT NULL + ORDER BY bp.BreweryPostID ASC + """, + MapBreweryRow, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID" + ); + } + /// public async Task UpdateAsync(BreweryPost brewery) { @@ -134,18 +227,18 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) { bool breweryExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 FROM - dbo.BreweryPost - WHERE - BreweryPostID = @BreweryPostId - """, - new { brewery.BreweryPostId }, - transaction + new CommandDefinition( + """ + SELECT 1 FROM + dbo.BreweryPost + WHERE + BreweryPostID = @BreweryPostId + """, + new { brewery.BreweryPostId }, + transaction + ) ) - ) - is not null; + is not null; if (!breweryExists) throw new NotFoundException("Brewery not found."); @@ -154,13 +247,13 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) { bool cityExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - "SELECT 1 FROM dbo.City WHERE CityID = @CityId", - new { brewery.Location.CityId }, - transaction + new CommandDefinition( + "SELECT 1 FROM dbo.City WHERE CityID = @CityId", + new { brewery.Location.CityId }, + transaction + ) ) - ) - is not null; + is not null; if (!cityExists) throw new NotFoundException("City not found."); @@ -212,17 +305,17 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) { bool locationExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 - FROM dbo.BreweryPostLocation - WHERE BreweryPostID = @BreweryPostId - """, - new { brewery.BreweryPostId }, - transaction + new CommandDefinition( + """ + SELECT 1 + FROM dbo.BreweryPostLocation + WHERE BreweryPostID = @BreweryPostId + """, + new { brewery.BreweryPostId }, + transaction + ) ) - ) - is not null; + is not null; if (locationExists) await connection.ExecuteAsync( @@ -303,9 +396,9 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) } return await GetByIdAsync(brewery.BreweryPostId) - ?? throw new InvalidOperationException( - $"Brewery '{brewery.BreweryPostId}' was not found after a successful update." - ); + ?? throw new InvalidOperationException( + $"Brewery '{brewery.BreweryPostId}' was not found after a successful update." + ); } /// @@ -339,38 +432,38 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) { bool userExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 - FROM - dbo.UserAccount - WHERE - UserAccountID = @PostedById - """, - new { brewery.PostedById }, - transaction + new CommandDefinition( + """ + SELECT 1 + FROM + dbo.UserAccount + WHERE + UserAccountID = @PostedById + """, + new { brewery.PostedById }, + transaction + ) ) - ) - is not null; + is not null; if (!userExists) throw new NotFoundException("User not found."); bool cityExists = await connection.ExecuteScalarAsync( - new CommandDefinition( - """ - SELECT 1 - FROM - dbo.City - WHERE - CityID = @CityId - """, - new { brewery.Location.CityId }, - transaction + new CommandDefinition( + """ + SELECT 1 + FROM + dbo.City + WHERE + CityID = @CityId + """, + new { brewery.Location.CityId }, + transaction + ) ) - ) - is not null; + is not null; if (!cityExists) throw new NotFoundException("City not found."); @@ -490,4 +583,4 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) post.Location = location; return post; } -} +} \ No newline at end of file diff --git a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs index e22c8520..e108de4d 100644 --- a/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/IBreweryRepository.cs @@ -21,6 +21,20 @@ public interface IBreweryRepository /// Number of rows to skip. Treated as zero if . Task> GetAllAsync(int? limit, int? offset); + /// + /// Retrieves brewery posts with a set location within of + /// , nearest first. Posts without coordinates are excluded. + /// + /// The origin point to measure distance from. + /// The maximum distance, in metres, from . + Task> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres); + + /// + /// Retrieves all brewery posts that have a set location, ordered by BreweryPostId. Posts + /// without coordinates are excluded. + /// + Task> GetAllLocations(); + /// /// Updates a brewery post's name and description, and upserts or clears its location, enforcing /// optimistic concurrency via 's RowVersion: the update is rejected @@ -48,4 +62,4 @@ public interface IBreweryRepository /// Thrown when 's PostedById or Location.CityId does not exist. /// Task CreateAsync(BreweryPost brewery); -} +} \ No newline at end of file diff --git a/web/frontend/app/features/breweries/breweries.server.ts b/web/frontend/app/features/breweries/breweries.server.ts index e5a364c4..cc8a4629 100644 --- a/web/frontend/app/features/breweries/breweries.server.ts +++ b/web/frontend/app/features/breweries/breweries.server.ts @@ -37,6 +37,12 @@ export interface Brewery { location: BreweryLocation | null; } +export interface SimplifiedBrewery { + breweryPostId: string; + breweryName: string; + location: BreweryLocation | null; +} + const fetchApi = async (path: string): Promise => { try { return await fetch(`${API_BASE_URL}${path}`); @@ -66,6 +72,20 @@ export const getBreweries = async (limit?: number, offset?: number): Promise => { + const res = await fetchApi('/api/brewery/locations'); + + if (!res.ok) { + throw data(`Failed to load brewery locations (${res.status}).`, { + status: res.status, + statusText: res.statusText, + }); + } + + const body: ApiResponse = await res.json(); + return body.payload; +}; + export const getBreweryById = async (id: string): Promise => { const res = await fetchApi(`/api/brewery/${encodeURIComponent(id)}`); diff --git a/web/frontend/app/features/breweries/routes/breweries.tsx b/web/frontend/app/features/breweries/routes/breweries.tsx index bfe56d91..77c4c44a 100644 --- a/web/frontend/app/features/breweries/routes/breweries.tsx +++ b/web/frontend/app/features/breweries/routes/breweries.tsx @@ -1,7 +1,7 @@ import RouteErrorState from '../../../components/ui/error/RouteErrorState'; import ClientOnly from '../../../components/ClientOnly'; import BreweryCard from '../components/BreweryCard'; -import { getBreweries, type Brewery } from '../breweries.server'; +import { getBreweries, getBreweryLocations, type SimplifiedBrewery } from '../breweries.server'; import type { Route } from './+types/breweries'; import { lazy, Suspense, useEffect, useRef, useState } from 'react'; import { useFetcher } from 'react-router'; @@ -18,13 +18,16 @@ export const loader = async ({ request }: Route.LoaderArgs) => { const url = new URL(request.url); const limit = Number(url.searchParams.get('limit')) || PAGE_SIZE; const offset = Number(url.searchParams.get('offset')) || 0; - const breweries = await getBreweries(limit, offset); - return { breweries, limit, offset }; + const [breweries, locations] = await Promise.all([ + getBreweries(limit, offset), + getBreweryLocations(), + ]); + return { breweries, locations, limit, offset }; }; const BreweryMap = lazy(() => import('../components/BreweryMap')); -const toPins = (breweries: Brewery[]): BreweryMapPin[] => +const toPins = (breweries: SimplifiedBrewery[]): BreweryMapPin[] => breweries .filter((brewery) => brewery.location?.coordinates) .map((brewery) => ({ @@ -64,7 +67,7 @@ const Breweries = ({ loaderData }: Route.ComponentProps) => { return () => observer.disconnect(); }, [hasMore, offset, fetcher]); - const pins = toPins(allBreweries); + const pins = toPins(loaderData.locations); return (